Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 945c44c475 | |||
| e58219668c |
@@ -175,6 +175,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
private StandardServer? _server;
|
private StandardServer? _server;
|
||||||
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
|
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
|
||||||
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
private readonly List<IUserTokenAuthenticator> _identityAuthenticators = [];
|
||||||
|
private readonly object _lifetimeLock = new();
|
||||||
|
private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||||
|
private int _disposeStarted;
|
||||||
|
|
||||||
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
/// <summary>Initializes a new instance of the OPC UA application host.</summary>
|
||||||
/// <param name="options">The host configuration options.</param>
|
/// <param name="options">The host configuration options.</param>
|
||||||
@@ -202,18 +205,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
public async Task StartAsync(StandardServer server, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (server is not OtOpcUaSdkServer sdkServer)
|
OtOpcUaSdkServer sdkServer;
|
||||||
throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server));
|
lock (_lifetimeLock)
|
||||||
_server = server;
|
|
||||||
// ApplicationInstance receives the host's ITelemetryContext. DefaultTelemetry.Create wires
|
|
||||||
// the SDK's internal logging; an empty builder keeps
|
|
||||||
// the SDK's trace off our ILogger (the host keeps its own _logger) — sufficient for the bootstrap.
|
|
||||||
_application = new ApplicationInstance(_telemetry)
|
|
||||||
{
|
{
|
||||||
ApplicationName = _options.ApplicationName,
|
ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this);
|
||||||
ApplicationType = ApplicationType.Server,
|
sdkServer = server as OtOpcUaSdkServer
|
||||||
ConfigSectionName = "OtOpcUa",
|
?? throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server));
|
||||||
};
|
_server = server;
|
||||||
|
// The empty telemetry builder keeps SDK tracing separate from the host's ILogger.
|
||||||
|
_application = new ApplicationInstance(_telemetry)
|
||||||
|
{
|
||||||
|
ApplicationName = _options.ApplicationName,
|
||||||
|
ApplicationType = ApplicationType.Server,
|
||||||
|
ConfigSectionName = "OtOpcUa",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
try
|
try
|
||||||
{
|
{
|
||||||
@@ -640,16 +646,63 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
|
|||||||
|
|
||||||
/// <summary>Disposes the application host and cleans up resources.</summary>
|
/// <summary>Disposes the application host and cleans up resources.</summary>
|
||||||
/// <returns>A task that represents the asynchronous operation.</returns>
|
/// <returns>A task that represents the asynchronous operation.</returns>
|
||||||
public async ValueTask DisposeAsync()
|
public ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
// Authenticators remain installed until the SDK closes every listener and disposes its registry.
|
if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) == 0)
|
||||||
if (_application is { } application)
|
_ = CompleteDisposalAsync();
|
||||||
|
return new ValueTask(_disposeCompletion.Task);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task CompleteDisposalAsync()
|
||||||
|
{
|
||||||
|
Exception? failure = null;
|
||||||
|
try
|
||||||
{
|
{
|
||||||
_application = null;
|
ApplicationInstance? application;
|
||||||
try { await application.DisposeAsync().ConfigureAwait(false); }
|
StandardServer? server;
|
||||||
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
|
bool adopted;
|
||||||
|
lock (_lifetimeLock)
|
||||||
|
{
|
||||||
|
application = _application;
|
||||||
|
server = _server;
|
||||||
|
// SDK2 adopts the server only when ApplicationInstance.StartAsync is reached.
|
||||||
|
adopted = server is not null && ReferenceEquals(application?.Server, server);
|
||||||
|
_application = null;
|
||||||
|
}
|
||||||
|
try
|
||||||
|
{
|
||||||
|
if (application is not null)
|
||||||
|
{
|
||||||
|
try { await application.DisposeAsync().ConfigureAwait(false); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
// Failed configuration/certificate work may precede the application's ownership handoff.
|
||||||
|
// An adopted server remains solely owned by ApplicationInstance.DisposeAsync.
|
||||||
|
if (!adopted)
|
||||||
|
{
|
||||||
|
try { if (server is not null) await server.DisposeAsync().ConfigureAwait(false); }
|
||||||
|
catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: unadopted server disposal threw"); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
catch (Exception error) { failure = error; }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
// Only the cleanup owner clears adapters after every listener and registry has closed.
|
||||||
|
_identityAuthenticators.Clear();
|
||||||
|
_server = null;
|
||||||
|
}
|
||||||
|
catch (Exception error) { failure = failure is null ? error : new AggregateException(failure, error); }
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
if (failure is null) _disposeCompletion.TrySetResult();
|
||||||
|
else _disposeCompletion.TrySetException(failure);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
_identityAuthenticators.Clear();
|
|
||||||
_server = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+277
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user