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