1d7a4b7a53
Claude-Session: https://claude.ai/code/session_01GT67AEHd2Acv1YPwGfXEoV
256 lines
12 KiB
C#
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, TestContext.Current.CancellationToken);
|
|
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, TestContext.Current.CancellationToken);
|
|
abandoned.IsCompleted.ShouldBeFalse();
|
|
await drv.ShutdownAsync(CancellationToken.None);
|
|
|
|
release.SetResult();
|
|
await Should.ThrowAsync<OperationCanceledException>(() => abandoned);
|
|
var createdAfter = probes.Count;
|
|
await Task.Delay(150, TestContext.Current.CancellationToken); // 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);
|
|
}
|
|
}
|