fix(drivers): InitializeAsync releases previous device/session state before overwrite (#630)

FOCAS, AbLegacy, AbCip, TwinCAT, S7 and OpcUaClient orphaned the previous
probe/recycle/fixed-tree loops and clients when re-initialised on a live driver.

Claude-Session: https://claude.ai/code/session_01Ef2ozxS7QPanGT8uoATbTe
This commit is contained in:
Joseph Doherty
2026-10-08 14:28:55 -04:00
parent d898262c4a
commit 461133235a
13 changed files with 494 additions and 36 deletions
+9
View File
@@ -425,6 +425,15 @@ re-entered this path; the watchdog makes it automatic and unbounded, so a device
have leaked ~360 transports at the 10 s retry interval. The shutdown is best-effort: a driver that throws
on it still gets its initialise attempt.
**`InitializeAsync` itself releases the previous device/connection state first (#630).** The retry above is not
the only way in: `DriverInstanceActor.AdoptConfigDuringInit` calls `InitializeAsync` on a live driver with *no*
teardown, so a driver that does `_devices[host] = new DeviceState(...)` (or `Session = ...`, `Plc = ...`) orphans
the previous state's probe / recycle / fixed-tree loops and its client. FOCAS, AbLegacy, AbCip, TwinCAT, S7 and
OpcUaClient now cancel, bounded-join (5 s) and dispose that state at the top of `InitializeAsync`
(`ReleaseDeviceStatesAsync` / `ReleaseConnectionAsync` / `ReleaseSessionAsync`), alongside Modbus and MQTT.
It is deliberately the device-state half of `ShutdownAsync` only: the poll engine and subscriptions are *not*
dropped, because nothing would re-drive them. A new driver must do the same.
#### The fan-out that watchdog triggers: indexed per driver, and one batch
`DisconnectObserved` raises `ConnectivityChanged`, and `DriverHostActor` answers it by pushing
@@ -335,8 +335,12 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
public string DriverType => "AbCip";
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Release the previous device states' probe loops + handles before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbCip driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
@@ -459,7 +463,36 @@ public sealed class AbCipDriver : IDriver, IReadable, IWritable, ITagDiscovery,
_logger.LogError(ex, "AbCip driver {DriverInstanceId} failed to initialize", _driverInstanceId);
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Cancels, joins (bounded) and disposes every existing device state's probe loop + handles, then
/// clears the device/route tables. Device part of <see cref="ShutdownAsync"/> only.
/// </summary>
private async Task ReleaseDeviceStatesAsync(CancellationToken cancellationToken)
{
if (_devices.Count == 0) return;
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch (ObjectDisposedException) { }
}
foreach (var state in _devices.Values)
{
var probeTask = state.ProbeTask;
if (probeTask is null) continue;
try { await probeTask.WaitAsync(TimeSpan.FromSeconds(5), cancellationToken).ConfigureAwait(false); }
catch (TimeoutException) { }
catch (OperationCanceledException) { }
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeHandles();
}
_devices.Clear();
_devicesByRoutingKey.Clear();
}
/// <inheritdoc />
@@ -143,8 +143,13 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
}
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync can run on a live driver (DriverInstanceActor.AdoptConfigDuringInit calls it
// without a teardown first). Release the previous device states' probe loops + runtimes BEFORE the
// overwrite below, or they are orphaned beyond the reach of ShutdownAsync. Deliberately NOT
// ShutdownAsync: that also drops _poll and every subscription, which nothing would re-drive.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
_logger.LogInformation(
"AbLegacy driver {DriverInstanceId} initializing: transport={Transport} tagFactory={TagFactory}",
@@ -255,7 +260,35 @@ public sealed class AbLegacyDriver : IDriver, IReadable, IWritable, ITagDiscover
_resolver.Clear(); // drop transient equipment-tag parses so a config change re-parses
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Cancels, bounded-joins and disposes every existing device state, then clears the table.
/// Device-state part of <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_devices.Count == 0) return;
foreach (var state in _devices.Values)
try { state.ProbeCts?.Cancel(); } catch { }
var loops = _devices.Values.Select(d => d.ProbeTask).OfType<Task>().ToList();
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning(
"AbLegacy re-initialise: {Count} probe loop(s) did not stop within {Timeout}; proceeding. Driver={DriverInstanceId}",
loops.Count, LoopJoinTimeout, _driverInstanceId);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeRuntimes();
}
_devices.Clear();
}
/// <inheritdoc />
@@ -178,8 +178,12 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
/// <param name="driverConfigJson">The driver configuration JSON (see remarks — not re-parsed).</param>
/// <param name="cancellationToken">Cancellation token for the operation.</param>
/// <returns>A completed task.</returns>
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: release the previous device states' loops + clients BEFORE they are overwritten (the actor's
// AdoptConfigDuringInit calls this without a teardown first). Not ShutdownAsync: that also drops _poll
// and subscriptions, which nothing would re-drive.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
Volatile.Write(ref _health, new DriverHealth(DriverState.Initializing, null, null));
try
{
@@ -316,7 +320,56 @@ public sealed class FocasDriver : IDriver, IReadable, IWritable, ITagDiscovery,
Volatile.Write(ref _health, new DriverHealth(DriverState.Faulted, null, ex.Message));
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Cancels, bounded-joins and disposes every existing device state (probe / recycle / fixed-tree
/// loops + client) and the alarm projection, then clears the table. Device part of
/// <see cref="ShutdownAsync"/> only (no poll / subscription teardown).
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_alarmProjection is { } proj)
{
await proj.DisposeAsync().ConfigureAwait(false);
_alarmProjection = null;
}
if (_devices.Count == 0) return;
var loops = new List<Task>();
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling probe CTS for {Host} failed", state.Options.HostAddress); }
try { state.RecycleCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling recycle CTS for {Host} failed", state.Options.HostAddress); }
try { state.FixedTreeCts?.Cancel(); }
catch (Exception ex) { _logger.LogDebug(ex, "Cancelling fixed-tree CTS for {Host} failed", state.Options.HostAddress); }
if (state.ProbeTask is { } pt) loops.Add(pt);
if (state.RecycleTask is { } rt) loops.Add(rt);
if (state.FixedTreeTask is { } ft) loops.Add(ft);
}
if (loops.Count > 0)
{
var all = Task.WhenAll(loops);
var finished = await Task.WhenAny(all, Task.Delay(LoopJoinTimeout, CancellationToken.None)).ConfigureAwait(false);
if (finished != all)
_logger.LogWarning("FOCAS re-initialise: {Count} background loop(s) did not stop within {Timeout}; proceeding",
loops.Count, LoopJoinTimeout);
}
foreach (var state in _devices.Values)
{
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.RecycleCts?.Dispose();
state.RecycleCts = null;
state.RecycleTask = null;
state.FixedTreeCts?.Dispose();
state.FixedTreeCts = null;
state.FixedTreeTask = null;
state.DisposeClient();
}
_devices.Clear();
}
/// <inheritdoc />
@@ -194,6 +194,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs
// it with no teardown first); `Session = session` below would otherwise orphan the previous session,
// its keep-alive handler and any reconnect handler.
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
@@ -587,32 +591,14 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
/// <summary>
/// Cancels any in-flight reconnect, detaches the keep-alive handler and closes + disposes the current
/// session. The session part of <see cref="ShutdownAsync"/>; also run at the top of
/// <see cref="InitializeAsync"/> so a re-initialise on a live driver (#630) does not orphan the
/// previous session and its reconnect handler. Subscriptions are NOT touched here.
/// </summary>
private async Task ReleaseSessionAsync(CancellationToken cancellationToken)
{
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
// whether or not the wire-side delete succeeds since the local handles are useless
// after close anyway. Before deleting each subscription we detach the Notification
// handlers we attached at subscribe time so the SDK's invocation list no longer
// holds the driver instance through the closure.
foreach (var rs in _subscriptions.Values)
{
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_subscriptions.Clear();
foreach (var ras in _alarmSubscriptions.Values)
{
try { ras.EventItem.Notification -= ras.Handler; }
catch { /* best-effort */ }
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_alarmSubscriptions.Clear();
// Abort any in-flight reconnect attempts before touching the session — BeginReconnect's
// retry loop holds a reference to the current session and would fight Session.CloseAsync
// if left spinning. Take the handler under _probeLock so a keep-alive callback racing
@@ -645,6 +631,35 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
try { if (sessionToClose is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
try { sessionToClose?.Dispose(); } catch { }
}
/// <inheritdoc />
public async Task ShutdownAsync(CancellationToken cancellationToken)
{
// Tear down remote subscriptions first — otherwise Session.Close will try and may fail
// with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared
// whether or not the wire-side delete succeeds since the local handles are useless
// after close anyway. Before deleting each subscription we detach the Notification
// handlers we attached at subscribe time so the SDK's invocation list no longer
// holds the driver instance through the closure.
foreach (var rs in _subscriptions.Values)
{
DetachNotificationHandlers(rs.ItemHandlers);
try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_subscriptions.Clear();
foreach (var ras in _alarmSubscriptions.Values)
{
try { ras.EventItem.Notification -= ras.Handler; }
catch { /* best-effort */ }
try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); }
catch { /* best-effort */ }
}
_alarmSubscriptions.Clear();
await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false);
_namespaceMap = null;
_connectedEndpointUrl = null;
@@ -223,6 +223,11 @@ public sealed class S7Driver
/// <inheritdoc />
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Stop the previous probe loop and drop the previous connection
// before they are overwritten below. Deliberately NOT ShutdownAsync: that also drops _poll and the
// subscriptions, which nothing would re-drive.
await ReleaseConnectionAsync().ConfigureAwait(false);
Health = new DriverHealth(DriverState.Initializing, null, null);
try
{
@@ -360,7 +365,18 @@ public sealed class S7Driver
// disposing its CTS (drain-before-dispose, STAB-6-S7), so no poll iteration touches _gate
// after this returns.
await _poll.DisposeAsync().ConfigureAwait(false);
await ReleaseConnectionAsync().ConfigureAwait(false);
_writeFault = null;
_connectBackoff.RecordSuccess();
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
/// <summary>
/// Cancels + bounded-joins the probe loop, then disposes its CTS and the PLC connection. The
/// connection part of <see cref="ShutdownAsync"/> (everything but the poll engine and health).
/// </summary>
private async Task ReleaseConnectionAsync()
{
var probeCts = _probeCts;
var probeTask = _probeTask;
try { probeCts?.Cancel(); } catch { }
@@ -381,11 +397,8 @@ public sealed class S7Driver
_initialized = false;
_plcDead = false;
_writeFault = null;
_connectBackoff.RecordSuccess();
try { Plc?.Dispose(); } catch { /* best-effort — tearing down anyway */ }
Plc = null;
Health = new DriverHealth(DriverState.Unknown, Health.LastSuccessfulRead, null);
}
/// <inheritdoc />
@@ -110,8 +110,12 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
public string DriverType => "TwinCAT";
/// <inheritdoc />
public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
{
// #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit
// runs it with no teardown first). Release the previous device states' probe loops + clients before
// the overwrite below. Deliberately NOT ShutdownAsync: that also drops _poll and the subscriptions.
await ReleaseDeviceStatesAsync().ConfigureAwait(false);
_health = new DriverHealth(DriverState.Initializing, null, null);
try
{
@@ -175,7 +179,30 @@ public sealed class TwinCATDriver : IDriver, IReadable, IWritable, ITagDiscovery
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
throw;
}
return Task.CompletedTask;
}
/// <summary>
/// Cancels, awaits and disposes every existing device state's probe loop, client and gate, then
/// clears the device table. Device part of <see cref="ShutdownAsync"/> only.
/// </summary>
private async Task ReleaseDeviceStatesAsync()
{
if (_devices.IsEmpty) return;
foreach (var state in _devices.Values)
{
try { state.ProbeCts?.Cancel(); } catch { }
if (state.ProbeTask is Task pt)
{
try { await pt.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false); }
catch { /* cancelled, faulted or wedged — teardown continues */ }
}
state.ProbeCts?.Dispose();
state.ProbeCts = null;
state.ProbeTask = null;
state.DisposeClient();
state.DisposeGate();
}
_devices.Clear();
}
/// <inheritdoc />
@@ -0,0 +1,54 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbCip.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + handles. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbCipReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe handle is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsHandles()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbCipTagFactory();
var drv = new AbCipDriver(new AbCipDriverOptions
{
Devices = [new AbCipDeviceOptions(host)],
Probe = new AbCipProbeOptions
{
Enabled = true,
Interval = TimeSpan.FromMilliseconds(20),
Timeout = TimeSpan.FromMilliseconds(200),
ProbeTagPath = "@raw_cpu_type",
},
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and has initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("@raw_cpu_type", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["@raw_cpu_type"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,50 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.PlcFamilies;
namespace ZB.MOM.WW.OtOpcUa.Driver.AbLegacy.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + runtime. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class AbLegacyReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has stopped and its probe runtime is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsRuntime()
{
const string host = "ab://10.0.0.5/1,0";
var factory = new FakeAbLegacyTagFactory();
var drv = new AbLegacyDriver(new AbLegacyDriverOptions
{
Devices = [new AbLegacyDeviceOptions(host, AbLegacyPlcFamily.Slc500)],
Probe = new AbLegacyProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), ProbeAddress = "S:0" },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is running and has created + initialised its runtime.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Tags.TryGetValue("S:0", out var t) && t.InitializeCount > 0))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never started");
await Task.Delay(10);
}
var firstTag = factory.Tags["S:0"];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
firstTag.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstTag.Disposed.ShouldBeTrue();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}
@@ -0,0 +1,58 @@
using Shouldly;
using Xunit;
using ZB.MOM.WW.OtOpcUa.Driver.FOCAS;
namespace ZB.MOM.WW.OtOpcUa.Driver.FOCAS.Tests;
/// <summary>
/// #630 — <c>InitializeAsync</c> on a live driver (<c>DriverInstanceActor.AdoptConfigDuringInit</c> runs
/// it with no teardown first) overwrote <c>_devices[host]</c> and orphaned the previous state's
/// probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class FocasReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "focas://10.0.0.5:8193";
var factory = new FakeFocasClientFactory();
var options = new FocasDriverOptions
{
Devices = [new FocasDeviceOptions(host)],
RawTags = [FocasRawTags.Entry(new FocasTagDefinition("R", host, "R100", FocasDataType.Byte))],
Probe = new FocasProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(2) },
};
var drv = new FocasDriver(options, "focas-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first state's probe loop is demonstrably running and holds a live client.
await WaitUntilAsync(() => factory.Clients.Count > 0 && factory.Clients[0].IsConnected);
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
var firstClient = factory.Clients[0];
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
drv.GetDeviceState(host)!.ProbeTask.ShouldNotBeNull(); // the new state is running
await drv.ShutdownAsync(CancellationToken.None);
}
private static async Task WaitUntilAsync(Func<bool> cond)
{
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!cond())
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("condition not met");
await Task.Delay(10);
}
}
}
@@ -0,0 +1,30 @@
using Moq;
using Opc.Ua.Client;
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> first) used to
/// overwrite <c>Session</c> and orphan the previous session + its keep-alive handler.
/// </summary>
[Trait("Category", "Unit")]
public sealed class OpcUaClientReinitializeReleasesSessionTests
{
/// <summary>A second Initialize disposes the live session before attempting to build a new one.</summary>
[Fact]
public async Task Initialize_OnLiveDriver_DisposesThePreviousSession()
{
var options = new OpcUaClientDriverOptions { EndpointUrl = "opc.tcp://127.0.0.1:1" };
using var drv = new OpcUaClientDriver(options, "opcua-reinit");
var old = new Mock<ISession>();
drv.SetSessionForTest(old.Object);
// The new connect is refused (nothing listens on port 1) — what matters is what happened to the old one.
await Should.ThrowAsync<Exception>(() => drv.InitializeAsync("{}", TestContext.Current.CancellationToken));
old.Verify(s => s.Dispose(), Times.AtLeastOnce);
drv.Session.ShouldBeNull();
}
}
@@ -482,6 +482,37 @@ public sealed class S7DriverReconnectTests
(factory.Created.Count - atFirstAttempt).ShouldBeLessThanOrEqualTo(1);
}
/// <summary>
/// #630 — a second <c>InitializeAsync</c> on a live driver (no <c>ShutdownAsync</c> between, as
/// <c>DriverInstanceActor.AdoptConfigDuringInit</c> does) must stop the first probe loop and
/// dispose the first connection rather than orphan them.
/// </summary>
[Fact]
public async Task Second_Initialize_stops_first_probe_loop_and_disposes_first_connection()
{
var factory = new FakeS7PlcFactory();
using var drv = new S7Driver(ProbeEnabledOptions(), "s7-reinit", factory);
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
var first = factory.Created[0];
// Positive control: the first probe loop is demonstrably running on the first connection.
await WaitUntilAsync(() => Volatile.Read(ref first.ReadStatusCount) >= 2, TimeSpan.FromSeconds(5));
Volatile.Read(ref first.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(2);
first.Disposed.ShouldBeFalse();
await drv.InitializeAsync("{}", TestContext.Current.CancellationToken);
first.Disposed.ShouldBeTrue();
var frozen = Volatile.Read(ref first.ReadStatusCount);
var second = factory.Created[^1];
second.ShouldNotBeSameAs(first);
// Positive control for the absence check: the NEW loop keeps ticking over the same window.
var secondBefore = Volatile.Read(ref second.ReadStatusCount);
await WaitUntilAsync(() => Volatile.Read(ref second.ReadStatusCount) >= secondBefore + 3, TimeSpan.FromSeconds(5));
Volatile.Read(ref second.ReadStatusCount).ShouldBeGreaterThanOrEqualTo(secondBefore + 3);
Volatile.Read(ref first.ReadStatusCount).ShouldBe(frozen);
}
private static S7DriverOptions ProbeEnabledOptions() => new()
{
Host = "192.0.2.1",
@@ -626,8 +657,12 @@ public sealed class S7DriverReconnectTests
public Task WriteBytesAsync(S7NetDataType area, int db, int startByteAdr, byte[] value, CancellationToken cancellationToken) =>
Task.CompletedTask;
/// <summary>Number of probe <see cref="ReadStatusAsync"/> calls made on this connection.</summary>
public int ReadStatusCount;
public async Task ReadStatusAsync(CancellationToken cancellationToken)
{
Interlocked.Increment(ref ReadStatusCount);
if (ReadStatusHangsUntilCancelled)
await Task.Delay(System.Threading.Timeout.Infinite, cancellationToken).ConfigureAwait(false);
if (ReadStatusThrows is { } ex)
@@ -0,0 +1,48 @@
using Shouldly;
using Xunit;
namespace ZB.MOM.WW.OtOpcUa.Driver.TwinCAT.Tests;
/// <summary>
/// #630 (same pattern) — <c>InitializeAsync</c> on a live driver overwrote <c>_devices[host]</c> and
/// orphaned the previous state's probe loop + client. It now releases them first.
/// </summary>
[Trait("Category", "Unit")]
public sealed class TwinCATReinitializeReleasesStateTests
{
/// <summary>Initialize twice: the first state's probe loop has finished and its client is disposed.</summary>
[Fact]
public async Task SecondInitialize_StopsFirstStatesLoop_AndDisposesItsClient()
{
const string host = "ads://5.23.91.23.1.1:851";
var factory = new FakeTwinCATClientFactory();
var drv = new TwinCATDriver(new TwinCATDriverOptions
{
Devices = [new TwinCATDeviceOptions(host)],
Probe = new TwinCATProbeOptions { Enabled = true, Interval = TimeSpan.FromMilliseconds(20), Timeout = TimeSpan.FromSeconds(1) },
}, "drv-reinit", factory);
await drv.InitializeAsync("{}", CancellationToken.None);
var first = drv.GetDeviceState(host)!;
// Positive control: the first probe loop is running and holds a connected client.
var deadline = DateTime.UtcNow.AddSeconds(5);
while (!(factory.Clients.Count > 0 && factory.Clients[0].IsConnected))
{
if (DateTime.UtcNow > deadline) throw new TimeoutException("probe never connected");
await Task.Delay(10);
}
var firstClient = factory.Clients[0];
var firstLoop = first.ProbeTask;
firstLoop.ShouldNotBeNull();
firstLoop!.IsCompleted.ShouldBeFalse();
await drv.InitializeAsync("{}", CancellationToken.None);
firstLoop.IsCompleted.ShouldBeTrue(); // joined
firstClient.DisposeCount.ShouldBeGreaterThanOrEqualTo(1);
firstClient.IsConnected.ShouldBeFalse();
drv.GetDeviceState(host).ShouldNotBeSameAs(first);
await drv.ShutdownAsync(CancellationToken.None);
}
}