fix(runtime): dispose drivers on final stop, bound PostStop shutdown (A1)

DriverInstanceActor.PostStop now bounds IDriver.ShutdownAsync by a
shutdownTimeout (default 5 s, logged on overrun) and then disposes the
driver (IAsyncDisposable preferred, else IDisposable) — but only on a
real stop. PreRestart flags the restart so the PostStop it triggers
skips the dispose, because a supervision restart re-uses the same
driver object (Props closure) and the host re-initialises it.

DiscoveryDriverBrowser disposes its throwaway capture/probe driver
after the bounded shutdown, including after a failed or timed-out one.

Review refs: 02/L1, 02/P4, 06/G-1 (host context), 07/L2.

Refs #731

Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
Joseph Doherty
2026-10-22 06:23:39 -04:00
parent ad2b5c4044
commit eb23053700
4 changed files with 428 additions and 6 deletions
@@ -188,6 +188,13 @@ public sealed class DiscoveryDriverBrowser : IUniversalDriverBrowser
}
}
/// <summary>
/// Final teardown of a throwaway capture/probe driver: a bounded <see cref="IDriver.ShutdownAsync"/>
/// followed by a bounded dispose. The dispose is NOT optional and runs even after a failed or timed-out
/// shutdown: a Galaxy driver's <c>ShutdownAsync</c> releases none of its gRPC runtime (channels, MX
/// session, stream loops) — only <c>DisposeAsync</c> does — so a browse that stopped at shutdown leaked a
/// full runtime per <c>/raw</c> browse and per <see cref="CanBrowse"/> probe (02/L1, 06/G-1).
/// </summary>
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);
}
}
@@ -184,6 +184,16 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// </remarks>
public static readonly TimeSpan DefaultCommsLossTimeout = TimeSpan.FromSeconds(90);
/// <summary>
/// Bound on the driver's <see cref="IDriver.ShutdownAsync"/> (and its dispose) when the child stops or
/// restarts, and on the teardown that precedes every reconnect attempt. <c>PostStop</c> 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 <c>CoordinatedShutdown</c> and the host's own restart cascade. A shutdown that overruns is
/// abandoned (logged), never awaited further.
/// </summary>
public static readonly TimeSpan DefaultShutdownTimeout = TimeSpan.FromSeconds(5);
private readonly IDriver _driver;
/// <summary>Phase 6.1 resilience seam: every guarded driver-capability call this actor makes is
@@ -204,6 +214,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <summary>Comms-loss watchdog threshold; <see cref="TimeSpan.Zero"/> or negative disables it.</summary>
private readonly TimeSpan _commsLossTimeout;
/// <summary>Bound on the driver shutdown / dispose; see <see cref="DefaultShutdownTimeout"/>.</summary>
private readonly TimeSpan _shutdownTimeout;
/// <summary>
/// Set by <see cref="PreRestart"/> so the <see cref="PostStop"/> it triggers skips the dispose. A
/// supervision restart recreates the actor object around the SAME <see cref="IDriver"/> (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.
/// </summary>
private bool _restarting;
/// <summary>When the actor last entered <c>Connected</c>. 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.</summary>
@@ -307,6 +329,9 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
/// <param name="commsLossTimeout">Optional comms-loss watchdog threshold; defaults to
/// <see cref="DefaultCommsLossTimeout"/>. <see cref="TimeSpan.Zero"/> disables the watchdog — used by
/// tests that need a Connected driver to stay Connected while reporting non-Healthy.</param>
/// <param name="shutdownTimeout">Optional bound on the driver's shutdown / dispose on stop and on the
/// pre-reconnect teardown; defaults to <see cref="DefaultShutdownTimeout"/>. Exists so a test can prove a
/// hung shutdown is abandoned without waiting 5 s.</param>
/// <returns>A <see cref="Akka.Actor.Props"/> instance configured to create the wrapped <see cref="DriverInstanceActor"/>.</returns>
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));
/// <summary>
/// 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 <see cref="HealthPollInterval"/> when null.</param>
/// <param name="commsLossTimeout">Comms-loss watchdog threshold; defaults to
/// <see cref="DefaultCommsLossTimeout"/> when null, and is disabled when zero or negative.</param>
/// <param name="shutdownTimeout">Bound on the driver's shutdown / dispose; defaults to
/// <see cref="DefaultShutdownTimeout"/> when null.</param>
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<string, object?>("event", startStubbed ? "spawn_stub" : "spawn"),
new KeyValuePair<string, object?>("driver_type", driver.DriverType));
@@ -421,6 +452,18 @@ public sealed class DriverInstanceActor : ReceiveActor, IWithTimers
Context.Parent.Tell(new ReprovisionRequested(_driverInstanceId, reason.GetType().Name));
}
/// <summary>
/// Flags the restart before the base implementation runs <see cref="PostStop"/>, so that PostStop
/// shuts the driver down (the host re-initialises it on <see cref="ReprovisionRequested"/>) but does
/// NOT dispose it — see <see cref="_restarting"/>.
/// </summary>
/// <inheritdoc />
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<string, object?>("event", "stop"),
new KeyValuePair<string, object?>("driver_type", _driver.DriverType));
}
/// <summary>Final teardown of the wrapped driver: <see cref="IAsyncDisposable"/> preferred, else
/// <see cref="IDisposable"/>, never both. A driver implementing neither needs nothing here.</summary>
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<string, object?>("event", "dispose"),
new KeyValuePair<string, object?>("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);
}
}
}
@@ -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;
/// <summary>
/// Review 02 L1 (host context, 06/G-1): the browser's throwaway capture driver must be <b>disposed</b>, not
/// only shut down. A Galaxy driver's <c>ShutdownAsync</c> left its whole gRPC runtime alive until
/// <c>DisposeAsync</c>, which nothing called — so every <c>/raw</c> browse leaked two channels, an MX session
/// and two stream loops.
/// </summary>
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<DiscoveryDriverBrowser>.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<InvalidOperationException>(
() => 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);
}
/// <summary>A <see cref="FakeDiscoveryDriver"/> wrapped with <see cref="IAsyncDisposable"/> + <see cref="IDisposable"/>
/// (both, so the "prefer the async one, never both" rule is observable: exactly ONE dispose call lands).</summary>
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();
}
}
}
@@ -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;
/// <summary>
/// Review 02 L1 / P4: a driver child that stops for good must <b>dispose</b> its <see cref="IDriver"/>
/// (the runtime only ever called <c>ShutdownAsync</c>, so a Calculation driver's compiled-script ALCs and an
/// OpcUaClient driver's LoggerFactory + gate + CTS leaked on every respawn), its <c>ShutdownAsync</c> must be
/// bounded (it used to block a dispatcher thread for as long as a dead socket took to answer), and a
/// <b>supervision restart</b> must NOT dispose — the Props closure re-uses the same driver object for the
/// restarted incarnation, which then has to serve through it.
/// </summary>
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<DriverInstanceActor.ConnectivityChanged>(m => m.Connected, PresenceBudget);
await actor.Ask<DriverInstanceActor.SubscriptionEstablished>(
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<DriverInstanceActor.SubscriptionEstablished>(
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");
}
/// <summary>
/// A subscribable driver that records its lifecycle and is <see cref="IAsyncDisposable"/>. Deliberately
/// NOT also <see cref="IDisposable"/>: the actor prefers the async dispose when both exist, and a double
/// implementation would hide a double-dispose.
/// </summary>
private sealed class DisposableRecordingDriver : IDriver, ISubscribable, IAsyncDisposable
{
private readonly StubHandle _handle = new();
private int _initializeCount;
private int _shutdownCount;
private int _disposeCount;
public Func<CancellationToken, Task>? ShutdownBehavior { get; init; }
public List<string> 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<DataChangeEventArgs>? OnDataChange;
public Task<ISubscriptionHandle> SubscribeAsync(
IReadOnlyList<string> fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) =>
Task.FromResult<ISubscriptionHandle>(_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)));
/// <summary>The supervision crash seam — see <see cref="RecordingSubscribableDriver.FireMalformedDataChange"/>.</summary>
public void FireMalformedDataChange(string fullRef) =>
OnDataChange?.Invoke(this, new DataChangeEventArgs(_handle, fullRef, null!));
}
}