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