fix(twincat): bounded, race-safe lifecycle; drop counter; post-unsubscribe purge (L8)
- Device and tag tables are build-and-swap: InitializeAsync builds a fresh generation and publishes it by one swap under a lifecycle lock, then releases the replaced generation; teardown swaps in empty tables. A retained poll loop never sees a table mid-Clear (it used to read _tagsByRawPath while a re-init only ever added to it), and each device set is released by exactly one caller. - A lifecycle generation makes an InitializeAsync overtaken by a shutdown (or a newer init) release what it built instead of installing it - the Lane A abandoned-init-overlaps-shutdown case. - ShutdownAsync joins all probe loops together within 2 s (it awaited each unbounded - a probe wedged in the blocking ADS Connect hung the actor's teardown) in parallel with the poll-engine dispose, the whole bounded at 4.5 s; DisposeAsync may run concurrently and returns at once. A connect that completes on a released device disposes its client. - Releasing a generation disposes its native registrations, so a direct re-init no longer leaves live notifications bound to dead DeviceStates. - Connect gates and RMW semaphores are no longer disposed under in-flight users (no kernel handle is ever allocated); a fault on a released device is not recorded as driver health. - The notification channel counts DropWrite overflows (ITwinCATClient.DroppedNotificationCount); the probe tick warns with the delta. A queued value whose registration was disposed is purged in the client dispatch loop and dropped driver-side for any client. Refs #739 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -56,10 +56,22 @@ internal class FakeTwinCATClient : ITwinCATClient
|
||||
{
|
||||
ConnectCount++;
|
||||
if (ThrowOnConnect) throw Exception ?? new InvalidOperationException();
|
||||
if (BlockConnect is { } block)
|
||||
{
|
||||
// Models the real client's blocking SDK Connect: ignores the token until released.
|
||||
return block.Task.ContinueWith(_ => { IsConnected = true; }, TaskScheduler.Default);
|
||||
}
|
||||
IsConnected = true;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>When set, <see cref="ConnectAsync"/> does not complete (and ignores cancellation) until this
|
||||
/// is completed — a probe wedged in the synchronous ADS <c>Connect</c>.</summary>
|
||||
public TaskCompletionSource? BlockConnect { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the simulated notification-queue drop count.</summary>
|
||||
public long DroppedNotificationCount { get; set; }
|
||||
|
||||
/// <summary>Simulates reading a value from a symbol.</summary>
|
||||
/// <param name="symbolPath">The path to the symbol to read.</param>
|
||||
/// <param name="type">The data type of the symbol.</param>
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.Collections.Concurrent;
|
||||
using System.Diagnostics;
|
||||
using Shouldly;
|
||||
using Xunit;
|
||||
using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
|
||||
using ZB.MOM.WW.OtOpcUa.Driver.TwinCAT;
|
||||
|
||||
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// #739 L8 + the Lane A runtime contract (final stop = <c>ShutdownAsync</c> then <c>DisposeAsync</c>,
|
||||
/// bounded 5 s + 250 ms): a probe wedged in the blocking ADS connect cannot hang teardown; dispose may run
|
||||
/// while a shutdown is still running; an abandoned initialise overlapping the next shutdown never installs
|
||||
/// its generation; a value queued before unsubscribe is not delivered after it; re-init swaps its tables
|
||||
/// and retracts the previous generation's notifications; queue drops are reported.
|
||||
/// </summary>
|
||||
[Trait("Category", "Unit")]
|
||||
public sealed class TwinCATLifecycleTests
|
||||
{
|
||||
private const string Host = "ads://5.23.91.23.1.1:851";
|
||||
|
||||
private static TwinCATDriver NewDriver(FakeTwinCATClientFactory factory, bool probe = true, bool native = true,
|
||||
TwinCATCapturingLogger? log = null, params TwinCATTagDefinition[] tags) =>
|
||||
new(new TwinCATDriverOptions
|
||||
{
|
||||
Devices = [new TwinCATDeviceOptions(Host)],
|
||||
RawTags = TwinCATRawTags.Entries(tags.Length > 0
|
||||
? tags
|
||||
: [new TwinCATTagDefinition("Speed", Host, "MAIN.Speed", TwinCATDataType.DInt)]),
|
||||
Probe = probe
|
||||
? new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(30), Timeout = TimeSpan.FromMilliseconds(100) }
|
||||
: new TwinCATProbeOptions { Enabled = false },
|
||||
UseNativeNotifications = native,
|
||||
}, "drv-1", factory, log);
|
||||
|
||||
private static async Task WaitForAsync(Func<bool> condition, TimeSpan timeout)
|
||||
{
|
||||
var deadline = DateTime.UtcNow + timeout;
|
||||
while (!condition() && DateTime.UtcNow < deadline)
|
||||
await Task.Delay(10);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A probe wedged in the (uncancellable) connect no longer hangs ShutdownAsync — the join is bounded —
|
||||
/// and the client the wedged connect eventually produces is disposed, not installed on a dead device.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Shutdown_is_bounded_when_a_probe_is_wedged_in_connect()
|
||||
{
|
||||
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
|
||||
var drv = NewDriver(factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
|
||||
factory.Clients.Count.ShouldBe(1); // the probe is inside the wedged connect
|
||||
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
|
||||
|
||||
wedge.SetResult();
|
||||
await WaitForAsync(() => factory.Clients[0].DisposeCount == 1, TimeSpan.FromSeconds(2));
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// DisposeAsync while a ShutdownAsync is still running (joining a wedged probe) returns promptly and
|
||||
/// both complete without throwing; the device is released exactly once.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Dispose_concurrent_with_a_running_shutdown_completes_promptly()
|
||||
{
|
||||
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var factory = new FakeTwinCATClientFactory { Customise = () => new FakeTwinCATClient { BlockConnect = wedge } };
|
||||
var drv = NewDriver(factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
var shutdown = drv.ShutdownAsync(CancellationToken.None);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
sw.Elapsed.ShouldBeLessThan(TimeSpan.FromMilliseconds(250));
|
||||
shutdown.IsCompleted.ShouldBeFalse("the positive control: the first shutdown really was still joining the probe");
|
||||
|
||||
wedge.SetResult();
|
||||
await shutdown.WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
drv.Dispose(); // and a late sync Dispose is a no-op
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// An InitializeAsync overtaken by a ShutdownAsync before it commits must not install its devices or
|
||||
/// start probe loops when it resumes. Positive control: a fresh initialise afterwards installs normally.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_initialise_overtaken_by_shutdown_before_commit_installs_nothing()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = NewDriver(factory);
|
||||
var paused = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var resume = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
drv.BeforeCommitForTest = async () => { paused.TrySetResult(); await resume.Task; };
|
||||
|
||||
var abandoned = drv.InitializeAsync("{}", CancellationToken.None);
|
||||
await paused.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
resume.SetResult();
|
||||
|
||||
await Should.ThrowAsync<OperationCanceledException>(() => abandoned.WaitAsync(TimeSpan.FromSeconds(5)));
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken); // several probe intervals
|
||||
factory.Clients.ShouldBeEmpty("no probe loop of the overtaken generation may be running");
|
||||
drv.GetHealth().State.ShouldBe(DriverState.Unknown);
|
||||
|
||||
drv.BeforeCommitForTest = null;
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
drv.DeviceCount.ShouldBe(1);
|
||||
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
|
||||
factory.Clients.Count.ShouldBe(1);
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The Lane A shape: an InitializeAsync the runtime abandoned while it is still releasing the previous
|
||||
/// generation (joining a probe wedged in connect) overlaps the next ShutdownAsync. Shutdown stays
|
||||
/// bounded and, once everything settles, no device, probe loop or live client survives.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task An_abandoned_initialise_overlapping_shutdown_leaves_nothing_running()
|
||||
{
|
||||
var wedge = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var first = true;
|
||||
var factory = new FakeTwinCATClientFactory
|
||||
{
|
||||
Customise = () =>
|
||||
{
|
||||
var c = new FakeTwinCATClient { BlockConnect = first ? wedge : null };
|
||||
first = false;
|
||||
return c;
|
||||
},
|
||||
};
|
||||
var drv = NewDriver(factory);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => factory.Clients.Count == 1, TimeSpan.FromSeconds(2));
|
||||
|
||||
var abandoned = drv.InitializeAsync("{}", CancellationToken.None); // joins the wedged generation-1 probe
|
||||
await Task.Delay(50, TestContext.Current.CancellationToken);
|
||||
var sw = Stopwatch.StartNew();
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken).WaitAsync(TimeSpan.FromSeconds(10), TestContext.Current.CancellationToken);
|
||||
sw.Elapsed.ShouldBeLessThan(TwinCATDriver.ShutdownBudget + TimeSpan.FromMilliseconds(500));
|
||||
|
||||
wedge.SetResult();
|
||||
try { await abandoned.WaitAsync(TimeSpan.FromSeconds(5)); } catch (OperationCanceledException) { }
|
||||
|
||||
drv.DeviceCount.ShouldBe(0);
|
||||
await WaitForAsync(() => factory.Clients.All(c => c.DisposeCount > 0 || !c.IsConnected), TimeSpan.FromSeconds(2));
|
||||
var settled = factory.Clients.Count;
|
||||
await Task.Delay(150, TestContext.Current.CancellationToken);
|
||||
factory.Clients.Count.ShouldBe(settled, "no probe loop may still be creating clients");
|
||||
factory.Clients.ShouldAllBe(c => c.DisposeCount > 0 || !c.IsConnected);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A value the client queued before the unsubscribe is dropped if dispatched after it.
|
||||
/// Positive control: the same callback delivers while subscribed.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task A_notification_dispatched_after_unsubscribe_is_dropped()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = NewDriver(factory, probe: false);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
var events = new ConcurrentQueue<DataChangeEventArgs>();
|
||||
drv.OnDataChange += (_, e) => events.Enqueue(e);
|
||||
var handle = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
var callback = factory.Clients[0].Notifications.Single().OnChange; // what the client's queue holds
|
||||
|
||||
callback("MAIN.Speed", 1);
|
||||
events.Count.ShouldBe(1);
|
||||
|
||||
await drv.UnsubscribeAsync(handle, TestContext.Current.CancellationToken);
|
||||
callback("MAIN.Speed", 2);
|
||||
events.Count.ShouldBe(1);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A direct re-initialise with a changed config swaps the tag table (the removed tag is unknown, the
|
||||
/// added one resolves) and retracts the previous generation's live notifications.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Reinitialise_swaps_tables_and_retracts_the_previous_generations_notifications()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = NewDriver(factory, probe: false);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
_ = await drv.SubscribeAsync(["Speed"], TimeSpan.FromMilliseconds(100), TestContext.Current.CancellationToken);
|
||||
factory.Clients[0].Notifications.Count.ShouldBe(1);
|
||||
|
||||
var next = System.Text.Json.JsonSerializer.Serialize(new
|
||||
{
|
||||
devices = new[] { new { hostAddress = Host } },
|
||||
rawTags = new[]
|
||||
{
|
||||
new
|
||||
{
|
||||
rawPath = "Torque",
|
||||
tagConfig = TwinCATTagDefinitionFactory.ToTagConfig(
|
||||
new TwinCATTagDefinition("Torque", Host, "MAIN.Torque", TwinCATDataType.DInt)),
|
||||
writeIdempotent = false,
|
||||
deviceName = Host,
|
||||
},
|
||||
},
|
||||
probe = new { enabled = false },
|
||||
});
|
||||
await drv.InitializeAsync(next, TestContext.Current.CancellationToken);
|
||||
|
||||
factory.Clients[0].Notifications.ShouldBeEmpty();
|
||||
factory.Clients[0].DisposeCount.ShouldBe(1);
|
||||
(await drv.ReadAsync(["Speed"], TestContext.Current.CancellationToken)).Single().StatusCode
|
||||
.ShouldBe(TwinCATStatusMapper.BadNodeIdUnknown);
|
||||
(await drv.ReadAsync(["Torque"], TestContext.Current.CancellationToken)).Single().StatusCode
|
||||
.ShouldBe(TwinCATStatusMapper.Good);
|
||||
}
|
||||
|
||||
/// <summary>Reads racing a stream of re-initialises never throw (no table is ever mid-Clear).</summary>
|
||||
[Fact]
|
||||
public async Task Reads_racing_reinitialise_never_throw()
|
||||
{
|
||||
var factory = new FakeTwinCATClientFactory();
|
||||
var drv = NewDriver(factory, probe: false);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
using var stop = new CancellationTokenSource(TimeSpan.FromMilliseconds(400));
|
||||
var good = 0;
|
||||
var reader = Task.Run(async () =>
|
||||
{
|
||||
while (!stop.IsCancellationRequested)
|
||||
{
|
||||
var r = await drv.ReadAsync(["Speed"], CancellationToken.None);
|
||||
if (r[0].StatusCode == TwinCATStatusMapper.Good) Interlocked.Increment(ref good);
|
||||
}
|
||||
}, TestContext.Current.CancellationToken);
|
||||
while (!stop.IsCancellationRequested)
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await reader; // throws if any read threw
|
||||
good.ShouldBeGreaterThan(0);
|
||||
}
|
||||
|
||||
/// <summary>Notification-queue drops are reported once per probe tick that sees new drops.</summary>
|
||||
[Fact]
|
||||
public async Task Notification_queue_drops_are_reported()
|
||||
{
|
||||
var fake = new FakeTwinCATClient();
|
||||
var log = new TwinCATCapturingLogger();
|
||||
var drv = NewDriver(new FakeTwinCATClientFactory { Customise = () => fake }, log: log);
|
||||
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
|
||||
await WaitForAsync(() => fake.IsConnected, TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(0); // no drops, no warning
|
||||
|
||||
fake.DroppedNotificationCount = 5;
|
||||
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 5")), TimeSpan.FromSeconds(2));
|
||||
await Task.Delay(100, TestContext.Current.CancellationToken);
|
||||
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(1);
|
||||
|
||||
fake.DroppedNotificationCount = 8;
|
||||
await WaitForAsync(() => log.Warnings.Any(w => w.Contains("dropped 3")), TimeSpan.FromSeconds(2));
|
||||
log.Warnings.Count(w => w.Contains("dropped")).ShouldBe(2);
|
||||
await drv.ShutdownAsync(TestContext.Current.CancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user