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