diff --git a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs index 6cbec3f0..cfaecdca 100644 --- a/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs +++ b/src/Server/ZB.MOM.WW.OtOpcUa.OpcUaServer/OpcUaApplicationHost.cs @@ -175,6 +175,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable private StandardServer? _server; private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { }); private readonly List _identityAuthenticators = []; + private readonly object _lifetimeLock = new(); + private readonly TaskCompletionSource _disposeCompletion = new(TaskCreationOptions.RunContinuationsAsynchronously); + private int _disposeStarted; /// Initializes a new instance of the OPC UA application host. /// The host configuration options. @@ -202,18 +205,21 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// A task that represents the asynchronous operation. public async Task StartAsync(StandardServer server, CancellationToken cancellationToken) { - if (server is not OtOpcUaSdkServer sdkServer) - throw new ArgumentException("The server must support application authentication before its listeners open.", nameof(server)); - _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) + OtOpcUaSdkServer sdkServer; + lock (_lifetimeLock) { - ApplicationName = _options.ApplicationName, - ApplicationType = ApplicationType.Server, - ConfigSectionName = "OtOpcUa", - }; + ObjectDisposedException.ThrowIf(Volatile.Read(ref _disposeStarted) != 0, this); + sdkServer = server as OtOpcUaSdkServer + ?? 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 { @@ -640,16 +646,63 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable /// Disposes the application host and cleans up resources. /// A task that represents the asynchronous operation. - public async ValueTask DisposeAsync() + public ValueTask DisposeAsync() { - // Authenticators remain installed until the SDK closes every listener and disposes its registry. - if (_application is { } application) + if (Interlocked.CompareExchange(ref _disposeStarted, 1, 0) == 0) + _ = CompleteDisposalAsync(); + return new ValueTask(_disposeCompletion.Task); + } + + private async Task CompleteDisposalAsync() + { + Exception? failure = null; + try { - _application = null; - try { await application.DisposeAsync().ConfigureAwait(false); } - catch (Exception ex) { _logger.LogWarning(ex, "OpcUaApplicationHost: disposal threw"); } + ApplicationInstance? application; + StandardServer? server; + 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; } }