fix: share SDK2 host disposal completion and close unadopted servers

This commit is contained in:
Joseph Doherty
2026-11-10 17:06:27 -05:00
parent 58f8d57f5a
commit 19610c6812
@@ -175,6 +175,9 @@ public sealed class OpcUaApplicationHost : IAsyncDisposable
private StandardServer? _server;
private readonly ITelemetryContext _telemetry = DefaultTelemetry.Create(_ => { });
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>
/// <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>
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
/// <summary>Disposes the application host and cleans up resources.</summary>
/// <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 (_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;
}
}