fix(ablegacy): any answer restores Healthy; writes feed health and the backoff

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
This commit is contained in:
Joseph Doherty
2026-10-22 15:16:49 -04:00
parent 8a80dcc787
commit 17ae2eea0b
3 changed files with 360 additions and 63 deletions
@@ -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);
@@ -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;
/// <summary>
/// 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 <c>LastSuccessfulRead</c>; a no-reply failure on either path
/// degrades; writes respect the device's backoff window; teardown health is epoch-guarded.
/// </summary>
[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<AbLegacyTagCreateParams, FakeAbLegacyTag> Make { get; set; } = p => new FakeAbLegacyTag(p) { Value = 1 };
public System.Collections.Concurrent.ConcurrentDictionary<string, FakeAbLegacyTag> 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<AbLegacyDriver> 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);
}
/// <summary>A probe read that parks ignoring cancellation, so ShutdownAsync is still joining.</summary>
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);
}
}