test: cover SDK1 terminal server disposal ownership
This commit is contained in:
+164
@@ -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;
|
||||
|
||||
/// <summary>Verifies terminal server ownership on the SDK1 host (#807).</summary>
|
||||
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;
|
||||
|
||||
/// <summary>Normal and repeated host disposal releases the actual SDK request-queue owner once.</summary>
|
||||
[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); }
|
||||
}
|
||||
|
||||
/// <summary>A throwing Stop cannot bypass the separately owned terminal Dispose.</summary>
|
||||
[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); }
|
||||
}
|
||||
|
||||
/// <summary>A server accepted before startup fails remains owned by the host.</summary>
|
||||
[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<Exception>(() => host.StartAsync(server, Ct));
|
||||
failure.ToString().ShouldContain("controlled partial startup");
|
||||
await host.DisposeAsync();
|
||||
server.TerminalDisposals.ShouldBe(1);
|
||||
}
|
||||
finally { await CleanupAsync(host, server); }
|
||||
}
|
||||
|
||||
/// <summary>Every concurrent caller waits for the one terminal cleanup owner.</summary>
|
||||
[Fact]
|
||||
public async Task Concurrent_disposers_share_completion_and_one_cleanup_owner()
|
||||
{
|
||||
var (host, server) = await BootAsync();
|
||||
server.BlockStop = true;
|
||||
var calls = new List<Task>();
|
||||
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); }
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Terminal disposal before startup refuses a new owner instead of silently stranding it.</summary>
|
||||
[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<ObjectDisposedException>(() => 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<OpcUaApplicationHost>.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);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Removes only this test's private fixture.</summary>
|
||||
public void Dispose()
|
||||
{
|
||||
if (Directory.Exists(_pki)) Directory.Delete(_pki, true);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user