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 000000000..529befa86
--- /dev/null
+++ b/tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDisposalTests.cs
@@ -0,0 +1,164 @@
+using Microsoft.Extensions.Logging.Abstractions;
+using Opc.Ua;
+using Opc.Ua.Server;
+using Shouldly;
+using Xunit;
+
+namespace ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests;
+
+/// Verifies terminal server ownership on the SDK1 host (#807).
+public sealed class OpcUaApplicationHostDisposalTests : IDisposable
+{
+ private readonly string _pki = Path.Combine(Path.GetTempPath(), $"otopcua-disposal-{Guid.NewGuid():N}");
+ private static CancellationToken Ct => TestContext.Current.CancellationToken;
+
+ /// Normal and repeated host disposal releases the actual SDK request-queue owner once.
+ [Fact]
+ public async Task Host_terminally_disposes_its_booted_server_once()
+ {
+ var (host, server) = await BootAsync();
+ try
+ {
+ await host.DisposeAsync();
+ server.TerminalDisposals.ShouldBe(1);
+ await host.DisposeAsync();
+ server.TerminalDisposals.ShouldBe(1);
+ }
+ finally { await CleanupAsync(host, server); }
+ }
+
+ /// A throwing Stop cannot bypass the separately owned terminal Dispose.
+ [Fact]
+ public async Task Stop_failure_still_terminally_disposes_the_server()
+ {
+ var (host, server) = await BootAsync();
+ server.ThrowAfterStop = true;
+ try
+ {
+ await host.DisposeAsync();
+ server.StopCalls.ShouldBe(1);
+ server.TerminalDisposals.ShouldBe(1);
+ }
+ finally { await CleanupAsync(host, server); }
+ }
+
+ /// A server accepted before startup fails remains owned by the host.
+ [Fact]
+ public async Task Partial_startup_failure_does_not_strand_the_server()
+ {
+ var host = NewHost(OpcUaHostStart.AllocateFreePort());
+ var server = new RecordingServer { ThrowDuringStart = true };
+ try
+ {
+ var failure = await Should.ThrowAsync(() => host.StartAsync(server, Ct));
+ failure.ToString().ShouldContain("controlled partial startup");
+ await host.DisposeAsync();
+ server.TerminalDisposals.ShouldBe(1);
+ }
+ finally { await CleanupAsync(host, server); }
+ }
+
+ /// Every concurrent caller waits for the one terminal cleanup owner.
+ [Fact]
+ public async Task Concurrent_disposers_share_completion_and_one_cleanup_owner()
+ {
+ var (host, server) = await BootAsync();
+ server.BlockStop = true;
+ var calls = new List();
+ try
+ {
+ calls.Add(host.DisposeAsync().AsTask());
+ await server.StopEntered.Task.WaitAsync(TimeSpan.FromSeconds(10), Ct);
+ for (var i = 0; i < 3; i++) calls.Add(host.DisposeAsync().AsTask());
+ calls.ShouldAllBe(task => !task.IsCompleted);
+ server.StopCalls.ShouldBe(1);
+ server.TerminalDisposals.ShouldBe(0);
+ server.ReleaseStop.TrySetResult();
+ await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), Ct);
+ server.TerminalDisposals.ShouldBe(1);
+ }
+ finally
+ {
+ server.ReleaseStop.TrySetResult();
+ try { await Task.WhenAll(calls).WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); }
+ finally { await CleanupAsync(host, server); }
+ }
+ }
+
+ /// Terminal disposal before startup refuses a new owner instead of silently stranding it.
+ [Fact]
+ public async Task Disposed_host_rejects_start_without_taking_server_ownership()
+ {
+ var host = NewHost(OpcUaHostStart.AllocateFreePort());
+ var server = new RecordingServer();
+ try
+ {
+ await host.DisposeAsync();
+ await Should.ThrowAsync(() => host.StartAsync(server, Ct));
+ server.TerminalDisposals.ShouldBe(0, "the rejected server still belongs to its caller");
+ }
+ finally { await CleanupAsync(host, server); }
+ }
+
+ private Task<(OpcUaApplicationHost Host, RecordingServer Server)> BootAsync() =>
+ OpcUaHostStart.StartAsync(NewHost, () => new RecordingServer(), Ct);
+
+ private OpcUaApplicationHost NewHost(int port) => new(new OpcUaApplicationHostOptions
+ {
+ ApplicationName = "OtOpcUa.Disposal", ApplicationUri = "urn:OtOpcUa.Disposal",
+ PublicHostname = "localhost", OpcUaPort = port, PkiStoreRoot = _pki,
+ }, NullLogger.Instance);
+
+ private static async Task CleanupAsync(OpcUaApplicationHost host, RecordingServer server)
+ {
+ try { await host.DisposeAsync().AsTask().WaitAsync(TimeSpan.FromSeconds(20), CancellationToken.None); }
+ finally
+ {
+ // Red tests still release their test-owned server after recording the missing host disposal.
+ if (server.TerminalDisposals == 0) server.Dispose();
+ }
+ }
+
+ private sealed class RecordingServer : StandardServer
+ {
+ private int _terminalDisposals;
+ private int _stopCalls;
+ internal int TerminalDisposals => Volatile.Read(ref _terminalDisposals);
+ internal int StopCalls => Volatile.Read(ref _stopCalls);
+ internal bool ThrowDuringStart { get; init; }
+ internal bool ThrowAfterStop { get; set; }
+ internal bool BlockStop { get; set; }
+ internal TaskCompletionSource StopEntered { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+ internal TaskCompletionSource ReleaseStop { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously);
+
+ protected override void OnServerStarting(ApplicationConfiguration configuration)
+ {
+ base.OnServerStarting(configuration);
+ if (ThrowDuringStart) throw new InvalidOperationException("controlled partial startup");
+ }
+
+ public override async ValueTask StopAsync(CancellationToken cancellationToken = default)
+ {
+ Interlocked.Increment(ref _stopCalls);
+ if (BlockStop)
+ {
+ StopEntered.TrySetResult();
+ await ReleaseStop.Task.WaitAsync(cancellationToken);
+ }
+ await base.StopAsync(cancellationToken);
+ if (ThrowAfterStop) throw new InvalidOperationException("controlled Stop failure");
+ }
+
+ protected override void Dispose(bool disposing)
+ {
+ if (disposing) Interlocked.Increment(ref _terminalDisposals);
+ base.Dispose(disposing);
+ }
+ }
+
+ /// Removes only this test's private fixture.
+ public void Dispose()
+ {
+ if (Directory.Exists(_pki)) Directory.Delete(_pki, true);
+ }
+}