fix(drivers): join FOCAS and AbLegacy background loops on shutdown (#602 STAB-23/6)
Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
[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
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shutdown blocks on a probe loop parked in runtime init, then the loop disposes its probe runtime.</summary>
|
||||
[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();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// STAB-23 / #602 — the probe / recycle / fixed-tree loops were fire-and-forget, so shutdown
|
||||
/// cancelled and disposed while a loop could still be parked inside a connect that ignores
|
||||
/// cancellation, and that connect then completed against a shut-down driver. Shutdown now joins
|
||||
/// the loops (bounded) before disposing the client.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class FocasShutdownJoinTests
|
||||
{
|
||||
/// <summary>A client whose connect parks until released and deliberately ignores the cancellation token.</summary>
|
||||
private sealed class ParkedConnectClient(TaskCompletionSource entered, TaskCompletionSource release) : FakeFocasClient
|
||||
{
|
||||
public override async Task ConnectAsync(FocasHostAddress address, TimeSpan timeout, CancellationToken ct)
|
||||
{
|
||||
entered.TrySetResult();
|
||||
await release.Task.ConfigureAwait(false);
|
||||
await base.ConnectAsync(address, timeout, CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shutdown blocks on a probe loop parked in connect, then disposes the client that connect produced.</summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_WaitsForLoopParkedInConnect_ThenDisposesItsClient()
|
||||
{
|
||||
var entered = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeFocasClientFactory { Customise = () => new ParkedConnectClient(entered, release) };
|
||||
var options = new FocasDriverOptions
|
||||
{
|
||||
Devices = [new FocasDeviceOptions("focas://10.0.0.5:8193")],
|
||||
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", "focas://10.0.0.5:8193", "R100", FocasDataType.Byte))],
|
||||
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(50), Timeout = TimeSpan.FromSeconds(10) },
|
||||
};
|
||||
var drv = new FocasDriver(options, "focas-1", 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 connect");
|
||||
|
||||
// Positive control: releasing the parked connect lets shutdown finish.
|
||||
release.SetResult();
|
||||
await shutdown.WaitAsync(TimeSpan.FromSeconds(5));
|
||||
|
||||
factory.Clients.ShouldNotBeEmpty();
|
||||
factory.Clients.ShouldAllBe(c => c.DisposeCount >= 1);
|
||||
factory.Clients.ShouldAllBe(c => !c.IsConnected);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user