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); + } +}