using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies; namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests; /// /// STAB-6 / #602 — the probe loop was fire-and-forget, so shutdown cancelled and disposed while the /// loop could still be parked in a probe-runtime call that ignores cancellation, leaving its /// libplctag handle alive past teardown. Shutdown now joins the loop (bounded) first. /// [Trait("Category", "Unit")] public sealed class AbLegacyShutdownJoinTests { private sealed class ParkedInitTag( AbLegacyTagCreateParams p, TaskCompletionSource entered, TaskCompletionSource release) : FakeAbLegacyTag(p) { public override async Task InitializeAsync(CancellationToken ct) { entered.TrySetResult(); await release.Task.ConfigureAwait(false); // deliberately ignores ct } } /// Shutdown blocks on a probe loop parked in runtime init, then the loop disposes its probe runtime. [Fact] public async Task Shutdown_WaitsForProbeLoopParkedInInit_ThenProbeRuntimeIsDisposed() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeAbLegacyTagFactory { Customise = p => new ParkedInitTag(p, entered, release), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0" }, }, "drv-join", factory); await drv.InitializeAsync("{}", CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var shutdown = drv.ShutdownAsync(CancellationToken.None); await Task.Delay(300); shutdown.IsCompleted.ShouldBeFalse("shutdown must join the probe loop parked in runtime init"); // Positive control: releasing the parked call lets shutdown finish, with the probe runtime released. release.SetResult(); await shutdown.WaitAsync(TimeSpan.FromSeconds(5)); factory.Tags["S:0"].Disposed.ShouldBeTrue(); } /// The synchronous Dispose() path joins the parked loop too (it used to cancel and dispose under it). [Fact] public async Task Dispose_WaitsForProbeLoopParkedInInit_ThenProbeRuntimeIsDisposed() { var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var factory = new FakeAbLegacyTagFactory { Customise = p => new ParkedInitTag(p, entered, release), }; var drv = new AbLegacyDriver(new AbLegacyDriverOptions { Devices = [new AbLegacyDeviceOptions("ab://10.0.0.5/1,0", AbLegacyPlcFamily.Slc500)], Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), ProbeAddress = "S:0" }, }, "drv-join-dispose", factory); await drv.InitializeAsync("{}", CancellationToken.None); await entered.Task.WaitAsync(TimeSpan.FromSeconds(5)); var dispose = Task.Run(drv.Dispose); await Task.Delay(300); dispose.IsCompleted.ShouldBeFalse("Dispose must join the probe loop parked in runtime init"); // Positive control: releasing the parked call lets Dispose finish, with the probe runtime released. release.SetResult(); await dispose.WaitAsync(TimeSpan.FromSeconds(5)); factory.Tags["S:0"].Disposed.ShouldBeTrue(); } }