using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging.Abstractions; using Opc.Ua; using Opc.Ua.Client; using Opc.Ua.Configuration; using ZB.MOM.WW.OtOpcUa.Core.Abstractions; using ZB.MOM.WW.Secrets.Abstractions; namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient; /// /// OPC UA Client (gateway) driver. Opens a against a remote OPC UA /// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships /// the scaffold: only (connect / close / health). Browse, read, /// write, subscribe, and probe land in PRs 67-69. /// /// /// /// Builds its own rather than reusing /// Client.Shared — Client.Shared is oriented at the interactive CLI; this /// driver is an always-on service component with different session-lifetime needs /// (keep-alive monitor, session transfer on reconnect, multi-year uptime). /// /// /// Session lifetime: a single per driver instance. /// Subscriptions multiplex onto that session; SDK reconnect handler takes the session /// down and brings it back up on remote-server restart — the driver must re-send /// subscriptions + TransferSubscriptions on reconnect to avoid dangling /// monitored-item handles. That mechanic lands in PR 69. /// /// public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDisposable, IAsyncDisposable { /// Authored tags whose TagConfig carried no usable nodeId, so there is no upstream node /// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: NamespaceMap /// drops such an entry with a bare continue and not even a log line. private readonly SkippedTagLedger _skippedTags = new(); /// public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot(); /// /// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve /// each authored RawPath rather than by instrumenting NamespaceMap, so what is reported /// skipped is exactly what read/write/subscribe will fail to resolve — and the shared Contracts /// type stays free of driver bookkeeping. /// /// The map just built from the live session. private void RecordSkippedTags(NamespaceMap map) { var authored = _options.RawTags; _skippedTags.BeginRebuild(authored.Count); foreach (var entry in authored) { if (string.IsNullOrWhiteSpace(entry.RawPath)) _skippedTags.Skip("", "entry has no RawPath"); else if (!map.TryResolve(entry.RawPath, out _)) _skippedTags.Skip(entry.RawPath, "TagConfig has no usable 'nodeId' — no upstream node to dial"); } _skippedTags.Commit(); } private readonly ILogger _logger; /// Initializes a new instance of the class. /// Driver configuration. /// Stable logical ID from the config DB. /// Optional logger; defaults to NullLogger when not supplied. /// /// Optional shared secret resolver used to resolve secret:-prefixed /// / /// at session-open. /// Defaults to the null-object (which reports every /// secret absent) for the test/parse-only path; the production factory threads the /// DI-registered resolver. A secret: ref against the null-object fails closed. /// public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId, ILogger? logger = null, ISecretResolver? secretResolver = null) { _options = options; _driverInstanceId = driverInstanceId; _diag = new OpcUaClientDiagnostics(driverInstanceId); _logger = logger ?? NullLogger.Instance; _secretResolver = secretResolver ?? NullSecretResolver.Instance; } /// Mutable so can adopt a re-parsed config on reinitialize /// (#516). Only ever written on the init path, before any reader/session uses it. private OpcUaClientDriverOptions _options; private readonly ISecretResolver _secretResolver; private readonly string _driverInstanceId; // ---- IAlarmSource state ---- private readonly System.Collections.Concurrent.ConcurrentDictionary _alarmSubscriptions = new(); /// #619: RawPath → the upstream condition NodeId last seen for it. The host acknowledges by /// RawPath (its only identity for the condition) but Part 9 Acknowledge must be called on the CONDITION /// node, which differs from the authored source node. private readonly System.Collections.Concurrent.ConcurrentDictionary _alarmConditionByRawPath = new(StringComparer.Ordinal); /// #619 review: the (Active, Acked) state last told to the host per reported condition id, from /// which picks the Kind sequence that moves the host's projector to the /// upstream event's state. private readonly Dictionary _alarmStateByRawPath = new(StringComparer.Ordinal); /// #619 re-review: serialises the read-compute-emit over . Event /// notifications arrive on SDK publish threads (and, across a resubscribe, from two subscriptions at once), so /// a lock-free prior/target exchange could emit two kind sequences computed from the same prior. private readonly object _alarmStateLock = new(); /// #619 re-review: the ref set the last carried. Survives /// deliberately — see . private HashSet? _lastAlarmRefSet; /// Latched once the upstream server has refused ConditionRefresh as unsupported (logged once). private int _conditionRefreshUnsupportedLogged; private long _nextAlarmSubscriptionId; /// Occurs when an alarm event is received from the remote OPC UA server. public event EventHandler? OnAlarmEvent; // ---- ISubscribable + IHostConnectivityProbe state ---- private readonly System.Collections.Concurrent.ConcurrentDictionary _subscriptions = new(); private long _nextSubscriptionId; private readonly object _probeLock = new(); private HostState _hostState = HostState.Unknown; private DateTime _hostStateChangedUtc = DateTime.UtcNow; private KeepAliveEventHandler? _keepAliveHandler; /// Occurs when a monitored item's data value changes on the remote OPC UA server. public event EventHandler? OnDataChange; /// Occurs when the host connectivity status of the remote OPC UA server changes. public event EventHandler? OnHostStatusChanged; // OPC UA StatusCode constants the driver surfaces for local-side faults. Upstream-server // StatusCodes are passed through verbatim per driver-specs.md §8 "cascading quality" — // downstream clients need to distinguish 'remote source down' from 'local driver failure'. private const uint StatusBadNodeIdInvalid = 0x80330000u; private const uint StatusBadInternalError = 0x80020000u; private const uint StatusBadCommunicationError = 0x80050000u; private readonly SemaphoreSlim _gate = new(1, 1); /// Active OPC UA session. Null until returns cleanly. internal ISession? Session { get; private set; } /// Per-connection gate. PRs 67+ serialize read/write/browse on this. internal SemaphoreSlim Gate => _gate; /// /// Test-only seam to swap the active the way /// does at runtime, so the stale-session race the /// read/history paths guard against (re-read Session inside _gate, not before) /// can be exercised deterministically without standing up a live reconnect handler. /// Production code never calls this — the SDK reconnect handler owns the real swap. /// /// The session instance to install, or null to clear it. internal void SetSessionForTest(ISession? session) => Session = session; /// /// Test-only seam to install the that connect/reconnect builds at /// runtime, so the v3 RawPath → upstream node id resolution can be exercised without a live /// connect (which is what populates ). Production code never calls /// this — / own the real build. /// /// The namespace map to install, or null to clear it. internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map; /// Test-only view of the diagnostics counters (#276). internal OpcUaClientDiagnostics DiagnosticsForTest => _diag; private DriverHealth _health = new(DriverState.Unknown, null, null); /// Per-instance event counters published as OTel metrics (#276). private readonly OpcUaClientDiagnostics _diag; private NotificationEventHandler? _notificationHandler; private PublishErrorEventHandler? _publishErrorHandler; private bool _disposed; /// URL of the endpoint the driver actually connected to. Exposed via . private string? _connectedEndpointUrl; /// /// Cert-validation delegate wired when /// is true. Stored so / can /// detach it from the (potentially process-shared) /// and avoid leaking the closure. /// private CertificateValidationEventHandler? _certValidationHandler; /// The that owns . private CertificateValidator? _certValidatorRef; /// /// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery /// when the session's keep-alive channel reports a bad status. Null outside the /// reconnecting window; constructed lazily inside the keep-alive handler. Guarded by /// — keep-alive callbacks fire from the SDK timer thread and /// can race a check-then-set if left unsynchronized. /// private SessionReconnectHandler? _reconnectHandler; /// /// Bidirectional namespace map built at connect time from session.NamespaceUris. /// Stored NodeIds embed the server-stable namespace URI rather than the /// session-relative ns=N index, so a remote-server namespace-table reorder /// across a restart does not silently re-point stored references at the wrong /// namespace (driver-specs.md §8 "Namespace Remapping"). /// Null until returns cleanly. /// private NamespaceMap? _namespaceMap; /// public string DriverInstanceId => _driverInstanceId; /// public string DriverType => "OpcUaClient"; /// True when the supplied DriverConfig JSON carries a real body. The bootstrapper always /// passes a populated document; some unit tests pass "{}" or an empty string to exercise /// lifecycle shape without a config — those keep the constructor-supplied options. private static bool HasConfigBody(string? driverConfigJson) { if (string.IsNullOrWhiteSpace(driverConfigJson)) return false; var trimmed = driverConfigJson.Trim(); return trimmed is not "{}" and not "[]"; } /// public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { // #630: InitializeAsync is reachable on a live driver (DriverInstanceActor.AdoptConfigDuringInit runs // it with no teardown first); `Session = session` below would otherwise orphan the previous session, // its keep-alive handler and any reconnect handler. await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false); _health = new DriverHealth(DriverState.Initializing, null, null); try { // #516: re-parse the supplied DriverConfig so a config change delivered through the IDriver // contract is honoured. Without this the driver keeps serving the options it was CONSTRUCTED // with — the endpoint, security policy and tag set were all frozen at construction, and only // secret rotation was picked up. if (HasConfigBody(driverConfigJson)) _options = OpcUaClientDriverFactoryExtensions.ParseOptions(_driverInstanceId, driverConfigJson); ValidateTuning(_options); var appConfig = await BuildApplicationConfigurationAsync(cancellationToken).ConfigureAwait(false); // #278: when a DiscoveryUrl is configured, FindServers/GetEndpoints on it first and try the // discovered URLs ahead of the static list. Non-fatal — an LDS outage must not block init. IReadOnlyList discovered = []; if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl)) { IReadOnlyList found = []; try { found = await DiscoverEndpointsAsync( appConfig, _options.DiscoveryUrl!, _options.SecurityPolicy, _options.SecurityMode, cancellationToken).ConfigureAwait(false); } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) { throw; } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': discovery against '{DiscoveryUrl}' failed; " + "using the configured endpoint list only.", _driverInstanceId, _options.DiscoveryUrl); } // Selection is OUTSIDE the try on purpose: an ambiguous LDS must fail the connect with a // clear message, not be swallowed into "discovery unavailable". discovered = SelectDiscoveredUrls(found, _options.DiscoveryServerUri); } var candidates = ResolveEndpointCandidates(_options, discovered); // G-2b: resolve any secret:-prefixed Password / UserCertificatePassword through the // shared secret store ONCE, here in the async connect flow, just before the credentials // are consumed. _options stays the raw config (with secret: refs) — only this // connect-scoped local carries plaintext, and only for the duration of the connect. // Resolving on every InitializeAsync (a full reconnect calls it) picks up rotations. // Both credential consumers — the Username password below and the Certificate password // in BuildCertificateIdentity — flow through BuildUserIdentity(resolvedOptions), so a // single resolve covers both paths. var resolvedOptions = await OpcUaClientSecretResolution .ResolveSecretRefsAsync(_options, _secretResolver, cancellationToken).ConfigureAwait(false); var identity = BuildUserIdentity(resolvedOptions); // Failover sweep: try each endpoint in order, return the session from the first // one that successfully connects. Per-endpoint failures are captured so the final // aggregate exception names every URL that was tried and why — critical diag for // operators debugging 'why did the failover pick #3?'. var attemptErrors = new List(candidates.Count); ISession? session = null; string? connectedUrl = null; foreach (var url in candidates) { try { session = await OpenSessionOnEndpointAsync( appConfig, url, _options.SecurityPolicy, _options.SecurityMode, identity, cancellationToken).ConfigureAwait(false); connectedUrl = url; break; } catch (Exception ex) { attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}"); } } if (session is null) throw new AggregateException( "OPC UA Client failed to connect to any of the configured endpoints. " + "Tried:\n " + string.Join("\n ", attemptErrors), attemptErrors.Select(e => new InvalidOperationException(e))); // Wire the session's keep-alive channel into HostState + the reconnect trigger. // OPC UA keep-alives are authoritative for session liveness: the SDK pings on // KeepAliveInterval and sets KeepAliveStopped when N intervals elapse without a // response. On a bad keep-alive the driver spins up a SessionReconnectHandler // which transparently retries + swaps the underlying session. Subscriptions move // via TransferSubscriptions so local MonitoredItem handles stay valid. _keepAliveHandler = OnKeepAlive; session.KeepAlive += _keepAliveHandler; AttachDiagnostics(session); // Build the bidirectional namespace map from the freshly negotiated session's // NamespaceUris (driver-specs.md §8 "Namespace Remapping"). Stored NodeIds carry // the namespace URI, not the session-relative ns=N index, so a remote namespace // reorder across a restart can't silently misaddress nodes. v3: the same map also // ingests the authored RawTags so read/write/subscribe/history references (RawPaths) // resolve to their upstream node ids. _namespaceMap = NamespaceMap.FromSession(session, _options.RawTags); RecordSkippedTags(_namespaceMap); Session = session; _connectedEndpointUrl = connectedUrl; _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); TransitionTo(HostState.Running); } catch (Exception ex) { try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { } Session = null; _health = new DriverHealth(DriverState.Faulted, null, ex.Message); throw; } } /// /// Build a minimal in-memory . Certificates live /// under the OS user profile — on Windows that's %LocalAppData%\OtOpcUa\pki /// — so multiple driver instances in the same OtOpcUa server process share one /// certificate store without extra config. /// private async Task BuildApplicationConfigurationAsync(CancellationToken ct) { // The ITelemetryContext overload is the non-obsolete ctor; an empty builder keeps SDK // internals off any logging provider (mirrors Client.Shared's ClientTelemetry). var app = new ApplicationInstance(SdkTelemetry.Context) { ApplicationName = _options.SessionName, ApplicationType = ApplicationType.Client, }; var pkiRoot = Path.Combine( Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "OtOpcUa", "pki"); var config = new ApplicationConfiguration { ApplicationName = _options.SessionName, ApplicationType = ApplicationType.Client, ApplicationUri = _options.ApplicationUri, SecurityConfiguration = new SecurityConfiguration { ApplicationCertificate = new CertificateIdentifier { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pkiRoot, "own"), SubjectName = $"CN={_options.SessionName}", }, TrustedPeerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pkiRoot, "trusted"), }, TrustedIssuerCertificates = new CertificateTrustList { StoreType = CertificateStoreType.Directory, // #277: the issuer store's crl/ sub-folder is where the SDK reads issuer CRLs, so an // operator-supplied path is the CRL knob. Unset = the historical default. StorePath = string.IsNullOrWhiteSpace(_options.CertificateValidation.IssuerStorePath) ? Path.Combine(pkiRoot, "issuers") : _options.CertificateValidation.IssuerStorePath, }, RejectedCertificateStore = new CertificateTrustList { StoreType = CertificateStoreType.Directory, StorePath = Path.Combine(pkiRoot, "rejected"), }, AutoAcceptUntrustedCertificates = _options.AutoAcceptCertificates, RejectUnknownRevocationStatus = _options.CertificateValidation.RejectUnknownRevocationStatus, }, TransportQuotas = new TransportQuotas { OperationTimeout = (int)_options.Timeout.TotalMilliseconds }, ClientConfiguration = new ClientConfiguration { DefaultSessionTimeout = (int)_options.SessionTimeout.TotalMilliseconds, }, DisableHiResClock = true, }; await config.ValidateAsync(ApplicationType.Client, ct).ConfigureAwait(false); // AutoAccept=true is a dev-only escape hatch. Emit a prominent warning so a // production misconfiguration is immediately visible in logs. if (_options.AutoAcceptCertificates) { _logger.LogWarning( "OpcUaClientDriver '{DriverInstanceId}': AutoAcceptCertificates=true — all " + "remote server certificate errors are accepted, including expired / wrong-host " + "/ chain-incomplete. This MUST be false in production to prevent MITM attacks " + "against the opc.tcp channel.", _driverInstanceId); // Accept the full set of certificate-validation error codes: a real dev cert can // fail with BadCertificateChainIncomplete, BadCertificateTimeInvalid, or // BadCertificateHostNameInvalid, not only BadCertificateUntrusted. Only accepting // the latter would silently fail for those certs. A REVOKED certificate is the one // exception (#277): auto-accept is for dev self-signed certs, never for a cert its issuer // has explicitly withdrawn. CertificateValidationEventHandler handler = (_, e) => { if (IsRevocationFailure(e.Error.StatusCode)) { _logger.LogWarning( "OpcUaClientDriver '{DriverInstanceId}': REVOKED server certificate rejected " + "(subject={Subject}, status=0x{Status:X8}) — AutoAcceptCertificates does not apply to revoked certs.", _driverInstanceId, e.Certificate?.Subject, e.Error.StatusCode.Code); e.Accept = false; return; } e.Accept = true; }; config.CertificateValidator.CertificateValidation += handler; // Store refs so ShutdownAsync + Dispose can detach the delegate and avoid // leaking a closure on a potentially process-shared validator. _certValidationHandler = handler; _certValidatorRef = config.CertificateValidator; } // 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); return config; } /// True for the two hard "this certificate (or its issuer) is revoked" results (#277). /// The SDK's certificate-validation status. /// true when the status is Revoked / IssuerRevoked. internal static bool IsRevocationFailure(StatusCode status) => status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked; /// /// Resolve the ordered failover candidate list. EndpointUrls wins when /// non-empty; otherwise fall back to EndpointUrl as a single-URL shortcut so /// existing single-endpoint configs keep working without migration. /// /// Driver options containing endpoint configuration. /// The ordered list of endpoint URLs to try. internal static IReadOnlyList ResolveEndpointCandidates(OpcUaClientDriverOptions opts) => ResolveEndpointCandidates(opts, []); /// /// Resolve the ordered failover list with optional discovery results (#278). The configured /// endpoints always come FIRST — a discovered endpoint can only ever be a fallback, never outrank or /// replace the server the operator named. Discovered URLs follow, de-duplicated case-insensitively. /// With no discovered URLs the result is exactly the pre-discovery behaviour. /// /// Driver options containing endpoint configuration. /// URLs resolved via (may be empty). /// The ordered list of endpoint URLs to try. internal static IReadOnlyList ResolveEndpointCandidates( OpcUaClientDriverOptions opts, IReadOnlyList discovered) { IReadOnlyList staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl]; if (discovered.Count == 0) return staticList; var seen = new HashSet(StringComparer.OrdinalIgnoreCase); var merged = new List(discovered.Count + staticList.Count); foreach (var u in staticList) if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u); foreach (var u in discovered) if (!string.IsNullOrWhiteSpace(u) && seen.Add(u)) merged.Add(u); return merged; } /// /// FindServers on (an LDS, or the server itself), then /// GetEndpoints on each returned server's discovery URL, keeping the endpoints whose security /// policy + mode match. One unreachable server in the list is skipped, not fatal. The discovery /// channel is unsecured by design (OPC UA Part 4 §5.4) regardless of the session's security mode. /// /// Application configuration (client identity for the discovery channel). /// The LDS / server discovery URL. /// Required security policy. /// Required security mode. /// Cancellation token. /// Matching endpoint URLs in discovery order, de-duplicated. internal static async Task> DiscoverEndpointsAsync( ApplicationConfiguration appConfig, string discoveryUrl, OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct) { var wantedPolicyUri = MapSecurityPolicy(policy); var wantedMode = ToMessageSecurityMode(mode); ApplicationDescriptionCollection servers; using (var lds = await DiscoveryClient.CreateAsync( appConfig, new Uri(discoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false)) { servers = await lds.FindServersAsync(null, ct).ConfigureAwait(false); } var found = new List(); foreach (var server in servers) { if (server.DiscoveryUrls is null) continue; foreach (var serverDiscoveryUrl in server.DiscoveryUrls) { if (string.IsNullOrWhiteSpace(serverDiscoveryUrl)) continue; EndpointDescriptionCollection endpoints; try { using var ep = await DiscoveryClient.CreateAsync( appConfig, new Uri(serverDiscoveryUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false); endpoints = await ep.GetEndpointsAsync(null, ct).ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { throw; } catch { continue; } found.AddRange(FilterEndpointUrls(endpoints, wantedPolicyUri, wantedMode) .Select(u => new DiscoveredEndpoint(server.ApplicationUri ?? string.Empty, u))); } } return found; } /// One endpoint URL found by discovery, with the ApplicationUri of the server advertising it. /// The advertising server's ApplicationUri (empty when it published none). /// The endpoint URL. internal readonly record struct DiscoveredEndpoint(string ApplicationUri, string Url); /// /// Decide which discovered endpoints may be used (#278). An LDS can register many servers (identical /// line PLCs, a gateway plus a historian), and the first one that accepts a connection would be bound /// silently — mis-attributing every value and write. So: with set, only /// that ApplicationUri's endpoints; unset and exactly one distinct server, that server; unset and /// several, throw naming them so the operator pins one. /// /// Matching endpoints from every server the LDS listed. /// Optional DiscoveryServerUri filter. /// The usable URLs, de-duplicated, in discovery order (possibly empty). /// Several servers discovered and no filter configured. internal static IReadOnlyList SelectDiscoveredUrls( IReadOnlyList found, string? serverUri) { IEnumerable chosen = found; if (!string.IsNullOrWhiteSpace(serverUri)) { chosen = found.Where(e => string.Equals(e.ApplicationUri, serverUri, StringComparison.Ordinal)); } else { var servers = found.Select(e => e.ApplicationUri).Distinct(StringComparer.Ordinal).ToList(); if (servers.Count > 1) throw new InvalidOperationException( "OpcUaClient discovery found endpoints for more than one server " + $"({string.Join(", ", servers.Select(u => string.IsNullOrEmpty(u) ? "" : u))}). " + "Set DiscoveryServerUri to the ApplicationUri of the server this driver should use."); } return chosen.Select(e => e.Url).Distinct(StringComparer.OrdinalIgnoreCase).ToList(); } /// The endpoint URLs in matching the wanted policy URI + mode. /// Endpoints advertised by a server. /// Required SecurityPolicyUri. /// Required message security mode. /// Matching EndpointUrls, in advertised order. internal static IEnumerable FilterEndpointUrls( IEnumerable endpoints, string wantedPolicyUri, MessageSecurityMode wantedMode) => endpoints .Where(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode && !string.IsNullOrWhiteSpace(e.EndpointUrl)) .Select(e => e.EndpointUrl); private static MessageSecurityMode ToMessageSecurityMode(OpcUaSecurityMode mode) => mode switch { OpcUaSecurityMode.None => MessageSecurityMode.None, OpcUaSecurityMode.Sign => MessageSecurityMode.Sign, OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt, _ => throw new ArgumentOutOfRangeException(nameof(mode)), }; /// /// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at /// so a bad config fails like the namespace-kind check does rather /// than surfacing later as a server-side BadInvalidArgument on every subscribe. /// /// The options to validate. internal static void ValidateTuning(OpcUaClientDriverOptions opts) { if (opts.SubscriptionKeepAliveCount < 1) throw new InvalidOperationException("OpcUaClient SubscriptionKeepAliveCount must be >= 1."); if (opts.SubscriptionLifetimeCount < 3L * opts.SubscriptionKeepAliveCount) throw new InvalidOperationException( "OpcUaClient SubscriptionLifetimeCount must be at least 3x SubscriptionKeepAliveCount " + $"(got lifetime={opts.SubscriptionLifetimeCount}, keep-alive={opts.SubscriptionKeepAliveCount})."); if (opts.SubscriptionPublishingInterval is { } pi && pi <= TimeSpan.Zero) throw new InvalidOperationException("OpcUaClient SubscriptionPublishingInterval must be positive when set."); if (!string.IsNullOrWhiteSpace(opts.DiscoveryUrl) && !Uri.TryCreate(opts.DiscoveryUrl, UriKind.Absolute, out _)) throw new InvalidOperationException( $"OpcUaClient DiscoveryUrl '{opts.DiscoveryUrl}' is not an absolute URI."); } /// /// Publishing interval (ms) a new subscription gets: the configured override if set, else the /// interval the runtime requested, floored at 50 ms either way (servers negotiate up anyway, and /// sub-50 ms just wastes negotiation). /// /// Driver options. /// The per-call interval from SubscribeAsync. /// The publishing interval in milliseconds. internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested) { var effective = opts.SubscriptionPublishingInterval ?? requested; return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds; } /// /// Build the user-identity token from the driver options. Split out of /// so the failover sweep reuses one identity across /// every endpoint attempt — generating it N times would re-unlock the user cert's /// private key N times, wasteful + keeps the password in memory longer. /// /// Driver options containing authentication configuration. /// The constructed for session activation. internal static UserIdentity BuildUserIdentity(OpcUaClientDriverOptions options) => options.AuthType switch { OpcUaAuthType.Anonymous => new UserIdentity(new AnonymousIdentityToken()), OpcUaAuthType.Username => new UserIdentity( options.Username ?? string.Empty, System.Text.Encoding.UTF8.GetBytes(options.Password ?? string.Empty)), OpcUaAuthType.Certificate => BuildCertificateIdentity(options), _ => new UserIdentity(new AnonymousIdentityToken()), }; /// /// Open a session against a single endpoint URL. Bounded by /// so the failover /// sweep doesn't spend its full budget on one dead server. Moved out of /// so the failover loop body stays readable. /// private async Task OpenSessionOnEndpointAsync( ApplicationConfiguration appConfig, string endpointUrl, OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, UserIdentity identity, CancellationToken ct) { using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); cts.CancelAfter(_options.PerEndpointConnectTimeout); var selected = await SelectMatchingEndpointAsync( appConfig, endpointUrl, policy, mode, cts.Token).ConfigureAwait(false); var endpointConfig = EndpointConfiguration.Create(appConfig); endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds; var endpoint = new ConfiguredEndpoint(null, selected, endpointConfig); var session = await new DefaultSessionFactory(SdkTelemetry.Context).CreateAsync( appConfig, endpoint, false, // updateBeforeConnect _options.SessionName, (uint)_options.SessionTimeout.TotalMilliseconds, identity, null, // preferredLocales cts.Token).ConfigureAwait(false); session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds; return session; } /// /// Select the remote endpoint matching both the requested /// and . The SDK's CoreClientUtils.SelectEndpointAsync /// only honours a boolean "use security" flag; we need policy-aware matching so an /// operator asking for Basic256Sha256 against a server that also offers /// Basic128Rsa15 doesn't silently end up on the weaker cipher. /// private static async Task SelectMatchingEndpointAsync( ApplicationConfiguration appConfig, string endpointUrl, OpcUaSecurityPolicy policy, OpcUaSecurityMode mode, CancellationToken ct) { // GetEndpoints returns everything the server advertises; policy + mode filter is // applied client-side so the selection is explicit and fails loudly if the operator // asks for a combination the server doesn't publish. DiscoveryClient.CreateAsync // is the non-obsolete path in SDK 1.5.378; the synchronous Create(..) variants are // all deprecated. using var client = await DiscoveryClient.CreateAsync( appConfig, new Uri(endpointUrl), Opc.Ua.DiagnosticsMasks.None, ct).ConfigureAwait(false); var all = await client.GetEndpointsAsync(null, ct).ConfigureAwait(false); var wantedPolicyUri = MapSecurityPolicy(policy); var wantedMode = ToMessageSecurityMode(mode); var match = all.FirstOrDefault(e => e.SecurityPolicyUri == wantedPolicyUri && e.SecurityMode == wantedMode); if (match is null) { var advertised = string.Join(", ", all .Select(e => $"{ShortPolicyName(e.SecurityPolicyUri)}/{e.SecurityMode}")); throw new InvalidOperationException( $"No endpoint at '{endpointUrl}' matches SecurityPolicy={policy} + SecurityMode={mode}. " + $"Server advertises: {advertised}"); } return match; } /// /// Build a carrying a client user-authentication /// certificate loaded from . /// Used when the remote server's endpoint advertises Certificate-type user tokens. /// Fails fast if the path is missing, the file doesn't exist, or the certificate /// lacks a private key (the private key is required to sign the user-token /// challenge during session activation). /// /// Driver options containing certificate configuration. /// The certificate-backed . internal static UserIdentity BuildCertificateIdentity(OpcUaClientDriverOptions options) { if (string.IsNullOrWhiteSpace(options.UserCertificatePath)) throw new InvalidOperationException( "OpcUaAuthType.Certificate requires OpcUaClientDriverOptions.UserCertificatePath to be set."); if (!System.IO.File.Exists(options.UserCertificatePath)) throw new System.IO.FileNotFoundException( $"User certificate not found at '{options.UserCertificatePath}'.", options.UserCertificatePath); // X509CertificateLoader (new in .NET 9) is the only non-obsolete way to load a PFX // since the legacy X509Certificate2 ctors are marked obsolete on net10. Passes the // password through verbatim; PEM files with external keys fall back to // LoadCertificateFromFile which picks up the adjacent .key if present. var cert = System.Security.Cryptography.X509Certificates.X509CertificateLoader .LoadPkcs12FromFile(options.UserCertificatePath, options.UserCertificatePassword); if (!cert.HasPrivateKey) throw new InvalidOperationException( $"User certificate at '{options.UserCertificatePath}' has no private key — " + "the private key is required to sign the OPC UA user-token challenge at session activation."); return new UserIdentity(cert); } /// Convert a driver to the OPC UA policy URI. /// The driver security policy to map. /// The corresponding OPC UA security policy URI. internal static string MapSecurityPolicy(OpcUaSecurityPolicy policy) => policy switch { OpcUaSecurityPolicy.None => SecurityPolicies.None, OpcUaSecurityPolicy.Basic128Rsa15 => SecurityPolicies.Basic128Rsa15, OpcUaSecurityPolicy.Basic256 => SecurityPolicies.Basic256, OpcUaSecurityPolicy.Basic256Sha256 => SecurityPolicies.Basic256Sha256, OpcUaSecurityPolicy.Aes128_Sha256_RsaOaep => SecurityPolicies.Aes128_Sha256_RsaOaep, OpcUaSecurityPolicy.Aes256_Sha256_RsaPss => SecurityPolicies.Aes256_Sha256_RsaPss, _ => throw new ArgumentOutOfRangeException(nameof(policy), policy, null), }; private static string ShortPolicyName(string policyUri) => policyUri?.Substring(policyUri.LastIndexOf('#') + 1) ?? "(null)"; /// public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken) { await ShutdownAsync(cancellationToken).ConfigureAwait(false); await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false); } /// /// Cancels any in-flight reconnect, detaches the keep-alive handler and closes + disposes the current /// session. The session part of ; also run at the top of /// so a re-initialise on a live driver (#630) does not orphan the /// previous session and its reconnect handler. Subscriptions are NOT touched here. /// private async Task ReleaseSessionAsync(CancellationToken cancellationToken) { // Abort any in-flight reconnect attempts before touching the session — BeginReconnect's // retry loop holds a reference to the current session and would fight Session.CloseAsync // if left spinning. Take the handler under _probeLock so a keep-alive callback racing // through OnKeepAlive can't arm a fresh handler after we've torn this one down. SessionReconnectHandler? handlerToCancel; lock (_probeLock) { handlerToCancel = _reconnectHandler; _reconnectHandler = null; } try { handlerToCancel?.CancelReconnect(); } catch { } handlerToCancel?.Dispose(); // Take the session reference under _probeLock before touching it, so we can't race // an OnReconnectComplete that is simultaneously swapping to a new session. We clear // Session to null here so any concurrent caller that checks inside _gate sees null // immediately after shutdown begins. ISession? sessionToClose; lock (_probeLock) { sessionToClose = Session; if (_keepAliveHandler is not null && sessionToClose is not null) { try { sessionToClose.KeepAlive -= _keepAliveHandler; } catch { } } _keepAliveHandler = null; if (sessionToClose is not null) DetachDiagnostics(sessionToClose); Session = null; } try { if (sessionToClose is Session s) await s.CloseAsync(cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } try { sessionToClose?.Dispose(); } catch { } } /// public async Task ShutdownAsync(CancellationToken cancellationToken) { // Tear down remote subscriptions first — otherwise Session.Close will try and may fail // with BadSubscriptionIdInvalid noise in the upstream log. _subscriptions is cleared // whether or not the wire-side delete succeeds since the local handles are useless // after close anyway. Before deleting each subscription we detach the Notification // handlers we attached at subscribe time so the SDK's invocation list no longer // holds the driver instance through the closure. foreach (var rs in _subscriptions.Values) { DetachNotificationHandlers(rs.ItemHandlers); try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } } _subscriptions.Clear(); foreach (var ras in _alarmSubscriptions.Values) { try { ras.EventItem.Notification -= ras.Handler; } catch { /* best-effort */ } try { await ras.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort */ } } _alarmSubscriptions.Clear(); // #619 re-review: the last-told alarm states (and ack targets) deliberately SURVIVE a shutdown. A // ReinitializeAsync (comms-loss watchdog / Force Reconnect) is followed by the host re-subscribing the // same set over condition nodes it did NOT rebuild; forgetting here would make the post-subscribe // ConditionRefresh re-raise every active+acked alarm (an un-ack flicker). BeginAlarmSubscription forgets // them when the set changes, which is when the host did rebuild. await ReleaseSessionAsync(cancellationToken).ConfigureAwait(false); _namespaceMap = null; _connectedEndpointUrl = null; // Detach the cert-validation handler so the (potentially process-shared) // CertificateValidator doesn't hold a delegate to a shutting-down driver. if (_certValidationHandler is not null && _certValidatorRef is not null) { try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { } _certValidationHandler = null; _certValidatorRef = null; } TransitionTo(HostState.Unknown); _health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null); } /// public DriverHealth GetHealth() => _health; // ---- IReadable ---- /// public async Task> ReadAsync( IReadOnlyList fullReferences, CancellationToken cancellationToken) { // Make sure a session exists before queuing on the gate, but do NOT bind the wire // call to this reference — a reconnect can swap Session while we wait on _gate. The // session actually used is re-read inside the gate. _ = RequireSession(); var results = new DataValueSnapshot[fullReferences.Count]; var now = DateTime.UtcNow; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: if a reconnect completed while we // were blocked on _gate, OnReconnectComplete has already swapped in the new // session. NodeId parsing is namespace-relative, so it must also use the current // session's namespace table. var session = Session; if (session is null) { for (var i = 0; i < fullReferences.Count; i++) results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); return results; } // Resolve each RawPath reference (v3) to a live NodeId against the current session. // A RawPath the authored RawTags table doesn't know — or a node id that won't parse — // gets BadNodeIdInvalid and is omitted from the wire request, saving a round-trip for a // fault the driver can detect locally. var toSend = new ReadValueIdCollection(); var indexMap = new List(fullReferences.Count); // maps wire-index -> results-index for (var i = 0; i < fullReferences.Count; i++) { if (!TryResolveTagNode(session, fullReferences[i], out var nodeId)) { results[i] = new DataValueSnapshot(null, StatusBadNodeIdInvalid, null, now); continue; } toSend.Add(new ReadValueId { NodeId = nodeId, AttributeId = Attributes.Value }); indexMap.Add(i); } if (toSend.Count == 0) return results; try { var resp = await session.ReadAsync( requestHeader: null, maxAge: 0, timestampsToReturn: TimestampsToReturn.Both, nodesToRead: toSend, ct: cancellationToken).ConfigureAwait(false); var values = resp.Results; for (var w = 0; w < values.Count; w++) { var r = indexMap[w]; var dv = values[w]; // Preserve the upstream StatusCode verbatim — including Bad codes per // §8's cascading-quality rule. Also preserve SourceTimestamp so downstream // clients can detect stale upstream data. results[r] = new DataValueSnapshot( Value: dv.Value, StatusCode: dv.StatusCode.Code, SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp, ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? now : dv.ServerTimestamp); } _health = new DriverHealth(DriverState.Healthy, now, null); } catch (Exception ex) { // Transport / timeout / session-dropped — fan out the same fault across every // tag in this batch. Per-tag StatusCode stays BadCommunicationError (not // BadInternalError) so operators distinguish "upstream unreachable" from // "driver bug". for (var w = 0; w < indexMap.Count; w++) { var r = indexMap[w]; results[r] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now); } _health = new DriverHealth(DriverState.Degraded, _health.LastSuccessfulRead, ex.Message); } } finally { _gate.Release(); } return results; } // ---- IWritable ---- /// public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { // See ReadAsync — the wire call must use the session current inside the gate, not a // reference captured before WaitAsync. _ = RequireSession(); var results = new WriteResult[writes.Count]; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var session = Session; if (session is null) { // Writes are non-idempotent — but here the request never reached the wire, so // BadCommunicationError ("definitely did not happen") is the honest code. for (var i = 0; i < writes.Count; i++) results[i] = new WriteResult(StatusBadCommunicationError); return results; } var toSend = new WriteValueCollection(); var indexMap = new List(writes.Count); for (var i = 0; i < writes.Count; i++) { if (!TryResolveTagNode(session, writes[i].FullReference, out var nodeId)) { results[i] = new WriteResult(StatusBadNodeIdInvalid); continue; } toSend.Add(new WriteValue { NodeId = nodeId, AttributeId = Attributes.Value, Value = new DataValue(new Variant(writes[i].Value)), }); indexMap.Add(i); } if (toSend.Count == 0) return results; try { var resp = await session.WriteAsync( requestHeader: null, nodesToWrite: toSend, ct: cancellationToken).ConfigureAwait(false); var codes = resp.Results; for (var w = 0; w < codes.Count; w++) { var r = indexMap[w]; // Pass upstream WriteResult StatusCode through verbatim. Success codes // include Good (0) and any warning-level Good* variants; anything with // the severity bits set is a Bad. results[r] = new WriteResult(codes[w].Code); } } catch (OperationCanceledException) { // Timeout / cancellation after the wire request may have been dispatched. // Writes are non-idempotent — BadTimeout ("outcome unknown, do not blindly // retry") is more honest than BadCommunicationError ("definitely did not // happen"). Downstream callers that need retry semantics check for BadTimeout // and can decide whether to re-issue. const uint StatusBadTimeout = 0x800A0000u; for (var w = 0; w < indexMap.Count; w++) results[indexMap[w]] = new WriteResult(StatusBadTimeout); } catch (Exception) { // Pre-wire transport failure — the write definitely did not reach the server. for (var w = 0; w < indexMap.Count; w++) results[indexMap[w]] = new WriteResult(StatusBadCommunicationError); } } finally { _gate.Release(); } return results; } /// /// (v3) Resolve a tag's RawPath reference to a live against the /// 's current namespace table. Two stages: /// /// Map the RawPath (the wire reference handed to the driver under v3) to its /// upstream node id string via the authored RawTags table on /// (). /// Re-bind that node id string — a server-stable nsu=<uri>;… form or a plain /// ns=2;s=… serialized NodeId — against the current session so a remote /// namespace-table reorder across a restart is transparently corrected (driver-specs.md §8). /// /// An unknown/un-authored RawPath, a missing map, or a node id that won't parse returns false; /// the driver surfaces that as without a wire round-trip. /// /// The OPC UA session to resolve the node ID against. /// The tag's RawPath reference to resolve. /// The resolved live node ID when successful. /// true if the RawPath resolved to a live node ID; otherwise false. internal bool TryResolveTagNode(ISession session, string rawPath, out NodeId nodeId) { nodeId = NodeId.Null; var map = _namespaceMap; if (map is null) return false; // Stage 1: RawPath -> upstream node id string (authored as TagConfig.nodeId). if (!map.TryResolve(rawPath, out var upstreamNodeId)) return false; // Stage 2: node id string -> live NodeId re-bound against the current session table. return NamespaceMap.TryResolve(session, upstreamNodeId, out nodeId); } /// /// Render a discovered NodeId in the server-stable form persisted into the local /// address space. Falls back to the raw serialized NodeId if the namespace map is not /// yet built (it always is by the time runs). /// private string StableReference(NodeId nodeId) => _namespaceMap?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty; private ISession RequireSession() => Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized"); // ---- ITagDiscovery ---- /// // Run-once: DiscoverAsync recursively browses the remote server's address space and // registers every variable within the single call (browse + enrich passes are fully // awaited) — nothing fills in asynchronously after connect, so a single discovery pass // is sufficient. public DiscoveryRediscoverPolicy RediscoverPolicy => DiscoveryRediscoverPolicy.Once; /// public async Task DiscoverAsync(IAddressSpaceBuilder builder, CancellationToken cancellationToken) { ArgumentNullException.ThrowIfNull(builder); // Confirm a session exists before queuing; the session actually browsed is re-read // inside the gate so a reconnect mid-wait can't leave us browsing a closed session. _ = RequireSession(); var rootFolder = builder.Folder("Remote", "Remote"); var visited = new HashSet(); var discovered = 0; var pendingVariables = new List(); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { var session = Session ?? throw new InvalidOperationException( "OpcUaClient session was lost before discovery could browse the remote server."); var root = !string.IsNullOrEmpty(_options.BrowseRoot) ? NodeId.Parse(session.MessageContext, _options.BrowseRoot) : ObjectIds.ObjectsFolder; // Pass 1: browse hierarchy + create folders inline, collect variables into a // pending list. Defers variable registration until attributes are resolved — the // address-space builder's Variable call is the one-way commit, so doing it only // once per variable (with correct DataType/SecurityClass/IsArray) avoids the // alternative (register with placeholders + mutate later) which the // IAddressSpaceBuilder contract doesn't expose. await BrowseRecursiveAsync(session, root, rootFolder, visited, depth: 0, discovered: () => discovered, increment: () => discovered++, pendingVariables: pendingVariables, ct: cancellationToken).ConfigureAwait(false); // Pass 2: batch-read DataType + AccessLevel + ValueRank + Historizing per // variable. One wire request for up to ~N variables; for 10k-node servers this is // still a couple of hundred ms total since the SDK chunks ReadAsync automatically. await EnrichAndRegisterVariablesAsync(session, pendingVariables, cancellationToken) .ConfigureAwait(false); } finally { _gate.Release(); } } /// /// A variable collected during the browse pass, waiting for attribute enrichment /// before being registered on the address-space builder. /// private readonly record struct PendingVariable( IAddressSpaceBuilder ParentFolder, string BrowseName, string DisplayName, NodeId NodeId); private async Task BrowseRecursiveAsync( ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet visited, int depth, Func discovered, Action increment, List pendingVariables, CancellationToken ct) { if (depth >= _options.MaxBrowseDepth) return; if (discovered() >= _options.MaxDiscoveredNodes) return; if (!visited.Add(node)) return; var browseDescriptions = new BrowseDescriptionCollection { new() { NodeId = node, BrowseDirection = BrowseDirection.Forward, ReferenceTypeId = ReferenceTypeIds.HierarchicalReferences, IncludeSubtypes = true, NodeClassMask = (uint)(NodeClass.Object | NodeClass.Variable), ResultMask = (uint)(BrowseResultMask.BrowseName | BrowseResultMask.DisplayName | BrowseResultMask.NodeClass | BrowseResultMask.TypeDefinition), } }; ReferenceDescriptionCollection refs; try { var resp = await session.BrowseAsync( requestHeader: null, view: null, requestedMaxReferencesPerNode: 0, nodesToBrowse: browseDescriptions, ct: ct).ConfigureAwait(false); if (resp.Results.Count == 0) return; var result = resp.Results[0]; refs = result.References; // Follow browse continuation points. OPC UA servers cap the references returned // per node in a single response; when a folder has more children than the cap, // BrowseResult.ContinuationPoint is non-empty and the remainder must be pulled // with BrowseNext. Without this loop a large remote folder is silently truncated // and discovered tags go missing from the local address space. var continuationPoint = result.ContinuationPoint; while (continuationPoint is { Length: > 0 }) { BrowseNextResponse next; try { next = await session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: false, continuationPoints: [continuationPoint], ct: ct).ConfigureAwait(false); } catch (OperationCanceledException) { // Caller cancelled mid-pagination. Release the server-side cursor so it // doesn't hold resources until session close. Use CancellationToken.None // for the release — the original token is already cancelled so we must not // gate the release on it. Fire-and-forget: if the server is also unreachable, // the release fails silently and the cursor times out server-side anyway. try { await session.BrowseNextAsync( requestHeader: null, releaseContinuationPoints: true, continuationPoints: [continuationPoint], ct: CancellationToken.None).ConfigureAwait(false); } catch { /* best-effort — server may have already cleaned up */ } throw; } if (next.Results.Count == 0) break; var nextResult = next.Results[0]; if (nextResult.References is { Count: > 0 }) refs.AddRange(nextResult.References); continuationPoint = nextResult.ContinuationPoint; } } catch (OperationCanceledException) { // Propagate cancellation — don't silently swallow it like a transient browse // failure. The caller's CancellationToken was honoured; let it observe that. throw; } catch { // Transient browse failure on a sub-tree — don't kill the whole discovery, just // skip this branch. The driver's health surface will reflect the cascade via the // probe loop (PR 69). return; } foreach (var rf in refs) { if (discovered() >= _options.MaxDiscoveredNodes) break; var childId = ExpandedNodeId.ToNodeId(rf.NodeId, session.NamespaceUris); if (NodeId.IsNull(childId)) continue; var browseName = rf.BrowseName?.Name ?? childId.ToString(); var displayName = rf.DisplayName?.Text ?? browseName; if (rf.NodeClass == NodeClass.Object) { var subFolder = folder.Folder(browseName, displayName); increment(); await BrowseRecursiveAsync(session, childId, subFolder, visited, depth + 1, discovered, increment, pendingVariables, ct).ConfigureAwait(false); } else if (rf.NodeClass == NodeClass.Variable) { pendingVariables.Add(new PendingVariable(folder, browseName, displayName, childId)); increment(); } } } /// /// Pass 2 of discovery: batch-read DataType + ValueRank + AccessLevel + Historizing /// for every collected variable in one Session.ReadAsync (the SDK chunks internally /// to respect the server's per-request limits). Then register each variable on its /// parent folder with the real . /// /// /// /// Attributes read: DataType (NodeId of the value type), /// ValueRank (-1 = scalar, 1 = array), UserAccessLevel (the /// effective access mask for our session — more accurate than AccessLevel which /// is the server-side configured mask before user filtering), and /// Historizing (server flags whether historian data is available). /// /// /// When the upstream server returns Bad on any attribute, the variable falls back /// to safe defaults (Int32 / ViewOnly / not-array / not-historized) and is still /// registered — a partial enrichment failure shouldn't drop entire variables from /// the address space. Operators reading the Admin dashboard see the variable /// with conservative metadata which is obviously wrong and easy to triage. /// /// private async Task EnrichAndRegisterVariablesAsync( ISession session, IReadOnlyList pending, CancellationToken ct) { if (pending.Count == 0) return; // 4 attributes per variable: DataType, ValueRank, UserAccessLevel, Historizing. var nodesToRead = new ReadValueIdCollection(pending.Count * 4); foreach (var pv in pending) { nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.DataType }); nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.ValueRank }); nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.UserAccessLevel }); nodesToRead.Add(new ReadValueId { NodeId = pv.NodeId, AttributeId = Attributes.Historizing }); } DataValueCollection values; try { var resp = await session.ReadAsync( requestHeader: null, maxAge: 0, timestampsToReturn: TimestampsToReturn.Neither, nodesToRead: nodesToRead, ct: ct).ConfigureAwait(false); values = resp.Results; } catch { // Enrichment-read failed wholesale (server unreachable mid-browse). Register the // pending variables with conservative defaults rather than dropping them — the // downstream catalog is still useful for reading via IReadable. foreach (var pv in pending) RegisterFallback(pv); return; } for (var i = 0; i < pending.Count; i++) { var pv = pending[i]; var baseIdx = i * 4; var dataTypeDv = values[baseIdx]; var valueRankDv = values[baseIdx + 1]; var accessDv = values[baseIdx + 2]; var histDv = values[baseIdx + 3]; var dataType = StatusCode.IsGood(dataTypeDv.StatusCode) && dataTypeDv.Value is NodeId dtId ? MapUpstreamDataType(dtId) : DriverDataType.Int32; var valueRank = StatusCode.IsGood(valueRankDv.StatusCode) && valueRankDv.Value is int vr ? vr : -1; // OPC UA Part 3 ValueRank constants: -3 = ScalarOrOneDimension, -2 = Any, // -1 = Scalar, 0 = OneOrMoreDimensions, 1 = OneDimension, >1 = N specific dimensions. // Deliberate choice: treat anything >= 0 as an array (the spec guarantees -3/-2/-1 // are the only negative values, and any non-negative rank denotes at least one // array dimension). -3 ScalarOrOneDimension and -2 Any are conservatively treated // as scalar — array-of-one is exposed as scalar to the local address space until // the upstream variable carries a concrete dimensioned rank. var isArray = valueRank >= 0; var access = StatusCode.IsGood(accessDv.StatusCode) && accessDv.Value is byte ab ? ab : (byte)0; var securityClass = MapAccessLevelToSecurityClass(access); var historizing = StatusCode.IsGood(histDv.StatusCode) && histDv.Value is bool b && b; pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( FullName: StableReference(pv.NodeId), DriverDataType: dataType, IsArray: isArray, ArrayDim: null, SecurityClass: securityClass, IsHistorized: historizing, IsAlarm: false)); } void RegisterFallback(PendingVariable pv) { pv.ParentFolder.Variable(pv.BrowseName, pv.DisplayName, new DriverAttributeInfo( FullName: StableReference(pv.NodeId), DriverDataType: DriverDataType.Int32, IsArray: false, ArrayDim: null, SecurityClass: SecurityClassification.ViewOnly, IsHistorized: false, IsAlarm: false)); } } /// /// Map an upstream OPC UA built-in DataType NodeId (via DataTypeIds.*) to a /// . Unknown / custom types fall through to /// which is the safest passthrough for /// Variant-wrapped structs + enums + extension objects; downstream clients see a /// string rendering but the cascading-quality path still preserves upstream /// StatusCode + timestamps. /// /// The OPC UA data type NodeId to map. /// The mapped . internal static DriverDataType MapUpstreamDataType(NodeId dataType) { if (dataType == DataTypeIds.Boolean) return DriverDataType.Boolean; // SByte (signed 8-bit) shares Int16 — DriverDataType has no narrower signed type. // Byte (unsigned 8-bit) belongs in the unsigned family → UInt16, not Int16 // (mapping an unsigned 0-255 type onto Int16 misrepresents type metadata and // confuses range/validation logic keyed off DriverDataType). if (dataType == DataTypeIds.SByte || dataType == DataTypeIds.Int16) return DriverDataType.Int16; if (dataType == DataTypeIds.Byte || dataType == DataTypeIds.UInt16) return DriverDataType.UInt16; if (dataType == DataTypeIds.Int32) return DriverDataType.Int32; if (dataType == DataTypeIds.UInt32) return DriverDataType.UInt32; if (dataType == DataTypeIds.Int64) return DriverDataType.Int64; if (dataType == DataTypeIds.UInt64) return DriverDataType.UInt64; if (dataType == DataTypeIds.Float) return DriverDataType.Float32; if (dataType == DataTypeIds.Double) return DriverDataType.Float64; if (dataType == DataTypeIds.String) return DriverDataType.String; if (dataType == DataTypeIds.DateTime || dataType == DataTypeIds.UtcTime) return DriverDataType.DateTime; return DriverDataType.String; } /// /// Map an OPC UA AccessLevel/UserAccessLevel attribute value (AccessLevels /// bitmask) to a the local node-manager's ACL /// layer can gate writes off. CurrentWrite-capable variables surface as /// ; read-only as . /// /// The OPC UA access level bitmask. /// The mapped security classification. internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel) { const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02 return (accessLevel & CurrentWrite) != 0 ? SecurityClassification.Operate : SecurityClassification.ViewOnly; } // ---- ISubscribable ---- /// public async Task SubscribeAsync( IReadOnlyList fullReferences, TimeSpan publishingInterval, CancellationToken cancellationToken) { // Fast-fail before queuing if the driver isn't connected, but do NOT bind the // subscription to this reference — a reconnect can swap Session while we wait on _gate. // The session actually used is re-read inside the gate. _ = RequireSession(); var id = Interlocked.Increment(ref _nextSubscriptionId); var handle = new OpcUaSubscriptionHandle(id); // Floor the publishing interval at 50ms — OPC UA servers routinely negotiate // minimum-supported intervals up anyway, but sending sub-50ms wastes negotiation // bandwidth on every subscription create. #273: the options may pin the interval. var intervalMs = ResolvePublishingIntervalMs(_options, publishingInterval); var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"opcua-sub-{id}", PublishingInterval = intervalMs, KeepAliveCount = _options.SubscriptionKeepAliveCount, LifetimeCount = _options.SubscriptionLifetimeCount, MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish, PublishingEnabled = true, Priority = _options.SubscriptionPriority, TimestampsToReturn = TimestampsToReturn.Both, }); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: a reconnect completing while we were // blocked on _gate has already swapped in the new session via OnReconnectComplete. var session = Session ?? throw new InvalidOperationException( "OpcUaClient session was lost before the subscription could be created."); session.AddSubscription(subscription); await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); // Track each (MonitoredItem, handler) pair so UnsubscribeAsync / ShutdownAsync // can detach the Notification delegate before disposing the session. The lambda // captures `handle`, so we must hold the exact delegate instance returned by // `+=` to be able to remove it. var itemHandlers = new List(); foreach (var fullRef in fullReferences) { if (!TryResolveTagNode(session, fullRef, out var nodeId)) continue; // The RawPath (v3 tag identity) is routed through MonitoredItem.Handle so the // Notification handler reports the change under the same reference the runtime // subscribed with — StartNodeId is the resolved upstream node, Handle stays the RawPath. var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = fullRef, StartNodeId = nodeId, AttributeId = Attributes.Value, MonitoringMode = MonitoringMode.Reporting, SamplingInterval = intervalMs, QueueSize = 1, DiscardOldest = true, }) { Handle = fullRef, }; MonitoredItemNotificationEventHandler notifHandler = (mi, args) => OnMonitoredItemNotification(handle, mi, args); item.Notification += notifHandler; itemHandlers.Add(new MonitoredItemNotificationHandle(item, notifHandler)); subscription.AddItem(item); } await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false); _subscriptions[id] = new RemoteSubscription(subscription, handle, itemHandlers); } finally { _gate.Release(); } return handle; } /// public async Task UnsubscribeAsync(ISubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is not OpcUaSubscriptionHandle h) return; if (!_subscriptions.TryRemove(h.Id, out var rs)) return; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Detach Notification handlers BEFORE deleting the subscription so the SDK's // MonitoredItem.Notification multicast invocation list no longer holds a // closure that captures the driver instance. The delegate stored on // RemoteSubscription is the exact instance that was added, so `-=` removes it // cleanly. DetachNotificationHandlers(rs.ItemHandlers); try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort — the subscription may already be gone on reconnect */ } } finally { _gate.Release(); } } private static void DetachNotificationHandlers(IReadOnlyList items) { for (var i = 0; i < items.Count; i++) { var pair = items[i]; try { pair.Item.Notification -= pair.Handler; } catch { /* best-effort — SDK may have already cleared its invocation list on session loss */ } } } private void OnMonitoredItemNotification(OpcUaSubscriptionHandle handle, MonitoredItem item, MonitoredItemNotificationEventArgs args) { // args.NotificationValue arrives as a MonitoredItemNotification for value-change // subscriptions; extract its DataValue. The Handle property carries our tag string. if (args.NotificationValue is not MonitoredItemNotification mn) return; var dv = mn.Value; if (dv is null) return; var fullRef = (item.Handle as string) ?? item.DisplayName ?? string.Empty; _diag.Record(OpcUaClientDiagnostics.DataChange); if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue); var snapshot = new DataValueSnapshot( Value: dv.Value, StatusCode: dv.StatusCode.Code, SourceTimestampUtc: dv.SourceTimestamp == DateTime.MinValue ? null : dv.SourceTimestamp, ServerTimestampUtc: dv.ServerTimestamp == DateTime.MinValue ? DateTime.UtcNow : dv.ServerTimestamp); OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot)); } /// /// Live data-change subscription bookkeeping. Holds the SDK , /// the local handle, and the per-MonitoredItem (item, handler) pairs so /// / can detach the /// Notification delegates before the SDK disposes the subscription. /// private sealed record RemoteSubscription( Subscription Subscription, OpcUaSubscriptionHandle Handle, IReadOnlyList ItemHandlers); /// /// One (MonitoredItem, handler-delegate-instance) pair captured at subscribe time so /// the same delegate instance can be `-=` removed at unsubscribe time. The lambda /// captures the local OpcUaSubscriptionHandle, which is what makes detach /// necessary — without it the SDK's multicast invocation list holds the driver /// through the closure until the session itself is disposed. /// private sealed record MonitoredItemNotificationHandle( MonitoredItem Item, MonitoredItemNotificationEventHandler Handler); private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle { /// public string DiagnosticId => $"opcua-sub-{Id}"; } // ---- IAlarmSource ---- /// /// Field positions in the EventFilter SelectClauses below. Used to index into the /// EventFieldList.EventFields Variant collection when an event arrives. /// private const int AlarmFieldEventId = 0; private const int AlarmFieldEventType = 1; private const int AlarmFieldSourceNode = 2; private const int AlarmFieldMessage = 3; private const int AlarmFieldSeverity = 4; private const int AlarmFieldTime = 5; private const int AlarmFieldConditionId = 6; private const int AlarmFieldActiveState = 7; private const int AlarmFieldAckedState = 8; private const int AlarmFieldBranchId = 9; /// public async Task SubscribeAlarmsAsync( IReadOnlyList sourceNodeIds, CancellationToken cancellationToken) { // Fast-fail before queuing if the driver isn't connected, but do NOT bind the alarm // subscription to this reference — a reconnect can swap Session while we wait on _gate. // The session actually used is re-read inside the gate. _ = RequireSession(); var id = Interlocked.Increment(ref _nextAlarmSubscriptionId); var handle = new OpcUaAlarmSubscriptionHandle(id); // Pre-resolve the source-node filter set so the per-event notification handler can // match in O(1) without re-parsing on every event. // #619: under v3 the host passes RawPaths, never upstream node ids. Translate each to the upstream // source id its tag authored (TagConfig.nodeId) so the per-event match compares like with like. // Keys are server-stable (AlarmIndexKey) so a namespace reorder across an in-place SDK reconnect — // which transfers this subscription without re-running SubscribeAlarmsAsync — cannot orphan them. var sessionForResolve = Session; var mapForResolve = _namespaceMap; var sourceFilter = BuildAlarmSourceIndex(sourceNodeIds, raw => { if (sessionForResolve is null) return null; if (!TryResolveTagNode(sessionForResolve, raw, out var live) && !NamespaceMap.TryResolve(sessionForResolve, raw, out live)) return null; return AlarmIndexKey(mapForResolve, live); }); BeginAlarmSubscription(sourceNodeIds); var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"{AlarmSubscriptionNamePrefix}{id}", PublishingInterval = 500, // 500ms — alarms don't need fast polling; the server pushes KeepAliveCount = _options.SubscriptionKeepAliveCount, LifetimeCount = _options.SubscriptionLifetimeCount, MaxNotificationsPerPublish = _options.SubscriptionMaxNotificationsPerPublish, PublishingEnabled = true, Priority = 0, TimestampsToReturn = TimestampsToReturn.Both, }); // EventFilter SelectClauses — pick the standard BaseEventType fields we need to // materialize an AlarmEventArgs. Field positions are indexed by the AlarmField* // constants so the notification handler indexes in O(1) without re-examining the // QualifiedName BrowsePaths. var filter = new EventFilter(); void AddField(string browseName) => filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.BaseEventType, BrowsePath = [new QualifiedName(browseName)], AttributeId = Attributes.Value, }); AddField("EventId"); AddField("EventType"); AddField("SourceNode"); AddField("Message"); AddField("Severity"); AddField("Time"); // ConditionId on ConditionType nodes is the branch identifier for // acknowledgeable conditions. Not a BaseEventType field — reach it via the typed path. filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, BrowsePath = [], // empty path = the condition node itself AttributeId = Attributes.NodeId, }); // #619 review: the host derives Active/Acked solely from AlarmEventArgs.Kind, so the condition state // must be selected. TWO-element browse paths — a single "ActiveState/Id" QualifiedName names nothing. filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AlarmConditionType, BrowsePath = [new QualifiedName(BrowseNames.ActiveState), new QualifiedName(BrowseNames.Id)], AttributeId = Attributes.Value, }); filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.AcknowledgeableConditionType, BrowsePath = [new QualifiedName(BrowseNames.AckedState), new QualifiedName(BrowseNames.Id)], AttributeId = Attributes.Value, }); // #619 re-review: BranchId — a non-null one marks a condition BRANCH event (a previous state, with its own // EventId). Only the trunk (null BranchId) is the condition's current state. filter.SelectClauses.Add(new SimpleAttributeOperand { TypeDefinitionId = ObjectTypeIds.ConditionType, BrowsePath = [new QualifiedName(BrowseNames.BranchId)], AttributeId = Attributes.Value, }); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: a reconnect completing while we were // blocked on _gate has already swapped in the new session via OnReconnectComplete. var session = Session ?? throw new InvalidOperationException( "OpcUaClient session was lost before the alarm subscription could be created."); session.AddSubscription(subscription); await subscription.CreateAsync(cancellationToken).ConfigureAwait(false); var eventItem = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = "Server/Events", StartNodeId = ObjectIds.Server, AttributeId = Attributes.EventNotifier, MonitoringMode = MonitoringMode.Reporting, QueueSize = 1000, // deep queue — a server can fire many alarms in bursts DiscardOldest = false, Filter = filter, }) { Handle = handle, }; // Capture the exact delegate instance so UnsubscribeAlarmsAsync / ShutdownAsync // can `-=` it later. The lambda captures `handle` and `sourceFilter`, so without // the explicit detach the SDK's invocation list keeps the driver instance alive // until the session itself is disposed. MonitoredItemNotificationEventHandler notifHandler = (mi, args) => OnEventNotification(handle, sourceFilter, mi, args); eventItem.Notification += notifHandler; subscription.AddItem(eventItem); await subscription.CreateItemsAsync(cancellationToken).ConfigureAwait(false); _alarmSubscriptions[id] = new RemoteAlarmSubscription(subscription, handle, eventItem, notifHandler); // #619: a new event item only reports FUTURE transitions — a condition already active upstream would // stay invisible until it next changed. ConditionRefresh re-delivers every retained condition's // current state through the normal path (deduplicated against the last-told state). await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false); } finally { _gate.Release(); } return handle; } /// public async Task UnsubscribeAlarmsAsync(IAlarmSubscriptionHandle handle, CancellationToken cancellationToken) { if (handle is not OpcUaAlarmSubscriptionHandle h) return; if (!_alarmSubscriptions.TryRemove(h.Id, out var rs)) return; await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Detach the Notification handler before deleting the subscription so the SDK's // multicast invocation list no longer holds the driver instance through the // closure. try { rs.EventItem.Notification -= rs.Handler; } catch { /* best-effort */ } try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort — session may already be gone across a reconnect */ } } finally { _gate.Release(); } } /// public async Task AcknowledgeAsync( IReadOnlyList acknowledgements, CancellationToken cancellationToken) { // Short-circuit empty batch BEFORE touching the session so callers can pass an empty // list without guarding the size themselves — e.g. a bulk-ack UI that built an empty // list because the filter matched nothing. if (acknowledgements.Count == 0) return; // Fast-fail before queuing if the driver isn't connected, but do NOT bind the ack calls // (or the namespace-relative ConditionId parse) to this reference — a reconnect can swap // Session while we wait on _gate. The session actually used is re-read inside the gate. _ = RequireSession(); await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: a reconnect completing while we were // blocked on _gate has already swapped in the new session via OnReconnectComplete. // ConditionId parsing is namespace-relative, so it must use the current session's // namespace table too. var session = Session; if (session is null) return; // OPC UA A&C: call the AcknowledgeableConditionType.Acknowledge method on each // condition node with EventId + Comment arguments. CallAsync accepts a batch — // one CallMethodRequest per ack. var callRequests = new CallMethodRequestCollection(); foreach (var ack in acknowledgements) { // #619: the host acks by RawPath; Acknowledge must hit the upstream CONDITION node. // The remembered target pairs the condition node with the EventId of the event that named it, // so the ack can never mix one condition's node with another's EventId. Nothing seen yet ⇒ the // tag's authored node (correct when the tag was authored on the condition node), else the id // verbatim (pre-v3 callers that pass an upstream condition id directly). NodeId conditionId; byte[] eventId; if (_alarmConditionByRawPath.TryGetValue(ack.ConditionId, out var seen)) { if (!NamespaceMap.TryResolve(session, seen.ConditionRef, out conditionId)) continue; eventId = seen.EventId; } else { if (!TryResolveTagNode(session, ack.ConditionId, out conditionId) && !NamespaceMap.TryResolve(session, ack.ConditionId, out conditionId)) continue; eventId = Array.Empty(); } callRequests.Add(new CallMethodRequest { ObjectId = conditionId, MethodId = MethodIds.AcknowledgeableConditionType_Acknowledge, InputArguments = [ new Variant(eventId), // Part 9 §5.7.3: the EventId being acknowledged (empty only when none seen) new Variant(new LocalizedText(ack.Comment ?? string.Empty)), ], }); } if (callRequests.Count == 0) return; try { var resp = await session.CallAsync( requestHeader: null, methodsToCall: callRequests, ct: cancellationToken).ConfigureAwait(false); // Inspect per-ack results — the upstream server can reject individual acks // (BadConditionAlreadyAcked, BadNodeIdUnknown, BadUserAccessDenied) even when // the batch transport succeeds. Operators acking a critical alarm deserve to // know if the ack didn't take. if (resp?.Results is not null) { for (var i = 0; i < resp.Results.Count; i++) { var result = resp.Results[i]; if (StatusCode.IsBad(result.StatusCode)) { _logger.LogWarning( "OpcUaClientDriver '{DriverInstanceId}': AcknowledgeAsync ack[{Index}] " + "rejected by upstream server with StatusCode {StatusCode:X8}. " + "The acknowledgement may not have been applied.", _driverInstanceId, i, result.StatusCode.Code); } } } } catch (OperationCanceledException ex) { // Transport-level timeout / cancellation — propagate so the caller's // retry / re-ack mechanism can decide what to do. _logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': AcknowledgeAsync transport error.", _driverInstanceId); throw; } catch (Exception ex) { // Log genuine transport failures rather than swallowing them silently. _logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': AcknowledgeAsync failed; " + "acknowledgements may not have been applied.", _driverInstanceId); } } finally { _gate.Release(); } } private void OnEventNotification( OpcUaAlarmSubscriptionHandle handle, IReadOnlyDictionary sourceFilter, MonitoredItem item, MonitoredItemNotificationEventArgs args) { if (args.NotificationValue is not EventFieldList efl) return; RouteAlarmEvent(efl.EventFields, sourceFilter, handle); } /// /// #619 re-review: open (or re-open) the host's alarm subscription over . /// A CHANGED set means the host rebuilt its address space (an alarm-bearing delta always does), recreating /// every condition node inactive+acked — so every last-told state is forgotten and the post-subscribe /// ConditionRefresh re-reports from that default. The SAME set (a reinit / reconnect re-subscribe) keeps /// them, so the refresh copy of an already-reported state emits nothing. Ack targets of refs no longer /// subscribed are pruned either way. /// /// The refs the host is subscribing. internal void BeginAlarmSubscription(IReadOnlyList sourceNodeIds) { var set = new HashSet(sourceNodeIds, StringComparer.Ordinal); lock (_alarmStateLock) { if (_lastAlarmRefSet is null || !_lastAlarmRefSet.SetEquals(set)) _alarmStateByRawPath.Clear(); _lastAlarmRefSet = set; foreach (var known in _alarmConditionByRawPath.Keys) if (!set.Contains(known)) _alarmConditionByRawPath.TryRemove(known, out _); } } /// /// #619 — route one upstream event: drop refresh markers (a RefreshRequired asks for a new refresh), project /// it, and emit the kinds that move the host from the state last told to the event's. An event that changes /// nothing the host shows (a ConditionRefresh copy) emits nothing. /// /// The event's fields, in select-clause order. /// The subscription's upstream-key → RawPath index. /// The subscription handle to stamp. internal void RouteAlarmEvent( IReadOnlyList fields, IReadOnlyDictionary sourceFilter, IAlarmSubscriptionHandle handle) { if (IsRefreshRequired(fields)) { // Part 9 §5.11.7: the server lost events (queue overflow / restart) and asks for a refresh. _ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None)); return; } // Projection, the per-RawPath bookkeeping AND the emission run under one lock, so two notifications for // one condition (two publish threads; two subscriptions across a resubscribe) can never compute their // kinds from the same prior, nor reach the host out of order. The host's handler only marshals. lock (_alarmStateLock) { var e = ProjectEvent(fields, sourceFilter, handle, n => AlarmIndexKey(_namespaceMap, n), (raw, target) => _alarmConditionByRawPath[raw] = target, out var state); if (e is null) return; if (state is not { } target) { // Not a condition (no ActiveState/AckedState) — a message/severity-only event, as before. OnAlarmEvent?.Invoke(this, e); return; } // Fresh prior = the host projector's own default (inactive, acked). var told = new AlarmToldState(target.Active, target.Acked, e.Message, e.OpcUaSeverity); var priorKnown = _alarmStateByRawPath.TryGetValue(e.ConditionId, out var p); var prior = priorKnown ? p! : new AlarmToldState(false, true, null, null); _alarmStateByRawPath[e.ConditionId] = told; var kinds = AlarmTransitions((prior.Active, prior.Acked), target, priorKnown); if (kinds.Count == 0) { // Same state: a message/severity change still reaches the host; an identical copy does not. if (prior == told) return; kinds = [AlarmTransitionKind.Unspecified]; } foreach (var kind in kinds) OnAlarmEvent?.Invoke(this, e with { Kind = kind }); } } /// #619 re-review — what the host was last told about one condition: its state plus the message and /// exact severity, so a refresh copy is recognised as a duplicate. /// ActiveState/Id last told. /// AckedState/Id last told. /// Message last told (null before any event). /// Exact severity last told (null before any event). private readonly record struct AlarmToldState(bool Active, bool Acked, string? Message, int? Severity); /// True for a RefreshRequiredEventType event (the server asks the client to ConditionRefresh). /// The event's fields. /// Whether the event is a RefreshRequired marker. internal static bool IsRefreshRequired(IReadOnlyList fields) => fields.Count > AlarmFieldEventType && fields[AlarmFieldEventType].Value is NodeId t && t == ObjectTypeIds.RefreshRequiredEventType; /// True for the three ConditionRefresh marker event types — never alarms. /// The event's EventType. /// Whether it is a refresh marker. private static bool IsRefreshMarker(object? eventType) => eventType is NodeId t && (t == ObjectTypeIds.RefreshStartEventType || t == ObjectTypeIds.RefreshEndEventType || t == ObjectTypeIds.RefreshRequiredEventType); /// A ConditionRefresh refusal meaning "this server does not do refresh" (tolerated, logged once). /// The refusal status. /// Whether the server simply lacks ConditionRefresh. internal static bool IsConditionRefreshUnsupported(StatusCode code) => code == StatusCodes.BadNotSupported || code == StatusCodes.BadMethodInvalid || code == StatusCodes.BadNotImplemented || code == StatusCodes.BadServiceUnsupported || code == StatusCodes.BadNodeIdUnknown; /// /// #619 — call ConditionRefresh for so conditions already active upstream /// are reported. Never throws: an unsupported server is logged once, any other failure warns. /// /// The (created) alarm subscription. /// Cancellation. /// A task completing when the refresh call returns. private async Task TryConditionRefreshAsync(Subscription subscription, CancellationToken cancellationToken) { try { await subscription.ConditionRefreshAsync(cancellationToken).ConfigureAwait(false); } catch (ServiceResultException ex) when (IsConditionRefreshUnsupported(ex.StatusCode)) { if (Interlocked.Exchange(ref _conditionRefreshUnsupportedLogged, 1) == 0) _logger.LogInformation( "OpcUaClientDriver '{DriverInstanceId}': upstream server does not support ConditionRefresh " + "({StatusCode}); conditions already active are reported only on their next transition.", _driverInstanceId, ex.StatusCode); } catch (Exception ex) when (ex is not OperationCanceledException || !cancellationToken.IsCancellationRequested) { _logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': ConditionRefresh on alarm subscription {SubscriptionId} failed; " + "conditions already active upstream may not be reported until their next transition.", _driverInstanceId, subscription.Id); } catch (OperationCanceledException) { // The caller cancelled; the subscription itself is in place. } } /// /// #619 — ConditionRefresh every alarm subscription on the current session: after an in-place reconnect /// (transferred or SDK-recreated subscriptions — matched by display name, since a recreate clones the /// objects) and on a RefreshRequired event. Events missed during the outage /// reach the host through the normal deduplicated path. /// /// Cancellation. /// A task completing when every refresh call has returned. private async Task RefreshAllAlarmSubscriptionsAsync(CancellationToken cancellationToken) { if (_alarmSubscriptions.IsEmpty) return; try { await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (ObjectDisposedException) { return; } try { var session = Session; if (session is null) return; foreach (var sub in session.Subscriptions.ToList()) { if (sub.DisplayName?.StartsWith(AlarmSubscriptionNamePrefix, StringComparison.Ordinal) == true && sub.Created) await TryConditionRefreshAsync(sub, cancellationToken).ConfigureAwait(false); } } catch (Exception ex) { _logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': alarm ConditionRefresh sweep failed.", _driverInstanceId); } finally { try { _gate.Release(); } catch (ObjectDisposedException) { } } } /// Display-name prefix of every alarm subscription (how a refresh sweep finds them on a session). private const string AlarmSubscriptionNamePrefix = "opcua-alarm-sub-"; /// /// #619 review — the sequence that moves the host's /// NativeAlarmProjector (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked, /// Clear ⇒ inactive keeping acked) from to . A single /// upstream event can need two (an alarm that went active and was acknowledged between two notifications). /// No state change ⇒ empty (the caller decides whether a message/severity change warrants an Unspecified). /// /// The state last told to the host. /// The upstream event's ActiveState/Id + AckedState/Id. /// /// False when this driver instance has told the host nothing about the condition yet (first sight after a /// start or a deploy respawn), so is only the projector's default. Gitea #647 review: /// the host now treats a Raise on an already-active condition as a re-statement that KEEPS its ack, so an /// upstream that says unacknowledged must then be told with /// — the upstream's ack state is authoritative, and the host may be holding an ack from before the respawn. /// /// The kinds to emit, in order. internal static IReadOnlyList AlarmTransitions( (bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true) { if (prior == target) return []; var kinds = Transitions(prior, target); return priorKnown || target.Acked ? kinds : [.. kinds.Select(k => k == AlarmTransitionKind.Raise ? AlarmTransitionKind.Retrigger : k)]; } private static IReadOnlyList Transitions( (bool Active, bool Acked) prior, (bool Active, bool Acked) target) { return target switch { // Active+unacked: a fresh raise, or a re-arm of an active-acked condition. (true, false) => [prior.Active ? AlarmTransitionKind.Retrigger : AlarmTransitionKind.Raise], // Active+acked: raise first when it was inactive (Raise always lands unacked). (true, true) => prior.Active ? [AlarmTransitionKind.Acknowledge] : [AlarmTransitionKind.Raise, AlarmTransitionKind.Acknowledge], // Inactive+acked: Clear keeps the prior ack, so ack after it when that was unacked. (false, true) => prior.Active ? prior.Acked ? [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge] : [AlarmTransitionKind.Acknowledge], // Inactive+UNacked (went active and cleared unacknowledged — e.g. first seen by a ConditionRefresh): // the only kinds that un-ack are Raise/Retrigger, so pass through active, then Clear. (false, false) => prior.Active ? prior.Acked ? [AlarmTransitionKind.Retrigger, AlarmTransitionKind.Clear] : [AlarmTransitionKind.Clear] : [AlarmTransitionKind.Raise, AlarmTransitionKind.Clear], }; } /// /// #619 — index an alarm subscription's source refs by UPSTREAM source id. The host (v3) subscribes with /// the alarm tags' RawPaths; an upstream event carries its own source NodeId, so the match key is /// the upstream id the tag authored and the value is the RawPath to report back. A ref /// cannot resolve is kept verbatim (direct upstream-id use). /// /// The refs handed to SubscribeAlarmsAsync. /// RawPath → serialized live upstream NodeId, or null when unknown. /// Upstream source id → ref to report. internal static Dictionary BuildAlarmSourceIndex( IReadOnlyList refs, Func resolveUpstream) { var index = new Dictionary(StringComparer.Ordinal); foreach (var r in refs) index[resolveUpstream(r) ?? r] = r; return index; } /// /// #619 — project one upstream event into an . ConditionId and /// SourceNodeId are the authored RawPath, because DriverHostActor routes native alarms on /// (driver, RawPath); the upstream condition node (needed to acknowledge) is handed to /// . Null when the event's source is not an authored one. /// internal static AlarmEventArgs? ProjectEvent( IReadOnlyList fields, IReadOnlyDictionary sourceIndex, IAlarmSubscriptionHandle handle, Func keyOf, Action rememberCondition) => ProjectEvent(fields, sourceIndex, handle, keyOf, rememberCondition, out _); /// As the five-argument overload, also returning the event's (ActiveState/Id, AckedState/Id) through /// state when it is an alarm condition (null otherwise). internal static AlarmEventArgs? ProjectEvent( IReadOnlyList fields, IReadOnlyDictionary sourceIndex, IAlarmSubscriptionHandle handle, Func keyOf, Action rememberCondition, out (bool Active, bool Acked)? state) { state = null; if (fields.Count <= AlarmFieldConditionId) return null; // ConditionRefresh brackets its re-delivery with RefreshStart/RefreshEnd — markers, never alarms. if (IsRefreshMarker(fields[AlarmFieldEventType].Value)) return null; // A condition BRANCH event reports a previous state with its own EventId: projecting it would flip the // trunk's state, remembering it would make Acknowledge ack the branch. if (fields.Count > AlarmFieldBranchId && fields[AlarmFieldBranchId].Value is NodeId branch && !NodeId.IsNull(branch)) return null; if (fields.Count > AlarmFieldAckedState && fields[AlarmFieldActiveState].Value is bool active && fields[AlarmFieldAckedState].Value is bool acked) state = (active, acked); // Pass-through (no authored refs) keeps the raw serialized ids; an authored index compares // server-stable keys (see AlarmIndexKey), matching the CONDITION node first — the only way to tell // several conditions on one source apart — and then the source node. var sourceField = fields[AlarmFieldSourceNode].Value; var conditionField = fields[AlarmFieldConditionId].Value; string reported, upstreamCondition; if (sourceIndex.Count == 0) { reported = sourceField?.ToString() ?? string.Empty; upstreamCondition = conditionField?.ToString() ?? string.Empty; } else { upstreamCondition = conditionField is NodeId cn && !NodeId.IsNull(cn) ? keyOf(cn) : string.Empty; var sourceKey = sourceField is NodeId sn && !NodeId.IsNull(sn) ? keyOf(sn) : string.Empty; if (!(upstreamCondition.Length > 0 && sourceIndex.TryGetValue(upstreamCondition, out reported!)) && !(sourceKey.Length > 0 && sourceIndex.TryGetValue(sourceKey, out reported!))) return null; } var eventType = fields[AlarmFieldEventType].Value?.ToString() ?? "BaseEventType"; var message = (fields[AlarmFieldMessage].Value as LocalizedText)?.Text ?? string.Empty; var severity = fields[AlarmFieldSeverity].Value is ushort sev ? sev : (ushort)0; var time = fields[AlarmFieldTime].Value is DateTime t ? t : DateTime.UtcNow; if (sourceIndex.Count != 0 && upstreamCondition.Length > 0) rememberCondition(reported, new AlarmAckTarget( upstreamCondition, // A copy: the decoded notification's array is not ours to alias for the ack's lifetime. (fields[AlarmFieldEventId].Value as byte[])?.ToArray() ?? Array.Empty())); return new AlarmEventArgs( SubscriptionHandle: handle, SourceNodeId: reported, ConditionId: sourceIndex.Count == 0 ? upstreamCondition : reported, AlarmType: eventType, Message: message, Severity: MapSeverity(severity), SourceTimestampUtc: time, // #626: the upstream severity is already the exact Part 9 value — carry it, don't re-bucket. OpcUaSeverity: severity == 0 ? null : severity); } /// #619 — what Acknowledge needs for one RawPath: the upstream condition node (server-stable /// reference) and the EventId of the event that named it. /// Upstream condition NodeId, rendered by . /// EventId of the most recent event seen for that condition. internal readonly record struct AlarmAckTarget(string ConditionRef, byte[] EventId); /// #619 — the key both sides of the alarm index use: server-stable (nsu=) when a map is /// available, so a remote namespace-table reorder across an in-place reconnect cannot orphan it. /// The current session's namespace map, or null. /// The NodeId to key. /// The index key. internal static string AlarmIndexKey(NamespaceMap? map, NodeId nodeId) => map?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty; /// /// Map an OPC UA BaseEventType.Severity (1..1000) to our coarse-grained /// bucket. Thresholds match the OPC UA A&C Part 9 /// guidance: 1-200 Low, 201-500 Medium, 501-800 High, 801-1000 Critical. /// /// The OPC UA severity value (1-1000). /// The mapped coarse-grained alarm severity bucket. internal static AlarmSeverity MapSeverity(ushort opcSeverity) => opcSeverity switch { <= 200 => AlarmSeverity.Low, <= 500 => AlarmSeverity.Medium, <= 800 => AlarmSeverity.High, _ => AlarmSeverity.Critical, }; /// /// Live alarm-event subscription bookkeeping. Holds the SDK , /// the local handle, the single event-MonitoredItem (`Server/Events`), and the exact /// handler delegate instance so unsubscribe / shutdown can detach the Notification /// event before the SDK disposes the subscription. /// private sealed record RemoteAlarmSubscription( Subscription Subscription, OpcUaAlarmSubscriptionHandle Handle, MonitoredItem EventItem, MonitoredItemNotificationEventHandler Handler); private sealed record OpcUaAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle { /// public string DiagnosticId => $"opcua-alarm-sub-{Id}"; } // ---- IHostConnectivityProbe ---- /// /// Endpoint-URL-keyed host identity for the Admin /hosts dashboard. Reflects the /// endpoint the driver actually connected to after the failover sweep — not the /// first URL in the candidate list — so operators see which of the configured /// endpoints is currently serving traffic. Falls back to the first configured URL /// pre-init so the dashboard has something to render before the first connect. /// public string HostName => _connectedEndpointUrl ?? ResolveEndpointCandidates(_options).FirstOrDefault() ?? _options.EndpointUrl; /// public IReadOnlyList GetHostStatuses() { lock (_probeLock) return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)]; } /// Hook the SDK's publish-side events into the diagnostics counters (#276). /// The session to observe. private void AttachDiagnostics(ISession session) { _notificationHandler ??= (_, e) => { _diag.Record(OpcUaClientDiagnostics.PublishResponse); var msg = e.NotificationMessage; if (msg?.NotificationData is { } data) foreach (var nd in data) _diag.Record(OpcUaClientDiagnostics.Notification, CountNotifications(nd)); }; _publishErrorHandler ??= (_, _) => _diag.Record(OpcUaClientDiagnostics.PublishError); session.Notification -= _notificationHandler; session.Notification += _notificationHandler; session.PublishError -= _publishErrorHandler; session.PublishError += _publishErrorHandler; } private void DetachDiagnostics(ISession session) { try { if (_notificationHandler is not null) session.Notification -= _notificationHandler; if (_publishErrorHandler is not null) session.PublishError -= _publishErrorHandler; } catch { /* best-effort — SDK may have already cleared its invocation lists on session loss */ } } private static int CountNotifications(ExtensionObject nd) => nd.Body switch { DataChangeNotification dc => dc.MonitoredItems?.Count ?? 0, EventNotificationList ev => ev.Events?.Count ?? 0, StatusChangeNotification => 1, _ => 1, }; /// /// Session keep-alive handler. On a healthy ping, bumps HostState back to Running /// (typical bounce after a transient network blip). On a bad ping, starts the SDK's /// which retries on the configured period + /// fires when it lands a new session. /// private void OnKeepAlive(ISession sender, KeepAliveEventArgs e) { if (!ServiceResult.IsBad(e.Status)) { TransitionTo(HostState.Running); return; } TransitionTo(HostState.Stopped); _diag.Record(OpcUaClientDiagnostics.KeepAliveMiss); // Kick off the SDK's reconnect loop exactly once per drop. Keep-alive callbacks fire // from the SDK keep-alive timer thread and the SDK can fire this handler repeatedly // while the channel stays down — the check-then-set must be atomic, otherwise two // callbacks both observe null, both construct a SessionReconnectHandler, and the // second assignment leaks the first (its retry loop keeps running, unreferenced and // never disposed). Guard with _probeLock. SessionReconnectHandler handler; lock (_probeLock) { if (_reconnectHandler is not null || _disposed) return; handler = new SessionReconnectHandler(telemetry: null!, reconnectAbort: false, maxReconnectPeriod: (int)TimeSpan.FromMinutes(2).TotalMilliseconds); _reconnectHandler = handler; } // BeginReconnect is started outside the lock — it does no _reconnectHandler mutation // and we don't want to hold _probeLock across an SDK call. _diag.Record(OpcUaClientDiagnostics.ReconnectAttempt); handler.BeginReconnect( sender, (int)_options.ReconnectPeriod.TotalMilliseconds, OnReconnectComplete); } /// /// Called by when its retry loop has either /// successfully swapped to a new session or given up. Reads the new session off /// handler.Session, unwires the old keep-alive hook, rewires for the new /// one, and tears down the handler. Subscription migration is already handled /// inside the SDK via TransferSubscriptions (the SDK calls it automatically /// when is true, /// which is the default). /// private void OnReconnectComplete(object? sender, EventArgs e) { if (sender is not SessionReconnectHandler handler) return; var newSession = handler.Session; // All mutations to Session and _reconnectHandler run under _probeLock so // OnReconnectComplete, OnKeepAlive, and ShutdownAsync cannot race each other: // a session swap visible to concurrent ReadAsync/WriteAsync/DiscoverAsync callers // (which re-read Session inside _gate) must be atomic w.r.t. disposal and // re-arming. ISession? oldSession; lock (_probeLock) { oldSession = Session; // Rewire keep-alive before swapping the reference so a hot keep-alive can't // fire against the old session after we've already assigned the new one. if (oldSession is not null && _keepAliveHandler is not null) { try { oldSession.KeepAlive -= _keepAliveHandler; } catch { } } if (newSession is not null && _keepAliveHandler is not null) { newSession.KeepAlive += _keepAliveHandler; } // Detach-then-attach: the SDK may hand back the SAME session object, and -= on a handler that // is not attached is a no-op, so this never double-counts. if (oldSession is not null) DetachDiagnostics(oldSession); if (newSession is not null) AttachDiagnostics(newSession); Session = newSession; // Retire the handler that just finished. if (ReferenceEquals(_reconnectHandler, handler)) { _reconnectHandler.Dispose(); _reconnectHandler = null; } } if (newSession is not null) { // Reconnect succeeded. Rebuild the namespace map from the *new* session: the // remote server may have reordered its namespace table across the restart that // caused the drop (driver-specs.md §8). Stable nsu= references stored in the // address space re-resolve correctly against this fresh map. v3: re-ingest the // authored RawTags so RawPath resolution survives the reconnect too. _namespaceMap = NamespaceMap.FromSession(newSession, _options.RawTags); RecordSkippedTags(_namespaceMap); TransitionTo(HostState.Running); _health = new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null); _diag.Record(OpcUaClientDiagnostics.ReconnectSuccess); // #619: transitions during the outage were missed (a recreated subscription starts empty; a transferred // one may have overflowed). ConditionRefresh re-reports current states; unchanged ones dedup to nothing. if (!_alarmSubscriptions.IsEmpty) _ = Task.Run(() => RefreshAllAlarmSubscriptionsAsync(CancellationToken.None)); return; } // The reconnect handler gave up — its retry loop exhausted the 2-minute // maxReconnectPeriod and invoked the callback with a null Session. Without an // explicit Faulted signal the driver is permanently wedged: no session, no live // keep-alive to re-trigger OnKeepAlive, and the Core never learns it must offer an // operator reinitialize. Surface Faulted so the Core fans out Bad quality and // ReinitializeAsync becomes available, and arm a fresh reconnect attempt against // the last-known session for an always-on gateway rather than abandoning recovery // entirely. TransitionTo(HostState.Faulted); _diag.Record(OpcUaClientDiagnostics.ReconnectExhausted); _health = new DriverHealth( DriverState.Faulted, _health.LastSuccessfulRead, "OPC UA session reconnect exhausted its retry window without recovering. " + "The remote server is unreachable; reinitialize the driver once it is back."); if (oldSession is not null && !_disposed) TryRearmReconnect(handler, oldSession); } /// /// Arm a fresh reconnect attempt after a previous handler gave up. The OPC UA Client /// driver gateways an always-on remote server, so abandoning recovery permanently is /// the wrong default — a new keeps retrying so /// the driver self-heals when the remote returns, while the Faulted health set by the /// caller still lets an operator force a clean reinitialize in the meantime. /// private void TryRearmReconnect(SessionReconnectHandler exhausted, ISession lastSession) { SessionReconnectHandler handler; lock (_probeLock) { // Only re-arm if no other handler took over and we aren't shutting down. if (_disposed || (_reconnectHandler is not null && !ReferenceEquals(_reconnectHandler, exhausted))) return; handler = new SessionReconnectHandler(telemetry: null!, reconnectAbort: false, maxReconnectPeriod: (int)TimeSpan.FromMinutes(2).TotalMilliseconds); _reconnectHandler = handler; } try { handler.BeginReconnect( lastSession, (int)_options.ReconnectPeriod.TotalMilliseconds, OnReconnectComplete); } catch { // If the SDK refuses to re-arm (e.g. the last session is fully torn down), drop // the handler so a later operator ReinitializeAsync isn't blocked by a stale one. lock (_probeLock) { if (ReferenceEquals(_reconnectHandler, handler)) { _reconnectHandler.Dispose(); _reconnectHandler = null; } } } } private void TransitionTo(HostState newState) { HostState old; lock (_probeLock) { old = _hostState; if (old == newState) return; _hostState = newState; _hostStateChangedUtc = DateTime.UtcNow; } OnHostStatusChanged?.Invoke(this, new HostStatusChangedEventArgs(HostName, old, newState)); } /// /// Synchronous disposal. Cancels the reconnect handler and detaches the keep-alive /// 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). /// public void Dispose() { if (_disposed) return; _disposed = true; // Cancel any in-flight reconnect handler. SessionReconnectHandler? handlerToCancel; lock (_probeLock) { handlerToCancel = _reconnectHandler; _reconnectHandler = null; // Detach keep-alive and null Session so in-flight gated callers see null // after their next _gate.WaitAsync — they return BadCommunicationError cleanly. if (_keepAliveHandler is not null && Session is not null) { try { Session.KeepAlive -= _keepAliveHandler; } catch { } } _keepAliveHandler = null; Session = null; } try { handlerToCancel?.CancelReconnect(); } catch { } handlerToCancel?.Dispose(); // Detach the cert-validation handler registered during InitializeAsync so the // CertificateValidator (which may be process-shared) doesn't hold a reference to // a disposed driver. if (_certValidationHandler is not null && _certValidatorRef is not null) { try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { } _certValidationHandler = null; _certValidatorRef = null; } // Acquire the gate once so any in-flight gated operation (ReadAsync / WriteAsync / // DiscoverAsync) has definitely released before we dispose the gate. Without this // drain, a background read that calls _gate.Release() after Dispose throws // ObjectDisposedException. try { if (_gate.Wait(TimeSpan.FromSeconds(2))) _gate.Release(); } catch { /* timeout or already disposed — proceed */ } _gate.Dispose(); } /// Asynchronously disposes the driver and releases all associated resources. /// A task that represents the asynchronous operation. public async ValueTask DisposeAsync() { if (_disposed) return; _disposed = true; try { await ShutdownAsync(CancellationToken.None).ConfigureAwait(false); } catch { /* disposal is best-effort */ } // Detach the cert-validation handler. if (_certValidationHandler is not null && _certValidatorRef is not null) { try { _certValidatorRef.CertificateValidation -= _certValidationHandler; } catch { } _certValidationHandler = null; _certValidatorRef = null; } // Drain the gate before disposal so no in-flight _gate.Release() fires after // Dispose. try { await _gate.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); _gate.Release(); } catch { /* timeout or already disposed */ } _gate.Dispose(); } }