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().ToList(); + if (elements.Count != count) return AbLegacyStatusMapper.BadOutOfRange; + try + { + switch (type) + { + case AbLegacyDataType.Long: + { + var a = new int[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToInt32(Element(elements[i])); + typed = a; + break; + } + case AbLegacyDataType.Float: + { + var a = new float[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToSingle(Element(elements[i])); + typed = a; + break; + } + default: + { + var a = new short[count]; + for (var i = 0; i < count; i++) a[i] = Convert.ToInt16(Element(elements[i])); + typed = a; + break; + } + } + return AbLegacyStatusMapper.Good; + } + catch (OverflowException) { return AbLegacyStatusMapper.BadOutOfRange; } + catch (Exception ex) when (ex is FormatException or InvalidCastException) { return AbLegacyStatusMapper.BadTypeMismatch; } + } + + // A string element is not a number: Convert.ToInt16("12") would PARSE it, which no OPC UA numeric array carries. + private static object Element(object? e) => e switch + { + null => throw new InvalidCastException("null array element"), + string => throw new InvalidCastException("string array element"), + bool => throw new InvalidCastException("bool array element"), + _ => e, + }; +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs new file mode 100644 index 00000000..416d1b62 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -0,0 +1,250 @@ +using Microsoft.Extensions.Logging; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; +using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; + +namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; + +/// +/// Adjacent-element read coalescing (review 07 P7): scalar tags on the same N / B / F / L data file whose +/// elements are close together are read as ONE multi-element PCCC read (the same path array tags already use), +/// then each tag is decoded from its offset in the block. One round trip instead of one per tag. +/// +/// +/// Only shapes the single-tag path decodes identically are coalesced: a word tag +/// (Int/AnalogInt) or a bit of a word (Bit, with or without /n) on an N or B file; a +/// Float on an F file; a Long or a bit of a long on an L file. Indirect addresses, sub-elements +/// (T4:0.ACC), arrays, the file-number-less system files (S:, I:, O:) and any tag +/// authored with a type that does not match its file's element width keep the single-tag path, so a +/// mis-authored tag still gets exactly the answer it got before. +/// The span is safe to read. A block runs from its lowest to its highest member, so every element +/// it reads lies between two authored elements of the same file — inside the file whenever the authored tags +/// are. A device REFUSAL of a block (an authored element past the end of the file, a PLC-5 native non-N file) +/// — or ANY other status the device returned, incl. libplctag's remote error — falls back to single reads for that +/// block's members in the same pass, so one bad tag never blackens its neighbours; a shape refused twice in a row +/// is remembered so it is not retried every poll. Only a NO-REPLY failure fails the members together — they +/// share the connection — exactly as their single reads would have. +/// Turned off with the driver-config key coalesceReads: false. +/// +public sealed partial class AbLegacyDriver +{ + /// Largest block, in data bytes. Below the 236-byte PCCC typed-read limit, so libplctag never needs + /// to fragment a coalesced read (the native runtime would chunk it anyway). + internal const int MaxCoalescedBytes = 200; + + /// Largest run of UNAUTHORED elements a block may read across to join two members. + internal const int MaxCoalesceGap = 8; + + /// One unit of a device pass: a single tag, or a coalesced block of several. + private sealed record ReadUnit(ReadItem? Single, CoalescedBlock? Block); + + /// A coalesced block: elements of one file from . + private sealed record CoalescedBlock( + string FileLetter, int FileNumber, int Start, int Count, AbLegacyDataType ElementType, List Members) + { + /// The runtime-cache key; distinct from any RawPath (RawPaths never contain '|'). + public string Key => $"|block|{FileLetter}{FileNumber}:{Start}+{Count}"; + + /// The block's base address in libplctag form. + public string StartName => $"{FileLetter}{FileNumber}:{Start}"; + } + + /// + /// The element type a scalar tag is decoded from when it rides a coalesced block, or + /// when the tag keeps the single-tag path. + /// + private static AbLegacyDataType? CoalescedElementType(ReadItem item) + { + var a = item.Parsed; + if (a.IsIndirect || a.SubElement is not null || a.FileNumber is null || IsArrayTag(item.Def)) return null; + return (a.FileLetter, item.Def.DataType) switch + { + ("N" or "B", AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Bit) => AbLegacyDataType.Int, + ("F", AbLegacyDataType.Float) => AbLegacyDataType.Float, + ("L", AbLegacyDataType.Long) => AbLegacyDataType.Long, + ("L", AbLegacyDataType.Bit) when a.BitIndex is not null => AbLegacyDataType.Long, + _ => null, + }; + } + + /// + /// Plans a device pass: coalescable tags grouped per file into runs of nearby elements (a run of two or more + /// tags becomes a block), everything else a single unit. Block shapes the device refused before stay single. + /// + private List PlanReadUnits(DeviceState device, List items) + { + if (_options.CoalesceReads == false || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))]; + + var units = new List(items.Count); + var groups = new Dictionary<(string Letter, int File, AbLegacyDataType Type), List>(); + foreach (var item in items) + { + if (CoalescedElementType(item) is not { } type) + { + units.Add(new ReadUnit(item, null)); + continue; + } + var key = (item.Parsed.FileLetter, item.Parsed.FileNumber!.Value, type); + if (!groups.TryGetValue(key, out var list)) groups[key] = list = []; + list.Add(item); + } + + foreach (var ((letter, file, type), members) in groups) + { + var width = type == AbLegacyDataType.Int ? 2 : 4; + var maxElements = MaxCoalescedBytes / width; + var ordered = members.OrderBy(m => m.Parsed.WordNumber).ToList(); + var run = new List { ordered[0] }; + for (var i = 1; i <= ordered.Count; i++) + { + var next = i < ordered.Count ? ordered[i] : null; + var start = run[0].Parsed.WordNumber; + if (next is not null + && next.Parsed.WordNumber - run[^1].Parsed.WordNumber <= MaxCoalesceGap + 1 + && next.Parsed.WordNumber - start + 1 <= maxElements) + { + run.Add(next); + continue; + } + var count = run[^1].Parsed.WordNumber - start + 1; + var block = new CoalescedBlock(letter, file, start, count, type, run); + if (run.Count < 2 || device.RefusedBlocks.ContainsKey(block.Key)) + units.AddRange(run.Select(r => new ReadUnit(r, null))); + else + units.Add(new ReadUnit(null, block)); + if (next is not null) run = [next]; + } + } + return units; + } + + /// What a block read concluded; = read the members singly instead. + private enum BlockOutcome { Done, Fallback } + + /// + /// Reads one coalesced block and decodes each member from its offset. A device refusal or a local refusal + /// returns (and remembers the shape) without writing any member's result. + /// + private async Task<(BlockOutcome Outcome, OpVerdict Verdict, string? Message)> ReadBlockAsync( + DeviceState device, CoalescedBlock block, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + void FailAll(uint status) + { + foreach (var m in block.Members) results[m.Index] = new DataValueSnapshot(null, status, null, now); + } + + (BlockOutcome, OpVerdict, string?) Refused(string why) + { + // The handle is never kept for a refused shape (review 07 r0 M5): it may never be planned again. + EvictRuntime(device, block.Key); + // Every refusal falls back to per-member reads THIS pass, so each member gets its own answer and a bad + // member can never black out its neighbours. The shape is only REMEMBERED after a repeat — one refusal can + // be transient (program mode, a download in progress), two in a row are deterministic. + var refusals = device.BlockRefusals.AddOrUpdate(block.Key, 1, (_, n) => n + 1); + if (refusals >= 2 && device.RefusedBlocks.TryAdd(block.Key, 0)) + { + device.BlockRefusals.TryRemove(block.Key, out _); + _logger.LogInformation( + "AbLegacy coalesced read {Block} refused twice ({Reason}); its {Count} tags read singly from now on. Driver={DriverInstanceId} Device={DeviceHostAddress}", + block.StartName + "+" + block.Count, why, block.Members.Count, _driverInstanceId, device.Options.HostAddress); + } + return (BlockOutcome.Fallback, OpVerdict.Neutral, null); + } + + try + { + var createParams = new AbLegacyTagCreateParams( + Gateway: device.ParsedAddress.Gateway, + Port: device.ParsedAddress.Port, + CipPath: device.EffectiveCipPath, + LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, + TagName: block.StartName, + Timeout: _options.Timeout, + ElementCount: block.Count); + var runtime = await CreateSingleFlightAsync( + device, device.Runtimes, block.Key, () => _tagFactory.Create(createParams), ct).ConfigureAwait(false); + + int status; + Array? data = null; + var opLock = device.GetRuntimeLock(block.Key); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + await runtime.ReadAsync(ct).ConfigureAwait(false); + status = runtime.GetStatus(); + if (status == 0) data = runtime.DecodeArray(block.ElementType, block.Count) as Array; + } + finally + { + opLock.Release(); + } + + if (status != 0) + { + // ANY status the device returned (a refusal, a remote error, an unmapped reply, Unsupported) falls back + // to per-member reads; only a no-reply status fails the block's members together (review 07 r0 I1). + if (!IsNoReply((AbStatus)status)) + return Refused($"device status {(AbStatus)status}"); + EvictRuntime(device, block.Key); + FailAll(AbLegacyStatusMapper.MapStatus(status)); + return (BlockOutcome.Done, OpVerdict.Fault, $"transport status {status} reading {block.StartName}+{block.Count}"); + } + if (data is null || data.Length < block.Count) + return Refused("the block decoded short"); + + foreach (var m in block.Members) + results[m.Index] = new DataValueSnapshot(DecodeMember(block, m, data), AbLegacyStatusMapper.Good, now, now); + device.BlockRefusals.TryRemove(block.Key, out _); + device.FirstNonZeroStatusLogged = false; + return (BlockOutcome.Done, OpVerdict.Answered, null); + } + catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException tex) + { + FailAll(AbLegacyStatusMapper.BadCommunicationError); + return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message); + } + catch (AbTransportException ex) when (!IsNoReply(ex.Status)) + { + return Refused(ex.Message); + } + catch (NotSupportedException nse) + { + return Refused(nse.Message); + } + catch (Exception ex) + { + EvictRuntime(device, block.Key); + FailAll(AbLegacyStatusMapper.BadCommunicationError); + return (BlockOutcome.Done, OpVerdict.Fault, ex.Message); + } + } + + /// + /// Decodes one member from the block, returning exactly the CLR type and value the single-tag path's + /// would (int for a word or long, float, bool). + /// + private static object DecodeMember(CoalescedBlock block, ReadItem m, Array data) + { + var offset = m.Parsed.WordNumber - block.Start; + var bit = m.Parsed.BitIndex; + switch (data) + { + case short[] words: + { + var w = words[offset]; + return m.Def.DataType == AbLegacyDataType.Bit + ? ((ushort)w >> (bit ?? 0) & 1) != 0 + : (int)w; + } + case int[] longs: + { + var l = longs[offset]; + return m.Def.DataType == AbLegacyDataType.Bit ? ((uint)l >> bit!.Value & 1) != 0 : l; + } + case float[] floats: + return floats[offset]; + default: + throw new InvalidOperationException($"Unexpected block buffer {data.GetType().Name}."); + } + } +} diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 236720f9..2444f3c1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -12,7 +12,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// only at PR 1 time; read / write / discovery / subscribe / probe / /// host-resolver capabilities ship in PRs 2 and 3. /// -public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, ISubscribable, +public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscovery, IBatchSubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table. @@ -23,16 +23,30 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Mutable so can adopt a re-parsed config on reinitialize. /// Only ever written on the init path, before any reader/session uses it. - private AbLegacyDriverOptions _options; + private volatile AbLegacyDriverOptions _options; private readonly string _driverInstanceId; private readonly IAbLegacyTagFactory _tagFactory; private readonly ILogger _logger; private readonly PollGroupEngine _poll; - private readonly Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); + + // Device table, keyed by HostAddress. BUILD-AND-SWAP (review 07 H13): InitializeAsync builds a fresh + // dictionary and publishes it by reference; teardown swaps in an empty one. A published dictionary is never + // mutated, so the retained poll loop, the actor's GetHostStatuses and a server read can enumerate whatever + // snapshot they hold without "Collection was modified". volatile = the publish is visible to every reader. + private volatile Dictionary _devices = new(StringComparer.OrdinalIgnoreCase); // v3 authored-tag table, keyed by RawPath (the tag identity + driver wire reference). RawPath is - // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper. - private readonly Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + // case-sensitive ordinal. Built in InitializeAsync from _options.RawTags via the pure mapper, and published + // by reference alongside _devices (same build-and-swap rule). + private volatile Dictionary _tagsByRawPath = new(StringComparer.Ordinal); + + // Serialises every PUBLISH/RETIRE of the two tables with the lifecycle epoch. Never held across an await. + private readonly object _lifecycleLock = new(); + + // Bumped by every InitializeAsync start and every teardown. An initialise only publishes if the epoch is still + // the one it took — so an InitializeAsync the runtime abandoned (Lane A: it times the attempt out and calls + // ShutdownAsync) cannot resurrect its tables and probe loops behind a driver the runtime believes stopped. + private long _lifecycleEpoch; // Resolves a read/write/subscribe RawPath reference to its tag definition via a single hit on the // authored _tagsByRawPath table (v3: the reference is always a RawPath; a miss is a miss). @@ -50,6 +64,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public event EventHandler? OnDataChange; + /// + /// One raise per poll cycle carrying every tag that changed in it (review 02 P1). Raised alongside the per-tag + /// ; a consumer subscribes to exactly one of the two. + /// + public event EventHandler? OnDataChangeBatch; + /// /// Occurs when host status changes. /// @@ -85,6 +105,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, tagRef, snapshot)), onError: HandlePollError, backoffCap: PollBackoffCap, + onBatch: (handle, items) => + OnDataChangeBatch?.Invoke(this, new DataChangeBatchEventArgs(handle, items)), // The engine owns no Meter (Core.Abstractions takes no metrics dependency), so this // closure IS the production consumer of its overrun signal. onOverrun: _ => OtOpcUaTelemetry.PollOverruns.Add(1, @@ -121,9 +143,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover internal void HandlePollError(Exception ex) { _logger.LogWarning(ex, "AbLegacy poll reader failed. Driver={DriverInstanceId}", _driverInstanceId); - var current = _health; - if (current.State != DriverState.Faulted) - _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + lock (_lifecycleLock) + { + var current = _health; + if (current.State != DriverState.Faulted) + _health = new DriverHealth(DriverState.Degraded, current.LastSuccessfulRead, ex.Message); + } } /// @@ -145,66 +170,78 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { + long epoch; + lock (_lifecycleLock) epoch = ++_lifecycleEpoch; + // InitializeAsync can run on a live driver without a teardown first (the IDriver contract // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the - // overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT + // new table is published, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT // ShutdownAsync: that also drops _poll and every subscription, which are owned by the shutdown path (the actor re-subscribes after every successful init). - await ReleaseDeviceStatesAsync().ConfigureAwait(false); - _health = new DriverHealth(DriverState.Initializing, null, null); + await ReleaseDeviceStatesAsync(RetireTables(bumpEpoch: false).States, "re-initialise").ConfigureAwait(false); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Initializing, null, null)); _logger.LogInformation( "AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}", _driverInstanceId, _transportDescription, _tagFactory.GetType().Name); + + // Built locally and published by reference at the end (build-and-swap): nothing a concurrent reader holds + // is ever mutated. + var devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + var tags = new Dictionary(StringComparer.Ordinal); try { // re-parse the supplied DriverConfig so a config change delivered through the IDriver // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED // with, so an operator's edit is discarded while the deployment still seals green. - if (HasConfigBody(driverConfigJson)) - _options = AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); + var options = HasConfigBody(driverConfigJson) + ? AbLegacyDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson) + : _options; // Native is opt-in for every family (the default is libplctag); an operator may pick it explicitly // even for PLC-5 / LogixPccc. Allowed, but the limitation is client-visible, so name it ONCE per instance — // not on every init, which includes every comms-loss reconnect retry. if (!_limitedFamilyWarned && _transport == AbLegacyTransport.Native - && AbLegacyTransportResolver.NativeLimitedFamilies(_options.Devices) is { Count: > 0 } limited) + && AbLegacyTransportResolver.NativeLimitedFamilies(options.Devices) is { Count: > 0 } limited) { _limitedFamilyWarned = true; _logger.LogWarning( "AbLegacy driver {DriverInstanceId} runs the Native transport against {Families} device(s); native " + - "support is limited there (PLC-5: N-file reads only; LogixPccc: framing unverified). Set " + + "support is limited there (PLC-5: N-file reads only, every write refused; LogixPccc: framing unverified). Set " + "\"transport\": \"Libplctag\" to use the fallback stack.", _driverInstanceId, string.Join(", ", limited)); } - foreach (var device in _options.Devices) + foreach (var device in options.Devices) { var addr = AbLegacyHostAddress.TryParse(device.HostAddress) ?? throw new InvalidOperationException( $"AbLegacy device has invalid HostAddress '{device.HostAddress}' — expected 'ab://gateway[:port]/cip-path'."); var profile = AbLegacyPlcFamilyProfile.ForFamily(device.PlcFamily); - _devices[device.HostAddress] = new DeviceState(addr, device, profile); + devices[device.HostAddress] = new DeviceState(addr, device, profile); } // Build the RawPath → definition table from the authored raw tags. Each entry's TagConfig // blob is mapped by the pure factory; the entry's WriteIdempotent flag is threaded onto the // def (it lives on the RawTagEntry, not inside the blob), and its DeviceName is resolved to // the owning device's host so the existing DeviceHostAddress-keyed routing continues to work. // A mapper miss is skipped (logged), never thrown — a bad tag must not fail the whole init. - _skippedTags.BeginRebuild(_options.RawTags.Count); - foreach (var entry in _options.RawTags) + _skippedTags.BeginRebuild(options.RawTags.Count); + foreach (var entry in options.RawTags) { if (!AbLegacyTagDefinitionFactory.FromTagConfig(entry.TagConfig, entry.RawPath, out var def)) { - _skippedTags.Skip(entry.RawPath, "TagConfig did not map to a definition"); + // The deploy gate's own explanation (same authority) — e.g. an array of a structured type. + var reason = AbLegacyTagDefinitionFactory.Inspect(entry.TagConfig).FirstOrDefault() + ?? "TagConfig did not map to a definition"; + _skippedTags.Skip(entry.RawPath, reason); _logger.LogWarning( - "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath}", - _driverInstanceId, entry.RawPath); + "AbLegacy tag config did not map to a definition; skipping. Driver={DriverInstanceId} RawPath={RawPath} Reason={Reason}", + _driverInstanceId, entry.RawPath, reason); continue; } - _tagsByRawPath[entry.RawPath] = def with + tags[entry.RawPath] = def with { WriteIdempotent = entry.WriteIdempotent, - DeviceHostAddress = ResolveDeviceHost(entry.DeviceName), + DeviceHostAddress = ResolveDeviceHost(options, entry.DeviceName), }; } _skippedTags.Commit(); @@ -213,9 +250,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // and String (ST-file) are not supported by all PCCC families; reject them early // so a misconfigured tag fails at init time with a clear message rather than // surfacing an opaque comms error at runtime. - foreach (var tag in _tagsByRawPath.Values) + foreach (var tag in tags.Values) { - if (!_devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue; + if (!devices.TryGetValue(tag.DeviceHostAddress, out var deviceForTag)) continue; var profile = deviceForTag.Profile; if (tag.DataType == AbLegacyDataType.Long && !profile.SupportsLongFile) throw new InvalidOperationException( @@ -227,68 +264,108 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover $"(family {deviceForTag.Options.PlcFamily}) does not support ST-files."); } - // Probe loops — one per device when enabled + probe address configured. - if (_options.Probe.Enabled && !string.IsNullOrWhiteSpace(_options.Probe.ProbeAddress)) + // Publish, start the probe loops and go Healthy as ONE step under the lifecycle lock, and only if no + // teardown (or newer initialise) ran since this attempt began. Starting the loops inside the lock + // means a teardown that swaps this table out always sees their tasks and joins them. + lock (_lifecycleLock) { - foreach (var state in _devices.Values) + if (epoch != _lifecycleEpoch) + throw new OperationCanceledException( + $"AbLegacy driver {_driverInstanceId} initialise was superseded by a shutdown or a newer initialise."); + _options = options; + _tagsByRawPath = tags; + _devices = devices; + _resolver.Clear(); // drop transient parses made against the previous table + if (options.Probe.Enabled && !string.IsNullOrWhiteSpace(options.Probe.ProbeAddress)) { - state.ProbeCts = new CancellationTokenSource(); - var ct = state.ProbeCts.Token; - state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, ct), ct); + foreach (var state in devices.Values) + { + state.ProbeCts = new CancellationTokenSource(); + var ct = state.ProbeCts.Token; + var probe = options.Probe; + state.ProbeTask = Task.Run(() => ProbeLoopAsync(state, probe, ct), ct); + } } + _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } - _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); } catch (Exception ex) { - _health = new DriverHealth(DriverState.Faulted, null, ex.Message); + // Release what this attempt built. Nothing was published (the publish is the last statement of the + // try and runs entirely under the lock), so no loop is running against these states. + foreach (var state in devices.Values) state.Retire(); + if (ex is OperationCanceledException && epoch != Volatile.Read(ref _lifecycleEpoch)) + { + _logger.LogInformation( + "AbLegacy driver {DriverInstanceId} initialise abandoned: superseded before it could publish.", + _driverInstanceId); + throw; + } + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Faulted, null, ex.Message)); _logger.LogError(ex, "AbLegacy driver initialise failed. Driver={DriverInstanceId}", _driverInstanceId); - // Release cached state created before the failure so a caller who catches and abandons - // (rather than retrying via ReinitializeAsync) doesn't leave CancellationTokenSources and - // libplctag handles alive. No probe-loop join here: the loops start as the LAST statement of - // the try and nothing after that can throw, so a failure never has a running loop to join. - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); - _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses throw; } } - /// - /// Cancels, bounded-joins and disposes every existing device state, then clears the table. - /// Device-state part of only (no poll / subscription teardown). - /// - private async Task ReleaseDeviceStatesAsync() + /// Writes only while is still the current lifecycle + /// epoch, so a superseded initialise never overwrites the health of the teardown that superseded it. + private void SetHealthIfCurrent(long epoch, DriverHealth health) { - if (_devices.Count == 0) return; - foreach (var state in _devices.Values) + lock (_lifecycleLock) + { + if (epoch == _lifecycleEpoch) _health = health; + } + } + + /// The current lifecycle epoch; a read/write captures it so a late result cannot rewrite health after a teardown. + private long CurrentEpoch => Volatile.Read(ref _lifecycleEpoch); + + /// + /// Atomically swaps the device table (and, for a teardown, the tag table) for an empty one and returns the + /// device states that were published, for the caller to release. Concurrent teardowns (ShutdownAsync racing + /// Dispose) each get a DISJOINT set, so no state is released twice and neither enumerates a table the other + /// is clearing. + /// + /// True for a teardown: supersedes any in-flight initialise. + /// The device states that were published, and the epoch as of the retirement (a teardown writes its final + /// health only while that epoch is still current — review 07 r0 M1). + private (List States, long Epoch) RetireTables(bool bumpEpoch) + { + lock (_lifecycleLock) + { + if (bumpEpoch) + { + _lifecycleEpoch++; + _tagsByRawPath = new Dictionary(StringComparer.Ordinal); + } + var retired = _devices.Values.ToList(); + _devices = new Dictionary(StringComparer.OrdinalIgnoreCase); + return (retired, _lifecycleEpoch); + } + } + + /// + /// Cancels, bounded-joins and retires the given (already unpublished) device states. + /// + /// The states to release. + /// "shutdown" / "re-initialise", for the join-timeout warning. + private async Task ReleaseDeviceStatesAsync(List states, string phase) + { + if (states.Count == 0) return; + foreach (var state in states) try { state.ProbeCts?.Cancel(); } catch { } - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); + var loops = states.Select(d => d.ProbeTask).OfType().ToList(); if (loops.Count > 0) { var all = Task.WhenAll(loops); var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); if (finished != all) _logger.LogWarning( - "AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}", - loops.Count, LoopJoinTimeout, _driverInstanceId); + "AbLegacy {Phase}: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}", + phase, loops.Count, LoopJoinTimeout, _driverInstanceId); } - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); + foreach (var state in states) state.Retire(); } /// @@ -305,44 +382,39 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Synchronous bounded join of the (already-cancelled) probe loops for the sync teardown paths. /// The loop body uses ConfigureAwait(false) throughout, so blocking here cannot deadlock. /// - private void JoinProbeLoopsSync() + /// The retired states whose loops to join. + private static void JoinProbeLoopsSync(List states) { - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToArray(); + var loops = states.Select(d => d.ProbeTask).OfType().ToArray(); if (loops.Length == 0) return; try { Task.WaitAll(loops, LoopJoinTimeout); } catch (AggregateException) { /* loops observe cancellation; a fault is not actionable at teardown */ } } - /// + /// + /// Complete teardown: stops every poll subscription, cancels and (bounded) joins the probe loops, disposes + /// every cached runtime and unpublishes both tables. Safe to run concurrently with / + /// (each teardown retires a disjoint set of states) and with an in-flight + /// , which it supersedes: that attempt will not publish. + /// + /// Unused; teardown is bounded by . + /// A task that completes when teardown is done. public async Task ShutdownAsync(CancellationToken cancellationToken) { + // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing. + var (retired, teardownEpoch) = RetireTables(bumpEpoch: true); await _poll.DisposeAsync().ConfigureAwait(false); // Cancel the probe loops, JOIN them (bounded), then dispose the CTSs and runtimes. // The loop used to be fire-and-forget, so one parked in a probe-runtime create/read could - // outlive shutdown and keep a libplctag handle alive past DisposeRuntimes. - foreach (var state in _devices.Values) - try { state.ProbeCts?.Cancel(); } catch { } - var loops = _devices.Values.Select(d => d.ProbeTask).OfType().ToList(); - if (loops.Count > 0) - { - var all = Task.WhenAll(loops); - var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false); - if (finished != all) - _logger.LogWarning( - "AbLegacy shutdown: {Count} probe loop(s) did not stop within {Timeout}; proceeding with teardown. Driver={DriverInstanceId}", - loops.Count, LoopJoinTimeout, _driverInstanceId); - } - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); + // outlive shutdown and keep a libplctag handle alive past retirement. + await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + // A newer InitializeAsync that ran while this teardown was still joining owns health now. + lock (_lifecycleLock) + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -364,123 +436,431 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // ---- IReadable ---- + /// + /// How many devices one reads concurrently. Each device is still read serially (its + /// tags share one PCCC connection, and SLC/MicroLogix controllers accept few), so this bounds the number of + /// devices — not requests — in flight, which is what keeps a dead device from starving a healthy one. + /// + internal const int MaxConcurrentDevices = 8; + + /// + /// Consecutive comms faults within one device that mark it DOWN (opening its backoff window and failing the + /// rest of the pass fast). Two, not one: a single tag that times out (an odd address, a slow file) must not + /// blacken every sibling on a device that is answering. + /// + internal const int FaultsToMarkDeviceDown = 2; + + /// + /// The clock every per-device backoff decision reads (window open? when does it end?). Wall-clock UTC in + /// production; a test substitutes a manual clock so the window can be crossed deterministically rather than by + /// sleeping (review 07 r0 M7). + /// + internal Func Clock { get; set; } = () => DateTime.UtcNow; + + /// One resolved reference queued for its device's pass. + private sealed record ReadItem(int Index, string Reference, AbLegacyTagDefinition Def, AbLegacyAddress Parsed); + + /// + /// What one wire operation proved. = the device replied (a Good value OR a + /// deterministic device refusal such as "file not found") — the evidence that advances + /// LastSuccessfulRead; = a comms failure (timeout, dropped connection, garbage + /// reply); = failed fast inside the device's backoff window (Bad, Degraded, but no new + /// evidence for the backoff); = nothing reached the wire or it says nothing about + /// reachability (a local refusal, a bad index value). + /// + /// = a reply arrived that cannot be classified (libplctag ERR_REMOTE_ERR, native + /// BadReply/Partial): it may be the device refusing OR a routing hop reporting that the end device is + /// gone, so it is NOT an answer (no backoff reset, no LastSuccessfulRead) and NOT a fault (no fail-fast); + /// a pass that produced only opaque outcomes for a device degrades (review 07 r1 N1). + private enum OpVerdict { Neutral, Answered, Fault, Throttled, Opaque } + + /// The health evidence one device pass produced (collected, then written once per read). + private struct DeviceReadOutcome + { + public bool AnyGood; + public bool AnyAnswered; + public string? FaultMessage; + public string? OpaqueMessage; + + /// + /// Folds an opaque-only device into the fault class for STATE (Degraded, LastSuccessfulRead not advanced + /// by it) — option (a) of review 07 r1 N1: a dead PLC behind a bridge answers every request with a fast remote + /// error, and only this lets the comms-loss watchdog (non-Healthy AND frozen LastSuccessfulRead) reach + /// it. Cost: a single-tag device whose only tag draws an opaque refusal is re-initialised every 90 s. + /// + public void FoldOpaque() + { + if (OpaqueMessage is not null && !AnyAnswered) FaultMessage ??= OpaqueMessage; + } + } + /// + /// + /// Review 07 P7: references are bucketed by device and the devices are read concurrently (at most + /// ); within a device the tags are read in order. A device that produces + /// consecutive comms faults opens its + /// window and every remaining (and every later) read on it fails fast with BadCommunicationError, + /// touching no wire, until the window expires or the probe sees it answer. Health is written ONCE per call: + /// worst outcome wins, and LastSuccessfulRead advances when ANY device answered. + /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); + var epoch = CurrentEpoch; var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; + var devices = _devices; // one snapshot of the published table for the whole call + var buckets = new Dictionary>(ReferenceEqualityComparer.Instance); for (var i = 0; i < fullReferences.Count; i++) { var reference = fullReferences[i]; - if (!_resolver.TryResolve(reference, out var def)) + if (!_resolver.TryResolve(reference, out var def) || !devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (parsed is null) { + WarnMalformedOnce(device, def); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); continue; } + if (!buckets.TryGetValue(device, out var list)) buckets[device] = list = []; + list.Add(new ReadItem(i, reference, def, parsed)); + } + if (buckets.Count == 0) return results; - try + var passes = buckets.ToArray(); + var outcomes = new DeviceReadOutcome[passes.Length]; + using (var window = new SemaphoreSlim(MaxConcurrentDevices, MaxConcurrentDevices)) + { + var tasks = new Task[passes.Length]; + for (var d = 0; d < passes.Length; d++) + tasks[d] = RunPassAsync(d); + // WhenAll completes only once every pass has finished (it never short-circuits on a fault), so the + // window cannot be disposed under a pass still waiting on it. + await Task.WhenAll(tasks).ConfigureAwait(false); + + async Task RunPassAsync(int d) { - var preParsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); - if (preParsed is null) - { - WarnMalformedOnce(device, def); - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNodeIdUnknown, null, now); - continue; - } - if (preParsed.IsIndirect) - { - // Indirect: read the index word(s) NOW (never cached - the index can change between - // polls), compose the concrete address, then run the normal path against it. - var res = await ResolveIndirectAsync(device, def, preParsed, cancellationToken).ConfigureAwait(false); - if (res.Def is null) - { - results[i] = new DataValueSnapshot(null, res.Status, null, now); - if (res.TransportFault) - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {reference}"); - continue; - } - def = res.Def; - } - - var runtime = await EnsureTagRuntimeAsync(device, def, cancellationToken).ConfigureAwait(false); - - int status; - object? value; - // Serialise the Read → GetStatus → DecodeValue sequence against the shared - // runtime — the server read path and the poll loop both call ReadAsync against - // the same cached libplctag Tag handle, which is not concurrency-safe. - var opLock = device.GetRuntimeLock(def.Name); - await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); + await window.WaitAsync(cancellationToken).ConfigureAwait(false); try { - await runtime.ReadAsync(cancellationToken).ConfigureAwait(false); - status = runtime.GetStatus(); - var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); - if (status != 0) - value = null; - else if (IsArrayTag(def)) - value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def)); - else - value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + outcomes[d] = await ReadDevicePassAsync(passes[d].Key, passes[d].Value, results, now, cancellationToken) + .ConfigureAwait(false); } finally { - opLock.Release(); + window.Release(); } - - if (status != 0) - { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); - results[i] = new DataValueSnapshot(null, - AbLegacyStatusMapper.MapStatus(status), null, now); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport status {status} reading {reference}"); - if (!device.FirstNonZeroStatusLogged) - { - device.FirstNonZeroStatusLogged = true; - _logger.LogWarning( - "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}", - _driverInstanceId, def.DeviceHostAddress, reference, status); - } - continue; - } - // Healthy read — re-arm the per-device first-failure log so a future non-zero - // status logs again rather than being suppressed by an old flag from a prior outage. - device.FirstNonZeroStatusLogged = false; - - results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); - _health = new DriverHealth(DriverState.Healthy, now, null); - } - catch (OperationCanceledException) { throw; } - catch (AbTransportException ex) when (IsLocalRefusal(ex)) - { - // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). - // Not a comms fault: BadNotSupported, keep the handle, leave health alone. - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); - } - catch (Exception ex) - { - // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). - EvictRuntime(device, def.Name); - results[i] = new DataValueSnapshot(null, - AbLegacyStatusMapper.BadCommunicationError, null, now); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } + ApplyReadHealth(epoch, outcomes, now); return results; } + /// + /// Writes the read's health once, after every device pass: Degraded if any pass hit a comms fault, else + /// Healthy if anything read Good; LastSuccessfulRead advances when any device answered (a Good read or a + /// deterministic device refusal — both prove the device is reachable). A read that reached no device's + /// wire, or only drew refusals, does not change the state. + /// + private void ApplyReadHealth(long epoch, DeviceReadOutcome[] outcomes, DateTime now) + { + var anyGood = false; + var anyAnswered = false; + string? fault = null; + foreach (var o in outcomes) + { + anyGood |= o.AnyGood; + anyAnswered |= o.AnyAnswered; + fault ??= o.FaultMessage; + } + ApplyHealth(epoch, fault, anyGood, anyAnswered, now); + } + + /// + /// The single health write for a read or write call (worst wins): any comms fault ⇒ Degraded; otherwise any + /// answer — a Good value OR a device refusal, both prove the device replied — ⇒ Healthy (review 07 r0 I2: a + /// refusal-only pass used to leave a recovered driver Degraded). LastSuccessfulRead advances whenever + /// any device answered. A call that reached no wire changes nothing; a Faulted (config-level) state is kept. + /// Epoch-guarded so a late call cannot rewrite the health of a teardown or newer initialise. + /// + private void ApplyHealth(long epoch, string? fault, bool anyGood, bool anyAnswered, DateTime now) + { + if (fault is null && !anyGood && !anyAnswered) return; + lock (_lifecycleLock) + { + if (epoch != _lifecycleEpoch) return; + var current = _health; + var lastRead = anyAnswered ? now : current.LastSuccessfulRead; + if (current.State == DriverState.Faulted) + _health = current with { LastSuccessfulRead = lastRead }; + else if (fault is not null) + _health = new DriverHealth(DriverState.Degraded, lastRead, fault); + else + _health = new DriverHealth(DriverState.Healthy, lastRead, null); + } + } + + /// Reads one device's references in order, failing fast once the device is marked down. + private async Task ReadDevicePassAsync( + DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + var outcome = new DeviceReadOutcome(); + var work = new Queue(PlanReadUnits(device, items)); + while (work.TryDequeue(out var unit)) + { + var members = unit.Block?.Members ?? [unit.Single!]; + if (device.IsThrottled(Clock())) + { + foreach (var m in members) + results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; + continue; + } + + OpVerdict verdict; + string? message; + if (unit.Block is { } block) + { + (var blockOutcome, verdict, message) = await ReadBlockAsync(device, block, results, now, ct).ConfigureAwait(false); + if (blockOutcome == BlockOutcome.Fallback) + { + // Refused as a block: read its members singly, now, so each gets its own answer. + var rest = work.ToList(); + work.Clear(); + foreach (var m in block.Members) work.Enqueue(new ReadUnit(m, null)); + foreach (var r in rest) work.Enqueue(r); + continue; + } + } + else + { + (verdict, message) = await ReadOneAsync(device, unit.Single!, results, now, ct).ConfigureAwait(false); + } + switch (verdict) + { + case OpVerdict.Answered: + outcome.AnyAnswered = true; + if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true; + device.RecordAnswered(); + break; + case OpVerdict.Fault: + outcome.FaultMessage ??= message; + device.RecordCommsFault(Clock()); + break; + case OpVerdict.Throttled: + outcome.FaultMessage ??= message; + break; + case OpVerdict.Opaque: + outcome.OpaqueMessage ??= message; + break; + } + } + outcome.FoldOpaque(); + return outcome; + } + + /// Reads one reference; writes its snapshot and reports what the operation proved. + private async Task<(OpVerdict Verdict, string? Message)> ReadOneAsync( + DeviceState device, ReadItem item, DataValueSnapshot[] results, DateTime now, CancellationToken ct) + { + var def = item.Def; + var reference = item.Reference; + try + { + if (item.Parsed.IsIndirect) + { + // Indirect: read the index word(s) NOW (never cached - the index can change between + // polls), compose the concrete address, then run the normal path against it. + var res = await ResolveIndirectAsync(device, def, item.Parsed, ct).ConfigureAwait(false); + if (res.Def is null) + { + results[item.Index] = new DataValueSnapshot(null, res.Status, null, now); + return res.TransportFault + ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}") + : res.Opaque + ? (OpVerdict.Opaque, $"unclassifiable reply resolving indirect address for {reference}") + : (OpVerdict.Neutral, null); + } + def = res.Def; + } + + // A mis-authored array (its type does not match its data file) would decode the file at the wrong width + // and serve garbage as Good (review 07 r1 N2): answered BadTypeMismatch, warned once, no health write. + if (IsArrayTag(def) && AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) is { } widthError) + { + WarnRefusalOnce(device, def, "read", widthError, AbLegacyStatusMapper.BadTypeMismatch); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadTypeMismatch, null, now); + return (OpVerdict.Neutral, null); + } + + var runtime = await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); + + int status; + object? value; + // Serialise the Read → GetStatus → DecodeValue sequence against the shared + // runtime — the server read path and the poll loop both call ReadAsync against + // the same cached libplctag Tag handle, which is not concurrency-safe. + var opLock = device.GetRuntimeLock(def.Name); + device.BeforeOperationGate?.Invoke(def.Name); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + if (await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false) is not { } live) + { + // A swept indirect resolution (the index moved under us): never resurrect it under a forgotten gate. + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Neutral, null); + } + runtime = live; + await runtime.ReadAsync(ct).ConfigureAwait(false); + status = runtime.GetStatus(); + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); + if (status != 0) + value = null; + else if (IsArrayTag(def)) + value = runtime.DecodeArray(def.DataType, EffectiveArrayLength(def)); + else + value = runtime.DecodeValue(def.DataType, parsed?.BitIndex); + } + finally + { + opLock.Release(); + } + + if (status != 0) + { + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); + if (IsDeviceRefusal((AbStatus)status)) + { + // The device ANSWERED with a refusal (file/element missing, out of bounds, a remote error…): + // per-tag Bad, the handle is kept (re-creating it would only re-pay the init read), warned once + // per tag, and it is evidence of reachability rather than a comms fault. + WarnRefusalOnce(device, def, "read", $"device status {(AbStatus)status}", results[item.Index].StatusCode); + return (OpVerdict.Answered, null); + } + if ((AbStatus)status == AbStatus.Unsupported) + { + // Refused locally / not supported: says nothing about reachability. Keep the handle. + WarnRefusalOnce(device, def, "read", "status Unsupported", AbLegacyStatusMapper.BadNotSupported); + return (OpVerdict.Neutral, null); + } + if (IsOpaque((AbStatus)status)) + { + // A reply we cannot classify: per-tag Bad, keep the handle, warn once — neither an answer nor a fault. + WarnRefusalOnce(device, def, "read", $"unclassifiable reply {(AbStatus)status}", results[item.Index].StatusCode); + return (OpVerdict.Opaque, $"unclassifiable reply {(AbStatus)status} reading {reference}"); + } + // Evict the stale handle so the next call re-creates it (mirrors AbCip). + EvictRuntime(device, def.Name); + if (!device.FirstNonZeroStatusLogged) + { + device.FirstNonZeroStatusLogged = true; + _logger.LogWarning( + "AbLegacy non-zero transport status. Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Status={Status}", + _driverInstanceId, def.DeviceHostAddress, reference, status); + } + return (OpVerdict.Fault, $"transport status {status} reading {reference}"); + } + // Healthy read — re-arm the per-device first-failure log so a future non-zero + // status logs again rather than being suppressed by an old flag from a prior outage. + device.FirstNonZeroStatusLogged = false; + results[item.Index] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); + return (OpVerdict.Answered, null); + } + catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException tex) + { + // A cold connect refused inside the backoff window: no wire, but the tag has no value either. + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Throttled, tex.Message); + } + catch (AbTransportException ex) when (IsLocalRefusal(ex)) + { + // The runtime refused the operation before the wire (e.g. a native PLC-5 non-N read). + // Not a comms fault: BadNotSupported, keep the handle, leave health alone. + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.BadNotSupported); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + return (OpVerdict.Neutral, null); + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + // The native runtime raises a device's refusal (e.g. STS 0x20 "file not found") as an exception. + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); + return (OpVerdict.Answered, null); + } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + // A truncated / mismatched / unmapped reply (native BadReply, Partial): not proof the device answered. + WarnRefusalOnce(device, def, "read", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); + return (OpVerdict.Opaque, ex.Message); + } + catch (NotSupportedException nse) + { + // A deterministic decode refusal (review 07 H16) — the same answer every poll. BadNotSupported, keep + // the handle (re-creating it only re-paid the init read), warn once, and NO health write. + WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + return (OpVerdict.Neutral, null); + } + catch (Exception ex) + { + // Transport exception — evict so the next read creates a fresh handle (mirrors AbCip). + EvictRuntime(device, def.Name); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + return (OpVerdict.Fault, ex.Message); + } + } + + /// + /// True for a status meaning NO REPLY arrived: a timeout, a dropped / refused connection, an aborted request, a + /// request still pending, or a code this build does not know. Only these are comms faults — they evict the + /// handle, degrade health and count toward (review 07 r0 I1: fail-fast + /// must trip only when nothing came back). + /// + /// The transport status. + /// when no reply arrived. + internal static bool IsNoReply(AbStatus status) => + !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending; + + /// + /// True for a status that PROVES the device answered and refused this one operation — an ALLOW-LIST (review 07 + /// r1 N1): the file or element does not exist, the access is not allowed, it is out of bounds, a size or type is + /// wrong, or the processor is in a conflicting state. Per-tag Bad, keeps the handle, and is evidence of + /// reachability (backoff reset, LastSuccessfulRead, Healthy). + /// + /// + /// ⚠️ Inherits the native transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): it maps STS 0x30 + /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to NotAllowed/NotFound, + /// so on the NATIVE transport such a dead node still reads as answered. Follow-up F8 (review 07 r0 I4); this + /// classification needs no change once it lands. + /// + /// The transport status. + /// for a device refusal. + internal static bool IsDeviceRefusal(AbStatus status) => status is + AbStatus.NotFound or AbStatus.NotAllowed or AbStatus.OutOfBounds or AbStatus.TooLarge or AbStatus.TooSmall + or AbStatus.TypeMismatch or AbStatus.DeviceStateConflict; + + /// + /// True for a reply that arrived but cannot be classified (review 07 r1 N1): — + /// libplctag's opaque ERR_REMOTE_ERR/ERR_BAD_STATUS, which folds EVERY non-zero CIP general status + /// and PCCC STS together (including a routing hop's "unconnected send timed out", "connection lost" and "remote + /// node missing"), and the native transport's unmapped / truncated / mismatched reply — and + /// . Opaque statuses are per-tag Bad and trigger the coalescing fallback, but are + /// neither an answer nor a comms fault; see for the health rule. + /// + /// The transport status. + /// for an unclassifiable reply. + internal static bool IsOpaque(AbStatus status) => status is AbStatus.BadReply or AbStatus.Partial; + // ---- IWritable ---- /// @@ -488,7 +868,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); + var epoch = CurrentEpoch; var results = new WriteResult[writes.Count]; + var devices = _devices; // one snapshot of the published table for the whole call + // Writes feed health too (review 07 r0 I2): a Good or device-refused write proves the device answered; a + // no-reply failure is a comms fault. Collected per call and written once, worst wins. + var outcome = new DeviceReadOutcome(); for (var i = 0; i < writes.Count; i++) { @@ -503,7 +888,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover results[i] = new WriteResult(AbLegacyStatusMapper.BadNotWritable); continue; } - if (!_devices.TryGetValue(def.DeviceHostAddress, out var device)) + if (!devices.TryGetValue(def.DeviceHostAddress, out var device)) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNodeIdUnknown); continue; @@ -521,6 +906,15 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover continue; } + // Inside the device's backoff window nothing is dialled — the write fails fast exactly as a read + // does, instead of paying the full Timeout on a handle that is still cached (review 07 r0 M2). + if (device.IsThrottled(Clock())) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; + continue; + } + // Indirect: resolve the index first (non-atomic with the write - the index may change // between the two PLC accesses), then write the concrete address through the normal path. if (parsed.IsIndirect) @@ -530,8 +924,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { results[i] = new WriteResult(res.Status); if (res.TransportFault) - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, - $"transport fault resolving indirect address for {w.FullReference}"); + { + device.RecordCommsFault(Clock()); + outcome.FaultMessage ??= $"transport fault resolving indirect address for {w.FullReference}"; + } + else if (res.Opaque) + outcome.OpaqueMessage ??= $"unclassifiable reply resolving indirect address for {w.FullReference}"; continue; } def = res.Def; @@ -543,12 +941,46 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // READ, and against a device lacking that file the read failure surfaced as a comms fault — // BadCommunicationError, evicted handle, Degraded health — instead of the refusal. // Ahead of the bit-RMW branch too, which would otherwise read the parent word first. - if (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is not null) + if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) { + WarnRefusalOnce(device, def, "write", refusal); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); continue; } + // Array writes (review 07 U10): convert and length-check BEFORE any runtime exists, then encode every + // element. A bit / sub-element array has no flat encoding. + // The encode width is the DATA FILE's element width (review 07 r0 I3), never the authored type's: an + // authored type that does not match its file is refused BadTypeMismatch before anything is encoded. + Array? arrayValue = null; + AbLegacyDataType arrayFileType = default; + if (IsArrayTag(def)) + { + var fileType = AbLegacyTagDefinitionFactory.ArrayFileElementType(parsed); + var widthRefusal = AbLegacyTagDefinitionFactory.ArrayFileMismatch(def.DataType, def.Address) + ?? (fileType is null ? $"{def.Address} has no flat array layout." : null); + uint conversion; + if (parsed.BitIndex is not null || parsed.SubElement is not null || def.DataType == AbLegacyDataType.Bit) + conversion = AbLegacyStatusMapper.BadNotSupported; + else if (widthRefusal is not null || fileType is null) + { + WarnRefusalOnce(device, def, "write", widthRefusal ?? "no flat array layout", AbLegacyStatusMapper.BadTypeMismatch); + results[i] = new WriteResult(AbLegacyStatusMapper.BadTypeMismatch); + continue; + } + else + { + arrayFileType = fileType.Value; + conversion = AbLegacyArrayWriteEncoder.TryConvert(arrayFileType, w.Value, EffectiveArrayLength(def), out arrayValue); + } + if (conversion != AbLegacyStatusMapper.Good) + { + if (conversion == AbLegacyStatusMapper.BadNotSupported) + WarnRefusalOnce(device, def, "write", $"{def.DataType} array writes are not supported."); + results[i] = new WriteResult(conversion); + continue; + } + } // PCCC bit-within-word writes — RMW against a parallel parent-word runtime (strip the /N // bit suffix). The per-parent-word lock serialises concurrent bit writers. Applies to every // bit-addressable PCCC file: N-file (N7:0/3), B-file (B3:0/0), and the I/O image files @@ -557,10 +989,12 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // sub-element combined with a /N bit index, so their BitIndex is always null. NOTE: an Input-image (I) write is sent // to the PLC like any other write — the device drives the input image from physical inputs and // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. - if (def.DataType == AbLegacyDataType.Bit && parsed?.BitIndex is int bit) + else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit) { - results[i] = new WriteResult( - await WriteBitInWordAsync(device, parsed, bit, w.Value, cancellationToken).ConfigureAwait(false)); + var (code, raw) = await WriteBitInWordAsync(device, def, parsed, bit, w.Value, cancellationToken) + .ConfigureAwait(false); + results[i] = new WriteResult(code); + RecordWriteStatus(device, def, raw, ref outcome); continue; } @@ -573,7 +1007,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); + if (await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false) is not { } live) + { + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + continue; // the index moved under us (N3): not written, no health evidence + } + runtime = live; + if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue); + else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); } @@ -582,22 +1023,36 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover opLock.Release(); } - if (status != 0) - { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); - results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); - } - else - { - results[i] = new WriteResult(AbLegacyStatusMapper.Good); - } + results[i] = new WriteResult(status == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(status)); + // A no-reply status evicts the stale handle so the next call re-creates it (mirrors AbCip). + if (IsNoReply((AbStatus)status) && status != 0) EvictRuntime(device, def.Name); + RecordWriteStatus(device, def, status, ref outcome); } catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException tex) + { + // The device is in its reconnect backoff window: fail fast, no wire, no new evidence for the backoff. + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + outcome.FaultMessage ??= tex.Message; + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + device.RecordAnswered(); + outcome.AnyAnswered = true; + WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); + } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + outcome.OpaqueMessage ??= ex.Message; + WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); + } catch (NotSupportedException nse) { + // Deterministic (review 07 H16): nothing reached the wire, so it says nothing about the device. + WarnRefusalOnce(device, def, "write", nse.Message); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -611,20 +1066,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // The runtime refused the write before the wire — a native status-bit sub-element write or a // PLC-5 write. Not a comms fault: BadNotSupported, keep the handle, leave health alone. + WarnRefusalOnce(device, def, "write", ex.Message); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); } catch (Exception ex) { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); + device.RecordCommsFault(Clock()); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + outcome.FaultMessage ??= ex.Message; } } + outcome.FoldOpaque(); + ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow); return results; } + /// + /// Classifies a completed write's raw status into the call's health evidence and the device's backoff: + /// Good or a device refusal = answered; a no-reply status = comms fault (it used to leave health untouched on + /// this path while the exception path degraded — review 07 r0 M10); Unsupported = neutral. + /// + private void RecordWriteStatus(DeviceState device, AbLegacyTagDefinition def, int status, ref DeviceReadOutcome outcome) + { + var s = (AbStatus)status; + if (status == 0 || IsDeviceRefusal(s)) + { + device.RecordAnswered(); + outcome.AnyAnswered = true; + if (status != 0) + WarnRefusalOnce(device, def, "write", $"device status {s}", AbLegacyStatusMapper.MapStatus(status)); + } + else if (IsNoReply(s)) + { + device.RecordCommsFault(Clock()); + outcome.FaultMessage ??= $"transport status {status} writing {def.Name}"; + } + else if (IsOpaque(s)) + { + // Neither an answer nor a fault (review 07 r1 N1). + WarnRefusalOnce(device, def, "write", $"unclassifiable reply {s}", AbLegacyStatusMapper.MapStatus(status)); + outcome.OpaqueMessage ??= $"unclassifiable reply {s} writing {def.Name}"; + } + } + // ---- ITagDiscovery ---- /// @@ -684,7 +1171,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public IReadOnlyList GetHostStatuses() => [.. _devices.Values.Select(s => new HostConnectivityStatus(s.Options.HostAddress, s.HostState, s.HostStateChangedUtc))]; - private async Task ProbeLoopAsync(DeviceState state, CancellationToken ct) + private async Task ProbeLoopAsync(DeviceState state, AbLegacyProbeOptions probe, CancellationToken ct) { var probeParams = new AbLegacyTagCreateParams( Gateway: state.ParsedAddress.Gateway, @@ -693,9 +1180,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover LibplctagPlcAttribute: state.Profile.LibplctagPlcAttribute, // Normalised exactly like tag paths (PLC-5 octal I/O -> decimal); an unparseable probe address is passed through // so the transport reports the failure as it always did. - TagName: AbLegacyProbeAddress.Normalise(_options.Probe.ProbeAddress, state.Options.PlcFamily) - ?? _options.Probe.ProbeAddress!, - Timeout: _options.Probe.Timeout); + TagName: AbLegacyProbeAddress.Normalise(probe.ProbeAddress, state.Options.PlcFamily) + ?? probe.ProbeAddress!, + Timeout: probe.Timeout); IAbLegacyTagRuntime? probeRuntime = null; while (!ct.IsCancellationRequested) @@ -710,19 +1197,26 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover state.ProbeInitialized = true; } await probeRuntime.ReadAsync(ct).ConfigureAwait(false); - success = probeRuntime.GetStatus() == 0; + var status = probeRuntime.GetStatus(); + success = status == 0; + // The probe's verdict feeds the data path's backoff (review 07 P7): an answer resets the window so a + // recovered device is read again at once; a comms fault counts toward marking it down. + if (success || IsDeviceRefusal((AbStatus)status)) state.RecordAnswered(); + else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } - catch + catch (Exception ex) { try { probeRuntime?.Dispose(); } catch { } probeRuntime = null; state.ProbeInitialized = false; + if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); + else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock()); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); - try { await Task.Delay(_options.Probe.Interval, ct).ConfigureAwait(false); } + try { await Task.Delay(probe.Interval, ct).ConfigureAwait(false); } catch (OperationCanceledException) { break; } } try { probeRuntime?.Dispose(); } catch { } @@ -768,21 +1262,22 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// HostAddress as the connection key. The HostAddress fallback is deliberate: the Client CLI /// passes the host as the DeviceName. /// + /// The options being initialised (their device list is the routing table). /// The owning device name delivered on the . /// The matched device's HostAddress; empty when unroutable. - private string ResolveDeviceHost(string deviceName) + private static string ResolveDeviceHost(AbLegacyDriverOptions options, string deviceName) { if (!string.IsNullOrEmpty(deviceName)) { - foreach (var d in _options.Devices) + foreach (var d in options.Devices) if (string.Equals(d.DeviceName, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; - foreach (var d in _options.Devices) + foreach (var d in options.Devices) if (string.Equals(d.HostAddress, deviceName, StringComparison.OrdinalIgnoreCase)) return d.HostAddress; } // Best-available fallback: a single-device driver routes every tag to its sole device. - return _options.Devices.Count == 1 ? _options.Devices[0].HostAddress : ""; + return options.Devices.Count == 1 ? options.Devices[0].HostAddress : ""; } // ---- IPerCallHostResolver ---- @@ -810,8 +1305,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// . The parent word is masked to its native width before the /// bit operation so a sign-extended decode never corrupts the high bits. /// - private async Task WriteBitInWordAsync( - AbLegacyDriver.DeviceState device, AbLegacyAddress bitAddress, int bit, object? value, CancellationToken ct) + /// The OPC UA status the write is answered with, and the raw transport status (0 = Good) for health. + private async Task<(uint Code, int Raw)> WriteBitInWordAsync( + AbLegacyDriver.DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress bitAddress, int bit, object? value, + CancellationToken ct) { // The parent word width follows the file letter: an L-file element is 32-bit, every // other bit-addressable PCCC file (N/I/O/S/A) is a 16-bit word. bit is already @@ -824,6 +1321,23 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var parentAddress = bitAddress with { BitIndex = null }; var parentName = parentAddress.ToLibplctagName(); + // An indirect bit write (N7:[N7:0]/3) resolves to a different parent word per index value: release the + // previous resolution's parent handle so a sweeping index does not keep one per word (review 07 r0 M4). The + // per-word RMW gate is kept — removing a gate another writer may hold would let two RMWs of one word race. + var arrow = def.Name.IndexOf("=>", StringComparison.Ordinal); + if (arrow > 0) + { + var indirectName = def.Name[..arrow]; + var previous = device.IndirectLastParent.TryGetValue(indirectName, out var p) ? p : null; + device.IndirectLastParent[indirectName] = parentName; + if (previous is not null && !string.Equals(previous, parentName, StringComparison.OrdinalIgnoreCase)) + { + var oldGate = device.GetRmwLock(previous); + await oldGate.WaitAsync(ct).ConfigureAwait(false); + try { EvictParentRuntime(device, previous); } finally { oldGate.Release(); } + } + } + var rmwLock = device.GetRmwLock(parentName); await rmwLock.WaitAsync(ct).ConfigureAwait(false); try @@ -831,7 +1345,11 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var parentRuntime = await EnsureParentRuntimeAsync(device, parentName, ct).ConfigureAwait(false); await parentRuntime.ReadAsync(ct).ConfigureAwait(false); var readStatus = parentRuntime.GetStatus(); - if (readStatus != 0) return AbLegacyStatusMapper.MapStatus(readStatus); + if (readStatus != 0) + { + if (IsNoReply((AbStatus)readStatus)) EvictParentRuntime(device, parentName); + return (AbLegacyStatusMapper.MapStatus(readStatus), readStatus); + } // Mask the decoded word to its native width: DecodeValue for a 16-bit Int returns a // sign-extended int, so a word with bit 15 set decodes negative. Masking pins the @@ -848,9 +1366,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover parentRuntime.EncodeValue(AbLegacyDataType.Int, bitIndex: null, (short)updated); await parentRuntime.WriteAsync(ct).ConfigureAwait(false); var writeStatus = parentRuntime.GetStatus(); - return writeStatus == 0 - ? AbLegacyStatusMapper.Good - : AbLegacyStatusMapper.MapStatus(writeStatus); + if (writeStatus != 0 && IsNoReply((AbStatus)writeStatus)) EvictParentRuntime(device, parentName); + return (writeStatus == 0 ? AbLegacyStatusMapper.Good : AbLegacyStatusMapper.MapStatus(writeStatus), writeStatus); } finally { @@ -885,7 +1402,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover if (parsed.IndirectFileSource is { } fileSrc) { var r = await ReadIndexAsync(device, fileSrc, ct).ConfigureAwait(false); - if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault); + if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque); if (r.Value is < 0 or > MaxIndirectFile) return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false); fileNumber = (int)r.Value!; @@ -893,7 +1410,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover if (parsed.IndirectWordSource is { } wordSrc) { var r = await ReadIndexAsync(device, wordSrc, ct).ConfigureAwait(false); - if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault); + if (r.Status != 0) return new IndirectResolution(null, r.Status, r.TransportFault, r.Opaque); if (r.Value < 0 || r.Value > maxElement) return new IndirectResolution(null, AbLegacyStatusMapper.BadOutOfRange, false); word = (int)r.Value!; @@ -906,8 +1423,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var concreteText = concrete.ToLibplctagName(); var resolvedName = $"{def.Name}=>{concreteText}"; - // The index moved: drop the previous resolution's handle so a sweeping index does not accumulate one - // libplctag handle per value it ever took. + // The index moved: drop the previous resolution's handle AND its per-key gates (review 07 L7), so a sweeping + // index does not accumulate one libplctag handle plus two SemaphoreSlims per value it ever took. var previous = device.IndirectLastName.TryGetValue(def.Name, out var prior) ? prior : null; device.IndirectLastName[def.Name] = resolvedName; if (previous is not null && !string.Equals(previous, resolvedName, StringComparison.OrdinalIgnoreCase)) @@ -915,18 +1432,32 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover var oldLock = device.GetRuntimeLock(previous); await oldLock.WaitAsync(ct).ConfigureAwait(false); try { EvictRuntime(device, previous); } finally { oldLock.Release(); } + device.ForgetGates(previous); + // A concurrent reader of the same indirect tag can have cached a resolution between our TryGetValue and + // the write above (a stale one nothing would ever evict). Sweep every OTHER resolution of this tag. + var prefix = def.Name + "=>"; + foreach (var key in device.Runtimes.Keys) + { + if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) + || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue; + // Under the key's own gate (review 07 r0 M3), so no reader is mid-operation on the handle we dispose. + var gate = device.GetRuntimeLock(key); + await gate.WaitAsync(ct).ConfigureAwait(false); + try { EvictRuntime(device, key); } finally { gate.Release(); } + device.ForgetGates(key); + } } return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false); } /// Reads one direct index address as an integer through the shared parent-runtime cache. - private async Task<(long? Value, uint Status, bool TransportFault)> ReadIndexAsync( + private async Task<(long? Value, uint Status, bool TransportFault, bool Opaque)> ReadIndexAsync( DeviceState device, AbLegacyAddress inner, CancellationToken ct) { AbLegacyDataType type; if (inner.BitIndex is not null) type = AbLegacyDataType.Bit; else if (inner.FileLetter == "L") type = AbLegacyDataType.Long; - else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false); + else if (inner.FileLetter is "F" or "ST") return (null, AbLegacyStatusMapper.BadOutOfRange, false, false); else type = AbLegacyDataType.Int; // A bit-addressed index reads its parent word and decodes the bit, like the RMW path does. @@ -942,35 +1473,52 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await runtime.ReadAsync(ct).ConfigureAwait(false); } catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException) { throw; } // the caller answers it as a fail-fast catch (AbTransportException ex) when (IsLocalRefusal(ex)) { - return (null, AbLegacyStatusMapper.BadNotSupported, false); + return (null, AbLegacyStatusMapper.BadNotSupported, false, false); + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + // The device answered: the index file/element does not exist. Per-tag, not a comms fault. + return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, false); + } + catch (AbTransportException ex) when (IsOpaque(ex.Status)) + { + return (null, AbLegacyStatusMapper.MapStatus(ex.Status), false, true); } catch { EvictParentRuntime(device, runtimeName); - return (null, AbLegacyStatusMapper.BadCommunicationError, true); + return (null, AbLegacyStatusMapper.BadCommunicationError, true, false); } var status = runtime.GetStatus(); - if (status != 0) + if (status != 0 && IsDeviceRefusal((AbStatus)status)) + return (null, AbLegacyStatusMapper.MapStatus(status), false, false); + if (status != 0 && IsOpaque((AbStatus)status)) + return (null, AbLegacyStatusMapper.MapStatus(status), false, true); + // Only a NO-REPLY status is a transport fault (review 07 r1 N4: Unsupported used to fall in here). + if (status != 0 && IsNoReply((AbStatus)status)) { EvictParentRuntime(device, runtimeName); - return (null, AbLegacyStatusMapper.MapStatus(status), true); + return (null, AbLegacyStatusMapper.MapStatus(status), true, false); } + if (status != 0) + return (null, AbLegacyStatusMapper.MapStatus(status), false, false); var raw = runtime.DecodeValue(type, inner.BitIndex); try { return raw switch { - bool b => (b ? 1L : 0L, 0u, false), - null => (null, AbLegacyStatusMapper.BadOutOfRange, false), - _ => (Convert.ToInt64(raw), 0u, false), + bool b => (b ? 1L : 0L, 0u, false, false), + null => (null, AbLegacyStatusMapper.BadOutOfRange, false, false), + _ => (Convert.ToInt64(raw), 0u, false, false), }; } catch (Exception ex) when (ex is FormatException or InvalidCastException or OverflowException) { - return (null, AbLegacyStatusMapper.BadOutOfRange, false); + return (null, AbLegacyStatusMapper.BadOutOfRange, false, false); } } finally @@ -987,37 +1535,61 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } } - private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault); + private sealed record IndirectResolution(AbLegacyTagDefinition? Def, uint Status, bool TransportFault, bool Opaque = false); private async Task EnsureParentRuntimeAsync( AbLegacyDriver.DeviceState device, string parentName, CancellationToken ct) { - // Fast path: runtime already cached. - if (device.ParentRuntimes.TryGetValue(parentName, out var existing)) return existing; - - // Slow path: serialise creation per key so concurrent callers don't each create a - // runtime and one of them gets overwritten + leaked. Only one caller initialises; the - // others find the entry on the second TryGetValue inside the lock. - var creationLock = device.GetCreationLock($"parent:{parentName}"); - await creationLock.WaitAsync(ct).ConfigureAwait(false); - try - { - if (device.ParentRuntimes.TryGetValue(parentName, out existing)) return existing; - - var runtime = _tagFactory.Create(new AbLegacyTagCreateParams( + var timeout = _options.Timeout; + return await CreateSingleFlightAsync(device, device.ParentRuntimes, parentName, () => _tagFactory.Create( + new AbLegacyTagCreateParams( Gateway: device.ParsedAddress.Gateway, Port: device.ParsedAddress.Port, CipPath: device.EffectiveCipPath, LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, - Timeout: _options.Timeout)); - await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct) - .ConfigureAwait(false); + Timeout: timeout)), ct).ConfigureAwait(false); + } + + /// + /// Thrown when a cold connect is refused because the device's window is open + /// — a fail-fast that costs no wire traffic. Reads and writes answer it with BadCommunicationError. + /// + internal sealed class ConnectionThrottledException(string message) : Exception(message); + + /// + /// Returns the cached runtime for , or creates and initialises one. The COLD path is + /// single-flight per DEVICE (, review 07 P7): concurrent callers for + /// different cold tags of one device connect one at a time, so a down device sees one connect attempt instead + /// of one per caller, and inside an open backoff window no attempt is made at all + /// (). The HOT path (cached handle) never touches the gate. The + /// caller records the outcome on the device's backoff (once — recording here as well would double-count). + /// + private async Task CreateSingleFlightAsync( + DeviceState device, + System.Collections.Concurrent.ConcurrentDictionary table, + string key, + Func create, + CancellationToken ct) + { + if (table.TryGetValue(key, out var existing)) return existing; + + await device.ConnectGate.WaitAsync(ct).ConfigureAwait(false); + try + { + // Re-check under the gate: a sibling caller may have connected this very key while we waited. + if (table.TryGetValue(key, out existing)) return existing; + if (device.IsThrottled(Clock())) + throw new ConnectionThrottledException( + $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window."); + + var runtime = create(); + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(table, key, runtime), ct).ConfigureAwait(false); return runtime; } finally { - creationLock.Release(); + device.ConnectGate.Release(); } } @@ -1087,6 +1659,20 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, device.Options.PlcFamily); } + /// + /// A deterministic refusal (BadNotSupported) is answered the same way every time, so it is logged ONCE per + /// tag and operation — a polled tag must not flood the log, and the refusal never touches driver health. + /// + private void WarnRefusalOnce(DeviceState device, AbLegacyTagDefinition def, string operation, string reason, + uint status = AbLegacyStatusMapper.BadNotSupported) + { + if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return; + // The status the operation is actually answered with (review 07 r0 M8) — not always BadNotSupported. + _logger.LogWarning( + "AbLegacy {Operation} refused (0x{Status:X8}). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", + operation, status, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason); + } + /// The create parameters for a tag's own runtime — shared by creation and the write-refusal check, so /// the refusal is asked about exactly the runtime a write would otherwise create. private AbLegacyTagCreateParams CreateParamsFor(DeviceState device, AbLegacyTagDefinition def, AbLegacyAddress parsed) => @@ -1101,30 +1687,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover private async Task EnsureTagRuntimeAsync( DeviceState device, AbLegacyTagDefinition def, CancellationToken ct) { - // Fast path: runtime already cached. if (device.Runtimes.TryGetValue(def.Name, out var existing)) return existing; - // Slow path: serialise creation per tag name so concurrent callers for the same tag - // (server read path + poll loop) don't both create a runtime and one gets leaked. - var creationLock = device.GetCreationLock($"tag:{def.Name}"); - await creationLock.WaitAsync(ct).ConfigureAwait(false); - try - { - if (device.Runtimes.TryGetValue(def.Name, out existing)) return existing; + var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) + ?? throw new InvalidOperationException( + $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); + var createParams = CreateParamsFor(device, def, parsed); + return await CreateSingleFlightAsync(device, device.Runtimes, def.Name, () => _tagFactory.Create(createParams), ct) + .ConfigureAwait(false); + } - var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily) - ?? throw new InvalidOperationException( - $"AbLegacy tag '{def.Name}' has malformed Address '{def.Address}' for PLC family {device.Options.PlcFamily}."); - - var runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); - await InitializeOrKeepRefusedAsync(runtime, () => device.Runtimes[def.Name] = runtime, ct) - .ConfigureAwait(false); - return runtime; - } - finally - { - creationLock.Release(); - } + /// + /// Re-resolves a tag's runtime once its operation gate is HELD (review 07 r0 M3): the handle fetched before + /// the wait may since have been evicted and disposed (a failed sibling call, or an indirect index sweep), and + /// using it would fail spuriously as a comms fault. Returns the live cached handle, or connects a fresh one. + /// + private async Task CurrentRuntimeUnderLockAsync( + DeviceState device, AbLegacyTagDefinition def, IAbLegacyTagRuntime fetched, CancellationToken ct) + { + if (device.Runtimes.TryGetValue(def.Name, out var live)) + return live; + // A resolved INDIRECT key that is gone was swept because the index moved (review 07 r1 N3). Reconnecting it + // would resurrect a stale resolution under a gate the sweep already forgot (a later caller would get a fresh + // gate for the same handle). Null = the caller answers this one call neutrally; the next poll re-resolves. + if (def.Name.Contains("=>", StringComparison.Ordinal)) return null; + // Evicted between the fetch and the lock: a native runtime refused at init is cached too, so absence means + // it really was released. Connect again (single-flight per device). + // A retired (torn-down) state never reconnects: the in-flight call finishes on what it has. + if (device.IsRetired) return fetched; + return await EnsureTagRuntimeAsync(device, def, ct).ConfigureAwait(false); } /// @@ -1158,6 +1749,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public void Dispose() { + var (retired, teardownEpoch) = RetireTables(bumpEpoch: true); // ValueTask.ConfigureAwait(false).GetAwaiter().GetResult() — drains the poll engine // shutdown on the current thread without capturing the SynchronizationContext. The // engine cancels every loop CTS up-front then either completes immediately (no @@ -1165,20 +1757,17 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // already-shutting-down loop tasks. We swallow exceptions so a buggy poll-loop teardown // can't poison the rest of the disposal chain. try { _poll.DisposeAsync().ConfigureAwait(false).GetAwaiter().GetResult(); } catch { } - foreach (var state in _devices.Values) + foreach (var state in retired) try { state.ProbeCts?.Cancel(); } catch { } - JoinProbeLoopsSync(); - foreach (var state in _devices.Values) - { - state.ProbeCts?.Dispose(); - state.ProbeCts = null; - state.ProbeTask = null; - state.DisposeRuntimes(); - } - _devices.Clear(); - _tagsByRawPath.Clear(); + JoinProbeLoopsSync(retired); + foreach (var state in retired) state.Retire(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + // A newer InitializeAsync that ran while this teardown was still joining owns health now. + lock (_lifecycleLock) + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -1222,7 +1811,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// avoids the check-then-act race present on a plain Dictionary: two concurrent /// EnsureTagRuntimeAsync callers for the same key both miss the lookup on a /// plain dict and both create + store a runtime, leaking the loser. Access is guarded - /// by a per-key creation semaphore () so exactly one + /// by the per-device connect gate () so exactly one /// runtime is created per tag name. /// public System.Collections.Concurrent.ConcurrentDictionary Runtimes { get; } = @@ -1240,21 +1829,62 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } = new(StringComparer.OrdinalIgnoreCase); - /// - /// Per-key creation locks for and . - /// A caller holds this before the TryGetValue + Create + InitializeAsync + TryAdd - /// sequence so that a concurrent caller waits rather than creating a duplicate runtime - /// that would be leaked on . - /// - private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); + /// Indirect bit-write tag name to the parent word it last read-modify-wrote, so a moved index releases + /// the previous parent handle. + public System.Collections.Concurrent.ConcurrentDictionary IndirectLastParent { get; } = + new(StringComparer.OrdinalIgnoreCase); /// - /// Gets or creates the creation lock for the specified key. + /// Per-device connect gate (review 07 P7): every COLD runtime create + initialise on this device runs under + /// it, so connects are single-flight per device — a down device sees one attempt at a time, not one per + /// concurrent caller — and a concurrent caller for the same key finds the winner's runtime instead of + /// creating (and leaking) a duplicate. A cached runtime never touches it. Not disposed on retirement, for + /// the same reason as the per-key gates (an in-flight caller may still release it). /// - /// The lock key. - /// The semaphore slim for the key. - public SemaphoreSlim GetCreationLock(string key) => - _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + public SemaphoreSlim ConnectGate { get; } = new(1, 1); + + /// + /// Per-device reconnect throttle: opened after consecutive comms faults, + /// 1 s doubling to 30 s, reset by any answer from the device (data path or probe). Not internally + /// synchronised — every touch is under . + /// + private readonly ConnectionBackoff _backoff = new(TimeSpan.FromSeconds(1), TimeSpan.FromSeconds(30)); + + private readonly object _backoffLock = new(); + private int _faultStreak; + + /// True while the device's backoff window is open: operations fail fast without the wire. + /// The current UTC time. + /// when no attempt should be made now. + public bool IsThrottled(DateTime nowUtc) + { + lock (_backoffLock) return !_backoff.ShouldAttempt(nowUtc); + } + + /// The device answered (a Good value or a deterministic refusal): reset the streak and the window. + public void RecordAnswered() + { + lock (_backoffLock) + { + _faultStreak = 0; + _backoff.RecordSuccess(); + } + } + + /// + /// A comms fault: the th consecutive one marks the device down — opens + /// (or, after a failed retry, lengthens) the backoff window and restarts the streak. + /// + /// The current UTC time (the window is measured from here). + public void RecordCommsFault(DateTime nowUtc) + { + lock (_backoffLock) + { + if (++_faultStreak < FaultsToMarkDeviceDown) return; + _faultStreak = 0; + _backoff.RecordFailure(nowUtc); + } + } private readonly System.Collections.Concurrent.ConcurrentDictionary _rmwLocks = new(); @@ -1323,28 +1953,92 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public bool FirstNonZeroStatusLogged { get; set; } + /// Coalesced block shapes the device refused; their members read singly from then on (bounded by the + /// distinct subscription shapes, released with the state). + public System.Collections.Concurrent.ConcurrentDictionary RefusedBlocks { get; } = new(StringComparer.Ordinal); + + /// Consecutive refusals per coalesced block shape not yet remembered in + /// (a success clears the count). + public System.Collections.Concurrent.ConcurrentDictionary BlockRefusals { get; } = new(StringComparer.Ordinal); + + /// Tag operations already warned about for a deterministic refusal (once-per-tag-and-operation log). + public System.Collections.Concurrent.ConcurrentDictionary RefusalLogged { get; } = new(StringComparer.Ordinal); + /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); /// - /// Disposes all cached tag runtimes. + /// Drops the per-runtime and creation gates of a runtime key that is no longer in use (a moved indirect + /// resolution). The gates are removed, not disposed: a caller that already holds a reference finishes on + /// it, and the next user of the key simply gets a fresh gate. /// - public void DisposeRuntimes() + /// The runtime key whose gates to drop. + public void ForgetGates(string runtimeKey) { - foreach (var r in Runtimes.Values) r.Dispose(); - Runtimes.Clear(); - foreach (var r in ParentRuntimes.Values) r.Dispose(); - ParentRuntimes.Clear(); + _runtimeLocks.TryRemove(runtimeKey, out _); + } + + /// The number of per-key gates currently held (runtime + RMW). Tests only. + internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count; + + /// Test seam: invoked with the runtime key just before an operation waits on its gate (after the + /// handle was fetched), so a test can act at exactly that point instead of sleeping. + internal Action? BeforeOperationGate { get; set; } + + private volatile bool _retired; + + /// True once this state has been unpublished and released; nothing may be cached on it any more. + public bool IsRetired => _retired; + + /// + /// Caches a freshly initialised runtime under . A concurrent creator that won the + /// race keeps its runtime (the loser is disposed, never overwritten-and-leaked). A runtime cached AFTER + /// drained the table — a read that was already in flight when shutdown ran — is + /// removed and disposed here, because nothing would ever release it otherwise. + /// + /// The runtime table ( or ). + /// The runtime key. + /// The initialised runtime. + public void Cache(System.Collections.Concurrent.ConcurrentDictionary table, + string key, IAbLegacyTagRuntime runtime) + { + if (!table.TryAdd(key, runtime)) + { + if (!ReferenceEquals(table.GetValueOrDefault(key), runtime)) + { + try { runtime.Dispose(); } catch { /* best-effort */ } + return; + } + } + // The ConcurrentDictionary write above is a full fence, so this read cannot be satisfied before it: + // either Retire's drain sees the entry, or this check sees the retirement. + if (_retired && table.TryRemove(new KeyValuePair(key, runtime))) + { + try { runtime.Dispose(); } catch { /* best-effort */ } + } + } + + /// + /// Retires the state: marks it, disposes the probe CTS and every cached runtime. Idempotent. The + /// per-key gates are deliberately NOT disposed: an operation that resolved + /// this state before it was unpublished may still be waiting on or releasing one, and disposing it under + /// that caller turned a clean teardown into s. A SemaphoreSlim whose + /// AvailableWaitHandle was never touched holds no unmanaged resource; the gates go with the state. + /// + public void Retire() + { + _retired = true; + var cts = ProbeCts; + ProbeCts = null; + ProbeTask = null; + try { cts?.Dispose(); } catch { /* best-effort */ } + foreach (var key in Runtimes.Keys) + if (Runtimes.TryRemove(key, out var r)) + try { r.Dispose(); } catch { /* best-effort */ } + foreach (var key in ParentRuntimes.Keys) + if (ParentRuntimes.TryRemove(key, out var r)) + try { r.Dispose(); } catch { /* best-effort */ } IndirectLastName.Clear(); - // Dispose + clear the per-parent RMW gates and the per-runtime/creation locks so - // ReinitializeAsync cycles don't orphan their SemaphoreSlim instances (each leaks a - // wait handle once contended). - foreach (var sem in _rmwLocks.Values) sem.Dispose(); - _rmwLocks.Clear(); - foreach (var sem in _creationLocks.Values) sem.Dispose(); - _creationLocks.Clear(); - foreach (var sem in _runtimeLocks.Values) sem.Dispose(); - _runtimeLocks.Clear(); } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs index 9dfd9707..418ead02 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverFactoryExtensions.cs @@ -97,6 +97,7 @@ public static class AbLegacyDriverFactoryExtensions ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0", }, Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), + CoalesceReads = dto.CoalesceReads, // Absent ⇒ null ("use the default" = Libplctag, resolved by AbLegacyTransportResolver); // a bad value still fails exactly as any other bad enum field does. Transport = string.IsNullOrWhiteSpace(dto.Transport) @@ -157,6 +158,9 @@ public static class AbLegacyDriverFactoryExtensions /// public string? Transport { get; init; } + /// Gets or sets whether adjacent scalar reads are coalesced; absent means . + public bool? CoalesceReads { get; init; } + /// /// Gets or sets the list of devices to connect to. /// diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs index 172e1dea..ac46d8a7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriverProbe.cs @@ -55,6 +55,10 @@ public sealed class AbLegacyDriverProbe : IDriverProbe catch (Exception ex) { return new(false, $"Config JSON is invalid: {ex.Message}", null); } if (opts is null) return new(false, "Config JSON deserialized to null.", null); + // The authored "Admin UI probe timeout" (probeTimeoutSeconds) tightens the caller's budget. It was parsed by + // nothing before: Test Connect always ran on the caller's budget alone. + timeout = EffectiveTimeout(timeout, opts.ProbeTimeoutSeconds); + var (parsed, firstDevice) = ExtractTarget(opts); if (parsed is null || string.IsNullOrWhiteSpace(parsed.Gateway) || parsed.Port <= 0) return new(false, "Config has no host/port to probe.", null); @@ -169,6 +173,22 @@ public sealed class AbLegacyDriverProbe : IDriverProbe /// Whether the status proves the controller answered. internal static bool IsReachableStatus(AbStatus s) => s is AbStatus.Ok or AbStatus.NotFound; + /// + /// The deadline the probe runs under: the caller's budget, tightened by the config's + /// when that is positive and shorter (same rule as the + /// Galaxy probe). A non-positive caller budget is floored to 1 s first. + /// + /// The budget the host handed the probe. + /// The config's probeTimeoutSeconds; ignored when not positive. + /// The effective probe deadline. + internal static TimeSpan EffectiveTimeout(TimeSpan callerTimeout, int configuredSeconds) + { + var caller = callerTimeout > TimeSpan.Zero ? callerTimeout : TimeSpan.FromSeconds(1); + if (configuredSeconds <= 0) return caller; + var configured = TimeSpan.FromSeconds(configuredSeconds); + return configured < caller ? configured : caller; + } + private static (AbLegacyHostAddress? parsed, AbLegacyDeviceOptions? device) ExtractTarget(AbLegacyDriverOptions opts) { // Parse the first device's ab:// host address to extract the gateway IP + EIP port. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs index 502f6038..e41152a1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/IAbLegacyTagRuntime.cs @@ -50,6 +50,20 @@ public interface IAbLegacyTagRuntime : IDisposable /// Optional bit index for bit-level access. /// The value to encode. void EncodeValue(AbLegacyDataType type, int? bitIndex, object? value); + + /// + /// Encodes EVERY element of a multi-element tag for the next , at the element width + /// of . is already element-typed by the driver + /// (short[] for Int/AnalogInt, int[] for Long, float[] for Float) and its length must + /// equal the runtime's element count (), so no element of + /// the write comes from a stale read buffer. Bit and structured element arrays are not encodable. + /// + /// The PCCC element data type. + /// The typed elements, exactly element-count long. + /// The length differs from the element count, or the array's element type + /// does not match . + /// has no flat array encoding. + void EncodeArray(AbLegacyDataType type, Array values); } public interface IAbLegacyTagFactory diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs index 3d762e84..45855904 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyStatusTranslation.cs @@ -23,6 +23,15 @@ internal static class LibplctagLegacyStatusTranslation Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, Status.ErrorBadReply => AbStatus.BadReply, + // A reply ARRIVED but carries an error libplctag does not break out: it folds EVERY non-zero PCCC STS and CIP + // general status into PLCTAG_ERR_REMOTE_ERR / ERR_BAD_STATUS. That covers the PLC refusing (element past the + // end of the file) AND a routing hop reporting the end device is gone (CIP 0x01/0x0204 unconnected send timed + // out, 0x07 connection lost, PCCC STS 0x30 remote node missing). So it is neither a no-reply comms fault + // (review 07 r0 I1 — it must not fall into the BadConnection default, which black-outs coalesced neighbours and + // trips fail-fast) nor proof of reachability (review 07 r1 N1). AbStatus has no dedicated "opaque remote error" + // value and Driver.Ab.Transport is Lane K's, so this maps to the existing BadReply — "a reply we cannot + // classify" — which the driver treats as OPAQUE (AbLegacyDriver.IsOpaque). OPC UA: BadCommunicationError. + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.BadReply, _ => AbStatus.BadConnection, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs index 751e9701..3fd98a76 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/LibplctagLegacyTagRuntime.cs @@ -15,6 +15,9 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime { private readonly Tag _tag; + /// The element count the tag was created with (1 for a scalar). + private readonly int _count; + /// Why a write to this tag is refused before the wire (), else null. private readonly string? _writeRefusal; @@ -32,6 +35,7 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime public LibplctagLegacyTagRuntime(AbLegacyTagCreateParams p) { _tag = BuildTag(p); + _count = Math.Max(1, p.ElementCount); _writeRefusal = WriteRefusal(p); } @@ -244,6 +248,32 @@ internal sealed class LibplctagLegacyTagRuntime : IAbLegacyTagRuntime } } + /// + public void EncodeArray(AbLegacyDataType type, Array values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Length != _count) + throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values)); + // Same offsets DecodeArray reads. EVERY element is set, so the buffer libplctag sends holds nothing from the + // last read (a scalar Set*(0, ...) used to re-send elements 1..n-1 as they were last read). + switch (type, values) + { + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s): + for (var i = 0; i < s.Length; i++) _tag.SetInt16(i * 2, s[i]); + break; + case (AbLegacyDataType.Long, int[] l): + for (var i = 0; i < l.Length; i++) _tag.SetInt32(i * 4, l[i]); + break; + case (AbLegacyDataType.Float, float[] f): + for (var i = 0; i < f.Length; i++) _tag.SetFloat32(i * 4, f[i]); + break; + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _): + throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values)); + default: + throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding."); + } + } + /// Disposes the tag and releases its resources. public void Dispose() => _tag.Dispose(); diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs index 9a20acc0..8c372e5d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/NativeAbLegacyTagRuntime.cs @@ -448,6 +448,32 @@ internal sealed class NativeAbLegacyTagRuntime : IAbLegacyTagRuntime } } + /// + public void EncodeArray(AbLegacyDataType type, Array values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Length != _count) + throw new ArgumentException($"Array write of {values.Length} elements to a {_count}-element tag.", nameof(values)); + // Same layout as DecodeArray: element i at i * elementSize. Every element is overwritten, so nothing the + // write sends comes from the last read. + switch (type, values) + { + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt, short[] s): + for (var i = 0; i < s.Length; i++) BinaryPrimitives.WriteInt16LittleEndian(SliceW(i * 2, 2), s[i]); + break; + case (AbLegacyDataType.Long, int[] l): + for (var i = 0; i < l.Length; i++) BinaryPrimitives.WriteInt32LittleEndian(SliceW(i * 4, 4), l[i]); + break; + case (AbLegacyDataType.Float, float[] f): + for (var i = 0; i < f.Length; i++) BinaryPrimitives.WriteSingleLittleEndian(SliceW(i * 4, 4), f[i]); + break; + case (AbLegacyDataType.Int or AbLegacyDataType.AnalogInt or AbLegacyDataType.Long or AbLegacyDataType.Float, _): + throw new ArgumentException($"{values.GetType().Name} is not the element type of a {type} array.", nameof(values)); + default: + throw new NotSupportedException($"AbLegacyDataType {type} has no array write encoding."); + } + } + /// /// Releases the lease on the shared device connection (the last release closes it) without waiting: /// closing does I/O (Forward Close / UnRegisterSession) and the driver disposes under a per-tag lock. diff --git a/src/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() {