diff --git a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
index acf34e10..a4b62ec5 100644
--- a/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
+++ b/src/Core/ZB.MOM.WW.OtOpcUa.Commons/Browsing/DiscoveryDriverBrowser.cs
@@ -188,6 +188,13 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
}
}
+ ///
+ /// Final teardown of a throwaway capture/probe driver: a bounded
+ /// followed by a bounded dispose. The dispose is NOT optional and runs even after a failed or timed-out
+ /// shutdown: a Galaxy driver's ShutdownAsync releases none of its gRPC runtime (channels, MX
+ /// session, stream loops) — only DisposeAsync does — so a browse that stopped at shutdown leaked a
+ /// full runtime per /raw browse and per probe (02/L1, 06/G-1).
+ ///
private async Task BoundedShutdownAsync(IDriver driver, string driverType)
{
try
@@ -199,7 +206,25 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
catch (Exception ex)
{
_logger.LogWarning(ex,
- "Browse-capture shutdown for {DriverType} failed/timed out; instance abandoned", driverType);
+ "Browse-capture shutdown for {DriverType} failed/timed out; disposing the instance anyway", driverType);
+ }
+
+ try
+ {
+ switch (driver)
+ {
+ case IAsyncDisposable asyncDisposable:
+ await asyncDisposable.DisposeAsync().AsTask().WaitAsync(_shutdownTimeout).ConfigureAwait(false);
+ break;
+ case IDisposable disposable:
+ disposable.Dispose();
+ break;
+ }
+ }
+ catch (Exception ex)
+ {
+ _logger.LogWarning(ex,
+ "Browse-capture dispose for {DriverType} failed/timed out; instance abandoned", driverType);
}
}
diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
index 19616fc6..5610cb1f 100644
--- a/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
+++ b/src/Server/ZB.MOM.WW.OtOpcUa.Runtime/Drivers/DriverInstanceActor.cs
@@ -184,6 +184,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
///
public static readonly TimeSpan DefaultCommsLossTimeout = TimeSpan.FromSeconds(90);
+ ///
+ /// Bound on the driver's (and its dispose) when the child stops or
+ /// restarts, and on the teardown that precedes every reconnect attempt. PostStop runs on a
+ /// dispatcher thread and blocks on it, so a driver whose shutdown waits on a dead socket / COM proxy used
+ /// to pin that thread for as long as the wait took — a few such stops starved the default dispatcher and
+ /// wedged CoordinatedShutdown and the host's own restart cascade. A shutdown that overruns is
+ /// abandoned (logged), never awaited further.
+ ///
+ public static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(5);
+
private readonly IDriver _driver;
/// Phase 6.1 resilience seam: every guarded driver-capability call this actor makes is
@@ -204,6 +214,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// Comms-loss watchdog threshold; or negative disables it.
private readonly TimeSpan _commsLossTimeout;
+ /// Bound on the driver shutdown / dispose; see .
+ private readonly TimeSpan _shutdownTimeout;
+
+ ///
+ /// Set by so the it triggers skips the dispose. A
+ /// supervision restart recreates the actor object around the SAME (the Props closure
+ /// captured it) and the host then re-initialises that driver — disposing it here would hand the new
+ /// incarnation a dead object. Only a real stop (deploy removal, operator Restart, respawn on config change,
+ /// host shutdown) disposes.
+ ///
+ private bool _restarting;
+
/// When the actor last entered Connected. The comms-loss watchdog's baseline for a
/// driver that has never reported a successful read, so "connected but never read anything" is
/// measurable rather than indefinitely undefined.
@@ -307,6 +329,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// Optional comms-loss watchdog threshold; defaults to
/// . disables the watchdog — used by
/// tests that need a Connected driver to stay Connected while reporting non-Healthy.
+ /// Optional bound on the driver's shutdown / dispose on stop and on the
+ /// pre-reconnect teardown; defaults to . Exists so a test can prove a
+ /// hung shutdown is abandoned without waiting 5 s.
/// A instance configured to create the wrapped .
public static Props Props(
IDriver driver,
@@ -316,7 +341,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null,
- TimeSpan? commsLossTimeout = null) =>
+ TimeSpan? commsLossTimeout = null,
+ TimeSpan? shutdownTimeout = null) =>
Akka.Actor.Props.Create(() => new DriverInstanceActor(
driver,
reconnectInterval ?? DefaultReconnectInterval,
@@ -325,7 +351,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
clusterId ?? string.Empty,
invoker,
healthPollInterval,
- commsLossTimeout));
+ commsLossTimeout,
+ shutdownTimeout));
///
/// Returns true when the driver should boot in DEV-STUB mode based on host platform and
@@ -362,6 +389,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// subscription reconcile; defaults to when null.
/// Comms-loss watchdog threshold; defaults to
/// when null, and is disabled when zero or negative.
+ /// Bound on the driver's shutdown / dispose; defaults to
+ /// when null.
public DriverInstanceActor(
IDriver driver,
TimeSpan reconnectInterval,
@@ -370,7 +399,8 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
string? clusterId = null,
IDriverCapabilityInvoker? invoker = null,
TimeSpan? healthPollInterval = null,
- TimeSpan? commsLossTimeout = null)
+ TimeSpan? commsLossTimeout = null,
+ TimeSpan? shutdownTimeout = null)
{
_driver = driver;
_invoker = invoker ?? NullDriverCapabilityInvoker.Instance;
@@ -380,6 +410,7 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
_reconnectInterval = reconnectInterval;
_healthPollInterval = healthPollInterval ?? HealthPollInterval;
_commsLossTimeout = commsLossTimeout ?? DefaultCommsLossTimeout;
+ _shutdownTimeout = shutdownTimeout is { } bound && bound > TimeSpan.Zero ? bound : DefaultShutdownTimeout;
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair("driver_type", driver.DriverType));
@@ -421,6 +452,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Context.Parent.Tell(new ReprovisionRequested(_driverInstanceId, reason.GetType().Name));
}
+ ///
+ /// Flags the restart before the base implementation runs , so that PostStop
+ /// shuts the driver down (the host re-initialises it on ) but does
+ /// NOT dispose it — see .
+ ///
+ ///
+ protected override void PreRestart(Exception reason, object message)
+ {
+ _restarting = true;
+ base.PreRestart(reason, message);
+ }
+
private void Stubbed()
{
// Stubbed drivers accept the standard message contracts but return deterministic
@@ -1547,10 +1590,66 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
DetachRediscoverySource();
DetachHostStatusSource();
DetachConnectionLossSource();
- try { _driver.ShutdownAsync(CancellationToken.None).GetAwaiter().GetResult(); }
- catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
+
+ // BOUNDED. PostStop runs on a dispatcher thread and this blocks it, so an unbounded wait on a driver
+ // whose shutdown hangs against a dead socket / COM proxy pinned that thread for the whole wait, and a
+ // few such stops starved the dispatcher. A shutdown that overruns the bound is abandoned.
+ using (var cts = new CancellationTokenSource(_shutdownTimeout))
+ {
+ try { _driver.ShutdownAsync(cts.Token).WaitAsync(_shutdownTimeout).GetAwaiter().GetResult(); }
+ catch (TimeoutException)
+ {
+ _log.Warning("DriverInstance {Id}: ShutdownAsync did not complete within {Bound} on PostStop; abandoning it",
+ _driverInstanceId, _shutdownTimeout);
+ }
+ catch (Exception ex) { _log.Warning(ex, "DriverInstance {Id}: ShutdownAsync threw on PostStop", _driverInstanceId); }
+ }
+
+ // Dispose ONLY on a real stop. A supervision restart (PreRestart → this PostStop) re-uses the same driver
+ // object for the next incarnation, which the host re-initialises. Everything else that stops a child —
+ // deploy removal, operator Restart, respawn on a config change, host shutdown — is final, and the driver
+ // is never touched again: before this, nothing in the runtime ever disposed an IDriver, so a Calculation
+ // driver's compiled-script AssemblyLoadContexts and an OpcUaClient driver's LoggerFactory / gate / CTS
+ // leaked once per respawn (02/L1). Bounded like the shutdown, and runs even after a timed-out shutdown —
+ // leaking the object is strictly worse than disposing one whose shutdown is still wedged.
+ if (!_restarting) DisposeDriver();
+
OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
new KeyValuePair("event", "stop"),
new KeyValuePair("driver_type", _driver.DriverType));
}
+
+ /// Final teardown of the wrapped driver: preferred, else
+ /// , never both. A driver implementing neither needs nothing here.
+ private void DisposeDriver()
+ {
+ try
+ {
+ switch (_driver)
+ {
+ case IAsyncDisposable asyncDisposable:
+ asyncDisposable.DisposeAsync().AsTask().WaitAsync(_shutdownTimeout).GetAwaiter().GetResult();
+ break;
+ case IDisposable disposable:
+ disposable.Dispose();
+ break;
+ default:
+ return;
+ }
+
+ _log.Info("DriverInstance {Id}: disposed {Type} driver on stop", _driverInstanceId, _driver.DriverType);
+ OtOpcUaTelemetry.DriverInstanceLifecycle.Add(1,
+ new KeyValuePair("event", "dispose"),
+ new KeyValuePair("driver_type", _driver.DriverType));
+ }
+ catch (TimeoutException)
+ {
+ _log.Warning("DriverInstance {Id}: DisposeAsync did not complete within {Bound} on PostStop; abandoning it",
+ _driverInstanceId, _shutdownTimeout);
+ }
+ catch (Exception ex)
+ {
+ _log.Warning(ex, "DriverInstance {Id}: disposing the driver threw on PostStop", _driverInstanceId);
+ }
+ }
}
diff --git a/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserDisposeTests.cs b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserDisposeTests.cs
new file mode 100644
index 00000000..5db9edd8
--- /dev/null
+++ b/tests/Core/ZB.MOM.WW.OtOpcUa.Commons.Tests/Browsing/DiscoveryDriverBrowserDisposeTests.cs
@@ -0,0 +1,125 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.Browsing;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+
+namespace ZB.MOM.WW.OtOpcUa.Commons.Tests.Browsing;
+
+///
+/// Review 02 L1 (host context, 06/G-1): the browser's throwaway capture driver must be disposed, not
+/// only shut down. A Galaxy driver's ShutdownAsync left its whole gRPC runtime alive until
+/// DisposeAsync, which nothing called — so every /raw browse leaked two channels, an MX session
+/// and two stream loops.
+///
+public sealed class DiscoveryDriverBrowserDisposeTests
+{
+ private static readonly TimeSpan ShortOpen = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ShortShutdown = TimeSpan.FromMilliseconds(300);
+
+ private static DiscoveryDriverBrowser Browser(IDriverFactory factory) =>
+ new(factory, NullLogger.Instance, ShortOpen, ShortShutdown);
+
+ [Fact]
+ public async Task OpenAsync_disposes_the_capture_driver_after_shutdown()
+ {
+ var driver = new DisposableFakeDiscoveryDriver();
+ var browser = Browser(new FakeDriverFactory((_, _, _) => driver));
+
+ var session = await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+
+ session.ShouldNotBeNull();
+ driver.Inner.Trace.ShouldBe(new[] { "init", "discover", "shutdown" });
+ driver.DisposeCalls.ShouldBe(1);
+ driver.DisposedAfterShutdown.ShouldBeTrue("dispose is the final teardown, after the shutdown");
+ }
+
+ [Fact]
+ public async Task OpenAsync_disposes_the_capture_driver_even_when_discovery_throws()
+ {
+ var driver = new DisposableFakeDiscoveryDriver(new FakeDiscoveryDriver
+ {
+ OnDiscover = (_, _) => throw new InvalidOperationException("boom-discovery"),
+ });
+ var browser = Browser(new FakeDriverFactory((_, _, _) => driver));
+
+ await Should.ThrowAsync(
+ () => browser.OpenAsync("Fake", "{}", CancellationToken.None));
+
+ driver.DisposeCalls.ShouldBe(1);
+ }
+
+ [Fact]
+ public async Task OpenAsync_disposes_the_capture_driver_even_when_its_shutdown_hangs()
+ {
+ var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ var driver = new DisposableFakeDiscoveryDriver(new FakeDiscoveryDriver { OnShutdown = _ => never.Task });
+ var browser = Browser(new FakeDriverFactory((_, _, _) => driver));
+
+ await browser.OpenAsync("Fake", "{}", CancellationToken.None);
+
+ driver.DisposeCalls.ShouldBe(1, "a shutdown that timed out must not skip the dispose");
+ }
+
+ [Fact]
+ public async Task CanBrowse_disposes_its_probe_driver()
+ {
+ var driver = new DisposableFakeDiscoveryDriver();
+ var browser = Browser(new FakeDriverFactory((_, _, _) => driver));
+
+ browser.CanBrowse("Fake", "{}").ShouldBeTrue();
+
+ // The CanBrowse teardown is fire-and-forget; wait for it.
+ await driver.Disposed.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken);
+ driver.DisposeCalls.ShouldBe(1);
+ }
+
+ /// A wrapped with +
+ /// (both, so the "prefer the async one, never both" rule is observable: exactly ONE dispose call lands).
+ private sealed class DisposableFakeDiscoveryDriver : IDriver, ITagDiscovery, IAsyncDisposable, IDisposable
+ {
+ private int _disposeCalls;
+
+ public DisposableFakeDiscoveryDriver(FakeDiscoveryDriver? inner = null) => Inner = inner ?? new FakeDiscoveryDriver();
+
+ public FakeDiscoveryDriver Inner { get; }
+ public int DisposeCalls => Volatile.Read(ref _disposeCalls);
+ public bool DisposedAfterShutdown { get; private set; }
+ public TaskCompletionSource Disposed { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ public string DriverInstanceId => Inner.DriverInstanceId;
+ public string DriverType => Inner.DriverType;
+ public bool SupportsOnlineDiscovery => Inner.SupportsOnlineDiscovery;
+ public DiscoveryRediscoverPolicy RediscoverPolicy => Inner.RediscoverPolicy;
+
+ public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
+ Inner.InitializeAsync(driverConfigJson, cancellationToken);
+
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) =>
+ Inner.ReinitializeAsync(driverConfigJson, cancellationToken);
+
+ public Task ShutdownAsync(CancellationToken cancellationToken) => Inner.ShutdownAsync(cancellationToken);
+
+ public DriverHealth GetHealth() => Inner.GetHealth();
+
+#pragma warning disable OTOPCUA0001 // test double delegating to a test double — no resilience pipeline applies
+ public Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) =>
+ Inner.DiscoverAsync(builder, cancellationToken);
+#pragma warning restore OTOPCUA0001
+
+ public ValueTask DisposeAsync()
+ {
+ Record();
+ return ValueTask.CompletedTask;
+ }
+
+ public void Dispose() => Record();
+
+ private void Record()
+ {
+ Interlocked.Increment(ref _disposeCalls);
+ DisposedAfterShutdown = Inner.ShutdownCalls > 0;
+ Disposed.TrySetResult();
+ }
+ }
+}
diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDisposeTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDisposeTests.cs
new file mode 100644
index 00000000..815575fb
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.Runtime.Tests/Drivers/DriverInstanceActorDisposeTests.cs
@@ -0,0 +1,173 @@
+using System.Diagnostics;
+using Akka.Actor;
+using Shouldly;
+using Xunit;
+using ZB.MOM.WW.OtOpcUa.Commons.OpcUa;
+using ZB.MOM.WW.OtOpcUa.Commons.Types;
+using ZB.MOM.WW.OtOpcUa.Core.Abstractions;
+using ZB.MOM.WW.OtOpcUa.Runtime.Drivers;
+using ZB.MOM.WW.OtOpcUa.Runtime.Tests.Harness;
+
+namespace ZB.MOM.WW.OtOpcUa.Runtime.Tests.Drivers;
+
+///
+/// Review 02 L1 / P4: a driver child that stops for good must dispose its
+/// (the runtime only ever called ShutdownAsync, so a Calculation driver's compiled-script ALCs and an
+/// OpcUaClient driver's LoggerFactory + gate + CTS leaked on every respawn), its ShutdownAsync must be
+/// bounded (it used to block a dispatcher thread for as long as a dead socket took to answer), and a
+/// supervision restart must NOT dispose — the Props closure re-uses the same driver object for the
+/// restarted incarnation, which then has to serve through it.
+///
+public sealed class DriverInstanceActorDisposeTests : RuntimeActorTestBase
+{
+ [Fact]
+ public void Stopping_the_child_shuts_the_driver_down_and_then_disposes_it_exactly_once()
+ {
+ var driver = new DisposableRecordingDriver();
+ var parent = CreateTestProbe();
+ parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
+ var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver));
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitCondition(() => driver.InitializeCount == 1, PresenceBudget);
+
+ Watch(actor);
+ Sys.Stop(actor);
+ ExpectTerminated(actor, PresenceBudget);
+
+ driver.ShutdownCount.ShouldBe(1);
+ driver.DisposeCount.ShouldBe(1, "a child stopped for good must dispose the driver it wrapped");
+ driver.Trace.ToArray().ShouldBe(new[] { "init", "shutdown", "dispose" }, "shutdown must land before dispose");
+ }
+
+ [Fact]
+ public async Task A_supervision_restart_keeps_the_driver_undisposed_and_the_restarted_child_still_serves()
+ {
+ var driver = new DisposableRecordingDriver();
+ var parent = CreateTestProbe();
+ // The probe parent's own strategy STOPS a failing child; the production host (no strategy anywhere under
+ // src/Server) gets Akka's default decider, which RESTARTS it — the route under test.
+ var actor = parent.ChildActorOf(
+ DriverInstanceActor.Props(driver), new OneForOneStrategy(_ => Directive.Restart));
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ // Barrier: Subscribe is only handled once Connected (it is dropped in Connecting).
+ parent.ExpectMsg(m => m.Connected, PresenceBudget);
+ await actor.Ask(
+ new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)), PresenceBudget);
+
+ // The crash seam (see DriverHostActorRestartTests): a null snapshot NREs on the actor thread, and the
+ // probe parent's default decider restarts the child in place around the SAME driver object.
+ driver.FireMalformedDataChange("tag-a");
+ parent.FishForMessage(m => m is DriverInstanceActor.ReprovisionRequested, PresenceBudget);
+
+ driver.ShutdownCount.ShouldBe(1, "PreRestart runs PostStop, which shuts the driver down");
+ driver.DisposeCount.ShouldBe(0, "a restart re-uses the driver object — disposing it would kill the new incarnation");
+
+ // Positive control: the restarted incarnation serves through the undisposed driver. The host would
+ // re-provision it exactly like this (InitializeRequested + the desired set).
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ parent.FishForMessage(m => m is DriverInstanceActor.ConnectivityChanged { Connected: true }, PresenceBudget);
+ await actor.Ask(
+ new DriverInstanceActor.Subscribe(new[] { "tag-a" }, TimeSpan.FromMilliseconds(100)), PresenceBudget);
+ driver.FireDataChange("tag-a", 42.0);
+ var published = (DriverInstanceActor.AttributeValueBatchPublished)parent.FishForMessage(
+ m => m is DriverInstanceActor.AttributeValueBatchPublished, PresenceBudget);
+ published.Items.Single().FullReference.ShouldBe("tag-a");
+ published.Items.Single().Value.ShouldBe(42.0);
+
+ // ...and the eventual real stop still disposes it — once.
+ Watch(actor);
+ Sys.Stop(actor);
+ ExpectTerminated(actor, PresenceBudget);
+ driver.DisposeCount.ShouldBe(1);
+ }
+
+ [Fact]
+ public void A_hung_ShutdownAsync_is_abandoned_at_the_bound_and_the_driver_is_still_disposed()
+ {
+ var never = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
+ // Ignores its token on purpose: a wedged socket/COM proxy does not honour cancellation either.
+ var driver = new DisposableRecordingDriver { ShutdownBehavior = _ => never.Task };
+ var bound = TimeSpan.FromMilliseconds(300);
+ var parent = CreateTestProbe();
+ parent.IgnoreMessages(m => m is DriverInstanceActor.ConnectivityChanged);
+ var actor = parent.ChildActorOf(DriverInstanceActor.Props(driver, shutdownTimeout: bound));
+ actor.Tell(new DriverInstanceActor.InitializeRequested("{}"));
+ AwaitCondition(() => driver.InitializeCount == 1, PresenceBudget);
+
+ Watch(actor);
+ var sw = Stopwatch.StartNew();
+ Sys.Stop(actor);
+ ExpectTerminated(actor, PresenceBudget);
+ sw.Stop();
+
+ driver.ShutdownCount.ShouldBe(1, "the shutdown was attempted (positive control for the bound)");
+ never.Task.IsCompleted.ShouldBeFalse("the shutdown is still hung — the stop did not wait for it");
+ sw.Elapsed.ShouldBeLessThan(TimeSpan.FromSeconds(5), "the stop must not be pinned by the hung shutdown");
+ driver.DisposeCount.ShouldBe(1, "a shutdown that timed out must not skip the dispose");
+ }
+
+ ///
+ /// A subscribable driver that records its lifecycle and is . Deliberately
+ /// NOT also : the actor prefers the async dispose when both exist, and a double
+ /// implementation would hide a double-dispose.
+ ///
+ private sealed class DisposableRecordingDriver : IDriver, ISubscribable, IAsyncDisposable
+ {
+ private readonly StubHandle _handle = new();
+ private int _initializeCount;
+ private int _shutdownCount;
+ private int _disposeCount;
+
+ public Func? ShutdownBehavior { get; init; }
+
+ public List Trace { get; } = new();
+
+ public int InitializeCount => Volatile.Read(ref _initializeCount);
+ public int ShutdownCount => Volatile.Read(ref _shutdownCount);
+ public int DisposeCount => Volatile.Read(ref _disposeCount);
+
+ public string DriverInstanceId => "disposable-1";
+ public string DriverType => "Disposable";
+
+ public Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref _initializeCount);
+ lock (Trace) Trace.Add("init");
+ return Task.CompletedTask;
+ }
+
+ public Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public Task ShutdownAsync(CancellationToken cancellationToken)
+ {
+ Interlocked.Increment(ref _shutdownCount);
+ lock (Trace) Trace.Add("shutdown");
+ return ShutdownBehavior is { } behavior ? behavior(cancellationToken) : Task.CompletedTask;
+ }
+
+ public DriverHealth GetHealth() => new(DriverState.Healthy, DateTime.UtcNow, null);
+
+ public ValueTask DisposeAsync()
+ {
+ Interlocked.Increment(ref _disposeCount);
+ lock (Trace) Trace.Add("dispose");
+ return ValueTask.CompletedTask;
+ }
+
+ public event EventHandler? OnDataChange;
+
+ public Task SubscribeAsync(
+ IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
+ Task.FromResult(_handle);
+
+ public Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) => Task.CompletedTask;
+
+ public void FireDataChange(string fullRef, object? value) =>
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(
+ _handle, fullRef, new DataValueSnapshot(value, OpcUaStatus.Good, DateTime.UtcNow, DateTime.UtcNow)));
+
+ /// The supervision crash seam — see .
+ public void FireMalformedDataChange(string fullRef) =>
+ OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!));
+ }
+}