1d7a4b7a53
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
240 lines
11 KiB
C#
240 lines
11 KiB
C#
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, int seconds = 5)
|
|
{
|
|
var deadline = DateTime.UtcNow.AddSeconds(seconds);
|
|
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, TestContext.Current.CancellationToken);
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
var superseded = await Should.ThrowAsync<AbCipInitializationSupersededException>(() => init);
|
|
superseded.ShouldNotBeAssignableTo<OperationCanceledException>(); // M4: distinct from a caller cancellation
|
|
|
|
drv.GetDeviceState(Host).ShouldBeNull();
|
|
drv.GetHostStatuses().ShouldBeEmpty();
|
|
await Task.Delay(100, TestContext.Current.CancellationToken);
|
|
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, never trip over a per-tag lock a
|
|
/// retire disposed, and never leak a runtime they cold-created into a device state a retire already tore down.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Readers_survive_repeated_reinitialisation_and_leak_no_runtime()
|
|
{
|
|
var created = new System.Collections.Concurrent.ConcurrentBag<FakeAbCipTag>();
|
|
var factory = new FakeAbCipTagFactory
|
|
{
|
|
Customise = p =>
|
|
{
|
|
var t = new FakeAbCipTag(p) { Value = 1 };
|
|
created.Add(t);
|
|
return t;
|
|
},
|
|
};
|
|
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 = false },
|
|
}, "abcip-lifecycle", factory);
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
using var stop = new CancellationTokenSource();
|
|
var started = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var badHealth = 0;
|
|
var reader = Task.Run(async () =>
|
|
{
|
|
var reads = 0;
|
|
while (!stop.IsCancellationRequested)
|
|
{
|
|
_ = drv.GetHostStatuses();
|
|
_ = drv.ResolveHost("Speed");
|
|
await drv.ReadAsync(["Speed"], CancellationToken.None);
|
|
if (drv.GetHealth().State == DriverState.Degraded) Interlocked.Increment(ref badHealth);
|
|
reads++;
|
|
started.TrySetResult();
|
|
}
|
|
return reads;
|
|
});
|
|
await started.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
|
|
|
for (var i = 0; i < 200; i++)
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
stop.Cancel();
|
|
|
|
(await reader).ShouldBeGreaterThan(0); // positive control: the reader actually ran
|
|
Volatile.Read(ref badHealth).ShouldBe(0, "a re-init is not a comms fault");
|
|
await drv.DisposeAsync();
|
|
created.Where(t => !t.Disposed).ShouldBeEmpty("every runtime a reader created must be disposed by some teardown");
|
|
}
|
|
|
|
/// <summary>F10: DisposeAsync is the complete, idempotent teardown (the runtime prefers it).</summary>
|
|
[Fact]
|
|
public async Task DisposeAsync_is_a_complete_idempotent_teardown()
|
|
{
|
|
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();
|
|
}
|
|
|
|
/// <summary>
|
|
/// F10: the sync <c>Dispose()</c> no longer blocks the caller's thread on the async teardown (sync-over-async —
|
|
/// a dispatcher thread for up to the probe join). It starts the same teardown and returns; the teardown still
|
|
/// completes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task Sync_Dispose_does_not_block_on_the_teardown_but_still_completes_it()
|
|
{
|
|
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromSeconds(4));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
|
var loop = drv.GetDeviceState(Host)!.ProbeTask!;
|
|
await Task.Delay(50, TestContext.Current.CancellationToken); // the probe is inside its slow, cancellation-deaf read
|
|
|
|
var sw = System.Diagnostics.Stopwatch.StartNew();
|
|
((IDisposable)drv).Dispose();
|
|
sw.Stop();
|
|
|
|
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(2)); // did not wait out the 4 s probe read (CI-tolerant margin)
|
|
await WaitUntilAsync(() => loop.IsCompleted && factory.Tags["ProbeTag"].Disposed, "teardown never completed", seconds: 15);
|
|
drv.GetDeviceState(Host).ShouldBeNull();
|
|
}
|
|
|
|
/// <summary>
|
|
/// M4: a ShutdownAsync overtaken by a NEWER InitializeAsync (its probe join is slow) must not stamp Unknown over
|
|
/// the newer init's Healthy when it finally finishes.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task A_slow_shutdown_does_not_overwrite_a_newer_inits_health()
|
|
{
|
|
var (drv, factory) = Build(probeReadDelay: TimeSpan.FromMilliseconds(400));
|
|
await drv.InitializeAsync("{}", CancellationToken.None);
|
|
await WaitUntilAsync(() => factory.Tags.ContainsKey("ProbeTag"), "probe never started");
|
|
await Task.Delay(30, TestContext.Current.CancellationToken); // inside the slow probe read
|
|
|
|
var shutdown = drv.ShutdownAsync(CancellationToken.None);
|
|
await Task.Delay(30, TestContext.Current.CancellationToken);
|
|
await drv.InitializeAsync("{}", CancellationToken.None); // newer: publishes while the shutdown still joins
|
|
await shutdown;
|
|
|
|
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
|
|
drv.GetDeviceState(Host).ShouldNotBeNull();
|
|
await drv.DisposeAsync();
|
|
}
|
|
}
|