using Akka.Actor; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Shouldly; using Xunit; using ZB.MOM.WW.OtOpcUa.Host; namespace ZB.MOM.WW.OtOpcUa.Host.IntegrationTests; /// /// Unit coverage for the #459 down-if-alone recovery watchdog: an unexpected /// termination (SBR self-down) must stop the host so the supervisor /// restarts it, while a graceful host shutdown must stay silent and not re-trigger stop. /// [Trait("Category", "Unit")] public sealed class ActorSystemTerminationWatchdogTests { /// An unexpected ActorSystem termination stops the host so the supervisor restarts the node. [Fact] public async Task Unexpected_termination_stops_the_application() { var system = ActorSystem.Create("watchdog-unexpected"); var lifetime = new FakeHostApplicationLifetime(); var watchdog = new ActorSystemTerminationWatchdog( () => system, lifetime, NullLogger.Instance); await watchdog.StartAsync(CancellationToken.None); // Simulate the SBR self-down: the ActorSystem terminates while the host is still running. await system.Terminate(); await WaitForAsync(() => lifetime.StopApplicationCalls > 0, TimeSpan.FromSeconds(5)); lifetime.StopApplicationCalls.ShouldBe(1, "an out-of-band ActorSystem termination must stop the host once"); } /// A graceful host stop (StopAsync ran) leaves the termination continuation silent. [Fact] public async Task Graceful_stop_does_not_stop_the_application() { var system = ActorSystem.Create("watchdog-graceful"); var lifetime = new FakeHostApplicationLifetime(); var watchdog = new ActorSystemTerminationWatchdog( () => system, lifetime, NullLogger.Instance); await watchdog.StartAsync(CancellationToken.None); // Host is shutting down normally: StopAsync runs, THEN the system terminates. await watchdog.StopAsync(CancellationToken.None); await system.Terminate(); // Give any continuation a chance to run, then assert it stayed silent. await Task.Delay(500, TestContext.Current.CancellationToken); lifetime.StopApplicationCalls.ShouldBe(0, "a graceful shutdown must not re-trigger StopApplication"); } /// Termination while the host is already stopping (ApplicationStopping fired) stays silent. [Fact] public async Task Termination_while_already_stopping_stays_silent() { var system = ActorSystem.Create("watchdog-already-stopping"); var lifetime = new FakeHostApplicationLifetime(); var watchdog = new ActorSystemTerminationWatchdog( () => system, lifetime, NullLogger.Instance); await watchdog.StartAsync(CancellationToken.None); lifetime.TriggerStopping(); // the host is already tearing down (ApplicationStopping cancelled) await system.Terminate(); await Task.Delay(500, TestContext.Current.CancellationToken); lifetime.StopApplicationCalls.ShouldBe(0, "termination during an in-progress host shutdown must stay silent"); } private static async Task WaitForAsync(Func condition, TimeSpan timeout) { var deadline = DateTime.UtcNow + timeout; while (!condition() && DateTime.UtcNow < deadline) await Task.Delay(20); } /// Minimal that records calls. private sealed class FakeHostApplicationLifetime : IHostApplicationLifetime { private readonly CancellationTokenSource _stopping = new(); public int StopApplicationCalls { get; private set; } public CancellationToken ApplicationStarted => CancellationToken.None; public CancellationToken ApplicationStopping => _stopping.Token; public CancellationToken ApplicationStopped => CancellationToken.None; public void StopApplication() => StopApplicationCalls++; public void TriggerStopping() => _stopping.Cancel(); } }