diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs index e7e6767f..5d17c6a2 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientBrowseSession.cs @@ -17,7 +17,7 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession private readonly NodeId _rootNodeId; private readonly SemaphoreSlim _gate = new(1, 1); private volatile bool _disposed; - private readonly IDisposable? _ownedCredential; + private readonly IAsyncDisposable? _ownedApplication; /// /// Construct a browse session bound to an already-connected . @@ -26,10 +26,10 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession /// Namespace snapshot taken at connect time; used to render outbound /// NodeIds in the server-stable nsu=… form. /// The node under which browses one level. - /// A credential (the user certificate) this session owns and disposes with itself. - internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IDisposable? ownedCredential = null) + /// The SDK application whose certificate manager this session owns. + internal OpcUaClientBrowseSession(ISession session, NamespaceMap nsMap, NodeId rootNodeId, IAsyncDisposable? ownedApplication = null) { - _ownedCredential = ownedCredential; + _ownedApplication = ownedApplication; _session = session; _nsMap = nsMap; _rootNodeId = rootNodeId; @@ -177,11 +177,12 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession // Best-effort — mirrors OpcUaClientDriver's pagination loop. try { + using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await _session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: true, continuationPoints: [cp], - ct: CancellationToken.None).ConfigureAwait(false); + ct: release.Token).AsTask().WaitAsync(release.Token).ConfigureAwait(false); } catch { /* best-effort — server may have already cleaned up */ } throw; @@ -246,7 +247,9 @@ internal sealed class OpcUaClientBrowseSession : IBrowseSession try { _gate.Dispose(); } catch { /* best-effort */ } - try { _ownedCredential?.Dispose(); } - catch { /* best-effort */ } + if (_ownedApplication is not null) + { + try { await _ownedApplication.DisposeAsync().ConfigureAwait(false); } catch { /* best-effort */ } + } } } diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs index dad1be23..a1be2645 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Browser/OpcUaClientDriverBrowser.cs @@ -69,9 +69,10 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser // R06:O-5 — Certificate auth is supported: the identity is built (and any secret: credential resolved) by the // same factory the runtime driver uses, BEFORE any network I/O, so a bad certificate path / password fails fast // with its own message. - var (identity, ownedCertificate) = await BuildBrowseCredentialAsync(opts, _secretResolver, cancellationToken) + var identity = await BuildBrowseUserIdentityAsync(opts, _secretResolver, cancellationToken) .ConfigureAwait(false); + ApplicationInstance? ownedApplication = null; try { if (opts.AutoAcceptCertificates) @@ -81,7 +82,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser "driver's PKI store instead.", endpoint); - var appConfig = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); + var app = await BuildBrowseAppConfigurationAsync(cancellationToken).ConfigureAwait(false); + ownedApplication = app; + var appConfig = app.ApplicationConfiguration!; var perEndpointBudget = TimeSpan.FromSeconds( Math.Clamp(opts.PerEndpointConnectTimeout.TotalSeconds, 5, 30)); @@ -106,6 +109,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser try { + connectCts.Token.ThrowIfCancellationRequested(); var nsMap = NamespaceMap.FromSession(session); var rootNodeId = string.IsNullOrEmpty(opts.BrowseRoot) ? ObjectIds.ObjectsFolder @@ -115,7 +119,9 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser "AdminUI OPC UA browse session opened against {Endpoint} (policy {Policy}, mode {Mode})", endpoint, opts.SecurityPolicy, opts.SecurityMode); - return new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedCertificate); + var browse = new OpcUaClientBrowseSession(session, nsMap, rootNodeId, ownedApplication); + ownedApplication = null; + return browse; } catch { @@ -126,9 +132,8 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser } catch { - // Review m9: the certificate (and its private-key handle) is owned by the browse session once handed - // over; any failure before that releases it here instead of leaving it to the GC. - ownedCertificate?.Dispose(); + // The SDK application owns its certificate manager until the browse session takes it. + if (ownedApplication is not null) await ownedApplication.DisposeAsync().ConfigureAwait(false); throw; } } @@ -139,7 +144,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// trust decisions don't leak into the deployed driver's cert store. /// /// Cancellation token for the configuration build. - private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) + private static async Task BuildBrowseAppConfigurationAsync(CancellationToken ct) { // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK // internals off any logging provider. @@ -153,7 +158,7 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "adminui-browse-pki"); - var config = new ApplicationConfiguration + var config = new ApplicationConfiguration(BrowseTelemetry.Context) { ApplicationName = "OtOpcUa AdminUI Browse", ApplicationType = ApplicationType.Client, @@ -187,11 +192,19 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = 60_000 }, DisableHiResClock = true, }; - await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); app.ApplicationConfiguration = config; - await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct) - .ConfigureAwait(false); - return config; + try + { + await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); + await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct) + .ConfigureAwait(false); + return app; + } + catch + { + await app.DisposeAsync().ConfigureAwait(false); + throw; + } } /// @@ -203,28 +216,11 @@ public sealed class OpcUaClientDriverBrowser : IDriverBrowser /// Cancellation token for the secret resolution. /// The identity the browse session activates with. internal static async Task BuildBrowseUserIdentityAsync( - OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) => - (await BuildBrowseCredentialAsync(opts, secretResolver, ct).ConfigureAwait(false)).Identity; - - /// - /// As , also handing back the user certificate a Certificate-auth - /// identity holds, so the browse session that uses it can dispose it (review m9: the browse opens one per picker - /// session, and an undisposed X509Certificate2 pins a private-key handle until GC). - /// - /// Driver options carrying the form's auth fields. - /// The secret store, or null when none is available. - /// Cancellation token for the secret resolution. - /// The identity, and the certificate it owns (null for Anonymous / Username). - internal static async Task<(UserIdentity Identity, System.Security.Cryptography.X509Certificates.X509Certificate2? Certificate)> - BuildBrowseCredentialAsync(OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) + OpcUaClientDriverOptions opts, ISecretResolver? secretResolver, CancellationToken ct) { + ct.ThrowIfCancellationRequested(); var resolved = await OpcUaUserIdentityFactory.ResolveSecretRefsAsync(opts, secretResolver, ct).ConfigureAwait(false); - if (resolved.AuthType != OpcUaAuthType.Certificate) - { - return (OpcUaUserIdentityFactory.Build(resolved), null); - } - - return (OpcUaUserIdentityFactory.FromCertificate(resolved), null); + return OpcUaUserIdentityFactory.Build(resolved); } /// Select the endpoint matching the requested SecurityPolicy + SecurityMode pair. diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs index 2123467c..3e69b552 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Session/UpstreamValueMapping.cs @@ -19,7 +19,8 @@ public static class UpstreamValueMapping DateTimeUtc[] times => times.Select(t => t.ToDateTime()).ToArray(), Uuid[] uuids => uuids.Select(u => (Guid)u).ToArray(), Variant[] variants => variants.Select(ToClr).ToArray(), - object?[] objects => objects.Select(Detach).ToArray(), + byte[][] bytes => bytes.Select(b => b?.ToArray()).ToArray(), + object?[] objects when objects.GetType() == typeof(object[]) => objects.Select(Detach).ToArray(), Array array => array.Clone(), _ => value, }; diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 8eaad3de..c705200e 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -280,6 +280,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit private Func? _certValidationHandler; /// The that owns . private ICertificateManager? _certValidatorRef; + private ApplicationInstance? _ownedApplication; /// /// 1 while the CURRENT session's keep-alive is bad (an outage is in progress), else 0. The /// 0→1 edge publishes the outage once (Bad per subscribed ref + a quality-only alarm annotation); the 1→0 @@ -565,6 +566,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit string? connectedUrl = null; foreach (var url in candidates) { + cancellationToken.ThrowIfCancellationRequested(); try { session = OpenSessionForTest is { } open @@ -572,9 +574,11 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit : await OpenSessionOnEndpointAsync( appConfig, url, _options.SecurityPolicy, _options.SecurityMode, identity, cancellationToken).ConfigureAwait(false); + cancellationToken.ThrowIfCancellationRequested(); connectedUrl = url; break; } + catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); @@ -622,6 +626,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit DetachCertValidation(_certValidatorRef, _certValidationHandler); _certValidationHandler = certValidation.Handler; _certValidatorRef = certValidation.Validator; + _ownedApplication = certValidation.Application; installed = true; } } @@ -642,7 +647,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // superseded check) does not orphan it and its keep-alive timer until GC. if (session is not null && !installed) await DiscardSessionAsync(session, keepAliveHandler).ConfigureAwait(false); - if (!installed) DetachCertValidation(certValidation.Validator, certValidation.Handler); + if (!installed) + { + DetachCertValidation(certValidation.Validator, certValidation.Handler); + if (certValidation.Application is { } application) + await application.DisposeAsync().ConfigureAwait(false); + } if (Volatile.Read(ref _lifecycleGeneration) == generation) { if (installed) @@ -659,6 +669,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit { public Func? Handler { get; set; } public ICertificateManager? Validator { get; set; } + public ApplicationInstance? Application { get; set; } } private static void DetachCertValidation(ICertificateManager? validator, Func? handler) @@ -735,11 +746,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit ApplicationType = ApplicationType.Client, }; + certValidation.Application = app; var pkiRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki"); - var config = new ApplicationConfiguration + var config = new ApplicationConfiguration(_sdkTelemetry) { ApplicationName = _options.SessionName, ApplicationType = ApplicationType.Client, @@ -782,6 +794,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit DisableHiResClock = true, }; + app.ApplicationConfiguration = config; await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); // AutoAccept=true is a dev-only escape hatch. Emit a prominent warning so a @@ -821,7 +834,6 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit } // Ensure an application certificate exists. The SDK auto-generates one if missing. - app.ApplicationConfiguration = config; await app.CheckApplicationInstanceCertificatesAsync(silent: true, lifeTimeInMonths: null, ct) .ConfigureAwait(false); @@ -1181,10 +1193,13 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // Take the session reference under _probeLock and clear Session to null so any concurrent caller that // checks inside _gate sees null immediately after release begins. ISession? sessionToClose; + ApplicationInstance? applicationToDispose; lock (_probeLock) { if (Volatile.Read(ref _lifecycleGeneration) != generation) return; sessionToClose = Session; + applicationToDispose = _ownedApplication; + _ownedApplication = null; if (_keepAliveHandler is not null && sessionToClose is not null) { try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { } @@ -1209,6 +1224,10 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit } catch { /* best-effort */ } try { sessionToClose?.Dispose(); } catch { } + if (applicationToDispose is not null) + { + try { await applicationToDispose.DisposeAsync().ConfigureAwait(false); } catch { } + } Interlocked.Exchange(ref _sessionLost, 0); Interlocked.Exchange(ref _subscriptionLost, 0); Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0); @@ -1732,11 +1751,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // the release fails silently and the cursor times out server-side anyway. try { + using var release = new CancellationTokenSource(TimeSpan.FromSeconds(5)); await session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: true, continuationPoints: [continuationPoint], - ct: CancellationToken.None).ConfigureAwait(false); + ct: release.Token).AsTask().WaitAsync(release.Token).ConfigureAwait(false); } catch { /* best-effort — server may have already cleaned up */ } throw; @@ -2630,7 +2650,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit ObjectId = conditionId, MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge, InputArguments = [ - new Variant(eventId), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen) + Variant.From(eventId.ToByteString()), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen) new Variant(new LocalizedText(ack.Comment ?? string.Empty)), ], }); @@ -3702,8 +3722,8 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// hook synchronously (no async work on this hot path), then fires the cert-validation /// handler detach. The async session-close is intentionally skipped — it requires a /// live session + network round-trip and is unsafe to block-on from a potentially - /// single-threaded context (OPC UA stack thread). The session will be cleaned up by - /// the SDK's own finalizer on GC (no sync-over-async). + /// single-threaded context (OPC UA stack thread). Local session and certificate-manager + /// resources are disposed synchronously. /// public void Dispose() { @@ -3721,7 +3741,12 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit try { Session.KeepAlive -= _keepAliveHandler; } catch { } } _keepAliveHandler = null; + var session = Session; Session = null; + if (session is not null) DetachDiagnostics(session); + try { session?.Dispose(); } catch { } + try { (_ownedApplication?.ApplicationConfiguration?.CertificateManager as IDisposable)?.Dispose(); } catch { } + _ownedApplication = null; } // Detach the cert-validation handler registered during InitializeAsync so the diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs index 6cdc48da..5bae0547 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriverProbe.cs @@ -130,7 +130,7 @@ public sealed class OpcUaClientDriverProbe : IDriverProbe /// is plain-text and does not require a client certificate. /// private static ApplicationConfiguration BuildMinimalAppConfig() => - new() + new(SdkTelemetry.Context) { ApplicationName = "OtOpcUa.Probe", ApplicationType = ApplicationType.Client,