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!)); + } +}