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