test: verify SDK2 host disposal ownership and shared completion

This commit is contained in:
Joseph Doherty
2026-11-10 17:16:17 -05:00
parent 19610c6812
commit 2cf14f403b
@@ -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;
/// <summary>Verifies host ownership using the real sealed SDK2 server and public manager factories.</summary>
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}");
/// <summary>Normal and repeated disposal complete one manager shutdown and terminal server cleanup.</summary>
[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); }
}
/// <summary>Manager disposal during Stop alone does not satisfy the terminal server assertion.</summary>
[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<InvalidOperationException>(() =>
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); }
}
/// <summary>A manager shutdown failure is recorded by the SDK while terminal cleanup still completes.</summary>
[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); }
}
/// <summary>A factory failure after address-space initialization leaves the real server owned for cleanup.</summary>
[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<ServiceResultException>(() => 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); }
}
/// <summary>Configuration cancellation before ApplicationInstance adopts the server still releases its host owner.</summary>
[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<OperationCanceledException>(() => 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); }
}
/// <summary>Every concurrent host disposer waits until the one actual manager shutdown is released.</summary>
[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<Task>();
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); }
}
}
/// <summary>A terminal host rejects a new real server before invoking its manager factory.</summary>
[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<ObjectDisposedException>(() => 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<OpcUaApplicationHost>.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<ObjectDisposedException>(() =>
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<string, Certificate?> 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<string> 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.");
}
}
/// <summary>Removes only this test's private fixture after bounded server cleanup.</summary>
public void Dispose()
{
if (Directory.Exists(_pki)) Directory.Delete(_pki, true);
}
}