fix(abcip): build-and-swap device tables, volatile health, async-only dispose

H13: _devices/_devicesByRoutingKey are published by reference swap under a
lifecycle lock and never mutated after publish, so the retained poll engine,
discovery and the actor's GetHostStatuses pull never read a table mid-Clear.
Every retire (init's own release, shutdown, dispose) takes the table it
replaces, so ShutdownAsync racing DisposeAsync tears each state down once, and
a generation check makes an InitializeAsync overtaken by a shutdown publish
nothing (it throws OperationCanceledException) instead of resurrecting probe
loops behind the shutdown. Probe join is one bounded 5 s wait independent of
the caller token.

H21: _health is volatile and read-modify-write updates go through a CAS.

F10: the sync Dispose() (sync-over-async) is removed; DisposeAsync is the
complete, idempotent teardown the runtime calls after ShutdownAsync.

Refs #737
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 14:02:00 -04:00
parent 8a4cf8b823
commit 20cb2a52d6
2 changed files with 333 additions and 99 deletions
@@ -0,0 +1,172 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// Lifecycle overlap the runtime can produce (review 07 H13/F10, Lane A): the final stop runs
/// <c>ShutdownAsync</c> then <c>DisposeAsync</c> under one bounded budget, so a slow shutdown is abandoned and
/// the dispose runs CONCURRENTLY with it; an abandoned <c>InitializeAsync</c> can overlap the next attempt's
/// shutdown. Device tables are build-and-swapped, so each teardown owns a disjoint set of device states.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A fake whose reads ignore cancellation for a while, so a teardown waits on the probe loop.</summary>
private sealed class SlowTag(AbCipTagCreateParams p, TimeSpan readDelay) : FakeAbCipTag(p)
{
private int _disposeCount;
public int DisposeCount => Volatile.Read(ref _disposeCount);
public override async Task ReadAsync(CancellationToken cancellationToken)
{
await base.ReadAsync(cancellationToken);
if (readDelay > TimeSpan.Zero) await Task.Delay(readDelay, CancellationToken.None);
}
public override void Dispose()
{
Interlocked.Increment(ref _disposeCount);
base.Dispose();
}
}
private static (AbCipDriver Driver, FakeAbCipTagFactory Factory) Build(TimeSpan probeReadDelay)
{
var factory = new FakeAbCipTagFactory
{
Customise = p => new SlowTag(p, p.TagName == "ProbeTag" ? probeReadDelay : TimeSpan.Zero) { Value = 1 },
};
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(Host)],
RawTags = AbCipRawTags.From(new AbCipTagDefinition("Speed", Host, "Motor1.Speed", AbCipDataType.DInt)),
Probe = new AbCipProbeOptions
{
Enabled = true,
ProbeTagPath = "ProbeTag",
Interval = TimeSpan.FromMilliseconds(10),
Timeout = TimeSpan.FromMilliseconds(200),
},
}, "abcip-lifecycle", factory);
return (drv, factory);
}
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(5);
}
}
/// <summary>
/// Shutdown and Dispose racing each other tear the device down exactly once: the data runtime is
/// disposed once (not once per teardown), neither call throws, and the probe loop has exited.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_a_running_Shutdown_tears_each_state_down_exactly_once()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["Speed"], CancellationToken.None))[0].StatusCode.ShouldBe(AbCipStatusMapper.Good);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var state = drv.GetDeviceState(Host)!;
var probeLoop = state.ProbeTask!;
var data = (SlowTag)factory.Tags["Motor1.Speed"];
var shutdown = drv.ShutdownAsync(CancellationToken.None);
var dispose = drv.DisposeAsync().AsTask();
await Task.WhenAll(shutdown, dispose);
data.DisposeCount.ShouldBe(1);
probeLoop.IsCompleted.ShouldBeTrue();
drv.GetDeviceState(Host).ShouldBeNull();
}
/// <summary>
/// An InitializeAsync that a ShutdownAsync overtakes publishes nothing: no device state and no probe
/// loop survive the shutdown. Positive control: an init that nothing overtakes still publishes.
/// </summary>
[Fact]
public async Task An_init_overtaken_by_Shutdown_publishes_no_device_and_starts_no_probe()
{
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(300));
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var firstProbe = factory.Tags["ProbeTag"];
// The second init's own release waits on the slow first probe loop; the shutdown overtakes it there.
var init = drv.InitializeAsync("{}", CancellationToken.None);
await Task.Delay(50);
await drv.ShutdownAsync(CancellationToken.None);
await Should.ThrowAsync<OperationCanceledException>(() => init);
drv.GetDeviceState(Host).ShouldBeNull();
drv.GetHostStatuses().ShouldBeEmpty();
await Task.Delay(100);
factory.Tags["ProbeTag"].ShouldBeSameAs(firstProbe); // no second probe loop ever created a runtime
drv.GetHealth().State.ShouldNotBe(DriverState.Healthy);
// Positive control: a later init that nothing overtakes publishes the device again.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.GetDeviceState(Host).ShouldNotBeNull();
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await drv.DisposeAsync();
}
/// <summary>
/// Readers of the device/routing tables (the retained poll engine, the actor's host-status pull) never
/// see a table mid-mutation while the driver re-initialises underneath them.
/// </summary>
[Fact]
public async Task Readers_survive_repeated_reinitialisation()
{
var (drv, _) = Build(probeReadDelay: TimeSpan.Zero);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var reader = Task.Run(async () =>
{
var reads = 0;
while (!stop.IsCancellationRequested)
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("Speed");
await drv.ReadAsync(["Speed"], CancellationToken.None);
reads++;
}
return reads;
});
for (var i = 0; i < 50; i++)
await drv.InitializeAsync("{}", CancellationToken.None);
stop.Cancel();
(await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran
await drv.DisposeAsync();
}
/// <summary>F10: the driver exposes only an async dispose; a sync <c>Dispose()</c> that blocked on the async
/// teardown (sync-over-async) is gone, and the runtime takes the <see cref="IAsyncDisposable"/> path.</summary>
[Fact]
public async Task Driver_is_async_disposable_only_and_DisposeAsync_stops_the_probe()
{
typeof(IDisposable).IsAssignableFrom(typeof(AbCipDriver)).ShouldBeFalse();
var (drv, factory) = Build(probeReadDelay: TimeSpan.Zero);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
await drv.DisposeAsync();
await drv.DisposeAsync(); // idempotent
loop.IsCompleted.ShouldBeTrue();
factory.Tags["ProbeTag"].Disposed.ShouldBeTrue();
}
}