Files
lmxopcua/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests/AbLegacyLifecycleConcurrencyTests.cs
T
Joseph Doherty 92171a01ec fix(ablegacy): build-and-swap device/tag tables; teardown safe vs Dispose and abandoned init
Review 07 H13: _devices and _tagsByRawPath were plain dictionaries cleared and
refilled in place while the retained poll loop, the actor's GetHostStatuses and
server reads enumerated them. Both are now built locally and published by
reference under a lifecycle lock; teardown swaps in empty tables.

Lane A contract: ShutdownAsync and Dispose each retire a disjoint set of device
states, so Dispose running concurrently with a still-joining ShutdownAsync is
safe. A lifecycle epoch makes an InitializeAsync that the runtime abandoned
(overlapping the next ShutdownAsync) refuse to publish its tables, start probe
loops or write health. A runtime cached on an already-retired device state by an
in-flight read is disposed instead of leaked, and late reads/writes no longer
overwrite shutdown's health. Per-key semaphores are no longer disposed under
callers that may still hold them.

Refs #738
Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
2026-10-22 14:04:51 -04:00

256 lines
12 KiB
C#

using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// Review 07 H13 + the Lane A runtime contract: the device and tag tables are build-and-swapped (never cleared
/// in place under a reader), <c>Dispose</c> may run concurrently with a still-running <c>ShutdownAsync</c>, and
/// an abandoned <c>InitializeAsync</c> that overlaps the next <c>ShutdownAsync</c> must not resurrect state.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyLifecycleConcurrencyTests
{
private const string Host = "ab://10.0.0.5/1,0";
/// <summary>A probe tag whose read parks until released, ignoring cancellation (a wedged device call).</summary>
private sealed class ParkingProbeTag(AbLegacyTagCreateParams p, Task release) : FakeAbLegacyTag(p)
{
public readonly TaskCompletionSource Entered = new(TaskCreationOptions.RunContinuationsAsynchronously);
public override async Task ReadAsync(CancellationToken ct)
{
Entered.TrySetResult();
await release.ConfigureAwait(false);
await base.ReadAsync(ct).ConfigureAwait(false);
}
}
private static async Task WaitUntilAsync(Func<bool> condition, string what)
{
var deadline = DateTime.UtcNow.AddSeconds(10);
while (!condition())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException(what);
await Task.Delay(10);
}
}
/// <summary>
/// Re-initialising in a loop while other threads read the tables never throws "Collection was modified":
/// the readers see either the old or the new table, never one being cleared.
/// </summary>
[Fact]
public async Task Reinit_under_concurrent_readers_never_throws()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host), new AbLegacyDeviceOptions("ab://10.0.0.6/1,0")],
RawTags = AbLegacyRawTags.Entries(
new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int),
new AbLegacyTagDefinition("Y", "ab://10.0.0.6/1,0", "N7:1", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
using var stop = new CancellationTokenSource();
var failures = new System.Collections.Concurrent.ConcurrentQueue<Exception>();
var reads = 0;
var readers = Enumerable.Range(0, 3).Select(n => Task.Run(async () =>
{
while (!stop.IsCancellationRequested)
{
try
{
_ = drv.GetHostStatuses();
_ = drv.ResolveHost("X");
_ = await drv.ReadAsync(["X", "Y"], CancellationToken.None);
Interlocked.Increment(ref reads);
}
catch (Exception ex) { failures.Enqueue(ex); }
}
})).ToArray();
// Keep re-initialising until the readers have demonstrably overlapped many swaps.
var deadline = DateTime.UtcNow.AddSeconds(10);
for (var i = 0; i < 100 || Volatile.Read(ref reads) < 200; i++)
{
if (DateTime.UtcNow > deadline) break;
await drv.InitializeAsync("{}", CancellationToken.None);
}
stop.Cancel();
await Task.WhenAll(readers);
failures.ShouldBeEmpty();
reads.ShouldBeGreaterThan(0); // positive control: the readers really ran against the swapping tables
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>A re-init publishes a NEW device state; the old one is retired, not mutated in place.</summary>
[Fact]
public async Task Reinit_publishes_a_new_table_and_retires_the_old_state()
{
var factory = new FakeAbLegacyTagFactory { Customise = p => new FakeAbLegacyTag(p) { Value = 1 } };
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-swap", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
var old = drv.GetDeviceState(Host)!;
var oldTag = factory.Tags["N7:0"];
await drv.InitializeAsync("{}", CancellationToken.None);
var fresh = drv.GetDeviceState(Host)!;
fresh.ShouldNotBeSameAs(old);
old.IsRetired.ShouldBeTrue();
oldTag.Disposed.ShouldBeTrue();
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await drv.ShutdownAsync(CancellationToken.None);
}
/// <summary>
/// A runtime created by a read that was already in flight when shutdown retired its device state is disposed
/// instead of being cached on the retired state (where nothing would ever release it).
/// </summary>
[Fact]
public async Task Runtime_created_after_retirement_is_disposed_not_cached()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
FakeAbLegacyTag? created = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => created = new InitParkingTag(p, entered, release.Task) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = false },
}, "drv-late", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var state = drv.GetDeviceState(Host)!;
var read = drv.ReadAsync(["X"], CancellationToken.None);
await entered.Task;
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
_ = await read;
created.ShouldNotBeNull();
created!.Disposed.ShouldBeTrue();
state.Runtimes.ShouldBeEmpty();
drv.GetHealth().State.ShouldBe(DriverState.Unknown); // the late read did not overwrite shutdown's health
}
private sealed class InitParkingTag(AbLegacyTagCreateParams p, TaskCompletionSource entered, Task release)
: FakeAbLegacyTag(p)
{
public override async Task InitializeAsync(CancellationToken ct)
{
entered.TrySetResult();
await release.ConfigureAwait(false);
await base.InitializeAsync(ct).ConfigureAwait(false);
}
}
/// <summary>
/// Lane A's final stop calls ShutdownAsync and then DisposeAsync/Dispose; under the 5 s bound Dispose can run
/// while ShutdownAsync is still joining a wedged probe loop. Both complete without throwing and every runtime
/// is released.
/// </summary>
[Fact]
public async Task Dispose_concurrent_with_running_shutdown_is_safe()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
ParkingProbeTag? probe = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p => p.TagName == "S:0"
? probe = new ParkingProbeTag(p, release.Task)
: new FakeAbLegacyTag(p) { Value = 1 },
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host)],
RawTags = AbLegacyRawTags.Entries(new AbLegacyTagDefinition("X", Host, "N7:0", AbLegacyDataType.Int)),
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-dispose", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
(await drv.ReadAsync(["X"], CancellationToken.None))[0].StatusCode.ShouldBe(AbLegacyStatusMapper.Good);
await WaitUntilAsync(() => probe is not null, "probe never created");
await probe!.Entered.Task;
var shutdown = drv.ShutdownAsync(CancellationToken.None); // parks joining the wedged probe loop
await Task.Delay(50);
shutdown.IsCompleted.ShouldBeFalse();
Should.NotThrow(() => drv.Dispose());
await Should.NotThrowAsync(async () => await drv.DisposeAsync());
release.SetResult();
await shutdown;
factory.Tags["N7:0"].Disposed.ShouldBeTrue();
probe.Disposed.ShouldBeTrue();
drv.DeviceCount.ShouldBe(0);
}
/// <summary>
/// An InitializeAsync the runtime abandoned (it is parked releasing a wedged prior probe loop) overlaps the next
/// ShutdownAsync. When it resumes it must NOT publish its tables or start probe loops — that state would sit
/// behind a driver the runtime believes is stopped. A later Initialize publishes normally (positive control).
/// </summary>
[Fact]
public async Task Abandoned_initialize_overlapping_shutdown_does_not_resurrect_state()
{
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
var probes = new System.Collections.Concurrent.ConcurrentQueue<FakeAbLegacyTag>();
ParkingProbeTag? first = null;
var factory = new FakeAbLegacyTagFactory
{
Customise = p =>
{
FakeAbLegacyTag t = first is null ? first = new ParkingProbeTag(p, release.Task) : new FakeAbLegacyTag(p);
probes.Enqueue(t);
return t;
},
};
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(Host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20) },
}, "drv-abandon", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
await WaitUntilAsync(() => first is not null, "probe never created");
await first!.Entered.Task;
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // parks joining the wedged loop
await Task.Delay(50);
abandoned.IsCompleted.ShouldBeFalse();
await drv.ShutdownAsync(CancellationToken.None);
release.SetResult();
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
var createdAfter = probes.Count;
await Task.Delay(150); // several probe intervals
drv.DeviceCount.ShouldBe(0);
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
probes.Count.ShouldBe(createdAfter);
// Positive control: a fresh Initialize after the stop publishes and probes as normal.
await drv.InitializeAsync("{}", CancellationToken.None);
drv.DeviceCount.ShouldBe(1);
drv.GetHealth().State.ShouldBe(DriverState.Healthy);
await WaitUntilAsync(() => probes.Count > createdAfter, "fresh init never probed");
await drv.ShutdownAsync(CancellationToken.None);
}
}