diff --git a/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDisposalTests.cs b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDisposalTests.cs new file mode 100644 index 00000000..c19daaf2 --- /dev/null +++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDisposalTests.cs @@ -0,0 +1,277 @@ +using Microsoft.Extensions.Logging.Abstractions; +using Opc.Ua; +using Opc.Ua.Security.Certificates; +using Opc.Ua.Server; +using Shouldly; +using Xunit; + +namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests; + +/// Verifies host ownership using the real sealed SDK2 server and public manager factories. +public sealed class OpcUaApplicationHostDisposalTests : IDisposable +{ + private static CancellationToken Ct => TestContext.Current.CancellationToken; + private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otopcua-sdk2-disposal-{Guid.NewGuid():N}"); + + /// Normal and repeated disposal complete one manager shutdown and terminal server cleanup. + [Fact] + public async Task Host_terminally_disposes_its_booted_server_once() + { + var factory = new RecordingSessionManagerFactory(); + var (host, server) = await BootAsync(factory); + var manager = factory.Created.ShouldNotBeNull(); + try + { + await host.DisposeAsync(); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + await RequireTerminalServerAsync(server); + + await host.DisposeAsync(); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + await RequireTerminalServerAsync(server); + } + finally { await CleanupAsync(host, server); } + } + + /// Manager disposal during Stop alone does not satisfy the terminal server assertion. + [Fact] + public async Task Stop_only_is_distinct_from_terminal_server_disposal() + { + var factory = new RecordingSessionManagerFactory(); + var (host, server) = await BootAsync(factory); + var manager = factory.Created.ShouldNotBeNull(); + try + { + await server.StopAsync(Ct); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + var probe = new NonCreatingNodeManagerFactory(); + var stopped = await Should.ThrowAsync(() => + server.NodeManagerLifecycle.AddAsync(probe, callerContext: null, Ct).AsTask()); + stopped.GetType().ShouldBe(typeof(InvalidOperationException)); + probe.CreateCalls.ShouldBe(0); + + await host.DisposeAsync(); + await RequireTerminalServerAsync(server); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + } + finally { await CleanupAsync(host, server); } + } + + /// A manager shutdown failure is recorded by the SDK while terminal cleanup still completes. + [Fact] + public async Task Shutdown_stage_failure_still_terminally_disposes_the_server() + { + var factory = new RecordingSessionManagerFactory { ThrowAfterShutdown = true }; + var (host, server) = await BootAsync(factory); + var manager = factory.Created.ShouldNotBeNull(); + try + { + await host.DisposeAsync(); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + ServiceResult.IsBad(server.ServerError).ShouldBeTrue(); + server.ServerError.ToString().ShouldContain(RecordingSessionManager.ShutdownFailure); + await RequireTerminalServerAsync(server); + } + finally { await CleanupAsync(host, server); } + } + + /// A factory failure after address-space initialization leaves the real server owned for cleanup. + [Fact] + public async Task Partial_startup_failure_does_not_strand_the_server() + { + var factory = new RecordingSessionManagerFactory { ThrowDuringCreate = true }; + var host = NewHost(OpcUaHostStart.AllocateFreePort()); + var server = new OtOpcUaSdkServer { SessionManagerFactory = factory }; + try + { + var failure = await Should.ThrowAsync(() => host.StartAsync(server, Ct)); + failure.StatusCode.ShouldBe(StatusCodes.BadInternalError); + failure.InnerResult.ShouldNotBeNull().LocalizedText.Text.ShouldNotBeNull() + .ShouldContain(RecordingSessionManagerFactory.StartupFailure); + factory.CreateCalls.ShouldBe(1); + factory.Created.ShouldBeNull("the sentinel fails before constructing an unowned manager"); + host.ApplicationInstance.ShouldBeNull(); + host.Server.ShouldBeNull(); + await RequireTerminalServerAsync(server); + await host.DisposeAsync(); + await RequireTerminalServerAsync(server); + } + finally { await CleanupAsync(host, server); } + } + + /// Configuration cancellation before ApplicationInstance adopts the server still releases its host owner. + [Fact] + public async Task Canceled_start_before_application_adoption_terminally_disposes_the_server() + { + var factory = new RecordingSessionManagerFactory(); + var host = NewHost(OpcUaHostStart.AllocateFreePort()); + var server = new OtOpcUaSdkServer { SessionManagerFactory = factory }; + using var cancellation = new CancellationTokenSource(); + cancellation.Cancel(); + try + { + await Should.ThrowAsync(() => host.StartAsync(server, cancellation.Token)); + factory.CreateCalls.ShouldBe(0); + factory.Created.ShouldBeNull(); + host.ApplicationInstance.ShouldBeNull(); + host.Server.ShouldBeNull(); + await host.DisposeAsync(); + await RequireTerminalServerAsync(server); + } + finally { await CleanupAsync(host, server); } + } + + /// Every concurrent host disposer waits until the one actual manager shutdown is released. + [Fact] + public async Task Concurrent_disposers_share_completion_and_one_cleanup_owner() + { + var factory = new RecordingSessionManagerFactory(); + var (host, server) = await BootAsync(factory); + var manager = factory.Created.ShouldNotBeNull(); + manager.BlockShutdown = true; + var calls = new List(); + try + { + calls.Add(Task.Run(async () => await host.DisposeAsync(), Ct)); + await manager.ShutdownEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct); + for (var i = 0; i < 3; i++) calls.Add(host.DisposeAsync().AsTask()); + calls.ShouldAllBe(task => !task.IsCompleted); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(0); + manager.ReleaseShutdown.TrySetResult(); + await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), Ct); + manager.ShutdownCalls.ShouldBe(1); + manager.DisposalCalls.ShouldBe(1); + await RequireTerminalServerAsync(server); + } + finally + { + manager.ReleaseShutdown.TrySetResult(); + try { await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); } + finally { await CleanupAsync(host, server); } + } + } + + /// A terminal host rejects a new real server before invoking its manager factory. + [Fact] + public async Task Disposed_host_rejects_start_without_taking_server_ownership() + { + var factory = new RecordingSessionManagerFactory(); + var host = NewHost(OpcUaHostStart.AllocateFreePort()); + var server = new OtOpcUaSdkServer { SessionManagerFactory = factory }; + try + { + await host.DisposeAsync(); + await Should.ThrowAsync(() => host.StartAsync(server, Ct)); + factory.CreateCalls.ShouldBe(0, "the rejected server still belongs to its caller"); + host.ApplicationInstance.ShouldBeNull(); + host.Server.ShouldBeNull(); + } + finally { await CleanupAsync(host, server); } + } + + private Task<(OpcUaApplicationHost Host, OtOpcUaSdkServer Server)> BootAsync(RecordingSessionManagerFactory factory) => + OpcUaHostStart.StartAsync(NewHost, () => new OtOpcUaSdkServer { SessionManagerFactory = factory }, Ct); + + private OpcUaApplicationHost NewHost(int port) => new(new OpcUaApplicationHostOptions + { + ApplicationName = "OtOpcUa.SDK2.Disposal", ApplicationUri = "urn:OtOpcUa.SDK2.Disposal", + PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki, AllowAnonymous = false, + }, NullLogger.Instance); + + private static async Task RequireTerminalServerAsync(OtOpcUaSdkServer server) + { + // The public lifecycle checks terminal disposal before considering or invoking this factory. + var probe = new NonCreatingNodeManagerFactory(); + var disposed = await Should.ThrowAsync(() => + server.NodeManagerLifecycle.AddAsync(probe, callerContext: null, CancellationToken.None).AsTask()); + disposed.GetType().ShouldBe(typeof(ObjectDisposedException)); + probe.CreateCalls.ShouldBe(0); + } + + private static async Task CleanupAsync(OpcUaApplicationHost host, OtOpcUaSdkServer server) + { + try { await host.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); } + finally + { + // RED assertions still release the actual test-owned server; never dispose a manager manually. + await server.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); + } + } + + private sealed class RecordingSessionManagerFactory : ISessionManagerFactory + { + internal const string StartupFailure = "controlled SDK2 partial startup"; + private int _createCalls; + internal int CreateCalls => Volatile.Read(ref _createCalls); + internal RecordingSessionManager? Created { get; private set; } + internal bool ThrowDuringCreate { get; init; } + internal bool ThrowAfterShutdown { get; init; } + + public ISessionManager Create(IServerInternal server, ApplicationConfiguration configuration, + TimeProvider timeProvider, Func serverCertificateProvider) + { + Interlocked.Increment(ref _createCalls); + if (ThrowDuringCreate) throw new InvalidOperationException(StartupFailure); + return Created = new RecordingSessionManager(server, configuration, timeProvider) + { + ThrowAfterShutdown = ThrowAfterShutdown, + }; + } + } + + private sealed class RecordingSessionManager(IServerInternal server, ApplicationConfiguration configuration, TimeProvider timeProvider) + : SessionManager(server, configuration, timeProvider) + { + internal const string ShutdownFailure = "controlled SDK2 manager shutdown failure"; + private int _shutdownCalls; + private int _disposalCalls; + internal int ShutdownCalls => Volatile.Read(ref _shutdownCalls); + internal int DisposalCalls => Volatile.Read(ref _disposalCalls); + internal bool ThrowAfterShutdown { get; init; } + internal bool BlockShutdown { get; set; } + internal TaskCompletionSource ShutdownEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + internal TaskCompletionSource ReleaseShutdown { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + public override async ValueTask ShutdownAsync(CancellationToken cancellationToken = default) + { + Interlocked.Increment(ref _shutdownCalls); + if (BlockShutdown) + { + ShutdownEntered.TrySetResult(); + await ReleaseShutdown.Task.WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); + } + await base.ShutdownAsync(cancellationToken); + if (ThrowAfterShutdown) throw new InvalidOperationException(ShutdownFailure); + } + + protected override void Dispose(bool disposing) + { + if (disposing) Interlocked.Increment(ref _disposalCalls); + base.Dispose(disposing); + } + } + + private sealed class NonCreatingNodeManagerFactory : INodeManagerFactory + { + private int _createCalls; + internal int CreateCalls => Volatile.Read(ref _createCalls); + public ArrayOf NamespacesUris => []; + public INodeManager Create(IServerInternal server, ApplicationConfiguration configuration) + { + Interlocked.Increment(ref _createCalls); + throw new InvalidOperationException("The terminal lifecycle probe must not create a node manager."); + } + } + + /// Removes only this test's private fixture after bounded server cleanup. + public void Dispose() + { + if (Directory.Exists(_pki)) Directory.Delete(_pki, true); + } +}