diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
index e96b1a75..157caf2d 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/AbLegacyCommandBase.cs
@@ -32,6 +32,27 @@ public abstract class AbLegacyCommandBase : DriverCommandBase
"LogixPccc unverified).")]
public AbLegacyCliTransport Transport { get; init; } = AbLegacyCliTransport.Default;
+ ///
+ /// Runs a one-shot command's driver work so a Ctrl-C (cancellation of ) prints
+ /// Cancelled. instead of an unhandled stack trace (review 07 U7).
+ /// Any other exception — and an OCE the operator did not ask for — still propagates.
+ ///
+ /// The CLI console.
+ /// The console's Ctrl-C token.
+ /// The command body.
+ /// A task that completes when the body has finished or was cancelled.
+ internal static async ValueTask RunCancellableAsync(CliFx.Infrastructure.IConsole console, CancellationToken ct, Func body)
+ {
+ try
+ {
+ await body();
+ }
+ catch (OperationCanceledException) when (ct.IsCancellationRequested)
+ {
+ await console.Error.WriteLineAsync("Cancelled.");
+ }
+ }
+
/// Gets the per-operation timeout in milliseconds.
[CommandOption("timeout-ms", Description = "Per-operation timeout in ms (default 5000).")]
public int TimeoutMs { get; init; } = 5000;
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
index c1ae0737..f89d28d1 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ProbeCommand.cs
@@ -43,17 +43,20 @@ public sealed class ProbeCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync(["__probe"], ct);
- var health = driver.GetHealth();
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync(["__probe"], ct);
+ var health = driver.GetHealth();
- await console.Output.WriteLineAsync($"Gateway: {Gateway}");
- await console.Output.WriteLineAsync($"PLC type: {PlcType}");
- await console.Output.WriteLineAsync($"Health: {health.State}");
- if (health.LastError is { } err)
- await console.Output.WriteLineAsync($"Last error: {err}");
- await console.Output.WriteLineAsync();
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await console.Output.WriteLineAsync($"Gateway: {Gateway}");
+ await console.Output.WriteLineAsync($"PLC type: {PlcType}");
+ await console.Output.WriteLineAsync($"Health: {health.State}");
+ if (health.LastError is { } err)
+ await console.Output.WriteLineAsync($"Last error: {err}");
+ await console.Output.WriteLineAsync();
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
index 48c381c5..89a1e071 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/ReadCommand.cs
@@ -45,9 +45,12 @@ public sealed class ReadCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var snapshot = await driver.ReadAsync([tagName], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var snapshot = await driver.ReadAsync([tagName], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.Format(Address, snapshot[0]));
+ });
}
finally
{
diff --git a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
index 2a414bfc..d4c61ba4 100644
--- a/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
+++ b/src/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli/Commands/WriteCommand.cs
@@ -55,9 +55,12 @@ public sealed class WriteCommand : AbLegacyCommandBase
var driver = new AbLegacyDriver(options, DriverInstanceId);
try
{
- await driver.InitializeAsync("{}", ct);
- var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
- await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ await RunCancellableAsync(console, ct, async () =>
+ {
+ await driver.InitializeAsync("{}", ct);
+ var results = await driver.WriteAsync([new WriteRequest(tagName, parsed)], ct);
+ await console.Output.WriteLineAsync(SnapshotFormatter.FormatWrite(Address, results[0]));
+ });
}
finally
{
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
index 6f9cde21..c083d592 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyDriverOptions.cs
@@ -48,6 +48,16 @@ public sealed class AbLegacyDriverOptions
/// Gets or sets the default timeout for read/write operations.
public TimeSpan Timeout { get; init; } = TimeSpan.FromSeconds(2);
+ ///
+ /// Whether scalar tags on adjacent elements of one N / B / F / L file are read as ONE multi-element PCCC read
+ /// (driver-config key coalesceReads, default ). A block the device refuses falls
+ /// back to per-tag reads on its own; set this false to read every tag singly. (the key
+ /// absent) means the default, ON - and null is never written, so a save through any serializer (the AdminUI
+ /// form included) keeps a defaulted driver defaulted instead of pinning today's default (review 07 r0 M9).
+ ///
+ [System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull)]
+ public bool? CoalesceReads { get; init; }
+
///
/// Timeout for the AdminUI Test Connect probe, in seconds. The AdminUI clamps to a
/// 60s server-side maximum; this default is what the form pre-fills for new instances.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
index 8c0742f5..c10832d7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Contracts/AbLegacyTagDefinitionFactory.cs
@@ -77,6 +77,10 @@ public static class AbLegacyTagDefinitionFactory
if (rawLength >= 1)
arrayLength = Math.Min(rawLength, AbLegacyArray.MaxElements);
}
+ // A deterministic refusal (review 07 H16): String / Timer / Counter / Control elements are structured
+ // or variable-width and do not lay out as a flat array, so both runtimes refuse to decode them. Accepting
+ // the tag only bought a handle create + a NotSupportedException on every poll.
+ if (arrayLength is not null && !IsArrayElementTypeSupported(dataType)) return false;
// "writable" defaults to true when absent (today's value) — makes read-only equipment tags
// authorable.
var writable = TagConfigJson.ReadWritable(root);
@@ -173,6 +177,9 @@ public static class AbLegacyTagDefinitionFactory
{
if (AbLegacyAddress.Validate(accepted.Address, family) is { } addressError)
warnings.Add($"AbLegacy tag address: {addressError} The driver reads this tag as BadNodeIdUnknown.");
+ else if (accepted.ArrayLength is not null
+ && ArrayFileMismatch(accepted.DataType, accepted.Address) is { } widthError)
+ warnings.Add(widthError);
return warnings;
}
@@ -224,6 +231,78 @@ public static class AbLegacyTagDefinitionFactory
}
if (TagConfigJson.DescribeInvalidEnum(root, "dataType") is { } w) warnings.Add(w);
+
+ if (IsArrayFlag(root) && ReadInt(root, "arrayLength") >= 1
+ && TagConfigJson.TryReadEnumStrict(root, "dataType", AbLegacyDataType.Int, out var dt)
+ && !IsArrayElementTypeSupported(dt))
+ warnings.Add(ArrayElementTypeError(dt));
+ }
+
+ ///
+ /// Whether can be the element type of an array tag. Only the flat word/long/float/bit
+ /// files lay out as a contiguous array; String, Timer, Counter and Control elements are structured.
+ ///
+ /// The authored element data type.
+ /// for Bit, Int, AnalogInt, Long and Float.
+ public static bool IsArrayElementTypeSupported(AbLegacyDataType type) => type is
+ AbLegacyDataType.Bit or AbLegacyDataType.Int or AbLegacyDataType.AnalogInt
+ or AbLegacyDataType.Long or AbLegacyDataType.Float;
+
+ /// The authoring / deploy-gate message for an array of an unsupported element type.
+ /// The authored element data type.
+ /// The message.
+ public static string ArrayElementTypeError(AbLegacyDataType type) =>
+ $"AbLegacy {type} is not supported as an array element (String, Timer, Counter and Control elements are "
+ + "structured); author a scalar tag per element instead. The driver skips this tag and it reads BadNodeIdUnknown.";
+
+ ///
+ /// The element width an array on 's data file has, as the data type that decodes /
+ /// encodes it: N, B, A and the I/O/S word files are 16-bit words (), L is
+ /// 32-bit (), F is IEEE float ().
+ /// for a file with no flat array layout (ST, T, C, R, function files) or an address that
+ /// does not parse.
+ ///
+ /// The authored address.
+ /// The file's element type, or .
+ public static AbLegacyDataType? ArrayFileElementType(string address) =>
+ AbLegacyAddress.TryParse(address) is { } a ? ArrayFileElementType(a) : null;
+
+ ///
+ public static AbLegacyDataType? ArrayFileElementType(AbLegacyAddress a) => a.FileLetter switch
+ {
+ "N" or "B" or "A" or "I" or "O" or "S" => AbLegacyDataType.Int,
+ "L" => AbLegacyDataType.Long,
+ "F" => AbLegacyDataType.Float,
+ _ => null,
+ };
+
+ ///
+ /// Why an ARRAY authored as on does not match its data
+ /// file's element width (review 07 r0 I3), or when it does. Int / AnalogInt need a 16-bit
+ /// word file (N/B/A/I/O/S), Long an L file, Float an F file; Bit arrays are bit-packed words and fit any 16-bit
+ /// word file. A mismatch reads the file at the wrong width (garbage) and the driver refuses to WRITE it
+ /// (BadTypeMismatch), because encoding at the authored width would corrupt the neighbouring elements.
+ ///
+ /// The authored element data type.
+ /// The authored address.
+ /// The mismatch message, or .
+ public static string? ArrayFileMismatch(AbLegacyDataType type, string address)
+ {
+ if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal
+ // The file LETTER is static even for an indirect address (N7:[N7:0], N[N7:0]:5) — only the numbers move —
+ // so the width rule applies to it too (review 07 r1 N5).
+ if (AbLegacyAddress.TryParse(address) is not { } a) return null;
+ var file = ArrayFileElementType(a);
+ var ok = type switch
+ {
+ AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit => file == AbLegacyDataType.Int,
+ _ => file == type,
+ };
+ return ok
+ ? null
+ : $"AbLegacy {type} array on '{address}' does not match the {a.FileLetter} file's element width "
+ + "(Int/AnalogInt/Bit need an N/B/A word file, Long an L file, Float an F file): reads decode the wrong width "
+ + "and writes are refused BadTypeMismatch.";
}
private static int ReadInt(JsonElement o, string name)
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
new file mode 100644
index 00000000..a28e7169
--- /dev/null
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs
@@ -0,0 +1,79 @@
+using System.Collections;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy;
+
+///
+/// Converts an OPC UA array-write value into the element-typed CLR array a runtime's
+/// takes. Pure: it runs BEFORE any runtime is created, so a refused
+/// write never costs a wire round trip and never touches driver health.
+///
+///
+/// Elements are converted, not exact-matched. ScadaBridge writes CLR lists such as int[] to
+/// Int16 nodes, and an OPC UA client may send any numeric array, so each element goes through the same checked
+/// the scalar path uses. A value that does not fit is BadOutOfRange; one that is not
+/// a number at all is BadTypeMismatch.
+/// The length must equal the tag's element count (BadOutOfRange otherwise): a shorter write
+/// would have to fill the tail from the last read buffer, which is the stale-elements defect this replaces.
+/// Bit arrays are refused (BadNotSupported): a PCCC bit array is bit-packed into words, so
+/// encoding it rewrites whole words and would clobber neighbouring bits the PLC owns. String / Timer / Counter /
+/// Control arrays never reach here — the mapper rejects them.
+///
+internal static class AbLegacyArrayWriteEncoder
+{
+ /// Converts to a typed array of exactly elements.
+ /// The tag's element data type.
+ /// The value the client wrote.
+ /// The tag's element count.
+ /// The converted array when the result is Good.
+ /// , or the Bad status the write is answered with.
+ public static uint TryConvert(AbLegacyDataType type, object? value, int count, out Array? typed)
+ {
+ typed = null;
+ if (type is AbLegacyDataType.Bit) return AbLegacyStatusMapper.BadNotSupported;
+ if (type is not (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float))
+ return AbLegacyStatusMapper.BadNotSupported;
+ if (value is null or string || value is not IEnumerable items) return AbLegacyStatusMapper.BadTypeMismatch;
+
+ var elements = items.Cast
-public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable,
+public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable,
IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable
{
/// Authored tags this driver could not build into its poll table.
@@ -23,16 +23,30 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// Mutable so can adopt a re-parsed config on reinitialize.
/// Only ever written on the init path, before any reader/session uses it.
- private AbLegacyDriverOptions _options;
+ private volatile AbLegacyDriverOptions _options;
private readonly string _driverInstanceId;
private readonly IAbLegacyTagFactory _tagFactory;
private readonly ILogger _logger;
private readonly PollGroupEngine _poll;
- private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
+
+ // Device table, keyed by HostAddress. BUILD-AND-SWAP (review 07 H13): InitializeAsync builds a fresh
+ // dictionary and publishes it by reference; teardown swaps in an empty one. A published dictionary is never
+ // mutated, so the retained poll loop, the actor's GetHostStatuses and a server read can enumerate whatever
+ // snapshot they hold without "Collection was modified". volatile = the publish is visible to every reader.
+ private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase);
// v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is
- // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper.
- private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+ // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper, and published
+ // by reference alongside _devices (same build-and-swap rule).
+ private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal);
+
+ // Serialises every PUBLISH/RETIRE of the two tables with the lifecycle epoch. Never held across an await.
+ private readonly object _lifecycleLock = new();
+
+ // Bumped by every InitializeAsync start and every teardown. An initialise only publishes if the epoch is still
+ // the one it took — so an InitializeAsync the runtime abandoned (Lane A: it times the attempt out and calls
+ // ShutdownAsync) cannot resurrect its tables and probe loops behind a driver the runtime believes stopped.
+ private long _lifecycleEpoch;
// Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the
// authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss).
@@ -50,6 +64,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public event EventHandler? OnDataChange;
+ ///
+ /// One raise per poll cycle carrying every tag that changed in it (review 02 P1). Raised alongside the per-tag
+ /// ; a consumer subscribes to exactly one of the two.
+ ///
+ public event EventHandler? OnDataChangeBatch;
+
///
/// Occurs when host status changes.
///
@@ -85,6 +105,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)),
onError: HandlePollError,
backoffCap: PollBackoffCap,
+ onBatch: (handle, items) =>
+ OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)),
// The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this
// closure IS the production consumer of its overrun signal.
onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1,
@@ -121,9 +143,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
internal void HandlePollError(Exception ex)
{
_logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId);
- var current = _health;
- if (current.State != DriverState.Faulted)
- _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
+ lock (_lifecycleLock)
+ {
+ var current = _health;
+ if (current.State != DriverState.Faulted)
+ _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message);
+ }
}
///
@@ -145,66 +170,78 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
+ long epoch;
+ lock (_lifecycleLock) epoch = ++_lifecycleEpoch;
+
// InitializeAsync can run on a live driver without a teardown first (the IDriver contract
// allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the
- // overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
+ // new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init).
- await ReleaseDeviceStatesAsync().ConfigureAwait(false);
- _health = new DriverHealth(DriverState.Initializing, null, null);
+ await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false).States, "re-initialise").ConfigureAwait(false);
+ SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null));
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
_driverInstanceId, _transportDescription, _tagFactory.GetType().Name);
+
+ // Built locally and published by reference at the end (build-and-swap): nothing a concurrent reader holds
+ // is ever mutated.
+ var devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ var tags = new Dictionary(StringComparer.Ordinal);
try
{
// re-parse the supplied DriverConfig so a config change delivered through the IDriver
// contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED
// with, so an operator's edit is discarded while the deployment still seals green.
- if (HasConfigBody(driverConfigJson))
- _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson);
+ var options = HasConfigBody(driverConfigJson)
+ ? AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson)
+ : _options;
// Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly
// even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance —
// not on every init, which includes every comms-loss reconnect retry.
if (!_limitedFamilyWarned
&& _transport == AbLegacyTransport.Native
- && AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited)
+ && AbLegacyTransportResolver.NativeLimitedFamilies(options.Devices) is { Count: > 0 } limited)
{
_limitedFamilyWarned = true;
_logger.LogWarning(
"AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " +
- "support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " +
+ "support is limited there (PLC-5: N-file reads only, every write refused; LogixPccc: framing unverified). Set " +
"\"transport\": \"Libplctag\" to use the fallback stack.",
_driverInstanceId, string.Join(", ", limited));
}
- foreach (var device in _options.Devices)
+ foreach (var device in options.Devices)
{
var addr = AbLegacyHostAddress.TryParse(device.HostAddress)
?? throw new InvalidOperationException(
$"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'.");
var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily);
- _devices[device.HostAddress] = new DeviceState(addr, device, profile);
+ devices[device.HostAddress] = new DeviceState(addr, device, profile);
}
// Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig
// blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the
// def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to
// the owning device's host so the existing DeviceHostAddress-keyed routing continues to work.
// A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init.
- _skippedTags.BeginRebuild(_options.RawTags.Count);
- foreach (var entry in _options.RawTags)
+ _skippedTags.BeginRebuild(options.RawTags.Count);
+ foreach (var entry in options.RawTags)
{
if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def))
{
- _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition");
+ // The deploy gate's own explanation (same authority) — e.g. an array of a structured type.
+ var reason = AbLegacyTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault()
+ ?? "TagConfig did not map to a definition";
+ _skippedTags.Skip(entry.RawPath, reason);
_logger.LogWarning(
- "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}",
- _driverInstanceId, entry.RawPath);
+ "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}",
+ _driverInstanceId, entry.RawPath, reason);
continue;
}
- _tagsByRawPath[entry.RawPath] = def with
+ tags[entry.RawPath] = def with
{
WriteIdempotent = entry.WriteIdempotent,
- DeviceHostAddress = ResolveDeviceHost(entry.DeviceName),
+ DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName),
};
}
_skippedTags.Commit();
@@ -213,9 +250,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// and String (ST-file) are not supported by all PCCC families; reject them early
// so a misconfigured tag fails at init time with a clear message rather than
// surfacing an opaque comms error at runtime.
- foreach (var tag in _tagsByRawPath.Values)
+ foreach (var tag in tags.Values)
{
- if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
+ if (!devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue;
var profile = deviceForTag.Profile;
if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile)
throw new InvalidOperationException(
@@ -227,68 +264,108 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
$"(family {deviceForTag.Options.PlcFamily}) does not support ST-files.");
}
- // Probe loops — one per device when enabled + probe address configured.
- if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress))
+ // Publish, start the probe loops and go Healthy as ONE step under the lifecycle lock, and only if no
+ // teardown (or newer initialise) ran since this attempt began. Starting the loops inside the lock
+ // means a teardown that swaps this table out always sees their tasks and joins them.
+ lock (_lifecycleLock)
{
- foreach (var state in _devices.Values)
+ if (epoch != _lifecycleEpoch)
+ throw new OperationCanceledException(
+ $"AbLegacy driver {_driverInstanceId} initialise was superseded by a shutdown or a newer initialise.");
+ _options = options;
+ _tagsByRawPath = tags;
+ _devices = devices;
+ _resolver.Clear(); // drop transient parses made against the previous table
+ if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeAddress))
{
- state.ProbeCts = new CancellationTokenSource();
- var ct = state.ProbeCts.Token;
- state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct);
+ foreach (var state in devices.Values)
+ {
+ state.ProbeCts = new CancellationTokenSource();
+ var ct = state.ProbeCts.Token;
+ var probe = options.Probe;
+ state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, probe, ct), ct);
+ }
}
+ _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
- _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null);
}
catch (Exception ex)
{
- _health = new DriverHealth(DriverState.Faulted, null, ex.Message);
+ // Release what this attempt built. Nothing was published (the publish is the last statement of the
+ // try and runs entirely under the lock), so no loop is running against these states.
+ foreach (var state in devices.Values) state.Retire();
+ if (ex is OperationCanceledException && epoch != Volatile.Read(ref _lifecycleEpoch))
+ {
+ _logger.LogInformation(
+ "AbLegacy driver {DriverInstanceId} initialise abandoned: superseded before it could publish.",
+ _driverInstanceId);
+ throw;
+ }
+ SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Faulted, null, ex.Message));
_logger.LogError(ex,
"AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId);
- // Release cached state created before the failure so a caller who catches and abandons
- // (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and
- // libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of
- // the try and nothing after that can throw, so a failure never has a running loop to join.
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
- _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
throw;
}
}
- ///
- /// Cancels, bounded-joins and disposes every existing device state, then clears the table.
- /// Device-state part of only (no poll / subscription teardown).
- ///
- private async Task ReleaseDeviceStatesAsync()
+ /// Writes only while is still the current lifecycle
+ /// epoch, so a superseded initialise never overwrites the health of the teardown that superseded it.
+ private void SetHealthIfCurrent(long epoch, DriverHealth health)
{
- if (_devices.Count == 0) return;
- foreach (var state in _devices.Values)
+ lock (_lifecycleLock)
+ {
+ if (epoch == _lifecycleEpoch) _health = health;
+ }
+ }
+
+ /// The current lifecycle epoch; a read/write captures it so a late result cannot rewrite health after a teardown.
+ private long CurrentEpoch => Volatile.Read(ref _lifecycleEpoch);
+
+ ///
+ /// Atomically swaps the device table (and, for a teardown, the tag table) for an empty one and returns the
+ /// device states that were published, for the caller to release. Concurrent teardowns (ShutdownAsync racing
+ /// Dispose) each get a DISJOINT set, so no state is released twice and neither enumerates a table the other
+ /// is clearing.
+ ///
+ /// True for a teardown: supersedes any in-flight initialise.
+ /// The device states that were published, and the epoch as of the retirement (a teardown writes its final
+ /// health only while that epoch is still current — review 07 r0 M1).
+ private (List States, long Epoch) RetireTables(bool bumpEpoch)
+ {
+ lock (_lifecycleLock)
+ {
+ if (bumpEpoch)
+ {
+ _lifecycleEpoch++;
+ _tagsByRawPath = new Dictionary(StringComparer.Ordinal);
+ }
+ var retired = _devices.Values.ToList();
+ _devices = new Dictionary(StringComparer.OrdinalIgnoreCase);
+ return (retired, _lifecycleEpoch);
+ }
+ }
+
+ ///
+ /// Cancels, bounded-joins and retires the given (already unpublished) device states.
+ ///
+ /// The states to release.
+ /// "shutdown" / "re-initialise", for the join-timeout warning.
+ private async Task ReleaseDeviceStatesAsync(List states, string phase)
+ {
+ if (states.Count == 0) return;
+ foreach (var state in states)
try { state.ProbeCts?.Cancel(); } catch { }
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList();
+ var loops = states.Select(d => d.ProbeTask).OfType().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
- "AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
- loops.Count, LoopJoinTimeout, _driverInstanceId);
+ "AbLegacy {Phase}: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
+ phase, loops.Count, LoopJoinTimeout, _driverInstanceId);
}
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
+ foreach (var state in states) state.Retire();
}
///
@@ -305,44 +382,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths.
/// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock.
///
- private void JoinProbeLoopsSync()
+ /// The retired states whose loops to join.
+ private static void JoinProbeLoopsSync(List states)
{
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray();
+ var loops = states.Select(d => d.ProbeTask).OfType().ToArray();
if (loops.Length == 0) return;
try { Task.WaitAll(loops, LoopJoinTimeout); }
catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ }
}
- ///
+ ///
+ /// Complete teardown: stops every poll subscription, cancels and (bounded) joins the probe loops, disposes
+ /// every cached runtime and unpublishes both tables. Safe to run concurrently with /
+ /// (each teardown retires a disjoint set of states) and with an in-flight
+ /// , which it supersedes: that attempt will not publish.
+ ///
+ /// Unused; teardown is bounded by .
+ /// A task that completes when teardown is done.
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
+ // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing.
+ var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
await _poll.DisposeAsync().ConfigureAwait(false);
// Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes.
// The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could
- // outlive shutdown and keep a libplctag handle alive past DisposeRuntimes.
- foreach (var state in _devices.Values)
- try { state.ProbeCts?.Cancel(); } catch { }
- var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList();
- if (loops.Count > 0)
- {
- var all = Task.WhenAll(loops);
- var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
- if (finished != all)
- _logger.LogWarning(
- "AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}",
- loops.Count, LoopJoinTimeout, _driverInstanceId);
- }
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
+ // outlive shutdown and keep a libplctag handle alive past retirement.
+ await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false);
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
- _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -364,123 +436,431 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// ---- IReadable ----
+ ///
+ /// How many devices one reads concurrently. Each device is still read serially (its
+ /// tags share one PCCC connection, and SLC/MicroLogix controllers accept few), so this bounds the number of
+ /// devices — not requests — in flight, which is what keeps a dead device from starving a healthy one.
+ ///
+ internal const int MaxConcurrentDevices = 8;
+
+ ///
+ /// Consecutive comms faults within one device that mark it DOWN (opening its backoff window and failing the
+ /// rest of the pass fast). Two, not one: a single tag that times out (an odd address, a slow file) must not
+ /// blacken every sibling on a device that is answering.
+ ///
+ internal const int FaultsToMarkDeviceDown = 2;
+
+ ///
+ /// The clock every per-device backoff decision reads (window open? when does it end?). Wall-clock UTC in
+ /// production; a test substitutes a manual clock so the window can be crossed deterministically rather than by
+ /// sleeping (review 07 r0 M7).
+ ///
+ internal Func Clock { get; set; } = () => DateTime.UtcNow;
+
+ /// One resolved reference queued for its device's pass.
+ private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed);
+
+ ///
+ /// What one wire operation proved. = the device replied (a Good value OR a
+ /// deterministic device refusal such as "file not found") — the evidence that advances
+ /// LastSuccessfulRead; = a comms failure (timeout, dropped connection, garbage
+ /// reply); = failed fast inside the device's backoff window (Bad, Degraded, but no new
+ /// evidence for the backoff); = nothing reached the wire or it says nothing about
+ /// reachability (a local refusal, a bad index value).
+ ///
+ /// = a reply arrived that cannot be classified (libplctag ERR_REMOTE_ERR, native
+ /// BadReply/Partial): it may be the device refusing OR a routing hop reporting that the end device is
+ /// gone, so it is NOT an answer (no backoff reset, no LastSuccessfulRead) and NOT a fault (no fail-fast);
+ /// a pass that produced only opaque outcomes for a device degrades (review 07 r1 N1).
+ private enum OpVerdict { Neutral, Answered, Fault, Throttled, Opaque }
+
+ /// The health evidence one device pass produced (collected, then written once per read).
+ private struct DeviceReadOutcome
+ {
+ public bool AnyGood;
+ public bool AnyAnswered;
+ public string? FaultMessage;
+ public string? OpaqueMessage;
+
+ ///
+ /// Folds an opaque-only device into the fault class for STATE (Degraded, LastSuccessfulRead not advanced
+ /// by it) — option (a) of review 07 r1 N1: a dead PLC behind a bridge answers every request with a fast remote
+ /// error, and only this lets the comms-loss watchdog (non-Healthy AND frozen LastSuccessfulRead) reach
+ /// it. Cost: a single-tag device whose only tag draws an opaque refusal is re-initialised every 90 s.
+ ///
+ public void FoldOpaque()
+ {
+ if (OpaqueMessage is not null && !AnyAnswered) FaultMessage ??= OpaqueMessage;
+ }
+ }
+
///
+ ///
+ /// Review 07 P7: references are bucketed by device and the devices are read concurrently (at most
+ /// ); within a device the tags are read in order. A device that produces
+ /// consecutive comms faults opens its
+ /// window and every remaining (and every later) read on it fails fast with BadCommunicationError,
+ /// touching no wire, until the window expires or the probe sees it answer. Health is written ONCE per call:
+ /// worst outcome wins, and LastSuccessfulRead advances when ANY device answered.
+ ///
public async Task> ReadAsync(
IReadOnlyList fullReferences, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(fullReferences);
+ var epoch = CurrentEpoch;
var now = DateTime.UtcNow;
var results = new DataValueSnapshot[fullReferences.Count];
+ var devices = _devices; // one snapshot of the published table for the whole call
+ var buckets = new Dictionary>(ReferenceEqualityComparer.Instance);
for (var i = 0; i < fullReferences.Count; i++)
{
var reference = fullReferences[i];
- if (!_resolver.TryResolve(reference, out var def))
+ if (!_resolver.TryResolve(reference, out var def) || !devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
+ if (parsed is null)
{
+ WarnMalformedOnce(device, def);
results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
continue;
}
+ if (!buckets.TryGetValue(device, out var list)) buckets[device] = list = [];
+ list.Add(new ReadItem(i, reference, def, parsed));
+ }
+ if (buckets.Count == 0) return results;
- try
+ var passes = buckets.ToArray();
+ var outcomes = new DeviceReadOutcome[passes.Length];
+ using (var window = new SemaphoreSlim(MaxConcurrentDevices, MaxConcurrentDevices))
+ {
+ var tasks = new Task[passes.Length];
+ for (var d = 0; d < passes.Length; d++)
+ tasks[d] = RunPassAsync(d);
+ // WhenAll completes only once every pass has finished (it never short-circuits on a fault), so the
+ // window cannot be disposed under a pass still waiting on it.
+ await Task.WhenAll(tasks).ConfigureAwait(false);
+
+ async Task RunPassAsync(int d)
{
- var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
- if (preParsed is null)
- {
- WarnMalformedOnce(device, def);
- results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now);
- continue;
- }
- if (preParsed.IsIndirect)
- {
- // Indirect: read the index word(s) NOW (never cached - the index can change between
- // polls), compose the concrete address, then run the normal path against it.
- var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false);
- if (res.Def is null)
- {
- results[i] = new DataValueSnapshot(null, res.Status, null, now);
- if (res.TransportFault)
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport fault resolving indirect address for {reference}");
- continue;
- }
- def = res.Def;
- }
-
- var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false);
-
- int status;
- object? value;
- // Serialise the Read → GetStatus → DecodeValue sequence against the shared
- // runtime — the server read path and the poll loop both call ReadAsync against
- // the same cached libplctag Tag handle, which is not concurrency-safe.
- var opLock = device.GetRuntimeLock(def.Name);
- await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
+ await window.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- await runtime.ReadAsync(cancellationToken).ConfigureAwait(false);
- status = runtime.GetStatus();
- var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
- if (status != 0)
- value = null;
- else if (IsArrayTag(def))
- value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
- else
- value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
+ outcomes[d] = await ReadDevicePassAsync(passes[d].Key, passes[d].Value, results, now, cancellationToken)
+ .ConfigureAwait(false);
}
finally
{
- opLock.Release();
+ window.Release();
}
-
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new DataValueSnapshot(null,
- AbLegacyStatusMapper.MapStatus(status), null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport status {status} reading {reference}");
- if (!device.FirstNonZeroStatusLogged)
- {
- device.FirstNonZeroStatusLogged = true;
- _logger.LogWarning(
- "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
- _driverInstanceId, def.DeviceHostAddress, reference, status);
- }
- continue;
- }
- // Healthy read — re-arm the per-device first-failure log so a future non-zero
- // status logs again rather than being suppressed by an old flag from a prior outage.
- device.FirstNonZeroStatusLogged = false;
-
- results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
- _health = new DriverHealth(DriverState.Healthy, now, null);
- }
- catch (OperationCanceledException) { throw; }
- catch (AbTransportException ex) when (IsLocalRefusal(ex))
- {
- // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
- // Not a comms fault: BadNotSupported, keep the handle, leave health alone.
- results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
- }
- catch (Exception ex)
- {
- // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new DataValueSnapshot(null,
- AbLegacyStatusMapper.BadCommunicationError, null, now);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
}
}
+ ApplyReadHealth(epoch, outcomes, now);
return results;
}
+ ///
+ /// Writes the read's health once, after every device pass: Degraded if any pass hit a comms fault, else
+ /// Healthy if anything read Good; LastSuccessfulRead advances when any device answered (a Good read or a
+ /// deterministic device refusal — both prove the device is reachable). A read that reached no device's
+ /// wire, or only drew refusals, does not change the state.
+ ///
+ private void ApplyReadHealth(long epoch, DeviceReadOutcome[] outcomes, DateTime now)
+ {
+ var anyGood = false;
+ var anyAnswered = false;
+ string? fault = null;
+ foreach (var o in outcomes)
+ {
+ anyGood |= o.AnyGood;
+ anyAnswered |= o.AnyAnswered;
+ fault ??= o.FaultMessage;
+ }
+ ApplyHealth(epoch, fault, anyGood, anyAnswered, now);
+ }
+
+ ///
+ /// The single health write for a read or write call (worst wins): any comms fault ⇒ Degraded; otherwise any
+ /// answer — a Good value OR a device refusal, both prove the device replied — ⇒ Healthy (review 07 r0 I2: a
+ /// refusal-only pass used to leave a recovered driver Degraded). LastSuccessfulRead advances whenever
+ /// any device answered. A call that reached no wire changes nothing; a Faulted (config-level) state is kept.
+ /// Epoch-guarded so a late call cannot rewrite the health of a teardown or newer initialise.
+ ///
+ private void ApplyHealth(long epoch, string? fault, bool anyGood, bool anyAnswered, DateTime now)
+ {
+ if (fault is null && !anyGood && !anyAnswered) return;
+ lock (_lifecycleLock)
+ {
+ if (epoch != _lifecycleEpoch) return;
+ var current = _health;
+ var lastRead = anyAnswered ? now : current.LastSuccessfulRead;
+ if (current.State == DriverState.Faulted)
+ _health = current with { LastSuccessfulRead = lastRead };
+ else if (fault is not null)
+ _health = new DriverHealth(DriverState.Degraded, lastRead, fault);
+ else
+ _health = new DriverHealth(DriverState.Healthy, lastRead, null);
+ }
+ }
+
+ /// Reads one device's references in order, failing fast once the device is marked down.
+ private async Task ReadDevicePassAsync(
+ DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ var outcome = new DeviceReadOutcome();
+ var work = new Queue(PlanReadUnits(device, items));
+ while (work.TryDequeue(out var unit))
+ {
+ var members = unit.Block?.Members ?? [unit.Single!];
+ if (device.IsThrottled(Clock()))
+ {
+ foreach (var m in members)
+ results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
+ continue;
+ }
+
+ OpVerdict verdict;
+ string? message;
+ if (unit.Block is { } block)
+ {
+ (var blockOutcome, verdict, message) = await ReadBlockAsync(device, block, results, now, ct).ConfigureAwait(false);
+ if (blockOutcome == BlockOutcome.Fallback)
+ {
+ // Refused as a block: read its members singly, now, so each gets its own answer.
+ var rest = work.ToList();
+ work.Clear();
+ foreach (var m in block.Members) work.Enqueue(new ReadUnit(m, null));
+ foreach (var r in rest) work.Enqueue(r);
+ continue;
+ }
+ }
+ else
+ {
+ (verdict, message) = await ReadOneAsync(device, unit.Single!, results, now, ct).ConfigureAwait(false);
+ }
+ switch (verdict)
+ {
+ case OpVerdict.Answered:
+ outcome.AnyAnswered = true;
+ if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true;
+ device.RecordAnswered();
+ break;
+ case OpVerdict.Fault:
+ outcome.FaultMessage ??= message;
+ device.RecordCommsFault(Clock());
+ break;
+ case OpVerdict.Throttled:
+ outcome.FaultMessage ??= message;
+ break;
+ case OpVerdict.Opaque:
+ outcome.OpaqueMessage ??= message;
+ break;
+ }
+ }
+ outcome.FoldOpaque();
+ return outcome;
+ }
+
+ /// Reads one reference; writes its snapshot and reports what the operation proved.
+ private async Task<(OpVerdict Verdict, string? Message)> ReadOneAsync(
+ DeviceState device, ReadItem item, DataValueSnapshot[] results, DateTime now, CancellationToken ct)
+ {
+ var def = item.Def;
+ var reference = item.Reference;
+ try
+ {
+ if (item.Parsed.IsIndirect)
+ {
+ // Indirect: read the index word(s) NOW (never cached - the index can change between
+ // polls), compose the concrete address, then run the normal path against it.
+ var res = await ResolveIndirectAsync(device, def, item.Parsed, ct).ConfigureAwait(false);
+ if (res.Def is null)
+ {
+ results[item.Index] = new DataValueSnapshot(null, res.Status, null, now);
+ return res.TransportFault
+ ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}")
+ : res.Opaque
+ ? (OpVerdict.Opaque, $"unclassifiable reply resolving indirect address for {reference}")
+ : (OpVerdict.Neutral, null);
+ }
+ def = res.Def;
+ }
+
+ // A mis-authored array (its type does not match its data file) would decode the file at the wrong width
+ // and serve garbage as Good (review 07 r1 N2): answered BadTypeMismatch, warned once, no health write.
+ if (IsArrayTag(def) && AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) is { } widthError)
+ {
+ WarnRefusalOnce(device, def, "read", widthError, AbLegacyStatusMapper.BadTypeMismatch);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadTypeMismatch, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+
+ var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
+
+ int status;
+ object? value;
+ // Serialise the Read → GetStatus → DecodeValue sequence against the shared
+ // runtime — the server read path and the poll loop both call ReadAsync against
+ // the same cached libplctag Tag handle, which is not concurrency-safe.
+ var opLock = device.GetRuntimeLock(def.Name);
+ device.BeforeOperationGate?.Invoke(def.Name);
+ await opLock.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ if (await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false) is not { } live)
+ {
+ // A swept indirect resolution (the index moved under us): never resurrect it under a forgotten gate.
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ runtime = live;
+ await runtime.ReadAsync(ct).ConfigureAwait(false);
+ status = runtime.GetStatus();
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily);
+ if (status != 0)
+ value = null;
+ else if (IsArrayTag(def))
+ value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def));
+ else
+ value = runtime.DecodeValue(def.DataType, parsed?.BitIndex);
+ }
+ finally
+ {
+ opLock.Release();
+ }
+
+ if (status != 0)
+ {
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now);
+ if (IsDeviceRefusal((AbStatus)status))
+ {
+ // The device ANSWERED with a refusal (file/element missing, out of bounds, a remote error…):
+ // per-tag Bad, the handle is kept (re-creating it would only re-pay the init read), warned once
+ // per tag, and it is evidence of reachability rather than a comms fault.
+ WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}", results[item.Index].StatusCode);
+ return (OpVerdict.Answered, null);
+ }
+ if ((AbStatus)status == AbStatus.Unsupported)
+ {
+ // Refused locally / not supported: says nothing about reachability. Keep the handle.
+ WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported);
+ return (OpVerdict.Neutral, null);
+ }
+ if (IsOpaque((AbStatus)status))
+ {
+ // A reply we cannot classify: per-tag Bad, keep the handle, warn once — neither an answer nor a fault.
+ WarnRefusalOnce(device, def, "read", $"unclassifiable reply {(AbStatus)status}", results[item.Index].StatusCode);
+ return (OpVerdict.Opaque, $"unclassifiable reply {(AbStatus)status} reading {reference}");
+ }
+ // Evict the stale handle so the next call re-creates it (mirrors AbCip).
+ EvictRuntime(device, def.Name);
+ if (!device.FirstNonZeroStatusLogged)
+ {
+ device.FirstNonZeroStatusLogged = true;
+ _logger.LogWarning(
+ "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}",
+ _driverInstanceId, def.DeviceHostAddress, reference, status);
+ }
+ return (OpVerdict.Fault, $"transport status {status} reading {reference}");
+ }
+ // Healthy read — re-arm the per-device first-failure log so a future non-zero
+ // status logs again rather than being suppressed by an old flag from a prior outage.
+ device.FirstNonZeroStatusLogged = false;
+ results[item.Index] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now);
+ return (OpVerdict.Answered, null);
+ }
+ catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException tex)
+ {
+ // A cold connect refused inside the backoff window: no wire, but the tag has no value either.
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Throttled, tex.Message);
+ }
+ catch (AbTransportException ex) when (IsLocalRefusal(ex))
+ {
+ // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read).
+ // Not a comms fault: BadNotSupported, keep the handle, leave health alone.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.BadNotSupported);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ // The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
+ return (OpVerdict.Answered, null);
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ // A truncated / mismatched / unmapped reply (native BadReply, Partial): not proof the device answered.
+ WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now);
+ return (OpVerdict.Opaque, ex.Message);
+ }
+ catch (NotSupportedException nse)
+ {
+ // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep
+ // the handle (re-creating it only re-paid the init read), warn once, and NO health write.
+ WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now);
+ return (OpVerdict.Neutral, null);
+ }
+ catch (Exception ex)
+ {
+ // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip).
+ EvictRuntime(device, def.Name);
+ results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now);
+ return (OpVerdict.Fault, ex.Message);
+ }
+ }
+
+ ///
+ /// True for a status meaning NO REPLY arrived: a timeout, a dropped / refused connection, an aborted request, a
+ /// request still pending, or a code this build does not know. Only these are comms faults — they evict the
+ /// handle, degrade health and count toward (review 07 r0 I1: fail-fast
+ /// must trip only when nothing came back).
+ ///
+ /// The transport status.
+ /// when no reply arrived.
+ internal static bool IsNoReply(AbStatus status) =>
+ !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending;
+
+ ///
+ /// True for a status that PROVES the device answered and refused this one operation — an ALLOW-LIST (review 07
+ /// r1 N1): the file or element does not exist, the access is not allowed, it is out of bounds, a size or type is
+ /// wrong, or the processor is in a conflicting state. Per-tag Bad, keeps the handle, and is evidence of
+ /// reachability (backoff reset, LastSuccessfulRead, Healthy).
+ ///
+ ///
+ /// ⚠️ Inherits the native transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): it maps STS 0x30
+ /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to NotAllowed/NotFound,
+ /// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this
+ /// classification needs no change once it lands.
+ ///
+ /// The transport status.
+ /// for a device refusal.
+ internal static bool IsDeviceRefusal(AbStatus status) => status is
+ AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall
+ or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict;
+
+ ///
+ /// True for a reply that arrived but cannot be classified (review 07 r1 N1): —
+ /// libplctag's opaque ERR_REMOTE_ERR/ERR_BAD_STATUS, which folds EVERY non-zero CIP general status
+ /// and PCCC STS together (including a routing hop's "unconnected send timed out", "connection lost" and "remote
+ /// node missing"), and the native transport's unmapped / truncated / mismatched reply — and
+ /// . Opaque statuses are per-tag Bad and trigger the coalescing fallback, but are
+ /// neither an answer nor a comms fault; see for the health rule.
+ ///
+ /// The transport status.
+ /// for an unclassifiable reply.
+ internal static bool IsOpaque(AbStatus status) => status is AbStatus.BadReply or AbStatus.Partial;
+
// ---- IWritable ----
///
@@ -488,7 +868,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
IReadOnlyList writes, CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(writes);
+ var epoch = CurrentEpoch;
var results = new WriteResult[writes.Count];
+ var devices = _devices; // one snapshot of the published table for the whole call
+ // Writes feed health too (review 07 r0 I2): a Good or device-refused write proves the device answered; a
+ // no-reply failure is a comms fault. Collected per call and written once, worst wins.
+ var outcome = new DeviceReadOutcome();
for (var i = 0; i < writes.Count; i++)
{
@@ -503,7 +888,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable);
continue;
}
- if (!_devices.TryGetValue(def.DeviceHostAddress, out var device))
+ if (!devices.TryGetValue(def.DeviceHostAddress, out var device))
{
results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown);
continue;
@@ -521,6 +906,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
continue;
}
+ // Inside the device's backoff window nothing is dialled — the write fails fast exactly as a read
+ // does, instead of paying the full Timeout on a handle that is still cached (review 07 r0 M2).
+ if (device.IsThrottled(Clock()))
+ {
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window";
+ continue;
+ }
+
// Indirect: resolve the index first (non-atomic with the write - the index may change
// between the two PLC accesses), then write the concrete address through the normal path.
if (parsed.IsIndirect)
@@ -530,8 +924,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
results[i] = new WriteResult(res.Status);
if (res.TransportFault)
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead,
- $"transport fault resolving indirect address for {w.FullReference}");
+ {
+ device.RecordCommsFault(Clock());
+ outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}";
+ }
+ else if (res.Opaque)
+ outcome.OpaqueMessage ??= $"unclassifiable reply resolving indirect address for {w.FullReference}";
continue;
}
def = res.Def;
@@ -543,12 +941,46 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// READ, and against a device lacking that file the read failure surfaced as a comms fault —
// BadCommunicationError, evicted handle, Degraded health — instead of the refusal.
// Ahead of the bit-RMW branch too, which would otherwise read the parent word first.
- if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null)
+ if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal)
{
+ WarnRefusalOnce(device, def, "write", refusal);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
continue;
}
+ // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every
+ // element. A bit / sub-element array has no flat encoding.
+ // The encode width is the DATA FILE's element width (review 07 r0 I3), never the authored type's: an
+ // authored type that does not match its file is refused BadTypeMismatch before anything is encoded.
+ Array? arrayValue = null;
+ AbLegacyDataType arrayFileType = default;
+ if (IsArrayTag(def))
+ {
+ var fileType = AbLegacyTagDefinitionFactory.ArrayFileElementType(parsed);
+ var widthRefusal = AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address)
+ ?? (fileType is null ? $"{def.Address} has no flat array layout." : null);
+ uint conversion;
+ if (parsed.BitIndex is not null || parsed.SubElement is not null || def.DataType == AbLegacyDataType.Bit)
+ conversion = AbLegacyStatusMapper.BadNotSupported;
+ else if (widthRefusal is not null || fileType is null)
+ {
+ WarnRefusalOnce(device, def, "write", widthRefusal ?? "no flat array layout", AbLegacyStatusMapper.BadTypeMismatch);
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch);
+ continue;
+ }
+ else
+ {
+ arrayFileType = fileType.Value;
+ conversion = AbLegacyArrayWriteEncoder.TryConvert(arrayFileType, w.Value, EffectiveArrayLength(def), out arrayValue);
+ }
+ if (conversion != AbLegacyStatusMapper.Good)
+ {
+ if (conversion == AbLegacyStatusMapper.BadNotSupported)
+ WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported.");
+ results[i] = new WriteResult(conversion);
+ continue;
+ }
+ }
// PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N
// bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every
// bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files
@@ -557,10 +989,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent
// to the PLC like any other write — the device drives the input image from physical inputs and
// may reject it; we surface that real PCCC status rather than pre-rejecting at the driver.
- if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit)
+ else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit)
{
- results[i] = new WriteResult(
- await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false));
+ var (code, raw) = await WriteBitInWordAsync(device, def, parsed, bit, w.Value, cancellationToken)
+ .ConfigureAwait(false);
+ results[i] = new WriteResult(code);
+ RecordWriteStatus(device, def, raw, ref outcome);
continue;
}
@@ -573,7 +1007,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await opLock.WaitAsync(cancellationToken).ConfigureAwait(false);
try
{
- runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value);
+ if (await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false) is not { } live)
+ {
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ continue; // the index moved under us (N3): not written, no health evidence
+ }
+ runtime = live;
+ if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue);
+ else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value);
await runtime.WriteAsync(cancellationToken).ConfigureAwait(false);
status = runtime.GetStatus();
}
@@ -582,22 +1023,36 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
opLock.Release();
}
- if (status != 0)
- {
- // Evict the stale handle so the next call re-creates it (mirrors AbCip).
- EvictRuntime(device, def.Name);
- results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status));
- }
- else
- {
- results[i] = new WriteResult(AbLegacyStatusMapper.Good);
- }
+ results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(status));
+ // A no-reply status evicts the stale handle so the next call re-creates it (mirrors AbCip).
+ if (IsNoReply((AbStatus)status) && status != 0) EvictRuntime(device, def.Name);
+ RecordWriteStatus(device, def, status, ref outcome);
}
catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException tex)
+ {
+ // The device is in its reconnect backoff window: fail fast, no wire, no new evidence for the backoff.
+ results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
+ outcome.FaultMessage ??= tex.Message;
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ device.RecordAnswered();
+ outcome.AnyAnswered = true;
+ WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ outcome.OpaqueMessage ??= ex.Message;
+ WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status));
+ results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status));
+ }
catch (NotSupportedException nse)
{
+ // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device.
+ WarnRefusalOnce(device, def, "write", nse.Message);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message);
}
catch (Exception ex) when (ex is FormatException or InvalidCastException)
{
@@ -611,20 +1066,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
{
// The runtime refused the write before the wire — a native status-bit sub-element write or a
// PLC-5 write. Not a comms fault: BadNotSupported, keep the handle, leave health alone.
+ WarnRefusalOnce(device, def, "write", ex.Message);
results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported);
}
catch (Exception ex)
{
// Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip).
EvictRuntime(device, def.Name);
+ device.RecordCommsFault(Clock());
results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError);
- _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message);
+ outcome.FaultMessage ??= ex.Message;
}
}
+ outcome.FoldOpaque();
+ ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow);
return results;
}
+ ///
+ /// Classifies a completed write's raw status into the call's health evidence and the device's backoff:
+ /// Good or a device refusal = answered; a no-reply status = comms fault (it used to leave health untouched on
+ /// this path while the exception path degraded — review 07 r0 M10); Unsupported = neutral.
+ ///
+ private void RecordWriteStatus(DeviceState device, AbLegacyTagDefinition def, int status, ref DeviceReadOutcome outcome)
+ {
+ var s = (AbStatus)status;
+ if (status == 0 || IsDeviceRefusal(s))
+ {
+ device.RecordAnswered();
+ outcome.AnyAnswered = true;
+ if (status != 0)
+ WarnRefusalOnce(device, def, "write", $"device status {s}", AbLegacyStatusMapper.MapStatus(status));
+ }
+ else if (IsNoReply(s))
+ {
+ device.RecordCommsFault(Clock());
+ outcome.FaultMessage ??= $"transport status {status} writing {def.Name}";
+ }
+ else if (IsOpaque(s))
+ {
+ // Neither an answer nor a fault (review 07 r1 N1).
+ WarnRefusalOnce(device, def, "write", $"unclassifiable reply {s}", AbLegacyStatusMapper.MapStatus(status));
+ outcome.OpaqueMessage ??= $"unclassifiable reply {s} writing {def.Name}";
+ }
+ }
+
// ---- ITagDiscovery ----
///
@@ -684,7 +1171,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public IReadOnlyList GetHostStatuses() =>
[.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))];
- private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct)
+ private async Task ProbeLoopAsync(DeviceState state, AbLegacyProbeOptions probe, CancellationToken ct)
{
var probeParams = new AbLegacyTagCreateParams(
Gateway: state.ParsedAddress.Gateway,
@@ -693,9 +1180,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute,
// Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through
// so the transport reports the failure as it always did.
- TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily)
- ?? _options.Probe.ProbeAddress!,
- Timeout: _options.Probe.Timeout);
+ TagName: AbLegacyProbeAddress.Normalise(probe.ProbeAddress, state.Options.PlcFamily)
+ ?? probe.ProbeAddress!,
+ Timeout: probe.Timeout);
IAbLegacyTagRuntime? probeRuntime = null;
while (!ct.IsCancellationRequested)
@@ -710,19 +1197,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
state.ProbeInitialized = true;
}
await probeRuntime.ReadAsync(ct).ConfigureAwait(false);
- success = probeRuntime.GetStatus() == 0;
+ var status = probeRuntime.GetStatus();
+ success = status == 0;
+ // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a
+ // recovered device is read again at once; a comms fault counts toward marking it down.
+ if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered();
+ else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral
}
catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; }
- catch
+ catch (Exception ex)
{
try { probeRuntime?.Dispose(); } catch { }
probeRuntime = null;
state.ProbeInitialized = false;
+ if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered();
+ else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock());
}
TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped);
- try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); }
+ try { await Task.Delay(probe.Interval, ct).ConfigureAwait(false); }
catch (OperationCanceledException) { break; }
}
try { probeRuntime?.Dispose(); } catch { }
@@ -768,21 +1262,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI
/// passes the host as the DeviceName.
///
+ /// The options being initialised (their device list is the routing table).
/// The owning device name delivered on the .
/// The matched device's HostAddress; empty when unroutable.
- private string ResolveDeviceHost(string deviceName)
+ private static string ResolveDeviceHost(AbLegacyDriverOptions options, string deviceName)
{
if (!string.IsNullOrEmpty(deviceName))
{
- foreach (var d in _options.Devices)
+ foreach (var d in options.Devices)
if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
- foreach (var d in _options.Devices)
+ foreach (var d in options.Devices)
if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase))
return d.HostAddress;
}
// Best-available fallback: a single-device driver routes every tag to its sole device.
- return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : "";
+ return options.Devices.Count == 1 ? options.Devices[0].HostAddress : "";
}
// ---- IPerCallHostResolver ----
@@ -810,8 +1305,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// . The parent word is masked to its native width before the
/// bit operation so a sign-extended decode never corrupts the high bits.
///
- private async Task WriteBitInWordAsync(
- AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct)
+ /// The OPC UA status the write is answered with, and the raw transport status (0 = Good) for health.
+ private async Task<(uint Code, int Raw)> WriteBitInWordAsync(
+ AbLegacyDriver.DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress bitAddress, int bit, object? value,
+ CancellationToken ct)
{
// The parent word width follows the file letter: an L-file element is 32-bit, every
// other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already
@@ -824,6 +1321,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentAddress = bitAddress with { BitIndex = null };
var parentName = parentAddress.ToLibplctagName();
+ // An indirect bit write (N7:[N7:0]/3) resolves to a different parent word per index value: release the
+ // previous resolution's parent handle so a sweeping index does not keep one per word (review 07 r0 M4). The
+ // per-word RMW gate is kept — removing a gate another writer may hold would let two RMWs of one word race.
+ var arrow = def.Name.IndexOf("=>", StringComparison.Ordinal);
+ if (arrow > 0)
+ {
+ var indirectName = def.Name[..arrow];
+ var previous = device.IndirectLastParent.TryGetValue(indirectName, out var p) ? p : null;
+ device.IndirectLastParent[indirectName] = parentName;
+ if (previous is not null && !string.Equals(previous, parentName, StringComparison.OrdinalIgnoreCase))
+ {
+ var oldGate = device.GetRmwLock(previous);
+ await oldGate.WaitAsync(ct).ConfigureAwait(false);
+ try { EvictParentRuntime(device, previous); } finally { oldGate.Release(); }
+ }
+ }
+
var rmwLock = device.GetRmwLock(parentName);
await rmwLock.WaitAsync(ct).ConfigureAwait(false);
try
@@ -831,7 +1345,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false);
await parentRuntime.ReadAsync(ct).ConfigureAwait(false);
var readStatus = parentRuntime.GetStatus();
- if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus);
+ if (readStatus != 0)
+ {
+ if (IsNoReply((AbStatus)readStatus)) EvictParentRuntime(device, parentName);
+ return (AbLegacyStatusMapper.MapStatus(readStatus), readStatus);
+ }
// Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a
// sign-extended int, so a word with bit 15 set decodes negative. Masking pins the
@@ -848,9 +1366,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated);
await parentRuntime.WriteAsync(ct).ConfigureAwait(false);
var writeStatus = parentRuntime.GetStatus();
- return writeStatus == 0
- ? AbLegacyStatusMapper.Good
- : AbLegacyStatusMapper.MapStatus(writeStatus);
+ if (writeStatus != 0 && IsNoReply((AbStatus)writeStatus)) EvictParentRuntime(device, parentName);
+ return (writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(writeStatus), writeStatus);
}
finally
{
@@ -885,7 +1402,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (parsed.IndirectFileSource is { } fileSrc)
{
var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false);
- if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
+ if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
if (r.Value is < 0 or > MaxIndirectFile)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
fileNumber = (int)r.Value!;
@@ -893,7 +1410,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
if (parsed.IndirectWordSource is { } wordSrc)
{
var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false);
- if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault);
+ if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque);
if (r.Value < 0 || r.Value > maxElement)
return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false);
word = (int)r.Value!;
@@ -906,8 +1423,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var concreteText = concrete.ToLibplctagName();
var resolvedName = $"{def.Name}=>{concreteText}";
- // The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one
- // libplctag handle per value it ever took.
+ // The index moved: drop the previous resolution's handle AND its per-key gates (review 07 L7), so a sweeping
+ // index does not accumulate one libplctag handle plus two SemaphoreSlims per value it ever took.
var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null;
device.IndirectLastName[def.Name] = resolvedName;
if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase))
@@ -915,18 +1432,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
var oldLock = device.GetRuntimeLock(previous);
await oldLock.WaitAsync(ct).ConfigureAwait(false);
try { EvictRuntime(device, previous); } finally { oldLock.Release(); }
+ device.ForgetGates(previous);
+ // A concurrent reader of the same indirect tag can have cached a resolution between our TryGetValue and
+ // the write above (a stale one nothing would ever evict). Sweep every OTHER resolution of this tag.
+ var prefix = def.Name + "=>";
+ foreach (var key in device.Runtimes.Keys)
+ {
+ if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)
+ || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue;
+ // Under the key's own gate (review 07 r0 M3), so no reader is mid-operation on the handle we dispose.
+ var gate = device.GetRuntimeLock(key);
+ await gate.WaitAsync(ct).ConfigureAwait(false);
+ try { EvictRuntime(device, key); } finally { gate.Release(); }
+ device.ForgetGates(key);
+ }
}
return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false);
}
/// Reads one direct index address as an integer through the shared parent-runtime cache.
- private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync(
+ private async Task<(long? Value, uint Status, bool TransportFault, bool Opaque)> ReadIndexAsync(
DeviceState device, AbLegacyAddress inner, CancellationToken ct)
{
AbLegacyDataType type;
if (inner.BitIndex is not null) type = AbLegacyDataType.Bit;
else if (inner.FileLetter == "L") type = AbLegacyDataType.Long;
- else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false);
+ else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
else type = AbLegacyDataType.Int;
// A bit-addressed index reads its parent word and decodes the bit, like the RMW path does.
@@ -942,35 +1473,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
await runtime.ReadAsync(ct).ConfigureAwait(false);
}
catch (OperationCanceledException) { throw; }
+ catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast
catch (AbTransportException ex) when (IsLocalRefusal(ex))
{
- return (null, AbLegacyStatusMapper.BadNotSupported, false);
+ return (null, AbLegacyStatusMapper.BadNotSupported, false, false);
+ }
+ catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status))
+ {
+ // The device answered: the index file/element does not exist. Per-tag, not a comms fault.
+ return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false);
+ }
+ catch (AbTransportException ex) when (IsOpaque(ex.Status))
+ {
+ return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, true);
}
catch
{
EvictParentRuntime(device, runtimeName);
- return (null, AbLegacyStatusMapper.BadCommunicationError, true);
+ return (null, AbLegacyStatusMapper.BadCommunicationError, true, false);
}
var status = runtime.GetStatus();
- if (status != 0)
+ if (status != 0 && IsDeviceRefusal((AbStatus)status))
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
+ if (status != 0 && IsOpaque((AbStatus)status))
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, true);
+ // Only a NO-REPLY status is a transport fault (review 07 r1 N4: Unsupported used to fall in here).
+ if (status != 0 && IsNoReply((AbStatus)status))
{
EvictParentRuntime(device, runtimeName);
- return (null, AbLegacyStatusMapper.MapStatus(status), true);
+ return (null, AbLegacyStatusMapper.MapStatus(status), true, false);
}
+ if (status != 0)
+ return (null, AbLegacyStatusMapper.MapStatus(status), false, false);
var raw = runtime.DecodeValue(type, inner.BitIndex);
try
{
return raw switch
{
- bool b => (b ? 1L : 0L, 0u, false),
- null => (null, AbLegacyStatusMapper.BadOutOfRange, false),
- _ => (Convert.ToInt64(raw), 0u, false),
+ bool b => (b ? 1L : 0L, 0u, false, false),
+ null => (null, AbLegacyStatusMapper.BadOutOfRange, false, false),
+ _ => (Convert.ToInt64(raw), 0u, false, false),
};
}
catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException)
{
- return (null, AbLegacyStatusMapper.BadOutOfRange, false);
+ return (null, AbLegacyStatusMapper.BadOutOfRange, false, false);
}
}
finally
@@ -987,37 +1535,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
}
- private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault);
+ private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault, bool Opaque = false);
private async Task EnsureParentRuntimeAsync(
AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct)
{
- // Fast path: runtime already cached.
- if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing;
-
- // Slow path: serialise creation per key so concurrent callers don't each create a
- // runtime and one of them gets overwritten + leaked. Only one caller initialises; the
- // others find the entry on the second TryGetValue inside the lock.
- var creationLock = device.GetCreationLock($"parent:{parentName}");
- await creationLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing;
-
- var runtime = _tagFactory.Create(new AbLegacyTagCreateParams(
+ var timeout = _options.Timeout;
+ return await CreateSingleFlightAsync(device, device.ParentRuntimes, parentName, () => _tagFactory.Create(
+ new AbLegacyTagCreateParams(
Gateway: device.ParsedAddress.Gateway,
Port: device.ParsedAddress.Port,
CipPath: device.EffectiveCipPath,
LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute,
TagName: parentName,
- Timeout: _options.Timeout));
- await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct)
- .ConfigureAwait(false);
+ Timeout: timeout)), ct).ConfigureAwait(false);
+ }
+
+ ///
+ /// Thrown when a cold connect is refused because the device's window is open
+ /// — a fail-fast that costs no wire traffic. Reads and writes answer it with BadCommunicationError.
+ ///
+ internal sealed class ConnectionThrottledException(string message) : Exception(message);
+
+ ///
+ /// Returns the cached runtime for , or creates and initialises one. The COLD path is
+ /// single-flight per DEVICE (, review 07 P7): concurrent callers for
+ /// different cold tags of one device connect one at a time, so a down device sees one connect attempt instead
+ /// of one per caller, and inside an open backoff window no attempt is made at all
+ /// (). The HOT path (cached handle) never touches the gate. The
+ /// caller records the outcome on the device's backoff (once — recording here as well would double-count).
+ ///
+ private async Task CreateSingleFlightAsync(
+ DeviceState device,
+ System.Collections.Concurrent.ConcurrentDictionary table,
+ string key,
+ Func create,
+ CancellationToken ct)
+ {
+ if (table.TryGetValue(key, out var existing)) return existing;
+
+ await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false);
+ try
+ {
+ // Re-check under the gate: a sibling caller may have connected this very key while we waited.
+ if (table.TryGetValue(key, out existing)) return existing;
+ if (device.IsThrottled(Clock()))
+ throw new ConnectionThrottledException(
+ $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window.");
+
+ var runtime = create();
+ await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(table, key, runtime), ct).ConfigureAwait(false);
return runtime;
}
finally
{
- creationLock.Release();
+ device.ConnectGate.Release();
}
}
@@ -1087,6 +1659,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily);
}
+ ///
+ /// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per
+ /// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health.
+ ///
+ private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason,
+ uint status = AbLegacyStatusMapper.BadNotSupported)
+ {
+ if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return;
+ // The status the operation is actually answered with (review 07 r0 M8) — not always BadNotSupported.
+ _logger.LogWarning(
+ "AbLegacy {Operation} refused (0x{Status:X8}). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}",
+ operation, status, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason);
+ }
+
/// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so
/// the refusal is asked about exactly the runtime a write would otherwise create.
private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) =>
@@ -1101,30 +1687,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
private async Task EnsureTagRuntimeAsync(
DeviceState device, AbLegacyTagDefinition def, CancellationToken ct)
{
- // Fast path: runtime already cached.
if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing;
- // Slow path: serialise creation per tag name so concurrent callers for the same tag
- // (server read path + poll loop) don't both create a runtime and one gets leaked.
- var creationLock = device.GetCreationLock($"tag:{def.Name}");
- await creationLock.WaitAsync(ct).ConfigureAwait(false);
- try
- {
- if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing;
+ var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
+ ?? throw new InvalidOperationException(
+ $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
+ var createParams = CreateParamsFor(device, def, parsed);
+ return await CreateSingleFlightAsync(device, device.Runtimes, def.Name, () => _tagFactory.Create(createParams), ct)
+ .ConfigureAwait(false);
+ }
- var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily)
- ?? throw new InvalidOperationException(
- $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}.");
-
- var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed));
- await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct)
- .ConfigureAwait(false);
- return runtime;
- }
- finally
- {
- creationLock.Release();
- }
+ ///
+ /// Re-resolves a tag's runtime once its operation gate is HELD (review 07 r0 M3): the handle fetched before
+ /// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and
+ /// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one.
+ ///
+ private async Task CurrentRuntimeUnderLockAsync(
+ DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct)
+ {
+ if (device.Runtimes.TryGetValue(def.Name, out var live))
+ return live;
+ // A resolved INDIRECT key that is gone was swept because the index moved (review 07 r1 N3). Reconnecting it
+ // would resurrect a stale resolution under a gate the sweep already forgot (a later caller would get a fresh
+ // gate for the same handle). Null = the caller answers this one call neutrally; the next poll re-resolves.
+ if (def.Name.Contains("=>", StringComparison.Ordinal)) return null;
+ // Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means
+ // it really was released. Connect again (single-flight per device).
+ // A retired (torn-down) state never reconnects: the in-flight call finishes on what it has.
+ if (device.IsRetired) return fetched;
+ return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false);
}
///
@@ -1158,6 +1749,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public void Dispose()
{
+ var (retired, teardownEpoch) = RetireTables(bumpEpoch: true);
// ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine
// shutdown on the current thread without capturing the SynchronizationContext. The
// engine cancels every loop CTS up-front then either completes immediately (no
@@ -1165,20 +1757,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
// already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown
// can't poison the rest of the disposal chain.
try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { }
- foreach (var state in _devices.Values)
+ foreach (var state in retired)
try { state.ProbeCts?.Cancel(); } catch { }
- JoinProbeLoopsSync();
- foreach (var state in _devices.Values)
- {
- state.ProbeCts?.Dispose();
- state.ProbeCts = null;
- state.ProbeTask = null;
- state.DisposeRuntimes();
- }
- _devices.Clear();
- _tagsByRawPath.Clear();
+ JoinProbeLoopsSync(retired);
+ foreach (var state in retired) state.Retire();
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
- _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ // A newer InitializeAsync that ran while this teardown was still joining owns health now.
+ lock (_lifecycleLock)
+ {
+ if (teardownEpoch == _lifecycleEpoch)
+ _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
+ }
}
///
@@ -1222,7 +1811,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
/// avoids the check-then-act race present on a plain Dictionary: two concurrent
/// EnsureTagRuntimeAsync callers for the same key both miss the lookup on a
/// plain dict and both create + store a runtime, leaking the loser. Access is guarded
- /// by a per-key creation semaphore () so exactly one
+ /// by the per-device connect gate () so exactly one
/// runtime is created per tag name.
///
public System.Collections.Concurrent.ConcurrentDictionary Runtimes { get; } =
@@ -1240,21 +1829,62 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } =
new(StringComparer.OrdinalIgnoreCase);
- ///
- /// Per-key creation locks for and .
- /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd
- /// sequence so that a concurrent caller waits rather than creating a duplicate runtime
- /// that would be leaked on .
- ///
- private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase);
+ /// Indirect bit-write tag name to the parent word it last read-modify-wrote, so a moved index releases
+ /// the previous parent handle.
+ public System.Collections.Concurrent.ConcurrentDictionary IndirectLastParent { get; } =
+ new(StringComparer.OrdinalIgnoreCase);
///
- /// Gets or creates the creation lock for the specified key.
+ /// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under
+ /// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per
+ /// concurrent caller — and a concurrent caller for the same key finds the winner's runtime instead of
+ /// creating (and leaking) a duplicate. A cached runtime never touches it. Not disposed on retirement, for
+ /// the same reason as the per-key gates (an in-flight caller may still release it).
///
- /// The lock key.
- /// The semaphore slim for the key.
- public SemaphoreSlim GetCreationLock(string key) =>
- _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1));
+ public SemaphoreSlim ConnectGate { get; } = new(1, 1);
+
+ ///
+ /// Per-device reconnect throttle: opened after consecutive comms faults,
+ /// 1 s doubling to 30 s, reset by any answer from the device (data path or probe). Not internally
+ /// synchronised — every touch is under .
+ ///
+ private readonly ConnectionBackoff _backoff = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30));
+
+ private readonly object _backoffLock = new();
+ private int _faultStreak;
+
+ /// True while the device's backoff window is open: operations fail fast without the wire.
+ /// The current UTC time.
+ /// when no attempt should be made now.
+ public bool IsThrottled(DateTime nowUtc)
+ {
+ lock (_backoffLock) return !_backoff.ShouldAttempt(nowUtc);
+ }
+
+ /// The device answered (a Good value or a deterministic refusal): reset the streak and the window.
+ public void RecordAnswered()
+ {
+ lock (_backoffLock)
+ {
+ _faultStreak = 0;
+ _backoff.RecordSuccess();
+ }
+ }
+
+ ///
+ /// A comms fault: the th consecutive one marks the device down — opens
+ /// (or, after a failed retry, lengthens) the backoff window and restarts the streak.
+ ///
+ /// The current UTC time (the window is measured from here).
+ public void RecordCommsFault(DateTime nowUtc)
+ {
+ lock (_backoffLock)
+ {
+ if (++_faultStreak < FaultsToMarkDeviceDown) return;
+ _faultStreak = 0;
+ _backoff.RecordFailure(nowUtc);
+ }
+ }
private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new();
@@ -1323,28 +1953,92 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
///
public bool FirstNonZeroStatusLogged { get; set; }
+ /// Coalesced block shapes the device refused; their members read singly from then on (bounded by the
+ /// distinct subscription shapes, released with the state).
+ public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal);
+
+ /// Consecutive refusals per coalesced block shape not yet remembered in
+ /// (a success clears the count).
+ public System.Collections.Concurrent.ConcurrentDictionary BlockRefusals { get; } = new(StringComparer.Ordinal);
+
+ /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log).
+ public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal);
+
/// Tags already warned about for a malformed address (once-per-tag log).
public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new();
///
- /// Disposes all cached tag runtimes.
+ /// Drops the per-runtime and creation gates of a runtime key that is no longer in use (a moved indirect
+ /// resolution). The gates are removed, not disposed: a caller that already holds a reference finishes on
+ /// it, and the next user of the key simply gets a fresh gate.
///
- public void DisposeRuntimes()
+ /// The runtime key whose gates to drop.
+ public void ForgetGates(string runtimeKey)
{
- foreach (var r in Runtimes.Values) r.Dispose();
- Runtimes.Clear();
- foreach (var r in ParentRuntimes.Values) r.Dispose();
- ParentRuntimes.Clear();
+ _runtimeLocks.TryRemove(runtimeKey, out _);
+ }
+
+ /// The number of per-key gates currently held (runtime + RMW). Tests only.
+ internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count;
+
+ /// Test seam: invoked with the runtime key just before an operation waits on its gate (after the
+ /// handle was fetched), so a test can act at exactly that point instead of sleeping.
+ internal Action? BeforeOperationGate { get; set; }
+
+ private volatile bool _retired;
+
+ /// True once this state has been unpublished and released; nothing may be cached on it any more.
+ public bool IsRetired => _retired;
+
+ ///
+ /// Caches a freshly initialised runtime under . A concurrent creator that won the
+ /// race keeps its runtime (the loser is disposed, never overwritten-and-leaked). A runtime cached AFTER
+ /// drained the table — a read that was already in flight when shutdown ran — is
+ /// removed and disposed here, because nothing would ever release it otherwise.
+ ///
+ /// The runtime table ( or ).
+ /// The runtime key.
+ /// The initialised runtime.
+ public void Cache(System.Collections.Concurrent.ConcurrentDictionary table,
+ string key, IAbLegacyTagRuntime runtime)
+ {
+ if (!table.TryAdd(key, runtime))
+ {
+ if (!ReferenceEquals(table.GetValueOrDefault(key), runtime))
+ {
+ try { runtime.Dispose(); } catch { /* best-effort */ }
+ return;
+ }
+ }
+ // The ConcurrentDictionary write above is a full fence, so this read cannot be satisfied before it:
+ // either Retire's drain sees the entry, or this check sees the retirement.
+ if (_retired && table.TryRemove(new KeyValuePair(key, runtime)))
+ {
+ try { runtime.Dispose(); } catch { /* best-effort */ }
+ }
+ }
+
+ ///
+ /// Retires the state: marks it, disposes the probe CTS and every cached runtime. Idempotent. The
+ /// per-key gates are deliberately NOT disposed: an operation that resolved
+ /// this state before it was unpublished may still be waiting on or releasing one, and disposing it under
+ /// that caller turned a clean teardown into s. A SemaphoreSlim whose
+ /// AvailableWaitHandle was never touched holds no unmanaged resource; the gates go with the state.
+ ///
+ public void Retire()
+ {
+ _retired = true;
+ var cts = ProbeCts;
+ ProbeCts = null;
+ ProbeTask = null;
+ try { cts?.Dispose(); } catch { /* best-effort */ }
+ foreach (var key in Runtimes.Keys)
+ if (Runtimes.TryRemove(key, out var r))
+ try { r.Dispose(); } catch { /* best-effort */ }
+ foreach (var key in ParentRuntimes.Keys)
+ if (ParentRuntimes.TryRemove(key, out var r))
+ try { r.Dispose(); } catch { /* best-effort */ }
IndirectLastName.Clear();
- // Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so
- // ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a
- // wait handle once contended).
- foreach (var sem in _rmwLocks.Values) sem.Dispose();
- _rmwLocks.Clear();
- foreach (var sem in _creationLocks.Values) sem.Dispose();
- _creationLocks.Clear();
- foreach (var sem in _runtimeLocks.Values) sem.Dispose();
- _runtimeLocks.Clear();
}
}
}
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
index 9dfd9707..418ead02 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs
@@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions
ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0",
},
Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000),
+ CoalesceReads = dto.CoalesceReads,
// Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver);
// a bad value still fails exactly as any other bad enum field does.
Transport = string.IsNullOrWhiteSpace(dto.Transport)
@@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions
///
public string? Transport { get; init; }
+ /// Gets or sets whether adjacent scalar reads are coalesced; absent means .
+ public bool? CoalesceReads { get; init; }
+
///
/// Gets or sets the list of devices to connect to.
///
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
index 172e1dea..ac46d8a7 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs
@@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); }
if (opts is null) return new(false, "Config JSON deserialized to null.", null);
+ // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by
+ // nothing before: Test Connect always ran on the caller's budget alone.
+ timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds);
+
var (parsed, firstDevice) = ExtractTarget(opts);
if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0)
return new(false, "Config has no host/port to probe.", null);
@@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe
/// Whether the status proves the controller answered.
internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound;
+ ///
+ /// The deadline the probe runs under: the caller's budget, tightened by the config's
+ /// when that is positive and shorter (same rule as the
+ /// Galaxy probe). A non-positive caller budget is floored to 1 s first.
+ ///
+ /// The budget the host handed the probe.
+ /// The config's probeTimeoutSeconds; ignored when not positive.
+ /// The effective probe deadline.
+ internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds)
+ {
+ var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1);
+ if (configuredSeconds <= 0) return caller;
+ var configured = TimeSpan.FromSeconds(configuredSeconds);
+ return configured < caller ? configured : caller;
+ }
+
private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts)
{
// Parse the first device's ab:// host address to extract the gateway IP + EIP port.
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
index 502f6038..e41152a1 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs
@@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable
/// Optional bit index for bit-level access.
/// The value to encode.
void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value);
+
+ ///
+ /// Encodes EVERY element of a multi-element tag for the next , at the element width
+ /// of . is already element-typed by the driver
+ /// (short[] for Int/AnalogInt, int[] for Long, float[] for Float) and its length must
+ /// equal the runtime's element count (), so no element of
+ /// the write comes from a stale read buffer. Bit and structured element arrays are not encodable.
+ ///
+ /// The PCCC element data type.
+ /// The typed elements, exactly element-count long.
+ /// The length differs from the element count, or the array's element type
+ /// does not match .
+ /// has no flat array encoding.
+ void EncodeArray(AbLegacyDataType type, Array values);
}
public interface IAbLegacyTagFactory
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
index 3d762e84..45855904 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs
@@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation
Status.ErrorPartial => AbStatus.Partial,
Status.ErrorAbort => AbStatus.Abort,
Status.ErrorBadReply => AbStatus.BadReply,
+ // A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP
+ // general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the
+ // end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed
+ // out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault
+ // (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and
+ // trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error"
+ // value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot
+ // classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError.
+ Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply,
_ => AbStatus.BadConnection,
};
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
index 751e9701..3fd98a76 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs
@@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
{
private readonly Tag _tag;
+ /// The element count the tag was created with (1 for a scalar).
+ private readonly int _count;
+
/// Why a write to this tag is refused before the wire (), else null.
private readonly string? _writeRefusal;
@@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p)
{
_tag = BuildTag(p);
+ _count = Math.Max(1, p.ElementCount);
_writeRefusal = WriteRefusal(p);
}
@@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the
+ // last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read).
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
/// Disposes the tag and releases its resources.
public void Dispose() => _tag.Dispose();
diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
index 9a20acc0..8c372e5d 100644
--- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
+++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs
@@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime
}
}
+ ///
+ public void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ ArgumentNullException.ThrowIfNull(values);
+ if (values.Length != _count)
+ throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values));
+ // Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the
+ // write sends comes from the last read.
+ switch (type, values)
+ {
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s):
+ for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]);
+ break;
+ case (AbLegacyDataType.Long, int[] l):
+ for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]);
+ break;
+ case (AbLegacyDataType.Float, float[] f):
+ for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]);
+ break;
+ case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _):
+ throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values));
+ default:
+ throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding.");
+ }
+ }
+
///
/// Releases the lease on the shared device connection (the last release closes it) without waiting:
/// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock.
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
index 9db2085a..81ec9d9c 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Components/Shared/Drivers/Forms/AbLegacyDriverForm.razor
@@ -28,6 +28,13 @@
Which protocol stack talks to the controller. Default omits the setting (libplctag). Native is opt-in: wire-proven only for N-file reads/writes and F8 reads. Takes effect on the next deploy.
+
+
+
+
+
+
Read nearby scalar tags on one N/B/F/L file as a single PCCC request. A block the PLC refuses falls back to per-tag reads automatically. Default on.
+
@@ -97,7 +104,7 @@
/// JSON paths this form edits; everything else in the stored config is preserved.
internal static readonly string[] OwnedPaths =
[
- "transport", "probeTimeoutSeconds",
+ "transport", "probeTimeoutSeconds", "coalesceReads",
"probe.enabled", "probe.probeAddress",
];
@@ -163,6 +170,7 @@
// PLC-5 / LogixPccc exclusion still applies). Never re-emit a concrete default here — that would pin
// every edited driver to it.
public AbLegacyTransport? Transport { get; set; }
+ public bool CoalesceReads { get; set; } = true;
// Probe
public bool ProbeEnabled { get; set; } = true;
@@ -200,6 +208,7 @@
_loadedProbeTimeout = o.Probe.Timeout,
TimeoutSeconds = (int)o.Timeout.TotalSeconds,
Transport = o.Transport,
+ CoalesceReads = o.CoalesceReads ?? true,
ProbeEnabled = o.Probe.Enabled,
ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds,
ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds,
@@ -220,6 +229,8 @@
},
Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout),
Transport = Transport,
+ // On = the default: OMIT the key (never pin a concrete default); off is the only value written.
+ CoalesceReads = CoalesceReads ? null : false,
ProbeTimeoutSeconds = AdminProbeTimeoutSeconds,
};
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
index ca742cff..4d5a88ee 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.AdminUI/Uns/TagEditors/AbLegacyTagConfigModel.cs
@@ -87,6 +87,16 @@ public sealed class AbLegacyTagConfigModel
if (isArray && arrayLength > AbLegacyArray.MaxElements)
return $"AbLegacy array length cannot exceed {AbLegacyArray.MaxElements} (PCCC data-file element limit).";
+ // Same rule the reader enforces (review 07 H16): structured elements never form an array. Stated here so
+ // the CSV-import / API paths get the specific reason instead of the parse gate's generic one.
+ if (isArray && arrayLength >= 1 && !AbLegacyTagDefinitionFactory.IsArrayElementTypeSupported(DataType))
+ return AbLegacyTagDefinitionFactory.ArrayElementTypeError(DataType);
+
+ // An array must match its data file's element width (review 07 r0 I3): the driver refuses to write a
+ // mismatched one, and reads it at the wrong width.
+ if (isArray && arrayLength >= 1 && AbLegacyTagDefinitionFactory.ArrayFileMismatch(DataType, Address.Trim()) is { } widthError)
+ return widthError;
+
// Parse-gate backstop: the checks above mirror the reader's whole rejection surface today, so this is
// expected to be silent for AbLegacy. It is here so a future reader rule cannot become a gap.
return TagConfigParseGate.Describe(
diff --git a/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs
new file mode 100644
index 00000000..bb42e5f6
--- /dev/null
+++ b/tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs
@@ -0,0 +1,52 @@
+using CliFx.Infrastructure;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests;
+
+///
+/// Review 07 U7: the AbLegacy probe/read/write commands had no arm, so a
+/// Ctrl-C printed a stack trace. They now run their driver work through
+/// .
+///
+[Trait("Category", "Unit")]
+public sealed class CancellationHandlingTests
+{
+ [Fact]
+ public async Task Operator_cancellation_prints_Cancelled_instead_of_throwing()
+ {
+ using var console = new FakeInMemoryConsole();
+ using var cts = new CancellationTokenSource();
+ cts.Cancel();
+
+ await Should.NotThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
+ console, cts.Token, () => Task.FromCanceled(cts.Token)));
+
+ console.ReadErrorString().ShouldContain("Cancelled.");
+ }
+
+ [Fact]
+ public async Task A_cancellation_the_operator_did_not_request_still_propagates()
+ {
+ using var console = new FakeInMemoryConsole();
+
+ await Should.ThrowAsync(async () => await AbLegacyCommandBase.RunCancellableAsync(
+ console, CancellationToken.None, () => throw new OperationCanceledException()));
+ }
+
+ [Fact]
+ public async Task A_completed_body_runs_to_completion()
+ {
+ using var console = new FakeInMemoryConsole();
+ var ran = false;
+
+ await AbLegacyCommandBase.RunCancellableAsync(console, CancellationToken.None, () =>
+ {
+ ran = true;
+ return Task.CompletedTask;
+ });
+
+ ran.ShouldBeTrue(); // positive control
+ console.ReadErrorString().ShouldBeEmpty();
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs
new file mode 100644
index 00000000..9a098667
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs
@@ -0,0 +1,95 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
+
+///
+/// Review 07 U10: an N-file array write (4 elements) round-trips every element on both transports and leaves the
+/// words either side untouched. Gated by . Doubles as the real-controller
+/// hardware gate: AB_LEGACY_ARRAY_WRITE_BASE (default N7:1) names the first of the SIX consecutive
+/// words it uses (base = "before" sentinel, base+1..base+4 = the array, base+5 = "after" sentinel); every one is
+/// read first and written back in a finally, so a run restores them byte-exact.
+///
+[Collection(AbLegacyServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "ab_server-PCCC")]
+public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim)
+{
+ /// The env var naming the first word of the six the test may overwrite (and restores).
+ public const string BaseVar = "AB_LEGACY_ARRAY_WRITE_BASE";
+
+ /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back, restores.
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task N_file_array_write_round_trips_every_element(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ IntegrationWriteGate.SkipUnlessWritesAllowed();
+
+ var baseText = Environment.GetEnvironmentVariable(BaseVar) is { Length: > 0 } b ? b : "N7:1";
+ var baseAddr = AbLegacyAddress.TryParse(baseText, profile.Family);
+ if (baseAddr is not { FileLetter: "N", FileNumber: int file, BitIndex: null, SubElement: null } || baseAddr.IsIndirect)
+ throw new InvalidOperationException($"{BaseVar}='{baseText}' must be a plain N-file word such as N7:200.");
+ string At(int offset) => $"N{file}:{baseAddr.WordNumber + offset}";
+
+ var deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ var words = Enumerable.Range(0, 6)
+ .Select(i => new AbLegacyTagDefinition($"W{i}", deviceUri, At(i), AbLegacyDataType.Int))
+ .ToArray();
+ await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(
+ [.. words, new AbLegacyTagDefinition("Arr", deviceUri, At(1), AbLegacyDataType.Int, ArrayLength: 4)]),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ CoalesceReads = false, // read each word on its own, independent of the array path under test
+ }, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}");
+ var ct = TestContext.Current.CancellationToken;
+ await drv.InitializeAsync("{}", ct);
+ string[] wordRefs = [.. words.Select(w => w.Name)];
+
+ if (profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native)
+ {
+ // Native PLC-5 writes are refused before the wire (no capture of the PLC-5 write frames exists).
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ return;
+ }
+
+ var original = await drv.ReadAsync(wordRefs, ct);
+ original.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, "the six words must exist before anything is written");
+ try
+ {
+ (await drv.WriteAsync([new WriteRequest("W0", (short)111), new WriteRequest("W5", (short)666)], ct))
+ .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
+
+ // ScadaBridge shape: a CLR int[] to an Int16 array node.
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 10, -20, 30, 32767 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+
+ var arr = (await drv.ReadAsync(["Arr"], ct)).Single();
+ arr.StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ arr.Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
+ var back = await drv.ReadAsync(wordRefs, ct);
+ back.Select(s => Convert.ToInt32(s.Value)).ShouldBe(new[] { 111, 10, -20, 30, 32767, 666 }); // per word, neighbours untouched
+
+ // A length mismatch is refused before the wire and changes nothing.
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3 })], ct)).Single()
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ (await drv.ReadAsync(["Arr"], ct)).Single().Value.ShouldBe(new short[] { 10, -20, 30, 32767 });
+ }
+ finally
+ {
+ // Restore all six words exactly as found (CancellationToken.None: restore even if the run was cancelled).
+ var restore = words.Select((w, i) => new WriteRequest(w.Name, Convert.ToInt16(original[i].Value))).ToArray();
+ (await drv.WriteAsync(restore, CancellationToken.None))
+ .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good, "restoring the original words must succeed");
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs
new file mode 100644
index 00000000..64eb19fb
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs
@@ -0,0 +1,69 @@
+using System.Diagnostics;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
+
+///
+/// Review 07 P7 (High), wire level: one driver serving a live ab_server device AND a dead device (a
+/// blackholed address, so every connect waits out the full timeout). Before the fix the dead device's 20 tags
+/// cost 20 x Timeout per poll and the live device was read once per cycle; now the live device reads Good in
+/// the same call, the dead device costs at most two timeouts, and the next poll does not touch it at all.
+/// Read-only.
+///
+[Collection(AbLegacyServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "ab_server-PCCC")]
+public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim)
+{
+ // TEST-NET-1 (RFC 5737): never routed, so a connect blackholes until the driver's timeout.
+ private const string DeadUri = "ab://192.0.2.1:44818/1,0";
+
+ /// The live device is served promptly while a blackholed sibling is throttled.
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task Dead_device_does_not_stall_the_live_one(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+
+ var liveUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ var timeout = TimeSpan.FromSeconds(2);
+ var dead = Enumerable.Range(0, 20)
+ .Select(i => new AbLegacyTagDefinition($"Dead{i}", DeadUri, $"N7:{i}", AbLegacyDataType.Int))
+ .ToArray();
+ await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(liveUri, profile.Family), new AbLegacyDeviceOptions(DeadUri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(
+ [new AbLegacyTagDefinition("Live", liveUri, "N7:0", AbLegacyDataType.Int), .. dead]),
+ Timeout = timeout,
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ }, driverInstanceId: $"ablegacy-p7-{profile.Family}-{transport}");
+ var ct = TestContext.Current.CancellationToken;
+ await drv.InitializeAsync("{}", ct);
+ string[] refs = ["Live", .. dead.Select(d => d.Name)];
+
+ // Two consecutive comms faults mark the device down. With coalescing on, the dead device's 20 tags are ONE
+ // block (one fault per poll), so the window can open on the second poll rather than the first — either way
+ // at most two timeouts are paid before the device stops being dialled.
+ var total = TimeSpan.Zero;
+ TimeSpan last = default;
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var sw = Stopwatch.StartNew();
+ var r = await drv.ReadAsync(refs, ct);
+ sw.Stop();
+ total += last = sw.Elapsed;
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: the live device must be served in the same poll");
+ r.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good);
+ }
+
+ total.ShouldBeLessThan(timeout * 2.5, "a dead device may cost at most two timeouts per backoff window");
+ last.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled");
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
new file mode 100644
index 00000000..8542eb22
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs
@@ -0,0 +1,146 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests;
+
+///
+/// Review 07 P7: a coalesced read of nearby N7 words and bits must decode exactly what per-tag reads return,
+/// on the wire, on both transports. Seeds distinct values first when writes are allowed (simulator only);
+/// otherwise compares whatever the simulator holds.
+///
+[Collection(AbLegacyServerCollection.Name)]
+[Trait("Category", "Integration")]
+[Trait("Simulator", "ab_server-PCCC")]
+public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture sim)
+{
+ /// Coalesced and per-tag reads of the same tags agree value for value.
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task Coalesced_reads_match_single_reads(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ var ct = TestContext.Current.CancellationToken;
+ var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ AbLegacyTagDefinition[] tags =
+ [
+ new("W0", uri, "N7:0", AbLegacyDataType.Int),
+ new("W1", uri, "N7:1", AbLegacyDataType.Int),
+ new("W4", uri, "N7:4", AbLegacyDataType.Int),
+ new("W4b3", uri, "N7:4/3", AbLegacyDataType.Bit),
+ ];
+ string[] refs = [.. tags.Select(t => t.Name)];
+
+ AbLegacyDriver Make(bool coalesce) => new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ CoalesceReads = coalesce,
+ }, driverInstanceId: $"ablegacy-coalesce-{coalesce}-{profile.Family}-{transport}");
+
+ await using var single = Make(coalesce: false);
+ await using var coalesced = Make(coalesce: true);
+ await single.InitializeAsync("{}", ct);
+ await coalesced.InitializeAsync("{}", ct);
+
+ var writesRefused = profile.Family == AbLegacyPlcFamily.Plc5 && transport == AbLegacyTransport.Native;
+ if (Environment.GetEnvironmentVariable(IntegrationWriteGate.AllowWritesVar) == "1"
+ && Environment.GetEnvironmentVariable(IntegrationWriteGate.DifferentialEndpointVar) is not { Length: > 0 }
+ && !writesRefused)
+ {
+ (await single.WriteAsync(
+ [new WriteRequest("W0", (short)101), new WriteRequest("W1", (short)-202), new WriteRequest("W4", (short)0x0008)], ct))
+ .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good);
+ }
+
+ var a = await single.ReadAsync(refs, ct);
+ var b = await coalesced.ReadAsync(refs, ct);
+
+ a.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ b.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ b.Select(s => s.Value).ShouldBe(a.Select(s => s.Value));
+ }
+
+ ///
+ /// Review 07 r0 I1, on the wire: one coalesced member past the END of the file (the fixture's N7 holds 10
+ /// words) makes the PLC refuse the block with a real transport status (libplctag: ERR_REMOTE_ERR). Its
+ /// neighbours must still read Good on every poll, and the device must never be failed fast or degraded.
+ ///
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task A_member_past_the_end_of_the_file_never_blacks_out_its_neighbours(
+ AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ var ct = TestContext.Current.CancellationToken;
+ var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("A", uri, "N7:7", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("B", uri, "N7:8", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ }, driverInstanceId: $"ablegacy-pastend-{profile.Family}-{transport}");
+ await drv.InitializeAsync("{}", ct);
+
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var r = await drv.ReadAsync(["A", "B", "PastEnd"], ct);
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour A");
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: neighbour B");
+ r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}: the past-the-end tag");
+ }
+ // The refusal (libplctag: an opaque ERR_REMOTE_ERR; native: a mapped STS) is never a comms fault: the
+ // answering neighbours keep the driver Healthy and the device is never failed fast.
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ ///
+ /// Review 07 r1 N1, on the wire: libplctag reports the past-the-end refusal as the OPAQUE ERR_REMOTE_ERR —
+ /// the same code a bridge returns for a powered-off PLC behind it — so a device whose ONLY traffic is that
+ /// reply must not look reachable: Degraded, LastSuccessfulRead frozen (the comms-loss watchdog's
+ /// evidence). The native transport maps the STS itself and is only checked to stay non-Good.
+ ///
+ /// The simulator profile.
+ /// The transport stack the driver runs on.
+ [AbLegacyTheory]
+ [MemberData(nameof(TransportMatrix.Profiles), MemberType = typeof(TransportMatrix))]
+ public async Task An_opaque_only_device_never_proves_reachability(AbLegacyServerProfile profile, AbLegacyTransport transport)
+ {
+ if (sim.SkipReasonFor(profile) is { } skip) Assert.Skip(skip);
+ var ct = TestContext.Current.CancellationToken;
+ var uri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}";
+ await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(uri, profile.Family)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("PastEnd", uri, "N7:12", AbLegacyDataType.Int)),
+ Timeout = TimeSpan.FromSeconds(5),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ Transport = transport,
+ }, driverInstanceId: $"ablegacy-opaque-{profile.Family}-{transport}");
+ await drv.InitializeAsync("{}", ct);
+ var initLastRead = drv.GetHealth().LastSuccessfulRead;
+ await Task.Delay(20, ct);
+
+ for (var poll = 0; poll < 2; poll++)
+ (await drv.ReadAsync(["PastEnd"], ct))[0].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good);
+
+ if (transport == AbLegacyTransport.Libplctag)
+ {
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(initLastRead);
+ }
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs
new file mode 100644
index 00000000..1b209131
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs
@@ -0,0 +1,231 @@
+using libplctag;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Pins BOTH sides of the reply-classification line (review 07 r0 I1 + r1 N1).
+///
+/// No reply (Timeout / BadConnection / Abort / Pending / unknown) — comms fault: evict, Degraded,
+/// counts toward fail-fast.
+/// Answered (an allow-list: NotFound / NotAllowed / OutOfBounds / TooLarge / TooSmall / TypeMismatch /
+/// DeviceStateConflict) — proves reachability: per-tag Bad, Healthy, LastSuccessfulRead advances.
+/// Opaque (BadReply / Partial — incl. libplctag's ERR_REMOTE_ERR, which folds a PLC refusal
+/// and a routing hop's "end device gone" together) — neither: per-tag Bad, never fail-fast, never blacks out
+/// coalesced neighbours, but NEVER advances LastSuccessfulRead; an opaque-only device degrades so the
+/// comms-loss watchdog can reach a dead PLC behind a bridge.
+///
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyAnsweredStatusTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ /// What the libplctag runtime's GetStatus() reports for ERR_REMOTE_ERR (through the real translation).
+ private static readonly int RemoteErr = (int)LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr);
+
+ private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, bool coalesce, params AbLegacyTagDefinition[] tags) =>
+ new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = coalesce,
+ }, "drv-i1", factory);
+
+ // ---- the classification table ----
+
+ [Theory]
+ [InlineData(AbStatus.NotFound)]
+ [InlineData(AbStatus.NotAllowed)]
+ [InlineData(AbStatus.OutOfBounds)]
+ [InlineData(AbStatus.TooLarge)]
+ [InlineData(AbStatus.TooSmall)]
+ [InlineData(AbStatus.TypeMismatch)]
+ [InlineData(AbStatus.DeviceStateConflict)]
+ public void The_answered_class_is_an_allow_list(AbStatus status)
+ {
+ AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue();
+ AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
+ AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(AbStatus.BadReply)]
+ [InlineData(AbStatus.Partial)]
+ public void Unclassifiable_replies_are_opaque_not_answers(AbStatus status)
+ {
+ AbLegacyDriver.IsOpaque(status).ShouldBeTrue();
+ AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
+ AbLegacyDriver.IsNoReply(status).ShouldBeFalse();
+ }
+
+ [Theory]
+ [InlineData(AbStatus.Timeout)]
+ [InlineData(AbStatus.BadConnection)]
+ [InlineData(AbStatus.Abort)]
+ [InlineData(AbStatus.Pending)]
+ [InlineData((AbStatus)(-99))] // a code this build does not know: conservatively no reply
+ public void Only_no_reply_statuses_are_comms_faults(AbStatus status)
+ {
+ AbLegacyDriver.IsNoReply(status).ShouldBeTrue();
+ AbLegacyDriver.IsDeviceRefusal(status).ShouldBeFalse();
+ AbLegacyDriver.IsOpaque(status).ShouldBeFalse();
+ }
+
+ [Fact]
+ public void Every_non_Ok_status_lands_in_at_most_one_class_and_Unsupported_in_none()
+ {
+ foreach (var s in Enum.GetValues().Where(s => s != AbStatus.Ok))
+ {
+ var classes = new[] { AbLegacyDriver.IsNoReply(s), AbLegacyDriver.IsDeviceRefusal(s), AbLegacyDriver.IsOpaque(s) }
+ .Count(c => c);
+ if (s == AbStatus.Unsupported) classes.ShouldBe(0, "Unsupported is neutral");
+ else classes.ShouldBe(1, s.ToString());
+ }
+ }
+
+ [Fact]
+ public void libplctag_remote_errors_translate_to_opaque_not_to_an_answer_or_a_fault()
+ {
+ foreach (var st in new[] { Status.ErrorRemoteErr, Status.ErrorBadStatus })
+ {
+ var s = LibplctagLegacyStatusTranslation.ToAbStatus(st);
+ AbLegacyDriver.IsOpaque(s).ShouldBeTrue(st.ToString());
+ AbLegacyDriver.IsDeviceRefusal(s).ShouldBeFalse(st.ToString());
+ AbLegacyDriver.IsNoReply(s).ShouldBeFalse(st.ToString());
+ }
+ // Positive controls on the other two sides.
+ AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue();
+ AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorNotFound)).ShouldBeTrue();
+ }
+
+ // ---- behaviour: the I1 side (an opaque member never blacks out its neighbours, never trips fail-fast) ----
+
+ [Fact]
+ public async Task A_remote_error_member_never_blacks_out_its_coalesced_neighbours()
+ {
+ // N7:52 is past the end of the file: the block N7:48+5 and the single N7:52 both draw ERR_REMOTE_ERR.
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.ElementCount > 1 || p.TagName == "N7:52"
+ ? new FakeAbLegacyTag(p) { Status = RemoteErr }
+ : new FakeAbLegacyTag(p) { Value = 48 },
+ };
+ var drv = NewDriver(factory, coalesce: true,
+ new AbLegacyTagDefinition("A", Host, "N7:48", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("B", Host, "N7:49", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("C", Host, "N7:52", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ for (var poll = 0; poll < 4; poll++)
+ {
+ var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ }
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy); // the neighbours answered
+ drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse();
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.TagName == "N7:9" ? new FakeAbLegacyTag(p) { Value = 9 } : new FakeAbLegacyTag(p) { Status = RemoteErr },
+ };
+ var drv = NewDriver(factory, coalesce: false,
+ new AbLegacyTagDefinition("R1", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("R2", Host, "N7:3", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("R3", Host, "N7:6", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Good", Host, "N7:9", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var r = await drv.ReadAsync(["R1", "R2", "R3", "Good"], CancellationToken.None);
+ r[3].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}: never failed fast");
+ }
+ factory.Tags["N7:9"].ReadCount.ShouldBe(3);
+ factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // opaque handles are kept, not churned
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ // ---- behaviour: the N1 side (an opaque reply never proves reachability) ----
+
+ [Fact]
+ public async Task Remote_error_only_reads_never_advance_LastSuccessfulRead_and_degrade()
+ {
+ // A PLC behind a bridge is powered off: every request comes back fast with ERR_REMOTE_ERR.
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
+ var drv = NewDriver(factory, coalesce: false,
+ new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Y", Host, "N7:5", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ var healthyAt = drv.GetHealth().LastSuccessfulRead; // positive control: Good reads DID advance it
+ healthyAt.ShouldNotBeNull();
+ foreach (var t in factory.Tags.Values) t.Status = RemoteErr;
+ await Task.Delay(5);
+
+ for (var poll = 0; poll < 3; poll++)
+ (await drv.ReadAsync(["X", "Y"], CancellationToken.None)).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
+
+ var h = drv.GetHealth();
+ h.LastSuccessfulRead.ShouldBe(healthyAt); // frozen: the comms-loss watchdog can reach this device
+ h.State.ShouldBe(DriverState.Degraded);
+ drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); // but no fail-fast window
+ factory.Tags["N7:0"].ReadCount.ShouldBe(4); // every poll still asked
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ private sealed class ThrowingTag(AbLegacyTagCreateParams p, AbStatus status) : FakeAbLegacyTag(p)
+ {
+ public override Task ReadAsync(CancellationToken ct) => throw new AbTransportException(status, $"native {status}");
+ }
+
+ [Theory]
+ [InlineData(AbStatus.BadReply)]
+ [InlineData(AbStatus.Partial)]
+ public async Task A_native_unclassifiable_reply_is_not_counted_as_an_answer(AbStatus status)
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
+ var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var healthyAt = drv.GetHealth().LastSuccessfulRead;
+ drv.GetDeviceState(Host)!.Runtimes.TryRemove("X", out _);
+ factory.Customise = p => new ThrowingTag(p, status);
+ await Task.Delay(5);
+
+ var r = await drv.ReadAsync(["X"], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(status));
+ drv.GetHealth().LastSuccessfulRead.ShouldBe(healthyAt);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Positive_control_a_real_device_refusal_alone_still_proves_reachability()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound } };
+ var drv = NewDriver(factory, coalesce: false, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var before = drv.GetHealth().LastSuccessfulRead;
+ await Task.Delay(5);
+
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
+
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ drv.GetHealth().LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs
new file mode 100644
index 00000000..3f5a50f6
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs
@@ -0,0 +1,75 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Pins 's AbLegacy row against the REAL array encoder
+/// (), the same way the Modbus and S7 parity tests do: for every element
+/// type the server must advertise an array node writable exactly when the driver encodes it.
+///
+///
+/// ⚠️ SKIPPED until follow-up F1 lands: the driver gained an array encoder in this lane (review 07 U10), but
+/// (Core.Abstractions, not owned by the AbLegacy lane) still lists AbLegacy as
+/// scalar-only, so every AbLegacy array node stays read-only and the encoder is unreachable from OPC UA. When F1
+/// adds the row, delete the Skip; this test then holds the table and the encoder together.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyArrayWriteSupportParityTests
+{
+ public static TheoryData AllTypes() => new(Enum.GetValues());
+
+ private static string AddressFor(AbLegacyDataType t) => t switch
+ {
+ AbLegacyDataType.Float => "F8:0",
+ AbLegacyDataType.Long => "L9:0",
+ AbLegacyDataType.Bit => "B3:0",
+ AbLegacyDataType.String => "ST9:0",
+ AbLegacyDataType.TimerElement => "T4:0",
+ AbLegacyDataType.CounterElement => "C5:0",
+ AbLegacyDataType.ControlElement => "R6:0",
+ _ => "N7:0",
+ };
+
+ [Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
+ [MemberData(nameof(AllTypes))]
+ public void Table_agrees_with_the_array_encoder(AbLegacyDataType dataType)
+ {
+ var json = $$"""{"address":"{{AddressFor(dataType)}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
+ var encoderAccepts = AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _)
+ && AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good;
+
+ DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString());
+ }
+
+ ///
+ /// The shapes the WRITE PATH refuses although their element type alone looks writable (review 07 r0 M6): a
+ /// /bit or .sub address, an indirect address, a type that does not match its data file (I3), and
+ /// a native PLC-5 device. The F1 row must answer false for every one — keyed on dataType alone it would
+ /// advertise them writable. Skipped with the parity theory until F1 lands.
+ ///
+ [Theory(Skip = "F1: DriverArrayWriteSupport has no AbLegacy row yet (Core.Abstractions is outside this lane)")]
+ [InlineData("""{"address":"N7:0/3","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
+ [InlineData("""{"address":"T4:0.ACC","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
+ [InlineData("""{"address":"N7:[N7:0]","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
+ [InlineData("""{"address":"F8:0","dataType":"Int","isArray":true,"arrayLength":2}""", "{}")]
+ [InlineData("""{"address":"N7:0","dataType":"Long","isArray":true,"arrayLength":2}""", "{}")]
+ [InlineData("""{"address":"N7:0","dataType":"Int","isArray":true,"arrayLength":2}""",
+ """{"transport":"Native","devices":[{"hostAddress":"ab://10.0.0.5/1,0","plcFamily":"Plc5"}]}""")]
+ public void Table_refuses_the_shapes_the_write_path_refuses(string tagConfig, string driverConfig) =>
+ DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", tagConfig, driverConfig).ShouldBeFalse();
+
+ /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float on a matching file).
+ [Theory]
+ [InlineData(AbLegacyDataType.Int, true)]
+ [InlineData(AbLegacyDataType.AnalogInt, true)]
+ [InlineData(AbLegacyDataType.Long, true)]
+ [InlineData(AbLegacyDataType.Float, true)]
+ [InlineData(AbLegacyDataType.Bit, false)]
+ [InlineData(AbLegacyDataType.String, false)]
+ [InlineData(AbLegacyDataType.TimerElement, false)]
+ public void Encoder_accepts_exactly_the_flat_numeric_element_types(AbLegacyDataType dataType, bool accepts) =>
+ (AbLegacyArrayWriteEncoder.TryConvert(dataType, new object[] { 1, 1 }, 2, out _) == AbLegacyStatusMapper.Good)
+ .ShouldBe(accepts);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
new file mode 100644
index 00000000..f9a23fe3
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs
@@ -0,0 +1,260 @@
+using System.Buffers.Binary;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Cip;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport.Tests.Fakes;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 U10 (user decision "implement array encode now"): an array write used to encode a SCALAR — an
+/// int[] threw into BadTypeMismatch + Degraded, and a scalar to an
+/// array tag wrote element 0 (native) or re-sent the stale last-read buffer (libplctag). The driver now encodes
+/// exactly count elements at the element width, converting each element (ScadaBridge writes CLR lists
+/// such as int[] to Int16 nodes), and refuses a length mismatch with BadOutOfRange.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyArrayWriteTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ private sealed class RecordingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public Array? Encoded { get; private set; }
+ public AbLegacyDataType? EncodedType { get; private set; }
+
+ public override void EncodeArray(AbLegacyDataType type, Array values)
+ {
+ EncodedType = type;
+ Encoded = values;
+ }
+ }
+
+ private static async Task<(AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory)> NewAsync(
+ AbLegacyDataType type, string address, int length)
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new RecordingTag(p) };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("Arr", Host, address, type, ArrayLength: length)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-u10", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ return (drv, factory);
+ }
+
+ public static TheoryData IntPayloads => ["int[]", "short[]", "List", "object[]"];
+
+ private static object Payload(string kind) => kind switch
+ {
+ "int[]" => new[] { 1, -2, 3 }, // ScadaBridge: CLR int[] to an Int16 node
+ "short[]" => new short[] { 1, -2, 3 },
+ "List" => new List { 1, -2, 3 },
+ _ => new object[] { 1L, (short)-2, 3.0 },
+ };
+
+ [Theory]
+ [MemberData(nameof(IntPayloads))]
+ public async Task Int_array_write_converts_each_element_and_encodes_count_elements(string kind)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", Payload(kind))], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var tag = (RecordingTag)f.Tags["N7:0"];
+ tag.CreationParams.ElementCount.ShouldBe(3);
+ tag.EncodedType.ShouldBe(AbLegacyDataType.Int);
+ tag.Encoded.ShouldBe(new short[] { 1, -2, 3 });
+ tag.WriteCount.ShouldBe(1);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ /// Review 07 r0 I3: every data file type encodes at ITS element width (one case per file).
+ [Theory]
+ [InlineData("N7:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
+ [InlineData("N7:0", AbLegacyDataType.AnalogInt, AbLegacyDataType.Int)]
+ [InlineData("B3:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
+ [InlineData("A9:0", AbLegacyDataType.Int, AbLegacyDataType.Int)]
+ [InlineData("L9:0", AbLegacyDataType.Long, AbLegacyDataType.Long)]
+ [InlineData("F8:0", AbLegacyDataType.Float, AbLegacyDataType.Float)]
+ public async Task Each_file_type_encodes_at_the_file_element_width(string address, AbLegacyDataType authored, AbLegacyDataType fileWidth)
+ {
+ var (drv, f) = await NewAsync(authored, address, 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var tag = (RecordingTag)f.Tags[address];
+ tag.EncodedType.ShouldBe(fileWidth);
+ tag.Encoded!.GetType().ShouldBe(fileWidth switch
+ {
+ AbLegacyDataType.Long => typeof(int[]),
+ AbLegacyDataType.Float => typeof(float[]),
+ _ => typeof(short[]),
+ });
+ tag.WriteCount.ShouldBe(1);
+ }
+
+ ///
+ /// Review 07 r0 I3: an array whose authored type does not match its data file (Int on F8, Long on N7, Float on
+ /// L9) used to be encoded at the AUTHORED width into the file-typed buffer and sent — garbage in the
+ /// neighbouring elements, answered Good. It is now refused BadTypeMismatch before any runtime exists.
+ ///
+ [Theory]
+ [InlineData("F8:0", AbLegacyDataType.Int)]
+ [InlineData("N7:0", AbLegacyDataType.Long)]
+ [InlineData("N7:0", AbLegacyDataType.Float)]
+ [InlineData("L9:0", AbLegacyDataType.Float)]
+ [InlineData("L9:0", AbLegacyDataType.Int)]
+ public async Task A_type_that_does_not_match_its_file_is_refused_and_never_written(string address, AbLegacyDataType authored)
+ {
+ var (drv, f) = await NewAsync(authored, address, 4);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ f.Tags.ShouldBeEmpty(); // nothing created, nothing on the wire
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ AbLegacyTagDefinitionFactory.Inspect(
+ $$"""{"address":"{{address}}","dataType":"{{authored}}","isArray":true,"arrayLength":4}""")
+ .ShouldContain(w => w.Contains("element width")); // and the deploy gate names it
+ }
+
+ /// Review 07 r1 N5: the file letter of an indirect address is static, so the deploy gate checks it too.
+ [Theory]
+ [InlineData("N7:[N7:0]", "Float", true)]
+ [InlineData("F[N7:0]:2", "Int", true)]
+ [InlineData("N7:[N7:0]", "Int", false)]
+ [InlineData("F[N7:0]:2", "Float", false)]
+ public void An_indirect_array_type_file_mismatch_is_reported_at_the_gate(string address, string dataType, bool mismatch)
+ {
+ var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":2}""";
+ Enum.TryParse(dataType, out var type).ShouldBeTrue();
+
+ (AbLegacyTagDefinitionFactory.ArrayFileMismatch(type, address) is not null).ShouldBe(mismatch);
+ AbLegacyTagDefinitionFactory.Inspect(json).Any(w => w.Contains("element width")).ShouldBe(mismatch);
+ }
+
+ [Fact]
+ public async Task Positive_control_a_Float_array_on_an_F_file_is_written()
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Float, "F8:0", 4);
+ (await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 2, 3, 4 })], CancellationToken.None))[0]
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ ((RecordingTag)f.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1f, 2f, 3f, 4f });
+ AbLegacyTagDefinitionFactory.Inspect("""{"address":"F8:0","dataType":"Float","isArray":true,"arrayLength":4}""")
+ .ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Float_and_Long_arrays_encode_at_their_element_width()
+ {
+ var (fd, ff) = await NewAsync(AbLegacyDataType.Float, "F8:0", 2);
+ (await fd.WriteAsync([new WriteRequest("Arr", new[] { 1.5, 2 })], CancellationToken.None))[0]
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ ((RecordingTag)ff.Tags["F8:0"]).Encoded.ShouldBe(new[] { 1.5f, 2f });
+
+ var (ld, lf) = await NewAsync(AbLegacyDataType.Long, "L9:0", 2);
+ (await ld.WriteAsync([new WriteRequest("Arr", new short[] { 7, -8 })], CancellationToken.None))[0]
+ .StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ ((RecordingTag)lf.Tags["L9:0"]).Encoded.ShouldBe(new[] { 7, -8 });
+ }
+
+ [Theory]
+ [InlineData(2)]
+ [InlineData(4)]
+ public async Task Length_mismatch_is_BadOutOfRange_and_nothing_is_sent(int supplied)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 3);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new int[supplied])], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Element_overflow_is_BadOutOfRange_and_nothing_is_sent()
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new[] { 1, 70000 })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ }
+
+ [Theory]
+ [InlineData(5)]
+ [InlineData("1,2")]
+ public async Task Non_array_payload_or_unconvertible_element_is_BadTypeMismatch(object payload)
+ {
+ var (drv, f) = await NewAsync(AbLegacyDataType.Int, "N7:0", 2);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", payload)], CancellationToken.None);
+ var e = await drv.WriteAsync([new WriteRequest("Arr", new object[] { 1, "x" })], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ e[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Bit_array_write_is_refused_BadNotSupported_before_any_runtime()
+ {
+ // Bit arrays are bit-packed in words: an encode of N bits would rewrite whole words from a stale read.
+ var (drv, f) = await NewAsync(AbLegacyDataType.Bit, "B3:0", 16);
+
+ var r = await drv.WriteAsync([new WriteRequest("Arr", new bool[16])], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ f.Tags.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ }
+
+ [Fact]
+ public async Task Native_array_encode_writes_every_element_at_its_offset()
+ {
+ var channel = new FakeCipChannel();
+ await using var scheduler = new RequestScheduler(channel, allowPacking: false);
+ var rt = new NativeAbLegacyTagRuntime(
+ new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "N7:0", TimeSpan.FromSeconds(2), 3),
+ scheduler, 0xF33D, 0x21504345);
+ channel.Respond = req => Task.FromResult(NativeEcho(req));
+
+ rt.EncodeArray(AbLegacyDataType.Int, new short[] { 10, -1, 300 });
+ await rt.WriteAsync(CancellationToken.None);
+
+ var data = channel.Sent.Single()[23..];
+ data.Length.ShouldBe(6);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(0)).ShouldBe((short)10);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(2)).ShouldBe((short)-1);
+ BinaryPrimitives.ReadInt16LittleEndian(data.AsSpan(4)).ShouldBe((short)300);
+ rt.Dispose();
+ }
+
+ [Fact]
+ public void Native_array_encode_refuses_a_length_other_than_the_runtime_count()
+ {
+ var channel = new FakeCipChannel();
+ var scheduler = new RequestScheduler(channel, allowPacking: false);
+ var rt = new NativeAbLegacyTagRuntime(
+ new AbLegacyTagCreateParams("10.0.0.1", 44818, "1,0", "slc500", "F8:0", TimeSpan.FromSeconds(2), 3),
+ scheduler, 0xF33D, 0x21504345);
+
+ Should.Throw(() => rt.EncodeArray(AbLegacyDataType.Float, new float[2]));
+ rt.Dispose();
+ }
+
+ // Execute PCCC reply echoing the request's requestor id and TNS with STS 0 and no data.
+ private static byte[] NativeEcho(byte[] req)
+ {
+ var body = new List(req[6..13]) { 0x4F, 0x00, req[15], req[16] };
+ return FakeCipChannel.Reply(0x4B, 0x00, [.. body]);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs
new file mode 100644
index 00000000..08c422b0
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs
@@ -0,0 +1,50 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 02 P1: AbLegacy ran but did not implement ,
+/// so every changed tag paid its own trip through the actor chain. It now raises one
+/// per poll cycle, alongside the per-tag event.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyBatchSubscribeTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ [Fact]
+ public async Task One_batch_per_poll_carries_every_changed_tag_and_per_tag_events_still_fire()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? 1 : 2 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("A", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("B", Host, "N9:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-batch", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ IBatchSubscribable batching = drv; // the capability the actor prefers
+
+ var batches = new System.Collections.Concurrent.ConcurrentQueue();
+ var singles = new System.Collections.Concurrent.ConcurrentQueue();
+ batching.OnDataChangeBatch += (_, e) => batches.Enqueue(e);
+ drv.OnDataChange += (_, e) => singles.Enqueue(e);
+
+ var handle = await drv.SubscribeAsync(["A", "B"], TimeSpan.FromMilliseconds(100), CancellationToken.None);
+ var deadline = DateTime.UtcNow.AddSeconds(5);
+ while (batches.IsEmpty && DateTime.UtcNow < deadline) await Task.Delay(10);
+
+ batches.TryPeek(out var first).ShouldBeTrue();
+ first!.SubscriptionHandle.ShouldBe(handle);
+ first.Items.Select(i => i.FullReference).ShouldBe(new[] { "A", "B" }, ignoreOrder: true);
+ first.Items.Single(i => i.FullReference == "A").Snapshot.Value.ShouldBe(1);
+ singles.Count.ShouldBeGreaterThanOrEqualTo(2); // positive control: the legacy per-tag event still fires
+
+ await drv.UnsubscribeAsync(handle, CancellationToken.None);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
index 3229fb53..6d7c1a1b 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBitRmwTests.cs
@@ -205,7 +205,7 @@ public sealed class AbLegacyBitRmwTests
var firstResult = await drv.WriteAsync([new WriteRequest("Bit2", true)], CancellationToken.None);
firstResult.Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
- // Teardown — DisposeRuntimes must dispose + clear the RMW lock map.
+ // Teardown — retirement drops the device state (and its RMW lock map) with it.
await drv.DisposeAsync();
// Second session: re-init on a fresh driver instance (mirrors ReinitializeAsync behavior).
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs
new file mode 100644
index 00000000..6542df4c
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs
@@ -0,0 +1,136 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 H16: an array of String/Timer/Counter/Control was accepted by the mapper, so every poll created a
+/// handle (one init read), threw from the decode, disposed the handle and
+/// latched Degraded. A deterministic refusal is now rejected at the mapper; a runtime
+/// that still occurs is per-tag BadNotSupported, keeps its handle,
+/// warns once and leaves driver health alone.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyDeterministicRefusalTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ [Theory]
+ [InlineData("String", "ST9:0")]
+ [InlineData("TimerElement", "T4:0")]
+ [InlineData("CounterElement", "C5:0")]
+ [InlineData("ControlElement", "R6:0")]
+ public void Mapper_rejects_arrays_of_structured_element_types(string dataType, string address)
+ {
+ var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
+
+ AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out _).ShouldBeFalse();
+ AbLegacyTagDefinitionFactory.Inspect(json).ShouldContain(w => w.Contains("not supported as an array"));
+ // Positive control: the same type as a SCALAR is still accepted.
+ AbLegacyTagDefinitionFactory.FromTagConfig(
+ $$"""{"address":"{{address}}","dataType":"{{dataType}}"}""", "p", out _).ShouldBeTrue();
+ }
+
+ [Theory]
+ [InlineData("Int", "N7:0")]
+ [InlineData("Float", "F8:0")]
+ [InlineData("Long", "L9:0")]
+ [InlineData("Bit", "B3:0")]
+ public void Mapper_keeps_arrays_of_flat_element_types(string dataType, string address)
+ {
+ var json = $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""";
+ AbLegacyTagDefinitionFactory.FromTagConfig(json, "p", out var def).ShouldBeTrue();
+ def.ArrayLength.ShouldBe(4);
+ AbLegacyTagDefinitionFactory.Inspect(json).ShouldBeEmpty();
+ }
+
+ [Fact]
+ public async Task Rejected_array_tag_is_skipped_with_its_reason_and_creates_no_runtime()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags =
+ [
+ new RawTagEntry("Strs", """{"address":"ST9:0","dataType":"String","isArray":true,"arrayLength":3}""", false, Host),
+ new RawTagEntry("Good", """{"address":"N7:0","dataType":"Int"}""", false, Host),
+ ],
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-h16", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["Strs", "Good"], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
+ factory.Tags.Keys.ShouldNotContain("ST9:0");
+ var skipped = drv.GetSkippedTags().ShouldNotBeNull();
+ skipped.Sample.ShouldContain(s => s.RawPath == "Strs" && s.Reason.Contains("not supported as an array"));
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ private sealed class RefusingDecodeTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public override object? DecodeArray(AbLegacyDataType type, int count) =>
+ throw new NotSupportedException("decode refused");
+
+ public override void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) =>
+ throw new NotSupportedException("encode refused");
+ }
+
+ [Fact]
+ public async Task Runtime_NotSupported_on_read_is_BadNotSupported_keeps_handle_no_health_warns_once()
+ {
+ var logger = new AbLegacyTestLogger();
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.TagName == "N7:0" ? new RefusingDecodeTag(p) : new FakeAbLegacyTag(p) { Value = 7 },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("Arr", Host, "N7:0", AbLegacyDataType.Int, ArrayLength: 4),
+ new AbLegacyTagDefinition("Good", Host, "N9:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-h16", factory, logger);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var first = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
+ var second = await drv.ReadAsync(["Arr", "Good"], CancellationToken.None);
+
+ first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ first[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: the refusal is per tag
+ var tag = factory.Tags["N7:0"];
+ tag.InitializeCount.ShouldBe(1); // handle kept, not churned
+ tag.Disposed.ShouldBeFalse();
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ logger.Warnings.Count(w => w.Contains("Arr") && w.Contains("decode refused")).ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Runtime_NotSupported_on_write_is_BadNotSupported_with_no_health_write()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new RefusingDecodeTag(p) { Value = 1 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-h16", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+
+ var w = await drv.WriteAsync([new WriteRequest("X", 5)], CancellationToken.None);
+
+ w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ factory.Tags["N7:0"].Disposed.ShouldBeFalse();
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
new file mode 100644
index 00000000..16064b8d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs
@@ -0,0 +1,255 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 P7 (High): reads were strictly serial across every device with no per-device backoff, so one dead
+/// device cost Timeout per tag per poll and starved every healthy device — long enough to trip the
+/// comms-loss watchdog against a reachable one. Devices are now read in parallel (bounded), each device pass
+/// stops paying for a dead device after two consecutive comms faults and opens a per-device
+/// window, and cold connects are single-flight per device.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyDeviceIsolationTests
+{
+ private const string HostA = "ab://10.0.0.5/1,0";
+ private const string HostB = "ab://10.0.0.6/1,0";
+
+ /// A tag on a dead device: each wire operation burns and then fails.
+ private sealed class DeadTag(AbLegacyTagCreateParams p, TimeSpan cost) : FakeAbLegacyTag(p)
+ {
+ public static int WireOps;
+
+ public override async Task InitializeAsync(CancellationToken ct)
+ {
+ Interlocked.Increment(ref WireOps);
+ await Task.Delay(cost, ct);
+ throw new System.Net.Sockets.SocketException(10060);
+ }
+
+ public override async Task ReadAsync(CancellationToken ct)
+ {
+ Interlocked.Increment(ref WireOps);
+ await Task.Delay(cost, ct);
+ throw new System.Net.Sockets.SocketException(10060);
+ }
+ }
+
+ private static AbLegacyDriver NewDriver(FakeAbLegacyTagFactory factory, params AbLegacyTagDefinition[] tags) =>
+ new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(HostA), new AbLegacyDeviceOptions(HostB)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = false, // per-tag isolation is under test here; coalescing has its own tests
+ }, "drv-p7", factory);
+
+ private static AbLegacyTagDefinition[] Tags(string prefix, string host, int n, string file = "N7") =>
+ [.. Enumerable.Range(0, n).Select(i => new AbLegacyTagDefinition($"{prefix}{i}", host, $"{file}:{i * 3}", AbLegacyDataType.Int))];
+
+ [Fact]
+ public async Task Dead_device_costs_at_most_two_timeouts_then_fails_fast_and_never_stalls_the_healthy_one()
+ {
+ var cost = TimeSpan.FromMilliseconds(150);
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.Gateway == "10.0.0.6" ? new DeadTag(p, cost) : new FakeAbLegacyTag(p) { Value = 7 },
+ };
+ var a = Tags("A", HostA, 5);
+ var b = Tags("B", HostB, 20);
+ var drv = NewDriver(factory, [.. a, .. b]);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var refs = a.Concat(b).Select(t => t.Name).ToList();
+ var before = drv.GetHealth().LastSuccessfulRead;
+ Interlocked.Exchange(ref DeadTag.WireOps, 0);
+
+ var first = await drv.ReadAsync(refs, CancellationToken.None);
+
+ // The proof is the wire-op count, not a stopwatch: serial-with-no-backoff would dial B 20 times.
+ Volatile.Read(ref DeadTag.WireOps).ShouldBeLessThanOrEqualTo(2);
+ first.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); // positive control: A is served
+ first.Skip(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
+ var health = drv.GetHealth();
+ health.State.ShouldBe(DriverState.Degraded);
+ health.LastSuccessfulRead.ShouldNotBeNull();
+ health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before ?? DateTime.MinValue); // A's success advances it
+
+ // Inside B's backoff window the next poll touches B's wire not at all.
+ var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps);
+ var second = await drv.ReadAsync(refs, CancellationToken.None);
+ Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst);
+ second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Devices_are_read_in_parallel()
+ {
+ // Device A's read cannot complete until device B's read has STARTED: a serial walk deadlocks here.
+ var bStarted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.Gateway == "10.0.0.5"
+ ? new GatedTag(p, wait: bStarted.Task) { Value = 1 }
+ : new GatedTag(p, signal: bStarted) { Value = 2 },
+ };
+ var drv = NewDriver(factory,
+ new AbLegacyTagDefinition("A", HostA, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None).WaitAsync(TimeSpan.FromSeconds(5));
+
+ r[0].Value.ShouldBe(1);
+ r[1].Value.ShouldBe(2);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ private sealed class GatedTag(AbLegacyTagCreateParams p, Task? wait = null, TaskCompletionSource? signal = null)
+ : FakeAbLegacyTag(p)
+ {
+ public override async Task ReadAsync(CancellationToken ct)
+ {
+ signal?.TrySetResult();
+ if (wait is not null) await wait.WaitAsync(ct);
+ await base.ReadAsync(ct);
+ }
+ }
+
+ [Fact]
+ public async Task One_timing_out_tag_does_not_blacken_the_rest_of_its_device()
+ {
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.TagName == "N7:0"
+ ? new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new TimeoutException() }
+ : new FakeAbLegacyTag(p) { Value = 5 },
+ };
+ var drv = NewDriver(factory, Tags("A", HostA, 4));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var r = await drv.ReadAsync(["A0", "A1", "A2", "A3"], CancellationToken.None);
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ r.Skip(1).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good, $"poll {poll}");
+ }
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Device_refusal_is_per_tag_Bad_advances_LastSuccessfulRead_and_keeps_the_handle()
+ {
+ var logger = new AbLegacyTestLogger();
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(HostA)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", HostA, "N7:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-p7", factory, logger);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var initHealth = drv.GetHealth();
+ await Task.Delay(20);
+
+ var r1 = await drv.ReadAsync(["X"], CancellationToken.None);
+ var r2 = await drv.ReadAsync(["X"], CancellationToken.None);
+
+ r1[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
+ r2[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
+ var health = drv.GetHealth();
+ health.State.ShouldBe(DriverState.Healthy); // the device answered: not a comms fault
+ health.LastSuccessfulRead!.Value.ShouldBeGreaterThan(initHealth.LastSuccessfulRead!.Value);
+ factory.Tags["N7:0"].InitializeCount.ShouldBe(1);
+ factory.Tags["N7:0"].ReadCount.ShouldBe(2); // positive control: both polls really asked the device
+ logger.Warnings.Count(w => w.Contains("Reference=X")).ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Cold_connects_on_one_device_are_single_flight()
+ {
+ var inFlight = 0;
+ var maxInFlight = 0;
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => new CountingInitTag(p, () =>
+ {
+ var now = Interlocked.Increment(ref inFlight);
+ int seen;
+ while (now > (seen = Volatile.Read(ref maxInFlight)) && Interlocked.CompareExchange(ref maxInFlight, now, seen) != seen) { }
+ }, () => Interlocked.Decrement(ref inFlight)) { Value = 1 },
+ };
+ var tags = Tags("A", HostA, 6);
+ var drv = NewDriver(factory, tags);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Three concurrent callers (poll loop, server read, write) each hitting different cold tags of one device.
+ await Task.WhenAll(
+ drv.ReadAsync(["A0", "A1"], CancellationToken.None),
+ drv.ReadAsync(["A2", "A3"], CancellationToken.None),
+ drv.WriteAsync([new WriteRequest("A4", 3), new WriteRequest("A5", 4)], CancellationToken.None));
+
+ maxInFlight.ShouldBe(1);
+ factory.Tags.Values.Count(t => t.InitializeCount == 1).ShouldBe(6); // positive control: all six connected
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ private sealed class CountingInitTag(AbLegacyTagCreateParams p, Action enter, Action exit) : FakeAbLegacyTag(p)
+ {
+ public override async Task InitializeAsync(CancellationToken ct)
+ {
+ enter();
+ try
+ {
+ await Task.Delay(30, ct);
+ await base.InitializeAsync(ct);
+ }
+ finally { exit(); }
+ }
+ }
+
+ [Fact]
+ public async Task A_recovered_device_is_read_again_once_its_window_expires()
+ {
+ var dead = true;
+ var creates = 0;
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p =>
+ {
+ Interlocked.Increment(ref creates);
+ return dead
+ ? new FakeAbLegacyTag(p) { ThrowOnInitialize = true, Exception = new System.Net.Sockets.SocketException(10061) }
+ : new FakeAbLegacyTag(p) { Value = 9 };
+ },
+ };
+ var drv = NewDriver(factory, new AbLegacyTagDefinition("B", HostB, "N7:0", AbLegacyDataType.Int));
+ var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
+ drv.Clock = () => now;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ // Two consecutive comms faults mark the device down and open its first (1 s) window.
+ (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ var attempts = Volatile.Read(ref creates);
+ dead = false;
+ // Inside the window: fails fast without a connect attempt, although the device is back.
+ (await drv.ReadAsync(["B"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ Volatile.Read(ref creates).ShouldBe(attempts);
+ now = now.AddMilliseconds(1100); // cross the first (1 s) window on the driver's clock, no sleeping
+
+ var recovered = await drv.ReadAsync(["B"], CancellationToken.None);
+ recovered[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ recovered[0].Value.ShouldBe(9);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
index f58536d7..c9c3aa51 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDriverProbeTests.cs
@@ -192,6 +192,40 @@ public sealed class AbLegacyDriverProbeTests
LibplctagLegacyName.Translate(status!).ShouldBe("S2:0");
}
+ private sealed class StallingTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public override Task InitializeAsync(CancellationToken ct) => Task.Delay(Timeout.Infinite, ct);
+ }
+
+ ///
+ /// The authored probeTimeoutSeconds ("Admin UI probe timeout") was parsed by nothing — Test Connect ran
+ /// on the caller's budget alone. It now tightens that budget (never raises it); absent or non-positive keeps
+ /// the caller's budget (the positive control).
+ ///
+ [Theory]
+ [InlineData(",\"probeTimeoutSeconds\":1", "1s")]
+ [InlineData(",\"probeTimeoutSeconds\":30", "2s")]
+ [InlineData("", "2s")]
+ [InlineData(",\"probeTimeoutSeconds\":0", "2s")]
+ public async Task Configured_probe_timeout_tightens_the_caller_budget(string key, string expectedBudget)
+ {
+ var listener = new TcpListener(IPAddress.Loopback, 0);
+ listener.Start();
+ try
+ {
+ var port = ((IPEndPoint)listener.LocalEndpoint).Port;
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new StallingTag(p) };
+ var json = $"{{\"devices\":[{{\"hostAddress\":\"ab://127.0.0.1:{port}/1,0\"}}]{key}}}";
+
+ var result = await new AbLegacyDriverProbe(factory)
+ .ProbeAsync(json, TimeSpan.FromSeconds(2), TestContext.Current.CancellationToken);
+
+ result.Ok.ShouldBeFalse();
+ result.Message.ShouldBe($"Probe timed out after {expectedBudget}.");
+ }
+ finally { listener.Stop(); }
+ }
+
[Fact]
public async Task Unparseable_probe_address_is_a_clean_failure_not_an_exception()
{
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
new file mode 100644
index 00000000..e5e15fcf
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs
@@ -0,0 +1,350 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 r0 I2 + M1/M2/M4/M10: any evidence that the device answered — a Good value, a device refusal, a Good
+/// or refused write — restores Healthy and advances LastSuccessfulRead; a no-reply failure on either path
+/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyHealthEvidenceTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ private sealed class SwitchableFactory : IAbLegacyTagFactory
+ {
+ public Func Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
+ public System.Collections.Concurrent.ConcurrentDictionary Tags { get; } = new();
+
+ public IAbLegacyTagRuntime Create(AbLegacyTagCreateParams p) => Tags[p.TagName] = Make(p);
+ }
+
+ private static FakeAbLegacyTag Dead(AbLegacyTagCreateParams p) =>
+ new(p) { ThrowOnRead = true, ThrowOnWrite = true, Exception = new System.Net.Sockets.SocketException(10060) };
+
+ private static AbLegacyDriver NewDriver(IAbLegacyTagFactory f, params AbLegacyTagDefinition[] tags) =>
+ new(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = false,
+ }, "drv-health", f);
+
+ private static async Task DegradedDriverAsync(SwitchableFactory f, params AbLegacyTagDefinition[] tags)
+ {
+ var drv = NewDriver(f, tags);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ f.Make = Dead;
+ (await drv.ReadAsync([tags[0].Name], CancellationToken.None))[0].StatusCode
+ .ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded); // precondition
+ return drv;
+ }
+
+ [Fact]
+ public async Task A_refusal_only_read_after_a_fault_restores_Healthy_and_advances_LastSuccessfulRead()
+ {
+ var f = new SwitchableFactory();
+ var drv = await DegradedDriverAsync(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
+ var before = drv.GetHealth().LastSuccessfulRead;
+ f.Make = p => new FakeAbLegacyTag(p) { Status = (int)AbStatus.NotFound };
+ await Task.Delay(5);
+
+ var r = await drv.ReadAsync(["X"], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNodeIdUnknown);
+ var h = drv.GetHealth();
+ h.State.ShouldBe(DriverState.Healthy);
+ h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Theory]
+ [InlineData(0)] // a Good write
+ [InlineData((int)AbStatus.NotAllowed)] // a write the device refused
+ public async Task A_good_or_refused_write_after_a_fault_restores_Healthy(int writeStatus)
+ {
+ var f = new SwitchableFactory();
+ var drv = await DegradedDriverAsync(f,
+ new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
+ var before = drv.GetHealth().LastSuccessfulRead;
+ f.Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = writeStatus };
+ await Task.Delay(5);
+
+ var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
+
+ w[0].StatusCode.ShouldBe(writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.BadNotWritable);
+ f.Tags["N7:5"].WriteCount.ShouldBe(1); // positive control: the write reached the device
+ var h = drv.GetHealth();
+ h.State.ShouldBe(DriverState.Healthy);
+ h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_no_reply_write_status_degrades_like_the_exception_path_does()
+ {
+ var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { WriteStatusOverride = (int)AbStatus.Timeout } };
+ var drv = NewDriver(f, new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var w = await drv.WriteAsync([new WriteRequest("W", 3)], CancellationToken.None);
+
+ w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTimeout);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task One_write_call_is_worst_wins_but_still_advances_LastSuccessfulRead()
+ {
+ var f = new SwitchableFactory
+ {
+ Make = p => p.TagName == "N7:6" ? Dead(p) : new FakeAbLegacyTag(p) { Value = 1 },
+ };
+ var drv = NewDriver(f,
+ new AbLegacyTagDefinition("Good", Host, "N7:5", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Bad", Host, "N7:6", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var before = drv.GetHealth().LastSuccessfulRead;
+ await Task.Delay(5);
+
+ var w = await drv.WriteAsync([new WriteRequest("Good", 1), new WriteRequest("Bad", 2)], CancellationToken.None);
+
+ w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ w[1].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ var h = drv.GetHealth();
+ h.State.ShouldBe(DriverState.Degraded);
+ h.LastSuccessfulRead!.Value.ShouldBeGreaterThan(before!.Value);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_write_inside_the_backoff_window_fails_fast_even_with_a_cached_handle()
+ {
+ var now = new DateTime(2026, 10, 22, 12, 0, 0, DateTimeKind.Utc);
+ var f = new SwitchableFactory();
+ var drv = NewDriver(f,
+ new AbLegacyTagDefinition("W", Host, "N7:5", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("D1", Host, "N7:6", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("D2", Host, "N7:7", AbLegacyDataType.Int));
+ drv.Clock = () => now;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["W"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // cache W
+ var cached = f.Tags["N7:5"];
+ f.Make = Dead;
+ await drv.ReadAsync(["D1", "D2"], CancellationToken.None); // two consecutive no-reply faults: device down
+ drv.GetDeviceState(Host)!.IsThrottled(now).ShouldBeTrue();
+
+ var w = await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None);
+
+ w[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ cached.WriteCount.ShouldBe(0);
+
+ // Positive control: once the window has passed, the same cached handle is written.
+ now = now.AddSeconds(2);
+ (await drv.WriteAsync([new WriteRequest("W", 9)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ cached.WriteCount.ShouldBe(1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task An_indirect_bit_write_releases_the_previous_parent_word_handle()
+ {
+ var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)0 } };
+ var drv = NewDriver(f, new AbLegacyTagDefinition("IndBit", Host, "N7:[N7:0]/3", AbLegacyDataType.Bit));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var state = drv.GetDeviceState(Host)!;
+ state.ParentRuntimes.ContainsKey("N7:5").ShouldBeTrue(); // positive control: the parent word was RMW'd
+ f.Tags["N7:0"].Value = (short)6;
+ (await drv.WriteAsync([new WriteRequest("IndBit", true)], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+
+ state.ParentRuntimes.ContainsKey("N7:5").ShouldBeFalse();
+ state.ParentRuntimes.ContainsKey("N7:6").ShouldBeTrue();
+ f.Tags["N7:5"].Disposed.ShouldBeTrue();
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.
+ private sealed class ParkingTag(AbLegacyTagCreateParams p, Task release, TaskCompletionSource entered) : FakeAbLegacyTag(p)
+ {
+ public override async Task ReadAsync(CancellationToken ct)
+ {
+ entered.TrySetResult();
+ await release.ConfigureAwait(false);
+ }
+ }
+
+ [Fact]
+ public async Task A_slow_teardown_does_not_overwrite_a_newer_initialise_health()
+ {
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var parked = false;
+ var f = new SwitchableFactory
+ {
+ Make = p =>
+ {
+ if (parked) return new FakeAbLegacyTag(p) { Value = 1 };
+ parked = true;
+ return new ParkingTag(p, release.Task, entered);
+ },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
+ }, "drv-m1", f);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await entered.Task;
+
+ var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
+ await Task.Delay(50);
+ shutdown.IsCompleted.ShouldBeFalse();
+ await drv.InitializeAsync("{}", CancellationToken.None); // a newer initialise wins
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ release.SetResult();
+ await shutdown;
+
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ // Positive control: an uncontested shutdown still writes Unknown.
+ await drv.ShutdownAsync(CancellationToken.None);
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+ }
+
+ private sealed class DisposeAwareTag(AbLegacyTagCreateParams p) : FakeAbLegacyTag(p)
+ {
+ public override Task ReadAsync(CancellationToken ct) =>
+ Disposed ? throw new ObjectDisposedException("tag") : base.ReadAsync(ct);
+ }
+
+ ///
+ /// Review 07 r0 M3: a reader that fetched a handle and then waited on its gate while another path evicted and
+ /// disposed it used to read the disposed instance — a spurious comms fault and Degraded. It now re-resolves
+ /// the handle once the gate is held.
+ ///
+ [Fact]
+ public async Task A_handle_evicted_while_a_reader_waited_for_its_gate_is_re_resolved()
+ {
+ var f = new SwitchableFactory { Make = p => new DisposeAwareTag(p) { Value = 4 } };
+ var drv = NewDriver(f, new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var state = drv.GetDeviceState(Host)!;
+ var first = f.Tags["N7:0"];
+
+ var gate = state.GetRuntimeLock("X");
+ await gate.WaitAsync();
+ // Signalled at exactly "handle fetched, about to wait on the gate" — no sleep, so the eviction below always
+ // lands between the reader's fetch and its use (review 07 r1 test nit: the sleep could pass vacuously).
+ var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ state.BeforeOperationGate = key => { if (key == "X") atGate.TrySetResult(); };
+ var read = drv.ReadAsync(["X"], CancellationToken.None);
+ await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ state.Runtimes.TryRemove("X", out var evicted).ShouldBeTrue();
+ evicted!.Dispose();
+ gate.Release();
+ var r = await read;
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ f.Tags["N7:0"].ShouldNotBeSameAs(first); // a fresh handle served it
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ ///
+ /// Review 07 r1 N3: a reader parked on a resolved INDIRECT key's gate while the index sweep evicted that key and
+ /// forgot its gate used to RECONNECT it under the lock — resurrecting a stale resolution under a gate nobody
+ /// else would get. It now answers that one call neutrally and leaves the key gone.
+ ///
+ [Fact]
+ public async Task A_swept_indirect_resolution_is_never_resurrected_under_a_forgotten_gate()
+ {
+ var creates = new System.Collections.Concurrent.ConcurrentQueue();
+ var f = new SwitchableFactory
+ {
+ Make = p =>
+ {
+ creates.Enqueue(p.TagName);
+ return new FakeAbLegacyTag(p) { Value = p.TagName == "N7:0" ? (short)5 : (short)42 };
+ },
+ };
+ var drv = NewDriver(f, new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["Ind"], CancellationToken.None))[0].Value.ShouldBe(42); // positive control
+ var state = drv.GetDeviceState(Host)!;
+ const string key = "Ind=>N7:5";
+ state.Runtimes.ContainsKey(key).ShouldBeTrue();
+
+ var gate = state.GetRuntimeLock(key);
+ await gate.WaitAsync();
+ var atGate = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ state.BeforeOperationGate = k => { if (k == key) atGate.TrySetResult(); };
+ var read = drv.ReadAsync(["Ind"], CancellationToken.None);
+ await atGate.Task.WaitAsync(TimeSpan.FromSeconds(5));
+ // What the sweep does when the index moves: evict the resolution, then forget its gate.
+ state.Runtimes.TryRemove(key, out var swept).ShouldBeTrue();
+ swept!.Dispose();
+ state.ForgetGates(key);
+ gate.Release();
+ var r = await read;
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadCommunicationError);
+ state.Runtimes.ContainsKey(key).ShouldBeFalse(); // not resurrected
+ creates.Count(n => n == "N7:5").ShouldBe(1); // no reconnect of the stale resolution
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy); // neutral: no health evidence either way
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Review 07 r1 N4: an index read answered Unsupported is neutral, not a transport fault.
+ [Fact]
+ public async Task An_index_read_answered_Unsupported_is_neutral()
+ {
+ var f = new SwitchableFactory
+ {
+ Make = p => p.TagName == "N7:0"
+ ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.Unsupported }
+ : new FakeAbLegacyTag(p) { Value = 1 },
+ };
+ var drv = NewDriver(f,
+ new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Plain", Host, "N9:0", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["Ind", "Plain"], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadNotSupported);
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ drv.GetDeviceState(Host)!.ParentRuntimes.ContainsKey("N7:0").ShouldBeTrue(); // handle kept, not evicted
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// Review 07 r1 N2: a persisted mismatched array READS BadTypeMismatch instead of garbage as Good.
+ [Fact]
+ public async Task A_mismatched_array_reads_BadTypeMismatch_not_garbage()
+ {
+ var f = new SwitchableFactory { Make = p => new FakeAbLegacyTag(p) { ArrayValue = new[] { 1f, 2f } } };
+ var drv = NewDriver(f,
+ new AbLegacyTagDefinition("Bad", Host, "F8:0", AbLegacyDataType.Int, ArrayLength: 2),
+ new AbLegacyTagDefinition("Good", Host, "F9:0", AbLegacyDataType.Float, ArrayLength: 2));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["Bad", "Good"], CancellationToken.None);
+
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.BadTypeMismatch);
+ f.Tags.ContainsKey("F8:0").ShouldBeFalse(); // nothing read at the wrong width
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control
+ r[1].Value.ShouldBe(new[] { 1f, 2f });
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs
index 6baf2165..bd034822 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyIndirectAddressingTests.cs
@@ -128,4 +128,32 @@ public sealed class AbLegacyIndirectAddressingTests
w.StatusCode.ShouldBe(AbLegacyStatusMapper.BadOutOfRange);
f.Tags.Values.ShouldAllBe(t => t.WriteCount == 0);
}
+
+ ///
+ /// Review 07 L7: every index value an indirect tag sweeps through used to leave one per-runtime semaphore and
+ /// one creation semaphore behind until shutdown. They are pruned with the stale resolution's handle, so the
+ /// per-key gate tables stay bounded by the authored set however far the index sweeps.
+ ///
+ [Fact]
+ public async Task Sweeping_index_does_not_accumulate_per_key_gates()
+ {
+ var (drv, f) = await NewDriverAsync(new AbLegacyTagDefinition("Ind", Host, "N7:[N7:0]", AbLegacyDataType.Int));
+ Seed(f, ("N7:0", (short)10));
+ (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single().StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var state = drv.GetDeviceState(Host)!;
+ var baseline = state.GateCount;
+
+ for (short i = 11; i < 60; i++)
+ {
+ f.Tags["N7:0"].Value = i;
+ f.Customise = p => new FakeAbLegacyTag(p) { Value = (short)(p.TagName == "N7:0" ? i : 1000 + i) };
+ var r = (await drv.ReadAsync(["Ind"], CancellationToken.None)).Single();
+ r.StatusCode.ShouldBe(AbLegacyStatusMapper.Good); // positive control: every resolution really read
+ r.Value.ShouldBe((short)(1000 + i));
+ }
+
+ state.GateCount.ShouldBe(baseline);
+ // Only the live resolution's handle (plus the index word's parent runtime) remains cached.
+ state.Runtimes.Keys.Count(k => k.StartsWith("Ind=>", StringComparison.Ordinal)).ShouldBe(1);
+ }
}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs
new file mode 100644
index 00000000..58f4f95d
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs
@@ -0,0 +1,255 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
+/// in place under a reader), Dispose may run concurrently with a still-running ShutdownAsync, and
+/// an abandoned InitializeAsync that overlaps the next ShutdownAsync must not resurrect state.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyLifecycleConcurrencyTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ /// A probe tag whose read parks until released, ignoring cancellation (a wedged device call).
+ private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
+ {
+ public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public override async Task ReadAsync(CancellationToken ct)
+ {
+ Entered.TrySetResult();
+ await release.ConfigureAwait(false);
+ await base.ReadAsync(ct).ConfigureAwait(false);
+ }
+ }
+
+ private static async Task WaitUntilAsync(Func condition, string what)
+ {
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ while (!condition())
+ {
+ if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
+ await Task.Delay(10);
+ }
+ }
+
+ ///
+ /// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
+ /// the readers see either the old or the new table, never one being cleared.
+ ///
+ [Fact]
+ public async Task Reinit_under_concurrent_readers_never_throws()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
+ RawTags = AbLegacyRawTags.Entries(
+ new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
+ new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-swap", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ using var stop = new CancellationTokenSource();
+ var failures = new System.Collections.Concurrent.ConcurrentQueue();
+ var reads = 0;
+ var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
+ {
+ while (!stop.IsCancellationRequested)
+ {
+ try
+ {
+ _ = drv.GetHostStatuses();
+ _ = drv.ResolveHost("X");
+ _ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
+ Interlocked.Increment(ref reads);
+ }
+ catch (Exception ex) { failures.Enqueue(ex); }
+ }
+ })).ToArray();
+
+ // Keep re-initialising until the readers have demonstrably overlapped many swaps.
+ var deadline = DateTime.UtcNow.AddSeconds(10);
+ for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
+ {
+ if (DateTime.UtcNow > deadline) break;
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ }
+ stop.Cancel();
+ await Task.WhenAll(readers);
+
+ failures.ShouldBeEmpty();
+ reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ /// A re-init publishes a NEW device state; the old one is retired, not mutated in place.
+ [Fact]
+ public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
+ {
+ var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-swap", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ var old = drv.GetDeviceState(Host)!;
+ var oldTag = factory.Tags["N7:0"];
+
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var fresh = drv.GetDeviceState(Host)!;
+ fresh.ShouldNotBeSameAs(old);
+ old.IsRetired.ShouldBeTrue();
+ oldTag.Disposed.ShouldBeTrue();
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ ///
+ /// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
+ /// instead of being cached on the retired state (where nothing would ever release it).
+ ///
+ [Fact]
+ public async Task Runtime_created_after_retirement_is_disposed_not_cached()
+ {
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ FakeAbLegacyTag? created = null;
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ }, "drv-late", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ var state = drv.GetDeviceState(Host)!;
+
+ var read = drv.ReadAsync(["X"], CancellationToken.None);
+ await entered.Task;
+ await drv.ShutdownAsync(CancellationToken.None);
+ release.SetResult();
+ _ = await read;
+
+ created.ShouldNotBeNull();
+ created!.Disposed.ShouldBeTrue();
+ state.Runtimes.ShouldBeEmpty();
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
+ }
+
+ private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
+ : FakeAbLegacyTag(p)
+ {
+ public override async Task InitializeAsync(CancellationToken ct)
+ {
+ entered.TrySetResult();
+ await release.ConfigureAwait(false);
+ await base.InitializeAsync(ct).ConfigureAwait(false);
+ }
+ }
+
+ ///
+ /// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
+ /// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
+ /// is released.
+ ///
+ [Fact]
+ public async Task Dispose_concurrent_with_running_shutdown_is_safe()
+ {
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ ParkingProbeTag? probe = null;
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p => p.TagName == "S:0"
+ ? probe = new ParkingProbeTag(p, release.Task)
+ : new FakeAbLegacyTag(p) { Value = 1 },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
+ Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
+ }, "drv-dispose", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ (await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
+ await WaitUntilAsync(() => probe is not null, "probe never created");
+ await probe!.Entered.Task;
+
+ var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
+ await Task.Delay(50);
+ shutdown.IsCompleted.ShouldBeFalse();
+
+ Should.NotThrow(() => drv.Dispose());
+ await Should.NotThrowAsync(async () => await drv.DisposeAsync());
+ release.SetResult();
+ await shutdown;
+
+ factory.Tags["N7:0"].Disposed.ShouldBeTrue();
+ probe.Disposed.ShouldBeTrue();
+ drv.DeviceCount.ShouldBe(0);
+ }
+
+ ///
+ /// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
+ /// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
+ /// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
+ ///
+ [Fact]
+ public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
+ {
+ var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var probes = new System.Collections.Concurrent.ConcurrentQueue();
+ ParkingProbeTag? first = null;
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p =>
+ {
+ FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
+ probes.Enqueue(t);
+ return t;
+ },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
+ Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
+ }, "drv-abandon", factory);
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ await WaitUntilAsync(() => first is not null, "probe never created");
+ await first!.Entered.Task;
+
+ var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
+ await Task.Delay(50);
+ abandoned.IsCompleted.ShouldBeFalse();
+ await drv.ShutdownAsync(CancellationToken.None);
+
+ release.SetResult();
+ await Should.ThrowAsync(() => abandoned);
+ var createdAfter = probes.Count;
+ await Task.Delay(150); // several probe intervals
+
+ drv.DeviceCount.ShouldBe(0);
+ drv.GetHealth().State.ShouldBe(DriverState.Unknown);
+ probes.Count.ShouldBe(createdAfter);
+
+ // Positive control: a fresh Initialize after the stop publishes and probes as normal.
+ await drv.InitializeAsync("{}", CancellationToken.None);
+ drv.DeviceCount.ShouldBe(1);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy);
+ await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
new file mode 100644
index 00000000..b1adfc1a
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs
@@ -0,0 +1,184 @@
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+///
+/// Review 07 P7: adjacent N/B/F/L elements of one device were never coalesced (one PCCC round trip per tag),
+/// although the array path proves the transport can read a span. Scalar tags near each other on one file now
+/// ride ONE multi-element read and are decoded from their offsets — with exactly the CLR types the single-tag
+/// path produces — and a block the device refuses falls back to per-tag reads.
+///
+[Trait("Category", "Unit")]
+public sealed class AbLegacyReadCoalescingTests
+{
+ private const string Host = "ab://10.0.0.5/1,0";
+
+ private static (AbLegacyDriver Drv, FakeAbLegacyTagFactory Factory, List Created) NewDriver(
+ Func make, bool coalesce, params AbLegacyTagDefinition[] tags)
+ {
+ var created = new List();
+ var factory = new FakeAbLegacyTagFactory
+ {
+ Customise = p =>
+ {
+ lock (created) created.Add(p);
+ return make(p);
+ },
+ };
+ var drv = new AbLegacyDriver(new AbLegacyDriverOptions
+ {
+ Devices = [new AbLegacyDeviceOptions(Host)],
+ RawTags = AbLegacyRawTags.Entries(tags),
+ Probe = new AbLegacyProbeOptions { Enabled = false },
+ CoalesceReads = coalesce,
+ }, "drv-coalesce", factory);
+ return (drv, factory, created);
+ }
+
+ private static AbLegacyTagDefinition Tag(string name, string address, AbLegacyDataType type) =>
+ new(name, Host, address, type);
+
+ [Fact]
+ public async Task Nearby_N_file_words_and_bits_ride_one_read_and_decode_like_single_reads()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { ArrayValue = new short[] { 10, 11, 12, 4, 14, -15 } },
+ coalesce: true,
+ Tag("W0", "N7:0", AbLegacyDataType.Int),
+ Tag("W1", "N7:1", AbLegacyDataType.AnalogInt),
+ Tag("B32", "N7:3/2", AbLegacyDataType.Bit),
+ Tag("B31", "N7:3/1", AbLegacyDataType.Bit),
+ Tag("W5", "N7:5", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["W0", "W1", "B32", "B31", "W5"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ r[0].Value.ShouldBe(10); // int, exactly as DecodeValue(Int) returns
+ r[1].Value.ShouldBe(11);
+ r[2].Value.ShouldBe(true); // word 3 = 0b100: bit 2 set
+ r[3].Value.ShouldBe(false);
+ r[4].Value.ShouldBe(-15);
+ created.Count.ShouldBe(1);
+ created[0].TagName.ShouldBe("N7:0");
+ created[0].ElementCount.ShouldBe(6);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Float_and_long_files_coalesce_at_their_element_width()
+ {
+ var (drv, _, created) = NewDriver(
+ p => p.TagName.StartsWith('F')
+ ? new FakeAbLegacyTag(p) { ArrayValue = new[] { 1.5f, 0f, 2.5f } }
+ : new FakeAbLegacyTag(p) { ArrayValue = new[] { 7, int.MinValue } },
+ coalesce: true,
+ Tag("F0", "F8:0", AbLegacyDataType.Float),
+ Tag("F2", "F8:2", AbLegacyDataType.Float),
+ Tag("L0", "L9:0", AbLegacyDataType.Long),
+ Tag("L1b31", "L9:1/31", AbLegacyDataType.Bit));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["F0", "F2", "L0", "L1b31"], CancellationToken.None);
+
+ r.Select(s => s.Value).ShouldBe(new object[] { 1.5f, 2.5f, 7, true });
+ created.Select(c => (c.TagName, c.ElementCount)).OrderBy(x => x.TagName)
+ .ShouldBe(new[] { ("F8:0", 3), ("L9:0", 2) });
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task Far_apart_or_ineligible_tags_keep_the_single_tag_path()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { Value = 1 },
+ coalesce: true,
+ Tag("Near", "N7:0", AbLegacyDataType.Int),
+ Tag("Far", "N7:40", AbLegacyDataType.Int), // gap > MaxCoalesceGap
+ Tag("Mistyped", "N7:1", AbLegacyDataType.Float), // width mismatch: single path answers as before
+ Tag("Acc", "T4:0.ACC", AbLegacyDataType.Int), // sub-element
+ Tag("Status", "S:1", AbLegacyDataType.Int), // no file number
+ Tag("Ind", "N7:[N7:0]", AbLegacyDataType.Int)); // indirect
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ _ = await drv.ReadAsync(["Near", "Far", "Mistyped", "Acc", "Status", "Ind"], CancellationToken.None);
+
+ created.ShouldAllBe(c => c.ElementCount == 1);
+ created.Select(c => c.TagName).ShouldContain("N7:40");
+ created.Select(c => c.TagName).ShouldContain("T4:0.ACC");
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_refused_block_falls_back_to_single_reads_and_is_remembered_after_a_repeat()
+ {
+ // N7:9 is past the end of the file: the BLOCK is refused; singly, only that tag is.
+ var (drv, _, created) = NewDriver(
+ p => p.ElementCount > 1 || p.TagName == "N7:9"
+ ? new FakeAbLegacyTag(p) { Status = (int)AbStatus.OutOfBounds }
+ : new FakeAbLegacyTag(p) { Value = 3 },
+ coalesce: true,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:2", AbLegacyDataType.Int),
+ Tag("C", "N7:9", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ for (var poll = 0; poll < 3; poll++)
+ {
+ var r = await drv.ReadAsync(["A", "B", "C"], CancellationToken.None);
+ r[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}");
+ r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds));
+ }
+ // Refused on polls 0 and 1 (each falling back to singles in the same pass); remembered after the repeat,
+ // so poll 2 does not try the block again.
+ created.Count(c => c.ElementCount > 1).ShouldBe(2);
+ drv.GetHealth().State.ShouldBe(DriverState.Healthy); // refusals are not comms faults
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task A_block_comms_fault_fails_its_members_together_and_degrades()
+ {
+ var (drv, _, _) = NewDriver(
+ p => new FakeAbLegacyTag(p) { ThrowOnRead = true, Exception = new System.Net.Sockets.SocketException(10054) },
+ coalesce: true,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:1", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.BadCommunicationError);
+ drv.GetHealth().State.ShouldBe(DriverState.Degraded);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Fact]
+ public async Task CoalesceReads_false_reads_every_tag_singly()
+ {
+ var (drv, _, created) = NewDriver(
+ p => new FakeAbLegacyTag(p) { Value = 1 },
+ coalesce: false,
+ Tag("A", "N7:0", AbLegacyDataType.Int),
+ Tag("B", "N7:1", AbLegacyDataType.Int));
+ await drv.InitializeAsync("{}", CancellationToken.None);
+
+ var r = await drv.ReadAsync(["A", "B"], CancellationToken.None);
+
+ r.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good);
+ created.Count.ShouldBe(2);
+ created.ShouldAllBe(c => c.ElementCount == 1);
+ await drv.ShutdownAsync(CancellationToken.None);
+ }
+
+ [Theory]
+ [InlineData("""{"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
+ [InlineData("""{"coalesceReads":false,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", false)]
+ [InlineData("""{"coalesceReads":true,"someFutureKey":1,"devices":[{"hostAddress":"ab://10.0.0.5/1,0"}]}""", true)]
+ public void ParseOptions_reads_coalesceReads_defaulting_true(string json, bool expected) =>
+ (AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected);
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs
new file mode 100644
index 00000000..fd946ffb
--- /dev/null
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs
@@ -0,0 +1,23 @@
+using Microsoft.Extensions.Logging;
+
+namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
+
+/// Thread-safe capturing logger shared by the remediation tests (poll loops log from pool threads).
+internal sealed class AbLegacyTestLogger : ILogger
+{
+ /// Every entry logged, in order.
+ public System.Collections.Concurrent.ConcurrentQueue<(LogLevel Level, string Message)> Entries { get; } = new();
+
+ /// The Warning-level messages logged.
+ public IReadOnlyList Warnings => [.. Entries.Where(e => e.Level == LogLevel.Warning).Select(e => e.Message)];
+
+ ///
+ public IDisposable? BeginScope(TState state) where TState : notnull => null;
+
+ ///
+ public bool IsEnabled(LogLevel logLevel) => true;
+
+ ///
+ public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception,
+ Func formatter) => Entries.Enqueue((logLevel, formatter(state, exception)));
+}
diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
index ba0a4618..dc3a868c 100644
--- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
+++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/FakeAbLegacyTag.cs
@@ -109,6 +109,11 @@ internal class FakeAbLegacyTag : IAbLegacyTagRuntime
/// The value to encode.
public virtual void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value) => Value = value;
+ /// Encodes a whole typed array (array writes); stored as .
+ /// The AbLegacy element type.
+ /// The typed elements.
+ public virtual void EncodeArray(AbLegacyDataType type, Array values) => ArrayValue = values;
+
/// Disposes the tag.
public virtual void Dispose() => Disposed = true;
}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
index f4297127..7dc4c263 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs
@@ -256,6 +256,27 @@ public sealed class AbLegacyDriverPageFormSerializationTests
bound.Probe.ProbeAddress.ShouldBe("S:1");
}
+ /// Review 07 P7: the coalesceReads switch is authored here and consumed by the driver factory.
+ [Theory]
+ [InlineData("""{"devices":[]}""", true, false)]
+ [InlineData("""{"coalesceReads":false,"devices":[]}""", false, true)]
+ public void CoalesceReads_round_trips_to_the_factory_bound_options(string original, bool loadedValue, bool edited)
+ {
+ var loaded = AbLegacyDriverForm.TryDeserialize(original)!;
+ var form = AbLegacyDriverForm.FormModel.FromJson(original, loaded);
+ form.CoalesceReads.ShouldBe(loadedValue);
+ form.CoalesceReads = edited;
+
+ var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original);
+
+ (ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved)
+ .CoalesceReads != false).ShouldBe(edited);
+ // Review 07 r0 M9: ON is the default and is OMITTED, never pinned; only an explicit off is written.
+ var root = System.Text.Json.Nodes.JsonNode.Parse(saved)!.AsObject();
+ if (edited) root.ContainsKey("coalesceReads").ShouldBeFalse();
+ else root["coalesceReads"]!.GetValue().ShouldBeFalse();
+ }
+
[Fact]
public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed()
{
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs
index 975bd68e..5d8ebfe1 100644
--- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs
@@ -122,6 +122,38 @@ public sealed class AbLegacyTagConfigModelTests
m.Validate().ShouldBeNull();
}
+ /// Review 07 H16: an array of a structured element type is refused with the reader's own reason.
+ [Theory]
+ [InlineData("String", "ST9:0")]
+ [InlineData("TimerElement", "T4:0")]
+ public void Validate_refuses_an_array_of_a_structured_element_type(string dataType, string address)
+ {
+ var m = AbLegacyTagConfigModel.FromJson(
+ $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
+
+ m.Validate().ShouldNotBeNull().ShouldContain("not supported as an array");
+ // Positive control: the same type as a scalar validates.
+ AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
+ }
+
+ /// Review 07 r0 I3: an array must match its data file's element width.
+ [Theory]
+ [InlineData("F8:0", "Int", false)]
+ [InlineData("N7:0", "Long", false)]
+ [InlineData("N7:0", "Int", true)]
+ [InlineData("L9:0", "Long", true)]
+ [InlineData("F8:0", "Float", true)]
+ public void Validate_refuses_an_array_whose_type_does_not_match_its_file(string address, string dataType, bool valid)
+ {
+ var m = AbLegacyTagConfigModel.FromJson(
+ $$"""{"address":"{{address}}","dataType":"{{dataType}}","isArray":true,"arrayLength":4}""");
+
+ if (valid) m.Validate().ShouldBeNull();
+ else m.Validate().ShouldNotBeNull().ShouldContain("element width");
+ // A SCALAR of the same shape is not this rule's business.
+ AbLegacyTagConfigModel.FromJson($$"""{"address":"{{address}}","dataType":"{{dataType}}"}""").Validate().ShouldBeNull();
+ }
+
[Fact]
public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length()
{