From 92171a01ec4ca2428c0ce6b2801c791ab1764a7e Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:04:51 -0400 Subject: [PATCH 01/18] fix(ablegacy): build-and-swap device/tag tables; teardown safe vs Dispose and abandoned init Review 07 H13: _devices and _tagsByRawPath were plain dictionaries cleared and refilled in place while the retained poll loop, the actor's GetHostStatuses and server reads enumerated them. Both are now built locally and published by reference under a lifecycle lock; teardown swaps in empty tables. Lane A contract: ShutdownAsync and Dispose each retire a disjoint set of device states, so Dispose running concurrently with a still-joining ShutdownAsync is safe. A lifecycle epoch makes an InitializeAsync that the runtime abandoned (overlapping the next ShutdownAsync) refuse to publish its tables, start probe loops or write health. A runtime cached on an already-retired device state by an in-flight read is disposed instead of leaked, and late reads/writes no longer overwrite shutdown's health. Per-key semaphores are no longer disposed under callers that may still hold them. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 362 +++++++++++------- .../AbLegacyBitRmwTests.cs | 2 +- .../AbLegacyLifecycleConcurrencyTests.cs | 255 ++++++++++++ .../AbLegacyTestLogger.cs | 23 ++ 4 files changed, 504 insertions(+), 138 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyTestLogger.cs 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..66db900d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -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). @@ -121,9 +135,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,53 +162,62 @@ 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), "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)) { @@ -201,10 +227,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover _driverInstanceId, entry.RawPath); 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 +239,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 +253,107 @@ 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. + private List 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; + } + } + + /// + /// 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 +370,35 @@ 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 = 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); + lock (_lifecycleLock) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// @@ -369,6 +425,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IReadOnlyList fullReferences, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(fullReferences); + var epoch = CurrentEpoch; var now = DateTime.UtcNow; var results = new DataValueSnapshot[fullReferences.Count]; @@ -404,8 +461,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { 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}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport fault resolving indirect address for {reference}")); continue; } def = res.Def; @@ -443,8 +500,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport status {status} reading {reference}")); if (!device.FirstNonZeroStatusLogged) { device.FirstNonZeroStatusLogged = true; @@ -459,7 +516,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover device.FirstNonZeroStatusLogged = false; results[i] = new DataValueSnapshot(value, AbLegacyStatusMapper.Good, now, now); - _health = new DriverHealth(DriverState.Healthy, now, null); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Healthy, now, null)); } catch (OperationCanceledException) { throw; } catch (AbTransportException ex) when (IsLocalRefusal(ex)) @@ -474,7 +531,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover EvictRuntime(device, def.Name); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } } @@ -488,6 +545,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover IReadOnlyList writes, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(writes); + var epoch = CurrentEpoch; var results = new WriteResult[writes.Count]; for (var i = 0; i < writes.Count; i++) @@ -530,8 +588,8 @@ 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}"); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, + $"transport fault resolving indirect address for {w.FullReference}")); continue; } def = res.Def; @@ -597,7 +655,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover catch (NotSupportedException nse) { results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message)); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -618,7 +676,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); - _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); + SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } } @@ -684,7 +742,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 +751,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) @@ -722,7 +780,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 +826,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 ---- @@ -1011,7 +1070,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover LibplctagPlcAttribute: device.Profile.LibplctagPlcAttribute, TagName: parentName, Timeout: _options.Timeout)); - await InitializeOrKeepRefusedAsync(runtime, () => device.ParentRuntimes[parentName] = runtime, ct) + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.ParentRuntimes, parentName, runtime), ct) .ConfigureAwait(false); return runtime; } @@ -1117,7 +1176,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover $"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) + await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.Runtimes, def.Name, runtime), ct) .ConfigureAwait(false); return runtime; } @@ -1158,6 +1217,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public void Dispose() { + var retired = 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 +1225,13 @@ 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); + lock (_lifecycleLock) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// @@ -1244,7 +1297,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// 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 . + /// that would be leaked on . /// private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); @@ -1326,25 +1379,60 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + 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; + /// - /// Disposes all cached tag runtimes. + /// 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. /// - public void DisposeRuntimes() + /// The runtime table ( or ). + /// The runtime key. + /// The initialised runtime. + public void Cache(System.Collections.Concurrent.ConcurrentDictionary table, + string key, IAbLegacyTagRuntime runtime) { - foreach (var r in Runtimes.Values) r.Dispose(); - Runtimes.Clear(); - foreach (var r in ParentRuntimes.Values) r.Dispose(); - ParentRuntimes.Clear(); + 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/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/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/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))); +} From 7377daa9b0d93b1b3128c446dccb5b6b6fff02a0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:17:16 -0400 Subject: [PATCH 02/18] fix(ablegacy): prune per-key gates when an indirect index moves Review 07 L7: each index value an indirect tag (N7:[N7:0]) swept through left a per-runtime and a creation SemaphoreSlim in the device's gate tables until shutdown. A moved index now drops the stale resolution's gates along with its handle, and sweeps any other stale resolution of the same tag a concurrent reader cached, so the tables stay bounded by the authored set. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 30 +++++++++++++++++-- .../AbLegacyIndirectAddressingTests.cs | 28 +++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) 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 66db900d..b5bc01e2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -965,8 +965,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)) @@ -974,6 +974,17 @@ 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; + EvictRuntime(device, key); + device.ForgetGates(key); + } } return new IndirectResolution(def with { Name = resolvedName, Address = concreteText }, 0, false); } @@ -1379,6 +1390,21 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// Tags already warned about for a malformed address (once-per-tag log). public System.Collections.Concurrent.ConcurrentDictionary MalformedAddressLogged { get; } = new(); + /// + /// 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. + /// + /// The runtime key whose gates to drop. + public void ForgetGates(string runtimeKey) + { + _runtimeLocks.TryRemove(runtimeKey, out _); + _creationLocks.TryRemove($"tag:{runtimeKey}", out _); + } + + /// The number of per-key gates currently held (runtime + creation + RMW). Tests only. + internal int GateCount => _runtimeLocks.Count + _creationLocks.Count + _rmwLocks.Count; + private volatile bool _retired; /// True once this state has been unpublished and released; nothing may be cached on it any more. 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); + } } From 8b50b43048f2b6efebd42fe397642cb57be5ab4f Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:20:17 -0400 Subject: [PATCH 03/18] fix(ablegacy): deterministic NotSupported refusals are BadNotSupported with no health write Review 07 H16: an array of String/Timer/Counter/Control passed the mapper, so every poll created a handle (one init read), the decode threw NotSupportedException, the generic catch evicted the handle and latched Degraded. FromTagConfig now rejects that combination (the skip carries the reason, Inspect/deploy gate and the AdminUI model state it), and a runtime NotSupportedException on read or write is BadNotSupported: handle kept, no health write, one Warning per tag and operation. Write refusals (native PLC-5 and status-bit writes, libplctag translated addresses) now warn once per tag too. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 26 ++++ .../AbLegacyDriver.cs | 39 ++++- .../Uns/TagEditors/AbLegacyTagConfigModel.cs | 5 + .../AbLegacyDeterministicRefusalTests.cs | 136 ++++++++++++++++++ .../Uns/AbLegacyTagConfigModelTests.cs | 14 ++ 5 files changed, 215 insertions(+), 5 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeterministicRefusalTests.cs 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..c6ab0a97 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); @@ -224,8 +228,30 @@ 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."; + private static int ReadInt(JsonElement o, string name) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; 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 b5bc01e2..9adfd47c 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -221,10 +221,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { 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; } tags[entry.RawPath] = def with @@ -523,6 +526,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // 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); + results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); + } + 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); results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); } catch (Exception ex) @@ -601,8 +612,9 @@ 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 (parsed is not null && _tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) { + WarnRefusalOnce(device, def, "write", refusal); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); continue; } @@ -654,8 +666,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover catch (OperationCanceledException) { throw; } 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); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, nse.Message)); } catch (Exception ex) when (ex is FormatException or InvalidCastException) { @@ -669,6 +682,7 @@ 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) @@ -1157,6 +1171,18 @@ 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) + { + if (!device.RefusalLogged.TryAdd($"{operation}:{def.Name}", 0)) return; + _logger.LogWarning( + "AbLegacy {Operation} refused (BadNotSupported). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", + operation, _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) => @@ -1387,6 +1413,9 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover /// public bool FirstNonZeroStatusLogged { get; set; } + /// 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(); 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..dcb67114 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,11 @@ 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); + // 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/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/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/Uns/AbLegacyTagConfigModelTests.cs index 975bd68e..2bf866bb 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,20 @@ 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(); + } + [Fact] public void Validate_returns_null_for_scalar_tag_regardless_of_no_array_length() { From 517837e79894fdb6cd052aa20cfe3f3a7bf33603 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:25:12 -0400 Subject: [PATCH 04/18] feat(ablegacy): encode array writes element by element Review 07 U10 (user decision: implement array encode now). Both runtimes encoded a scalar for an array tag: an int[] threw InvalidCastException into BadTypeMismatch + Degraded, a scalar wrote element 0 natively, and libplctag's SetInt32(0, ..) re-sent the stale last-read buffer for elements 1..n-1. IAbLegacyTagRuntime gains EncodeArray; both runtimes write every element at the element width (Int/AnalogInt 2 bytes, Long/Float 4). The driver converts the written value before any runtime exists: each element is converted, not exact-matched (ScadaBridge writes int[] to Int16 nodes), a length other than the tag's element count is BadOutOfRange, an overflowing element is BadOutOfRange, a non-array or non-numeric payload is BadTypeMismatch, and a Bit array (bit-packed words; an encode would clobber neighbour bits) is BadNotSupported. None of these touch health or the wire. Adds an env-gated ab_server round trip for an N-file array write. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteEncoder.cs | 79 ++++++++ .../AbLegacyDriver.cs | 23 ++- .../IAbLegacyTagRuntime.cs | 14 ++ .../LibplctagLegacyTagRuntime.cs | 30 +++ .../NativeAbLegacyTagRuntime.cs | 26 +++ .../AbLegacyArrayWriteIntegrationTests.cs | 70 +++++++ .../AbLegacyArrayWriteTests.cs | 183 ++++++++++++++++++ .../FakeAbLegacyTag.cs | 5 + 8 files changed, 427 insertions(+), 3 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyArrayWriteEncoder.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs 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.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs index 9adfd47c..97754d94 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -612,7 +612,7 @@ 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 { } refusal) + if (_tagFactory.WriteRefusal(CreateParamsFor(device, def, parsed)) is { } refusal) { WarnRefusalOnce(device, def, "write", refusal); results[i] = new WriteResult(AbLegacyStatusMapper.BadNotSupported); @@ -627,7 +627,23 @@ 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) + // 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. + Array? arrayValue = null; + if (IsArrayTag(def)) + { + var conversion = parsed.BitIndex is not null || parsed.SubElement is not null + ? AbLegacyStatusMapper.BadNotSupported + : AbLegacyArrayWriteEncoder.TryConvert(def.DataType, 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; + } + } + 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)); @@ -643,7 +659,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - runtime.EncodeValue(def.DataType, parsed?.BitIndex, w.Value); + if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue); + else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); status = runtime.GetStatus(); } 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/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/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..0b479e73 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs @@ -0,0 +1,70 @@ +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 (N7:2, 4 elements) round-trips every element against +/// ab_server on both transports, and leaves the words either side untouched. Simulator-only (gated by +/// ); the real-controller proof is a hardware userGate. +/// +[Collection(AbLegacyServerCollection.Name)] +[Trait("Category", "Integration")] +[Trait("Simulator", "ab_server-PCCC")] +public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim) +{ + /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back. + /// 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 deviceUri = $"ab://{sim.Host}:{sim.PortFor(profile)}/{sim.CipPath}"; + await using var drv = new AbLegacyDriver(new AbLegacyDriverOptions + { + Devices = [new AbLegacyDeviceOptions(deviceUri, profile.Family)], + RawTags = AbLegacyRawTags.Entries( + new AbLegacyTagDefinition("Before", deviceUri, "N7:1", AbLegacyDataType.Int), + new AbLegacyTagDefinition("Arr", deviceUri, "N7:2", AbLegacyDataType.Int, ArrayLength: 4), + new AbLegacyTagDefinition("After", deviceUri, "N7:6", AbLegacyDataType.Int)), + Timeout = TimeSpan.FromSeconds(5), + Probe = new AbLegacyProbeOptions { Enabled = false }, + Transport = transport, + }, driverInstanceId: $"ablegacy-arraywrite-{profile.Family}-{transport}"); + var ct = TestContext.Current.CancellationToken; + await drv.InitializeAsync("{}", ct); + + 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; + } + + (await drv.WriteAsync( + [new WriteRequest("Before", (short)111), new WriteRequest("After", (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 back = await drv.ReadAsync(["Before", "Arr", "After"], ct); + back.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); + back[1].Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); + Convert.ToInt32(back[0].Value).ShouldBe(111); // neighbours untouched + Convert.ToInt32(back[2].Value).ShouldBe(666); + + // 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 }); + } +} 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..0ef846a0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -0,0 +1,183 @@ +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); + } + + [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/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; } From e5527d02017137b906e1a1ef8c0f9ea7438ba078 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:33:54 -0400 Subject: [PATCH 05/18] =?UTF-8?q?fix(ablegacy):=20isolate=20a=20dead=20dev?= =?UTF-8?q?ice=20=E2=80=94=20parallel=20devices,=20per-device=20backoff,?= =?UTF-8?q?=20single-flight=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review 07 P7 (High): reads walked every reference of every device serially with no backoff, so a dead device cost Timeout per tag per poll (50 tags x 2 s = 100 s) and starved every healthy device long enough to trip the comms-loss watchdog against a reachable one. - ReadAsync buckets references by device and reads up to 8 devices concurrently; each device's tags are still read in order. - Each device has a ConnectionBackoff (1 s doubling to 30 s). Two consecutive comms faults mark it down: the rest of that pass and every later pass fail fast with BadCommunicationError and touch no wire until the window expires or any answer (data path or probe) resets it. One timing-out tag does not blacken its answering siblings. - Cold connects are single-flight per device (ConnectGate), replacing the per-key creation locks; a cached handle never touches the gate. - A status/exception proving the device ANSWERED with a deterministic refusal (not found, not allowed, out of bounds, type/size mismatch) is per-tag Bad, keeps its handle, warns once per tag and advances LastSuccessfulRead instead of evicting and degrading. Writes classify the same way. - Health is written once per read: Degraded if any device faulted, Healthy if anything read Good, LastSuccessfulRead advanced when any device answered. Adds an env-gated ab_server case with a blackholed sibling device. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 574 +++++++++++++----- .../AbLegacyDeadDeviceIsolationTests.cs | 65 ++ .../AbLegacyDeviceIsolationTests.cs | 259 ++++++++ 3 files changed, 738 insertions(+), 160 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs 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 97754d94..60da49b5 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -423,7 +423,50 @@ 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; + + /// 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). + /// + private enum OpVerdict { Neutral, Answered, Fault, Throttled } + + /// 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; + } + /// + /// + /// 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) { @@ -431,124 +474,255 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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) - SetHealthIfCurrent(epoch, 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); - SetHealthIfCurrent(epoch, 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); - SetHealthIfCurrent(epoch, 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. - WarnRefusalOnce(device, def, "read", ex.Message); - results[i] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); - } - 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); - 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); - SetHealthIfCurrent(epoch, 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; + } + if (fault is null && !anyGood && !anyAnswered) return; + + lock (_lifecycleLock) + { + if (epoch != _lifecycleEpoch) return; + var current = _health; + var lastRead = anyAnswered ? now : current.LastSuccessfulRead; + if (fault is not null) + _health = new DriverHealth(DriverState.Degraded, lastRead, fault); + else if (anyGood) + _health = new DriverHealth(DriverState.Healthy, lastRead, null); + else + _health = current with { LastSuccessfulRead = lastRead }; + } + } + + /// 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(); + foreach (var item in items) + { + if (device.IsThrottled(DateTime.UtcNow)) + { + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + outcome.FaultMessage ??= $"device {device.Options.HostAddress} is in its reconnect backoff window"; + continue; + } + + var (verdict, message) = await ReadOneAsync(device, item, results, now, ct).ConfigureAwait(false); + switch (verdict) + { + case OpVerdict.Answered: + outcome.AnyAnswered = true; + if (results[item.Index].StatusCode == AbLegacyStatusMapper.Good) outcome.AnyGood = true; + device.RecordAnswered(); + break; + case OpVerdict.Fault: + outcome.FaultMessage ??= message; + device.RecordCommsFault(DateTime.UtcNow); + break; + case OpVerdict.Throttled: + outcome.FaultMessage ??= message; + break; + } + } + 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}") + : (OpVerdict.Neutral, null); + } + def = res.Def; + } + + 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); + await opLock.WaitAsync(ct).ConfigureAwait(false); + try + { + 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 deterministic refusal (file/element missing, out of bounds…): 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}"); + return (OpVerdict.Answered, null); + } + // 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); + 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); + results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(ex.Status), null, now); + return (OpVerdict.Answered, null); + } + 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); + 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 that proves the DEVICE answered and refused this one operation deterministically — the + /// file or element does not exist, the access is not allowed, the data type or size is wrong. Such a status + /// is per-tag, keeps the handle, and is not a comms fault. Everything else non-zero (timeouts, dropped or + /// refused connections, malformed or partial replies, unknown codes) is treated as a comms fault. + /// is classified separately (). + /// + /// The transport status. + /// for a deterministic 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; + // ---- IWritable ---- /// @@ -558,6 +732,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 for (var i = 0; i < writes.Count; i++) { @@ -572,7 +747,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; @@ -619,14 +794,6 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 - // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements - // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a - // 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. // 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. Array? arrayValue = null; @@ -643,6 +810,14 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 + // (I:0/0, O:1/2); L-file bits RMW a 32-bit parent, the rest a 16-bit word. T/C/R sub-elements + // (T4:0.DN, even when authored Bit-typed) never reach this path: AbLegacyAddress.TryParse rejects a + // 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. else if (def.DataType == AbLegacyDataType.Bit && parsed.BitIndex is int bit) { results[i] = new WriteResult( @@ -669,18 +844,38 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover opLock.Release(); } - if (status != 0) + if (status == 0) { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); + device.RecordAnswered(); + results[i] = new WriteResult(AbLegacyStatusMapper.Good); + } + else if (IsDeviceRefusal((AbStatus)status)) + { + // The device answered and refused this write deterministically: keep the handle, warn once. + device.RecordAnswered(); + WarnRefusalOnce(device, def, "write", $"device status {(AbStatus)status}"); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); } else { - results[i] = new WriteResult(AbLegacyStatusMapper.Good); + // Evict the stale handle so the next call re-creates it (mirrors AbCip). + EvictRuntime(device, def.Name); + device.RecordCommsFault(DateTime.UtcNow); + results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); } } catch (OperationCanceledException) { throw; } + catch (ConnectionThrottledException) + { + // The device is in its reconnect backoff window: fail fast, no wire, no new evidence. + results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); + } + catch (AbTransportException ex) when (IsDeviceRefusal(ex.Status)) + { + device.RecordAnswered(); + WarnRefusalOnce(device, def, "write", ex.Message); + 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. @@ -706,6 +901,7 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); + device.RecordCommsFault(DateTime.UtcNow); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); } @@ -799,14 +995,21 @@ 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 state.RecordCommsFault(DateTime.UtcNow); } 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 (!IsLocalRefusal(ex)) state.RecordCommsFault(DateTime.UtcNow); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1043,10 +1246,16 @@ 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); } + 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); + } catch { EvictParentRuntime(device, runtimeName); @@ -1054,6 +1263,8 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover } var status = runtime.GetStatus(); + if (status != 0 && IsDeviceRefusal((AbStatus)status)) + return (null, AbLegacyStatusMapper.MapStatus(status), false); if (status != 0) { EvictParentRuntime(device, runtimeName); @@ -1093,32 +1304,56 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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.Cache(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 static 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(DateTime.UtcNow)) + 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(); } } @@ -1214,30 +1449,14 @@ 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 runtime = _tagFactory.Create(CreateParamsFor(device, def, parsed)); - await InitializeOrKeepRefusedAsync(runtime, () => device.Cache(device.Runtimes, def.Name, runtime), ct) - .ConfigureAwait(false); - return runtime; - } - finally - { - creationLock.Release(); - } + 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); } /// @@ -1329,7 +1548,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; } = @@ -1348,20 +1567,56 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover 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 . + /// 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). /// - private readonly System.Collections.Concurrent.ConcurrentDictionary _creationLocks = new(StringComparer.OrdinalIgnoreCase); + public SemaphoreSlim ConnectGate { get; } = new(1, 1); /// - /// Gets or creates the creation lock for the specified key. + /// 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 . /// - /// The lock key. - /// The semaphore slim for the key. - public SemaphoreSlim GetCreationLock(string key) => - _creationLocks.GetOrAdd(key, _ => new SemaphoreSlim(1, 1)); + 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(); @@ -1445,11 +1700,10 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover public void ForgetGates(string runtimeKey) { _runtimeLocks.TryRemove(runtimeKey, out _); - _creationLocks.TryRemove($"tag:{runtimeKey}", out _); } - /// The number of per-key gates currently held (runtime + creation + RMW). Tests only. - internal int GateCount => _runtimeLocks.Count + _creationLocks.Count + _rmwLocks.Count; + /// The number of per-key gates currently held (runtime + RMW). Tests only. + internal int GateCount => _runtimeLocks.Count + _rmwLocks.Count; private volatile bool _retired; 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..2e5be188 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs @@ -0,0 +1,65 @@ +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)]; + + var sw = Stopwatch.StartNew(); + var first = await drv.ReadAsync(refs, ct); + sw.Stop(); + + first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "the live device must be served in the same poll"); + first.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good); + sw.Elapsed.ShouldBeLessThan(timeout * 3, "a dead device may cost at most two timeouts per backoff window"); + + sw.Restart(); + var second = await drv.ReadAsync(refs, ct); + sw.Stop(); + second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); + sw.Elapsed.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.Tests/AbLegacyDeviceIsolationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs new file mode 100644 index 00000000..a28b8b60 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -0,0 +1,259 @@ +using System.Diagnostics; +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 }, + }, "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 sw = Stopwatch.StartNew(); + var first = await drv.ReadAsync(refs, CancellationToken.None); + sw.Stop(); + + // Serial-with-no-backoff would pay 20 x 150 ms = 3 s on device B alone. + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); + 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); + sw.Restart(); + var second = await drv.ReadAsync(refs, CancellationToken.None); + sw.Stop(); + Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst); + sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500)); + 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)); + 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); + await Task.Delay(TimeSpan.FromMilliseconds(1100)); + + 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); + } +} From 530f4bdcd4935a879bbae6e9afc3e91fba20790d Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:41:20 -0400 Subject: [PATCH 06/18] perf(ablegacy): coalesce adjacent N/B/F/L scalar reads into one PCCC request Review 07 P7 (rest): adjacent words on one data file were never coalesced (one round trip per tag) although the array path proves the transport can read a span. Within a device pass, scalar tags on one N/B/F/L file within 8 elements of each other (block <= 200 data bytes, under the 236-byte typed-read limit) now ride one multi-element read and are decoded from their offsets with exactly the CLR types the single-tag path returns (int / float / bool). Only shapes the single path decodes identically are coalesced: indirect, sub-element, array, file-number-less (S:/I:/O:) and width-mismatched tags keep the single path. A block the device refuses (an authored element past the end of the file, a native PLC-5 non-N file) falls back to per-tag reads for its members and its shape is remembered; a comms fault fails the members together. New driver-config key coalesceReads (default true) with an AdminUI switch (Razor change: AbLegacyDriverForm, for the Wave 4 live check). Adds an env-gated ab_server parity case (coalesced == per-tag values). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverOptions.cs | 7 + .../AbLegacyDriver.Coalescing.cs | 242 ++++++++++++++++++ .../AbLegacyDriver.cs | 35 ++- .../AbLegacyDriverFactoryExtensions.cs | 4 + .../Drivers/Forms/AbLegacyDriverForm.razor | 12 +- .../AbLegacyReadCoalescingIntegrationTests.cs | 69 +++++ .../AbLegacyDeviceIsolationTests.cs | 1 + .../AbLegacyReadCoalescingTests.cs | 182 +++++++++++++ ...bLegacyDriverPageFormSerializationTests.cs | 17 ++ 9 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs 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..36b21186 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,13 @@ 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. + /// + public bool CoalesceReads { get; init; } = true; + /// /// 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/AbLegacyDriver.Coalescing.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs new file mode 100644 index 00000000..c1c6fac1 --- /dev/null +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -0,0 +1,242 @@ +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) +/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the +/// block shape is remembered so it is not retried every poll. A comms fault 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 || 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) + { + device.RefusedBlocks.TryAdd(block.Key, 0); + _logger.LogInformation( + "AbLegacy coalesced read {Block} refused ({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) + { + if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported) + { + EvictRuntime(device, block.Key); + 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.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 (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status)) + { + EvictRuntime(device, block.Key); + return Refused(ex.Message); + } + catch (NotSupportedException nse) + { + EvictRuntime(device, block.Key); + 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 60da49b5..16f626da 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, ISubscribable, IHostConnectivityProbe, IPerCallHostResolver, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags this driver could not build into its poll table. @@ -565,21 +565,42 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover DeviceState device, List items, DataValueSnapshot[] results, DateTime now, CancellationToken ct) { var outcome = new DeviceReadOutcome(); - foreach (var item in items) + var work = new Queue(PlanReadUnits(device, items)); + while (work.TryDequeue(out var unit)) { + var members = unit.Block?.Members ?? [unit.Single!]; if (device.IsThrottled(DateTime.UtcNow)) { - results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); + 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; } - var (verdict, message) = await ReadOneAsync(device, item, results, now, ct).ConfigureAwait(false); + 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 (results[item.Index].StatusCode == AbLegacyStatusMapper.Good) outcome.AnyGood = true; + if (members.Any(m => results[m.Index].StatusCode == AbLegacyStatusMapper.Good)) outcome.AnyGood = true; device.RecordAnswered(); break; case OpVerdict.Fault: @@ -1685,6 +1706,10 @@ 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); + /// 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); 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..d06effd9 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 ?? true, // 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/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..a938652b 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, ProbeEnabled = o.Probe.Enabled, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, @@ -220,6 +229,7 @@ }, Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, + CoalesceReads = CoalesceReads, ProbeTimeoutSeconds = AdminProbeTimeoutSeconds, }; } 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..2558adc3 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -0,0 +1,69 @@ +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)); + } +} 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 index a28b8b60..213fea69 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -45,6 +45,7 @@ public sealed class AbLegacyDeviceIsolationTests 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") => 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..59fddba0 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -0,0 +1,182 @@ +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_not_retried() + { + // 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)); + } + created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered + 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.ShouldBe(expected); +} 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..162336cc 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,23 @@ 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.ShouldBe(edited); + } + [Fact] public void A_legacy_TimeSpan_string_key_is_loaded_then_migrated_to_ms_and_removed() { From 582e82899bcb79064270ed1e64a65febf490651c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:42:23 -0400 Subject: [PATCH 07/18] feat(ablegacy): implement IBatchSubscribable Review 02 P1 / Wave 2 rule (d): AbLegacy runs PollGroupEngine but raised only the per-tag OnDataChange, so every changed tag paid its own trip through the actor chain. The engine's per-cycle batch is now raised as OnDataChangeBatch (one per poll cycle), alongside the per-tag event for legacy consumers. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 10 +++- .../AbLegacyBatchSubscribeTests.cs | 50 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyBatchSubscribeTests.cs 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 16f626da..773acc64 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 partial 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. @@ -64,6 +64,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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. /// @@ -99,6 +105,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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, 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); + } +} From 66927a3faa77df2d6ec05930cbcf31bf7197dcd0 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:43:23 -0400 Subject: [PATCH 08/18] fix(ablegacy): wire the authored probeTimeoutSeconds into Test Connect Dead config field (user decision: wire them all): the AdminUI form authors probeTimeoutSeconds ("Admin UI probe timeout", default 5) but AbLegacyDriverProbe never read it, so Test Connect always ran on the caller's budget. It now tightens that budget (never raises it), the same rule as the Galaxy probe; a non-positive value keeps the caller's budget. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverProbe.cs | 20 +++++++++++ .../AbLegacyDriverProbeTests.cs | 34 +++++++++++++++++++ 2 files changed, 54 insertions(+) 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/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() { From ffa5b2a4fac81b9bcfd5cf4644444ad35845133c Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:44:51 -0400 Subject: [PATCH 09/18] fix(ablegacy-cli): Ctrl-C prints Cancelled instead of a stack trace Review 07 U7 (AbLegacy part): probe/read/write had no OperationCanceledException arm, so cancelling a slow read printed an unhandled-exception stack trace. The driver work now runs through AbLegacyCommandBase.RunCancellableAsync, which reports an operator cancellation as "Cancelled." and still propagates anything else. (The AbCip subscribe lock and SnapshotFormatter array rendering are other lanes' files.) Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyCommandBase.cs | 21 ++++++++ .../Commands/ProbeCommand.cs | 23 ++++---- .../Commands/ReadCommand.cs | 9 ++-- .../Commands/WriteCommand.cs | 9 ++-- .../CancellationHandlingTests.cs | 52 +++++++++++++++++++ 5 files changed, 98 insertions(+), 16 deletions(-) create mode 100644 tests/Drivers/Cli/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Cli.Tests/CancellationHandlingTests.cs 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/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(); + } +} From 61dd498eab13a41f7bb1a58b59996ac248649ab9 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:46:04 -0400 Subject: [PATCH 10/18] test(ablegacy): pin the array-write capability row against the encoder DriverArrayWriteSupport (Core.Abstractions, not this lane's) still lists AbLegacy as scalar-only, so every AbLegacy array node is materialised read-only and the new array encoder is unreachable from OPC UA. Adds the Modbus/S7-style parity test, skipped until follow-up F1 adds the AbLegacy row (Int/AnalogInt/Long/Float with a declared length), plus an active test pinning exactly which element types the encoder accepts. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteSupportParityTests.cs | 58 +++++++++++++++++++ 1 file changed, 58 insertions(+) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs 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..acae0cdd --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs @@ -0,0 +1,58 @@ +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()); + } + + /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float). + [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); +} From 34e455f412652f07f2b652e52fdfa3e4fcda1555 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:48:35 -0400 Subject: [PATCH 11/18] test(ablegacy): dead-device gate tolerates a coalesced single-unit pass Live run against ab_server (slc500/micrologix/plc5 x libplctag/native): with coalescing on, the blackholed device's 20 tags are one block, i.e. one comms fault per poll, so its backoff window opens on the second poll rather than the first. The gate now asserts the actual contract: at most two timeouts across three polls, the live device Good on every poll, and the third poll fast. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDeadDeviceIsolationTests.cs | 28 +++++++++++-------- 1 file changed, 16 insertions(+), 12 deletions(-) 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 index 2e5be188..64eb19fb 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyDeadDeviceIsolationTests.cs @@ -47,19 +47,23 @@ public sealed class AbLegacyDeadDeviceIsolationTests(AbLegacyServerFixture sim) await drv.InitializeAsync("{}", ct); string[] refs = ["Live", .. dead.Select(d => d.Name)]; - var sw = Stopwatch.StartNew(); - var first = await drv.ReadAsync(refs, ct); - sw.Stop(); + // 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); + } - first[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, "the live device must be served in the same poll"); - first.Skip(1).ShouldAllBe(s => s.StatusCode != AbLegacyStatusMapper.Good); - sw.Elapsed.ShouldBeLessThan(timeout * 3, "a dead device may cost at most two timeouts per backoff window"); - - sw.Restart(); - var second = await drv.ReadAsync(refs, ct); - sw.Stop(); - second[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good); - sw.Elapsed.ShouldBeLessThan(timeout, "inside the backoff window the dead device is not dialled"); + 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); } } From bc985f8857a292a669dd4c34984972683e90d383 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 14:53:46 -0400 Subject: [PATCH 12/18] test(ablegacy): array-write gate takes a base address and restores the words Makes the N-file array-write case usable as the real-controller hardware gate: AB_LEGACY_ARRAY_WRITE_BASE names the first of six consecutive words (default N7:1), each word is read first and written back in a finally, and the neighbours are verified per word with coalescing off. Live-run green on ab_server slc500/micrologix/plc5 x libplctag/native (39/39), and with a moved base (N7:3). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyArrayWriteIntegrationTests.cs | 69 +++++++++++++------ 1 file changed, 47 insertions(+), 22 deletions(-) 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 index 0b479e73..9a098667 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyArrayWriteIntegrationTests.cs @@ -6,16 +6,21 @@ using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests; /// -/// Review 07 U10: an N-file array write (N7:2, 4 elements) round-trips every element against -/// ab_server on both transports, and leaves the words either side untouched. Simulator-only (gated by -/// ); the real-controller proof is a hardware userGate. +/// 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) { - /// Seeds the neighbours, writes an int[] to a 4-element Int array, reads it all back. + /// 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] @@ -25,20 +30,29 @@ public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim 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( - new AbLegacyTagDefinition("Before", deviceUri, "N7:1", AbLegacyDataType.Int), - new AbLegacyTagDefinition("Arr", deviceUri, "N7:2", AbLegacyDataType.Int, ArrayLength: 4), - new AbLegacyTagDefinition("After", deviceUri, "N7:6", AbLegacyDataType.Int)), + [.. 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) { @@ -48,23 +62,34 @@ public sealed class AbLegacyArrayWriteIntegrationTests(AbLegacyServerFixture sim return; } - (await drv.WriteAsync( - [new WriteRequest("Before", (short)111), new WriteRequest("After", (short)666)], ct)) - .ShouldAllBe(r => r.StatusCode == AbLegacyStatusMapper.Good); + 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); + // 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 back = await drv.ReadAsync(["Before", "Arr", "After"], ct); - back.ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); - back[1].Value.ShouldBe(new short[] { 10, -20, 30, 32767 }); - Convert.ToInt32(back[0].Value).ShouldBe(111); // neighbours untouched - Convert.ToInt32(back[2].Value).ShouldBe(666); + 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 }); + // 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"); + } } } From 8a80dcc7875a169ac5eb94ffc3e023dbe67eb0b7 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:12:21 -0400 Subject: [PATCH 13/18] fix(ablegacy): a status the PLC returned is an answer, never a comms fault Review r0 I1. On the default libplctag transport a PLC refusal (non-zero PCCC STS, e.g. an element past the end of the file; or a CIP error) arrives as ERR_REMOTE_ERR / ERR_BAD_STATUS, which fell into the BadConnection default: a no-reply comms fault. A coalesced block with one such member then failed as a group every poll, and two refused tags in a row opened the device's fail-fast window against a device that was answering. - LibplctagLegacyStatusTranslation maps ERR_REMOTE_ERR / ERR_BAD_STATUS to the existing AbStatus.DeviceStateConflict (BadDeviceFailure): "reachable, refused the operation". No new AbStatus value (Driver.Ab.Transport is Lane K's). - IsNoReply (Timeout / BadConnection / Abort / Pending / unknown codes) is now the ONLY comms-fault class; IsDeviceRefusal is every other non-Ok status except Unsupported (BadReply / Partial included: a reply arrived). - A coalesced block refused with ANY returned status falls back to per-member reads in the same pass; the shape is remembered only after a repeat, and the refused block's handle is always evicted (M5). - A runtime status Unsupported is neutral (keeps the handle); the probe no longer counts it as a fault. - Refusal warnings log the status actually answered (M8). Live: the new past-end-of-file ab_server case fails on libplctag with the old translation and passes with it, on slc500/micrologix/plc5 x both transports. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.Coalescing.cs | 34 ++++-- .../AbLegacyDriver.cs | 69 ++++++++--- .../LibplctagLegacyStatusTranslation.cs | 8 ++ .../AbLegacyReadCoalescingIntegrationTests.cs | 40 ++++++ .../AbLegacyAnsweredStatusTests.cs | 115 ++++++++++++++++++ .../AbLegacyHostAndStatusTests.cs | 3 + .../AbLegacyReadCoalescingTests.cs | 6 +- 7 files changed, 240 insertions(+), 35 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs 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 index c1c6fac1..401ce098 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -19,8 +19,9 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy; /// 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) -/// falls back to single reads for that block's members, so one bad tag never blackens its neighbours, and the -/// block shape is remembered so it is not retried every poll. A comms fault fails the members together — they +/// — 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. /// @@ -133,10 +134,19 @@ public sealed partial class AbLegacyDriver (BlockOutcome, OpVerdict, string?) Refused(string why) { - device.RefusedBlocks.TryAdd(block.Key, 0); - _logger.LogInformation( - "AbLegacy coalesced read {Block} refused ({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); + // 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); } @@ -170,11 +180,10 @@ public sealed partial class AbLegacyDriver if (status != 0) { - if (IsDeviceRefusal((AbStatus)status) || (AbStatus)status == AbStatus.Unsupported) - { - EvictRuntime(device, block.Key); + // 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}"); @@ -184,6 +193,7 @@ public sealed partial class AbLegacyDriver 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); } @@ -193,14 +203,12 @@ public sealed partial class AbLegacyDriver FailAll(AbLegacyStatusMapper.BadCommunicationError); return (BlockOutcome.Done, OpVerdict.Throttled, tex.Message); } - catch (AbTransportException ex) when (IsLocalRefusal(ex) || IsDeviceRefusal(ex.Status)) + catch (AbTransportException ex) when (!IsNoReply(ex.Status)) { - EvictRuntime(device, block.Key); return Refused(ex.Message); } catch (NotSupportedException nse) { - EvictRuntime(device, block.Key); return Refused(nse.Message); } catch (Exception ex) 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 773acc64..2f303ee8 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -677,12 +677,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.MapStatus(status), null, now); if (IsDeviceRefusal((AbStatus)status)) { - // The device ANSWERED with a deterministic refusal (file/element missing, out of bounds…): 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}"); + // 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); + } // Evict the stale handle so the next call re-creates it (mirrors AbCip). EvictRuntime(device, def.Name); if (!device.FirstNonZeroStatusLogged) @@ -711,14 +717,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // 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); + 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); + 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); } @@ -726,7 +732,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // 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); + WarnRefusalOnce(device, def, "read", nse.Message, AbLegacyStatusMapper.BadNotSupported); results[item.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadNotSupported, null, now); return (OpVerdict.Neutral, null); } @@ -740,17 +746,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } /// - /// True for a status that proves the DEVICE answered and refused this one operation deterministically — the - /// file or element does not exist, the access is not allowed, the data type or size is wrong. Such a status - /// is per-tag, keeps the handle, and is not a comms fault. Everything else non-zero (timeouts, dropped or - /// refused connections, malformed or partial replies, unknown codes) is treated as a comms fault. - /// is classified separately (). + /// 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. - /// for a deterministic 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; + /// 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 non-zero status that proves the DEVICE answered and refused this one operation — the file or + /// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag + /// ERR_REMOTE_ERR, translated to ), or a reply the transport + /// could not map (, ). Such a status is per-tag Bad, + /// keeps the handle, and is evidence of reachability. is classified + /// separately (: it says nothing about reachability). + /// + /// + /// ⚠️ Depends on the transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): today it maps STS 0x30 + /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the + /// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog. + /// The remap is 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 not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status); // ---- IWritable ---- @@ -1029,7 +1052,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // 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 state.RecordCommsFault(DateTime.UtcNow); + else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1456,12 +1479,14 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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) + 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 (BadNotSupported). Driver={DriverInstanceId} Device={DeviceHostAddress} Reference={Reference} Address={Address} Reason={Reason}", - operation, _driverInstanceId, def.DeviceHostAddress, def.Name, def.Address, reason); + "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 @@ -1718,6 +1743,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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); 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..281c4ebd 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,14 @@ internal static class LibplctagLegacyStatusTranslation Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, Status.ErrorBadReply => AbStatus.BadReply, + // The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or + // element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero + // CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and + // Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning + // is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA + // BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which + // evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1). + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict, _ => AbStatus.BadConnection, }; 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 index 2558adc3..e3aa58d2 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -66,4 +66,44 @@ public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture 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"); + r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError, + $"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})"); + } + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + } } 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..8cbaba2a --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs @@ -0,0 +1,115 @@ +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; + +/// +/// Review 07 r0 I1: on the default libplctag transport a PLC's own refusal (a non-zero PCCC STS — e.g. an element +/// past the end of the file) arrives as ERR_REMOTE_ERR, which used to translate to BadConnection: a +/// no-reply comms fault. A coalesced block holding one such member then failed as a group every poll, and two +/// refused tags in a row tripped the device's fail-fast window. Statuses the device RETURNED are now answers; +/// only no-reply statuses are comms faults. +/// +[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 a PLC refusal (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); + + [Fact] + public void Remote_error_translates_to_an_answered_status_not_a_comms_fault() + { + var s = LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); + AbLegacyDriver.IsNoReply(s).ShouldBeFalse(); + AbLegacyDriver.IsDeviceRefusal(s).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadStatus)).ShouldBeTrue(); + // Positive control: real no-reply statuses still are. + AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue(); + AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadConnection)).ShouldBeTrue(); + } + + [Theory] + [InlineData(AbStatus.BadReply)] // native: an STS the transport does not map + [InlineData(AbStatus.Partial)] + [InlineData(AbStatus.NotFound)] + [InlineData(AbStatus.DeviceStateConflict)] + public void Statuses_the_device_returned_are_answers(AbStatus status) => + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + + [Theory] + [InlineData(AbStatus.Timeout)] + [InlineData(AbStatus.BadConnection)] + [InlineData(AbStatus.Abort)] + [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(); + } + + [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.ShouldBe(AbLegacyStatusMapper.BadDeviceFailure, $"poll {poll}"); + } + drv.GetHealth().State.ShouldBe(DriverState.Healthy); + drv.GetDeviceState(Host)!.IsThrottled(DateTime.UtcNow).ShouldBeFalse(); + await drv.ShutdownAsync(CancellationToken.None); + } + + [Fact] + public async Task Consecutive_refused_tags_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}: an answering device is never failed fast"); + } + factory.Tags["N7:9"].ReadCount.ShouldBe(3); + factory.Tags["N7:0"].InitializeCount.ShouldBe(1); // refused handles are kept, not churned + await drv.ShutdownAsync(CancellationToken.None); + } +} diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 8338dc3b..79cd5d80 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -89,6 +89,9 @@ public sealed class AbLegacyHostAndStatusTests Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable, Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange, Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported, + // DELIBERATE change (review 07 r0 I1): a PLC's own refusal (PCCC STS / CIP status → ERR_REMOTE_ERR / + // ERR_BAD_STATUS) is "device answered", not a comms error. + Status.ErrorRemoteErr or Status.ErrorBadStatus => AbLegacyStatusMapper.BadDeviceFailure, _ => AbLegacyStatusMapper.BadCommunicationError, }; 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 index 59fddba0..0dc8cf89 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -113,7 +113,7 @@ public sealed class AbLegacyReadCoalescingTests } [Fact] - public async Task A_refused_block_falls_back_to_single_reads_and_is_not_retried() + 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( @@ -133,7 +133,9 @@ public sealed class AbLegacyReadCoalescingTests r[1].StatusCode.ShouldBe(AbLegacyStatusMapper.Good, $"poll {poll}"); r[2].StatusCode.ShouldBe(AbLegacyStatusMapper.MapStatus(AbStatus.OutOfBounds)); } - created.Count(c => c.ElementCount > 1).ShouldBe(1); // the refused shape is remembered + // 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); } From 17ae2eea0b0692454e0a72c1b75b0b2e9e84aade Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:16:49 -0400 Subject: [PATCH 14/18] fix(ablegacy): any answer restores Healthy; writes feed health and the backoff MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review r0 I2 + M1/M2/M4/M7/M10. - I2: one health writer (ApplyHealth) for reads AND writes, worst wins per call: a comms fault => Degraded; otherwise any answer (Good value, device refusal, Good or refused write) => Healthy; LastSuccessfulRead advances on any answer. A refusal-only read used to leave a recovered driver Degraded, and writes never touched health at all. - M10: a no-reply write STATUS now degrades like the exception path did; a Good/refused write records an answer on the device's backoff, a no-reply one a fault (bit RMW included — it now reports its raw status). - M2: a write inside the device's backoff window fails fast with BadCommunicationError even when its handle is cached. - M1: ShutdownAsync/Dispose write their final Unknown only while the epoch they retired is still current (a newer initialise owns health otherwise). - M4: an indirect bit write releases the previous index value's parent-word handle (the per-word RMW gate is deliberately kept: removing a gate another writer may hold would let two RMWs of one word race). - M7: backoff decisions read an injectable Clock; the isolation tests drop their stopwatch asserts and cross the window on a manual clock. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 188 ++++++++++----- .../AbLegacyDeviceIsolationTests.cs | 13 +- .../AbLegacyHealthEvidenceTests.cs | 222 ++++++++++++++++++ 3 files changed, 360 insertions(+), 63 deletions(-) create mode 100644 tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs 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 2f303ee8..899e2c38 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -177,7 +177,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // allows it, and a reconnect retry can overlap a running attempt). Release the previous device states' probe loops + runtimes BEFORE the // 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(RetireTables(bumpEpoch: false), "re-initialise").ConfigureAwait(false); + 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}", @@ -328,8 +328,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// is clearing. /// /// True for a teardown: supersedes any in-flight initialise. - /// The device states that were published. - private List RetireTables(bool bumpEpoch) + /// 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) { @@ -340,7 +341,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } var retired = _devices.Values.ToList(); _devices = new Dictionary(StringComparer.OrdinalIgnoreCase); - return retired; + return (retired, _lifecycleEpoch); } } @@ -401,15 +402,19 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag public async Task ShutdownAsync(CancellationToken cancellationToken) { // Unpublish FIRST, so an in-flight initialise is superseded and new reads resolve nothing. - var retired = RetireTables(bumpEpoch: true); + 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 retirement. await ReleaseDeviceStatesAsync(retired, "shutdown").ConfigureAwait(false); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // A newer InitializeAsync that ran while this teardown was still joining owns health now. lock (_lifecycleLock) - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -445,6 +450,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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); @@ -552,19 +564,30 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag anyAnswered |= o.AnyAnswered; fault ??= o.FaultMessage; } - if (fault is null && !anyGood && !anyAnswered) return; + 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 (fault is not null) - _health = new DriverHealth(DriverState.Degraded, lastRead, fault); - else if (anyGood) - _health = new DriverHealth(DriverState.Healthy, lastRead, null); - else + 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); } } @@ -577,7 +600,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag while (work.TryDequeue(out var unit)) { var members = unit.Block?.Members ?? [unit.Single!]; - if (device.IsThrottled(DateTime.UtcNow)) + if (device.IsThrottled(Clock())) { foreach (var m in members) results[m.Index] = new DataValueSnapshot(null, AbLegacyStatusMapper.BadCommunicationError, null, now); @@ -613,7 +636,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag break; case OpVerdict.Fault: outcome.FaultMessage ??= message; - device.RecordCommsFault(DateTime.UtcNow); + device.RecordCommsFault(Clock()); break; case OpVerdict.Throttled: outcome.FaultMessage ??= message; @@ -785,6 +808,9 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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++) { @@ -817,6 +843,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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) @@ -826,8 +861,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { results[i] = new WriteResult(res.Status); if (res.TransportFault) - SetHealthIfCurrent(epoch, 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}"; + } continue; } def = res.Def; @@ -872,8 +909,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // may reject it; we surface that real PCCC status rather than pre-rejecting at the driver. 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; } @@ -896,36 +935,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag opLock.Release(); } - if (status == 0) - { - device.RecordAnswered(); - results[i] = new WriteResult(AbLegacyStatusMapper.Good); - } - else if (IsDeviceRefusal((AbStatus)status)) - { - // The device answered and refused this write deterministically: keep the handle, warn once. - device.RecordAnswered(); - WarnRefusalOnce(device, def, "write", $"device status {(AbStatus)status}"); - results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); - } - else - { - // Evict the stale handle so the next call re-creates it (mirrors AbCip). - EvictRuntime(device, def.Name); - device.RecordCommsFault(DateTime.UtcNow); - results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(status)); - } + 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) + catch (ConnectionThrottledException tex) { - // The device is in its reconnect backoff window: fail fast, no wire, no new evidence. + // 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(); - WarnRefusalOnce(device, def, "write", ex.Message); + outcome.AnyAnswered = true; + WarnRefusalOnce(device, def, "write", ex.Message, AbLegacyStatusMapper.MapStatus(ex.Status)); results[i] = new WriteResult(AbLegacyStatusMapper.MapStatus(ex.Status)); } catch (NotSupportedException nse) @@ -953,15 +979,38 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // Transport exception — evict so the next write/read creates a fresh handle (mirrors AbCip). EvictRuntime(device, def.Name); - device.RecordCommsFault(DateTime.UtcNow); + device.RecordCommsFault(Clock()); results[i] = new WriteResult(AbLegacyStatusMapper.BadCommunicationError); - SetHealthIfCurrent(epoch, new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message)); + outcome.FaultMessage ??= ex.Message; } } + 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}"; + } + } + // ---- ITagDiscovery ---- /// @@ -1052,7 +1101,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // 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 ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(DateTime.UtcNow); + else if ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock()); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1061,7 +1110,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag probeRuntime = null; state.ProbeInitialized = false; if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); - else if (!IsLocalRefusal(ex)) state.RecordCommsFault(DateTime.UtcNow); + else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock()); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1155,8 +1204,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// . 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 @@ -1169,6 +1220,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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 @@ -1176,7 +1244,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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 @@ -1193,9 +1265,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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 { @@ -1381,7 +1452,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// (). 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 static async Task CreateSingleFlightAsync( + private async Task CreateSingleFlightAsync( DeviceState device, System.Collections.Concurrent.ConcurrentDictionary table, string key, @@ -1395,7 +1466,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { // 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(DateTime.UtcNow)) + if (device.IsThrottled(Clock())) throw new ConnectionThrottledException( $"AbLegacy connect for '{key}' on {device.Options.HostAddress} throttled: the device is in its reconnect backoff window."); @@ -1544,7 +1615,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// public void Dispose() { - var retired = RetireTables(bumpEpoch: true); + 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 @@ -1557,8 +1628,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag JoinProbeLoopsSync(retired); foreach (var state in retired) state.Retire(); _resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses + // A newer InitializeAsync that ran while this teardown was still joining owns health now. lock (_lifecycleLock) - _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + { + if (teardownEpoch == _lifecycleEpoch) + _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); + } } /// @@ -1620,6 +1695,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag public System.Collections.Concurrent.ConcurrentDictionary IndirectLastName { get; } = 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); + /// /// 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 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 index 213fea69..16064b8d 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyDeviceIsolationTests.cs @@ -1,4 +1,3 @@ -using System.Diagnostics; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; @@ -67,12 +66,9 @@ public sealed class AbLegacyDeviceIsolationTests var before = drv.GetHealth().LastSuccessfulRead; Interlocked.Exchange(ref DeadTag.WireOps, 0); - var sw = Stopwatch.StartNew(); var first = await drv.ReadAsync(refs, CancellationToken.None); - sw.Stop(); - // Serial-with-no-backoff would pay 20 x 150 ms = 3 s on device B alone. - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(1.5)); + // 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); @@ -83,11 +79,8 @@ public sealed class AbLegacyDeviceIsolationTests // Inside B's backoff window the next poll touches B's wire not at all. var opsAfterFirst = Volatile.Read(ref DeadTag.WireOps); - sw.Restart(); var second = await drv.ReadAsync(refs, CancellationToken.None); - sw.Stop(); Volatile.Read(ref DeadTag.WireOps).ShouldBe(opsAfterFirst); - sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(500)); second.Take(5).ShouldAllBe(s => s.StatusCode == AbLegacyStatusMapper.Good); await drv.ShutdownAsync(CancellationToken.None); } @@ -239,6 +232,8 @@ public sealed class AbLegacyDeviceIsolationTests }, }; 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. @@ -249,7 +244,7 @@ public sealed class AbLegacyDeviceIsolationTests // 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); - await Task.Delay(TimeSpan.FromMilliseconds(1100)); + 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); 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..a982e84e --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -0,0 +1,222 @@ +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); + } +} From d758dd1e168261a3c0fcd6a6e7751a250229fd99 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:20:10 -0400 Subject: [PATCH 15/18] fix(ablegacy): encode array writes at the data file's element width Review r0 I3. The array encoder used the AUTHORED dataType's width while both runtimes size their buffer from the FILE: an Int array on F8 wrote four shorts into a 16-byte float buffer and sent it all (garbage neighbours, answered Good); Long/Float on N overran it. - The write path resolves the element width from the data file the address names (N/B/A/I/O/S 16-bit word, L 32-bit, F float) and encodes at THAT width. - An authored type that does not match its file is refused BadTypeMismatch before any runtime exists (warned once, no health write). - The same rule (AbLegacyTagDefinitionFactory.ArrayFileMismatch) backs an Inspect deploy warning and the AdminUI model's Validate error; FromTagConfig still accepts the tag so persisted configs keep loading. - M6: the skipped F1 parity test gains the shapes the write path refuses (/bit, .sub, indirect, type/file mismatch, PLC-5 native). Tests: one case per file type, a mismatch per pairing with nothing created, and a positive control. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 51 +++++++++++++++ .../AbLegacyDriver.cs | 25 ++++++-- .../Uns/TagEditors/AbLegacyTagConfigModel.cs | 5 ++ .../AbLegacyArrayWriteSupportParityTests.cs | 19 +++++- .../AbLegacyArrayWriteTests.cs | 62 +++++++++++++++++++ .../Uns/AbLegacyTagConfigModelTests.cs | 18 ++++++ 6 files changed, 175 insertions(+), 5 deletions(-) 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 c6ab0a97..ea87f079 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 @@ -177,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; } @@ -252,6 +255,54 @@ public static class AbLegacyTagDefinitionFactory $"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 + if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) 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) => o.TryGetProperty(name, out var e) && e.ValueKind == JsonValueKind.Number && e.TryGetInt32(out var v) ? v : 0; 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 899e2c38..ffc96f46 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -885,12 +885,29 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // 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 conversion = parsed.BitIndex is not null || parsed.SubElement is not null - ? AbLegacyStatusMapper.BadNotSupported - : AbLegacyArrayWriteEncoder.TryConvert(def.DataType, w.Value, EffectiveArrayLength(def), out arrayValue); + 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) @@ -925,7 +942,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - if (arrayValue is not null) runtime.EncodeArray(def.DataType, arrayValue); + 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(); 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 dcb67114..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 @@ -92,6 +92,11 @@ public sealed class AbLegacyTagConfigModel 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/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs index acae0cdd..3f5a50f6 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteSupportParityTests.cs @@ -43,7 +43,24 @@ public sealed class AbLegacyArrayWriteSupportParityTests DriverArrayWriteSupport.Supports(DriverTypeNames.AbLegacy, "Int16", json).ShouldBe(encoderAccepts, dataType.ToString()); } - /// What the encoder accepts today — the row F1 must add (Int, AnalogInt, Long, Float). + /// + /// 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)] 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 index 0ef846a0..20d9f203 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -72,6 +72,68 @@ public sealed class AbLegacyArrayWriteTests 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 + } + + [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() { 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 2bf866bb..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 @@ -136,6 +136,24 @@ public sealed class AbLegacyTagConfigModelTests 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() { From 16c2045cab3a987c75312503f62556b969c21eb8 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:22:17 -0400 Subject: [PATCH 16/18] fix(ablegacy): never operate on a handle evicted while waiting for its gate Review r0 M3. The indirect sweep disposed stale resolutions without holding their gates, and any reader/writer that fetched a handle before waiting on its gate could then use an instance a sibling had evicted and disposed - a spurious comms fault and Degraded. The sweep now evicts under each key's gate, and read/write re-resolve the handle once the gate is held (reconnecting single-flight if it was evicted; a retired state never reconnects). Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriver.cs | 24 ++++++++++++- .../AbLegacyHealthEvidenceTests.cs | 36 +++++++++++++++++++ 2 files changed, 59 insertions(+), 1 deletion(-) 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 ffc96f46..28b6fbe7 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -680,6 +680,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(ct).ConfigureAwait(false); try { + runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false); await runtime.ReadAsync(ct).ConfigureAwait(false); status = runtime.GetStatus(); var parsed = AbLegacyAddress.TryParse(def.Address, device.Options.PlcFamily); @@ -942,6 +943,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { + runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false); if (arrayValue is not null) runtime.EncodeArray(arrayFileType, arrayValue); else runtime.EncodeValue(def.DataType, parsed.BitIndex, w.Value); await runtime.WriteAsync(cancellationToken).ConfigureAwait(false); @@ -1356,7 +1358,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag { if (!key.StartsWith(prefix, StringComparison.OrdinalIgnoreCase) || string.Equals(key, resolvedName, StringComparison.OrdinalIgnoreCase)) continue; - EvictRuntime(device, key); + // 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); } } @@ -1601,6 +1606,23 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag .ConfigureAwait(false); } + /// + /// 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; + // 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); + } + /// /// Evict the runtime for from the device's cache and dispose it so /// the next read/write call re-creates and re-initializes a fresh libplctag handle. Called 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 index a982e84e..c65004f0 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -219,4 +219,40 @@ public sealed class AbLegacyHealthEvidenceTests 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(); + var read = drv.ReadAsync(["X"], CancellationToken.None); // fetches `first`, then parks on the gate + await Task.Delay(30); + 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); + } } From efa040813a0910e50d457bd626cf6ce28368c075 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:24:30 -0400 Subject: [PATCH 17/18] fix(ablegacy): coalesceReads absent means default; the form never pins it Review r0 M9. The AdminUI form wrote coalesceReads:true on every save, pinning today's default into every edited driver. AbLegacyDriverOptions.CoalesceReads is now bool? (null = default ON, never serialised, same encoding as Transport); the form omits the key when the switch is on and writes only an explicit false. Persisted coalesceReads:true keeps loading as on. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyDriverOptions.cs | 7 +++++-- .../AbLegacyDriver.Coalescing.cs | 2 +- .../AbLegacyDriverFactoryExtensions.cs | 2 +- .../Shared/Drivers/Forms/AbLegacyDriverForm.razor | 5 +++-- .../AbLegacyReadCoalescingTests.cs | 2 +- .../AbLegacyDriverPageFormSerializationTests.cs | 8 ++++++-- 6 files changed, 17 insertions(+), 9 deletions(-) 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 36b21186..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 @@ -51,9 +51,12 @@ public sealed class AbLegacyDriverOptions /// /// 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. + /// 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). /// - public bool CoalesceReads { get; init; } = true; + [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 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 index 401ce098..416d1b62 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.Coalescing.cs @@ -72,7 +72,7 @@ public sealed partial class AbLegacyDriver /// private List PlanReadUnits(DeviceState device, List items) { - if (!_options.CoalesceReads || items.Count < 2) return [.. items.Select(i => new ReadUnit(i, null))]; + 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>(); 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 d06effd9..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,7 +97,7 @@ public static class AbLegacyDriverFactoryExtensions ProbeAddress = dto.Probe?.ProbeAddress ?? "S:0", }, Timeout = PositiveTimeoutOrDefault(dto.TimeoutMs, 2_000), - CoalesceReads = dto.CoalesceReads ?? true, + 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) 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 a938652b..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 @@ -208,7 +208,7 @@ _loadedProbeTimeout = o.Probe.Timeout, TimeoutSeconds = (int)o.Timeout.TotalSeconds, Transport = o.Transport, - CoalesceReads = o.CoalesceReads, + CoalesceReads = o.CoalesceReads ?? true, ProbeEnabled = o.Probe.Enabled, ProbeIntervalSeconds = (int)o.Probe.Interval.TotalSeconds, ProbeTimeoutSeconds = (int)o.Probe.Timeout.TotalSeconds, @@ -229,7 +229,8 @@ }, Timeout = DriverConfigMs.Save(TimeoutSeconds, _loadedTimeout), Transport = Transport, - CoalesceReads = CoalesceReads, + // 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/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs index 0dc8cf89..b1adfc1a 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyReadCoalescingTests.cs @@ -180,5 +180,5 @@ public sealed class AbLegacyReadCoalescingTests [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.ShouldBe(expected); + (AbLegacyDriverFactoryExtensions.ParseOptions("drv", json).CoalesceReads != false).ShouldBe(expected); } 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 162336cc..7dc4c263 100644 --- a/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.AdminUI.Tests/AbLegacyDriverPageFormSerializationTests.cs @@ -269,8 +269,12 @@ public sealed class AbLegacyDriverPageFormSerializationTests var saved = AbLegacyDriverForm.Serialize(form.ToOptions(loaded.Devices), original); - ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.AbLegacyDriverFactoryExtensions.ParseOptions("t", saved) - .CoalesceReads.ShouldBe(edited); + (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] From e3cb4f0c7b6909bb5610edd1cc0c4c49f13577ec Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Thu, 22 Oct 2026 15:45:04 -0400 Subject: [PATCH 18/18] fix(ablegacy): opaque replies are neither answers nor faults; round-2 minors Review r1 N1 (Important): the I1 fix made IsDeviceRefusal default-open and mapped libplctag's ERR_REMOTE_ERR to an answer. libplctag folds EVERY non-zero PCCC STS and CIP status into that code, including a routing hop's "unconnected send timed out" / "connection lost" / "remote node missing", and native BadReply covers truncated / mismatched / unmapped replies - so a powered-off PLC behind a bridge read as reachable: Healthy, LastSuccessfulRead advancing, the comms-loss watchdog unable to fire. Three classes now, each pinned both ways: - no reply (Timeout/BadConnection/Abort/Pending/unknown): comms fault; - answered - an ALLOW-LIST again (NotFound/NotAllowed/OutOfBounds/TooLarge/ TooSmall/TypeMismatch/DeviceStateConflict): proves reachability; - OPAQUE (BadReply/Partial; ERR_REMOTE_ERR/ERR_BAD_STATUS now translate to the existing BadReply, OPC UA BadCommunicationError as before r0): per-tag Bad, handle kept, coalescing fallback, never fail-fast, never an answer. Option (a): a device whose pass/call produced only opaque outcomes is Degraded with LastSuccessfulRead not advanced, so the watchdog reaches a dead routed PLC. The probe and index reads classify the same way. Minors: - N2: a mismatched array (type vs data file) READS BadTypeMismatch instead of decoding the file at the wrong width as Good. - N3: a swept indirect resolution is never reconnected under a forgotten gate; that one call answers neutrally. - N4: an index read answered Unsupported is neutral, not a transport fault. - N5: ArrayFileMismatch checks indirect addresses (the file letter is static). - Test nit: the evicted-while-waiting test signals gate arrival via a seam instead of sleeping. Live: 51/51 on ab_server (slc500/micrologix/plc5 x both transports), incl. an opaque-only libplctag device staying Degraded with LastSuccessfulRead frozen. Refs #738 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT --- .../AbLegacyTagDefinitionFactory.cs | 4 +- .../AbLegacyDriver.cs | 167 ++++++++++++++---- .../LibplctagLegacyStatusTranslation.cs | 17 +- .../AbLegacyReadCoalescingIntegrationTests.cs | 41 ++++- .../AbLegacyAnsweredStatusTests.cs | 166 ++++++++++++++--- .../AbLegacyArrayWriteTests.cs | 15 ++ .../AbLegacyHealthEvidenceTests.cs | 96 +++++++++- .../AbLegacyHostAndStatusTests.cs | 3 - 8 files changed, 434 insertions(+), 75 deletions(-) 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 ea87f079..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 @@ -289,7 +289,9 @@ public static class AbLegacyTagDefinitionFactory public static string? ArrayFileMismatch(AbLegacyDataType type, string address) { if (!IsArrayElementTypeSupported(type)) return null; // structured arrays have their own refusal - if (AbLegacyAddress.TryParse(address) is not { } a || a.IsIndirect) return null; + // 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 { 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 28b6fbe7..2444f3c1 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy/AbLegacyDriver.cs @@ -468,7 +468,11 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// evidence for the backoff); = nothing reached the wire or it says nothing about /// reachability (a local refusal, a bad index value). /// - private enum OpVerdict { Neutral, Answered, Fault, Throttled } + /// = 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 @@ -476,6 +480,18 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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; + } } /// @@ -641,8 +657,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag case OpVerdict.Throttled: outcome.FaultMessage ??= message; break; + case OpVerdict.Opaque: + outcome.OpaqueMessage ??= message; + break; } } + outcome.FoldOpaque(); return outcome; } @@ -664,11 +684,22 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag results[item.Index] = new DataValueSnapshot(null, res.Status, null, now); return res.TransportFault ? (OpVerdict.Fault, $"transport fault resolving indirect address for {reference}") - : (OpVerdict.Neutral, null); + : 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; @@ -677,10 +708,17 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // 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 { - runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, ct).ConfigureAwait(false); + 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); @@ -713,6 +751,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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) @@ -752,6 +796,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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 @@ -781,23 +832,34 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag !Enum.IsDefined(status) || status is AbStatus.Timeout or AbStatus.BadConnection or AbStatus.Abort or AbStatus.Pending; /// - /// True for a non-zero status that proves the DEVICE answered and refused this one operation — the file or - /// element does not exist, the access is not allowed, a size / type is wrong, a remote error (libplctag - /// ERR_REMOTE_ERR, translated to ), or a reply the transport - /// could not map (, ). Such a status is per-tag Bad, - /// keeps the handle, and is evidence of reachability. is classified - /// separately (: it says nothing about reachability). + /// 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). /// /// - /// ⚠️ Depends on the transport's PCCC STS table (AbStatusCodes.FromPccc, Lane K): today it maps STS 0x30 - /// ("remote node missing" — a dead DH+/DH-485 node behind a bridge) and 0x20 to answered values, so on the - /// NATIVE transport such a dead node reads as answered and never trips the backoff or the comms-loss watchdog. - /// The remap is follow-up F8 (review 07 r0 I4); this classification needs no change once it lands. + /// ⚠️ 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 not (AbStatus.Ok or AbStatus.Unsupported) && !IsNoReply(status); + 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 ---- @@ -866,6 +928,8 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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; @@ -943,7 +1007,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag await opLock.WaitAsync(cancellationToken).ConfigureAwait(false); try { - runtime = await CurrentRuntimeUnderLockAsync(device, def, runtime, cancellationToken).ConfigureAwait(false); + 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); @@ -973,6 +1042,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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. @@ -1004,6 +1079,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } } + outcome.FoldOpaque(); ApplyHealth(epoch, outcome.FaultMessage, anyGood: outcome.AnyAnswered, anyAnswered: outcome.AnyAnswered, DateTime.UtcNow); return results; } @@ -1028,6 +1104,12 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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 ---- @@ -1120,7 +1202,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag // 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 ((AbStatus)status != AbStatus.Unsupported) state.RecordCommsFault(Clock()); + else if (IsNoReply((AbStatus)status)) state.RecordCommsFault(Clock()); // opaque / Unsupported: neutral } catch (OperationCanceledException) when (ct.IsCancellationRequested) { break; } catch (Exception ex) @@ -1129,7 +1211,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag probeRuntime = null; state.ProbeInitialized = false; if (ex is AbTransportException ate && IsDeviceRefusal(ate.Status)) state.RecordAnswered(); - else if (!IsLocalRefusal(ex)) state.RecordCommsFault(Clock()); + else if (ex is not AbTransportException { Status: var st } || IsNoReply(st)) state.RecordCommsFault(Clock()); } TransitionDeviceState(state, success ? HostState.Running : HostState.Stopped); @@ -1320,7 +1402,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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!; @@ -1328,7 +1410,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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!; @@ -1369,13 +1451,13 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } /// 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. @@ -1394,40 +1476,49 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag 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); + 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 && IsDeviceRefusal((AbStatus)status)) - return (null, AbLegacyStatusMapper.MapStatus(status), false); - if (status != 0) + 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 @@ -1444,7 +1535,7 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag } } - 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) @@ -1611,11 +1702,15 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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( + 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. @@ -1886,6 +1981,10 @@ public sealed partial class AbLegacyDriver : IDriver, IReadable, IWritable, ITag /// 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. 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 281c4ebd..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,14 +23,15 @@ internal static class LibplctagLegacyStatusTranslation Status.ErrorPartial => AbStatus.Partial, Status.ErrorAbort => AbStatus.Abort, Status.ErrorBadReply => AbStatus.BadReply, - // The PLC (or a CIP hop) ANSWERED with an error: libplctag's PCCC layer turns a non-zero STS — a file or - // element that does not exist, a write the processor refuses — into PLCTAG_ERR_REMOTE_ERR (and a non-zero - // CIP general status into ERR_REMOTE_ERR / ERR_BAD_STATUS). AbStatus has no dedicated "remote error" value and - // Driver.Ab.Transport is not this driver's to extend (Lane K), so this maps to the existing value whose meaning - // is closest — "the device is reachable but refused the operation" — DeviceStateConflict (OPC UA - // BadDeviceFailure). It MUST NOT fall into the BadConnection default: that is a no-reply comms fault, which - // evicts handles, degrades health and counts toward marking the device down (review 07 r0 I1). - Status.ErrorRemoteErr or Status.ErrorBadStatus => AbStatus.DeviceStateConflict, + // 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/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs index e3aa58d2..8542eb22 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.IntegrationTests/AbLegacyReadCoalescingIntegrationTests.cs @@ -101,9 +101,46 @@ public sealed class AbLegacyReadCoalescingIntegrationTests(AbLegacyServerFixture 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"); - r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.BadCommunicationError, - $"poll {poll}: the PLC answered — not a comms fault (status 0x{r[2].StatusCode:X8})"); } + // 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 index 8cbaba2a..1b209131 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyAnsweredStatusTests.cs @@ -7,18 +7,24 @@ using ZB.MOM.WW.OtOpcUa.Driver.Ab.Transport; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// -/// Review 07 r0 I1: on the default libplctag transport a PLC's own refusal (a non-zero PCCC STS — e.g. an element -/// past the end of the file) arrives as ERR_REMOTE_ERR, which used to translate to BadConnection: a -/// no-reply comms fault. A coalesced block holding one such member then failed as a group every poll, and two -/// refused tags in a row tripped the device's fail-fast window. Statuses the device RETURNED are now answers; -/// only no-reply statuses are comms faults. +/// 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 a PLC refusal (through the real translation). + /// 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) => @@ -30,37 +36,75 @@ public sealed class AbLegacyAnsweredStatusTests CoalesceReads = coalesce, }, "drv-i1", factory); - [Fact] - public void Remote_error_translates_to_an_answered_status_not_a_comms_fault() + // ---- 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) { - var s = LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorRemoteErr); - AbLegacyDriver.IsNoReply(s).ShouldBeFalse(); - AbLegacyDriver.IsDeviceRefusal(s).ShouldBeTrue(); - AbLegacyDriver.IsDeviceRefusal(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadStatus)).ShouldBeTrue(); - // Positive control: real no-reply statuses still are. - AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorTimeout)).ShouldBeTrue(); - AbLegacyDriver.IsNoReply(LibplctagLegacyStatusTranslation.ToAbStatus(Status.ErrorBadConnection)).ShouldBeTrue(); + AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + AbLegacyDriver.IsOpaque(status).ShouldBeFalse(); + AbLegacyDriver.IsNoReply(status).ShouldBeFalse(); } [Theory] - [InlineData(AbStatus.BadReply)] // native: an STS the transport does not map + [InlineData(AbStatus.BadReply)] [InlineData(AbStatus.Partial)] - [InlineData(AbStatus.NotFound)] - [InlineData(AbStatus.DeviceStateConflict)] - public void Statuses_the_device_returned_are_answers(AbStatus status) => - AbLegacyDriver.IsDeviceRefusal(status).ShouldBeTrue(); + 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() { @@ -82,15 +126,15 @@ public sealed class AbLegacyAnsweredStatusTests 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.BadDeviceFailure, $"poll {poll}"); + r[2].StatusCode.ShouldNotBe(AbLegacyStatusMapper.Good, $"poll {poll}"); } - drv.GetHealth().State.ShouldBe(DriverState.Healthy); + 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_refused_tags_do_not_trip_fail_fast_on_an_answering_device() + public async Task Consecutive_opaque_replies_do_not_trip_fail_fast_on_an_answering_device() { var factory = new FakeAbLegacyTagFactory { @@ -106,10 +150,82 @@ public sealed class AbLegacyAnsweredStatusTests 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}: an answering device is never failed fast"); + 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); // refused handles are kept, not churned + 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/AbLegacyArrayWriteTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs index 20d9f203..f9a23fe3 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyArrayWriteTests.cs @@ -123,6 +123,21 @@ public sealed class AbLegacyArrayWriteTests .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() { 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 index c65004f0..e5e15fcf 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHealthEvidenceTests.cs @@ -243,8 +243,12 @@ public sealed class AbLegacyHealthEvidenceTests var gate = state.GetRuntimeLock("X"); await gate.WaitAsync(); - var read = drv.ReadAsync(["X"], CancellationToken.None); // fetches `first`, then parks on the gate - await Task.Delay(30); + // 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(); @@ -255,4 +259,92 @@ public sealed class AbLegacyHealthEvidenceTests 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/AbLegacyHostAndStatusTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs index 79cd5d80..8338dc3b 100644 --- a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyHostAndStatusTests.cs @@ -89,9 +89,6 @@ public sealed class AbLegacyHostAndStatusTests Status.ErrorNotAllowed => AbLegacyStatusMapper.BadNotWritable, Status.ErrorOutOfBounds or Status.ErrorTooLarge or Status.ErrorTooSmall => AbLegacyStatusMapper.BadOutOfRange, Status.ErrorUnsupported or Status.ErrorNotImplemented => AbLegacyStatusMapper.BadNotSupported, - // DELIBERATE change (review 07 r0 I1): a PLC's own refusal (PCCC STS / CIP status → ERR_REMOTE_ERR / - // ERR_BAD_STATUS) is "device answered", not a comms error. - Status.ErrorRemoteErr or Status.ErrorBadStatus => AbLegacyStatusMapper.BadDeviceFailure, _ => AbLegacyStatusMapper.BadCommunicationError, };