From 84bfe6cd20285ccf136998998ef5c051917d8201 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Tue, 10 Nov 2026 16:51:31 -0500 Subject: [PATCH] test: cover SDK1 terminal server disposal ownership --- .../OpcUaApplicationHostDisposalTests.cs | 164 ++++++++++++++++++ 1 file changed, 164 insertions(+) create mode 100644 tests/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer.Tests/OpcUaApplicationHostDisposalTests.cs 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); + } +}