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