27bf4826d6
1. A per-ref delivery counter (bumped under the delivery lock by every publish path) is captured before the trailing read is issued; a ref delivered to since is skipped. Without source timestamps the read's sample could not be ordered against a notification that landed mid-read, and publishing it regressed the node. Test injects the notification inside the mocked read, with and without a read source time (red before). 2. Delivery-lock comment corrected (it serialises across subscriptions, which the SDK dispatches on separate workers); PublishCommsLoss now publishes under the lock too. 3. CancelSessionWork cancels without disposing, so a keep-alive thread reading the old token cannot hit ObjectDisposedException.
3356 lines
181 KiB
C#
3356 lines
181 KiB
C#
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;
|
||
|
||
/// <summary>
|
||
/// OPC UA Client (gateway) driver. Opens a <see cref="Session"/> against a remote OPC UA
|
||
/// server and re-exposes its address space through the local OtOpcUa server. PR 66 ships
|
||
/// the scaffold: <see cref="IDriver"/> only (connect / close / health). Browse, read,
|
||
/// write, subscribe, and probe land in PRs 67-69.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Builds its own <see cref="ApplicationConfiguration"/> rather than reusing
|
||
/// <c>Client.Shared</c> — 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).
|
||
/// </para>
|
||
/// <para>
|
||
/// <b>Session lifetime</b>: a single <see cref="Session"/> per driver instance.
|
||
/// Subscriptions multiplex onto that session. A lost session is reported (health Degraded,
|
||
/// subscribed values BadNoCommunication — Gitea #670) and recovered by the host's comms-loss
|
||
/// watchdog re-initialising the driver and re-subscribing; a session whose keep-alive comes
|
||
/// back on its own recovers in place.
|
||
/// </para>
|
||
/// </remarks>
|
||
public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWritable, ISubscribable, IHostConnectivityProbe, IAlarmSource, ISkippedTagReporter, IDriverConnectionLossSource, IDisposable, IAsyncDisposable
|
||
{
|
||
/// <summary>Authored tags whose TagConfig carried no usable <c>nodeId</c>, so there is no upstream node
|
||
/// to dial (Gitea #541). This driver's skip path was the quietest of the twelve: <c>NamespaceMap</c>
|
||
/// drops such an entry with a bare <c>continue</c> and not even a log line.</summary>
|
||
private readonly SkippedTagLedger _skippedTags = new();
|
||
|
||
/// <inheritdoc />
|
||
public SkippedTagReport? GetSkippedTags() => _skippedTags.Snapshot();
|
||
|
||
/// <summary>
|
||
/// Recomputes the skip report from the freshly-built map. Derived by re-asking the map to resolve
|
||
/// each authored RawPath rather than by instrumenting <c>NamespaceMap</c>, 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.
|
||
/// </summary>
|
||
/// <param name="map">The map just built from the live session.</param>
|
||
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<OpcUaClientDriver> _logger;
|
||
|
||
/// <summary>Initializes a new instance of the <see cref="OpcUaClientDriver"/> class.</summary>
|
||
/// <param name="options">Driver configuration.</param>
|
||
/// <param name="driverInstanceId">Stable logical ID from the config DB.</param>
|
||
/// <param name="logger">Optional logger; defaults to NullLogger when not supplied.</param>
|
||
/// <param name="secretResolver">
|
||
/// Optional shared secret resolver used to resolve <c>secret:</c>-prefixed
|
||
/// <see cref="OpcUaClientDriverOptions.Password"/> /
|
||
/// <see cref="OpcUaClientDriverOptions.UserCertificatePassword"/> at session-open.
|
||
/// Defaults to the <see cref="NullSecretResolver"/> null-object (which reports every
|
||
/// secret absent) for the test/parse-only path; the production factory threads the
|
||
/// DI-registered resolver. A <c>secret:</c> ref against the null-object fails closed.
|
||
/// </param>
|
||
public OpcUaClientDriver(OpcUaClientDriverOptions options, string driverInstanceId,
|
||
ILogger<OpcUaClientDriver>? logger = null, ISecretResolver? secretResolver = null)
|
||
{
|
||
_options = options;
|
||
_driverInstanceId = driverInstanceId;
|
||
_diag = new OpcUaClientDiagnostics(driverInstanceId);
|
||
_logger = logger ?? NullLogger<OpcUaClientDriver>.Instance;
|
||
_secretResolver = secretResolver ?? NullSecretResolver.Instance;
|
||
_sdkTelemetry = logger is null ? SdkTelemetry.Context : SdkTelemetry.ForLogger(logger);
|
||
PostRecoveryReconcileDelay = () => _options.Timeout + _options.KeepAliveInterval;
|
||
}
|
||
|
||
/// <summary>Mutable so <see cref="InitializeAsync"/> can adopt a re-parsed config on reinitialize
|
||
/// (#516). Only ever written on the init path, before any reader/session uses it.</summary>
|
||
private OpcUaClientDriverOptions _options;
|
||
private readonly ISecretResolver _secretResolver;
|
||
private readonly string _driverInstanceId;
|
||
// ---- IAlarmSource state ----
|
||
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteAlarmSubscription> _alarmSubscriptions = new();
|
||
|
||
/// <summary>#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.</summary>
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, AlarmAckTarget> _alarmConditionByRawPath =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>#619 review: the (Active, Acked) state last told to the host per reported condition id, from
|
||
/// which <see cref="AlarmTransitions"/> picks the Kind sequence that moves the host's projector to the
|
||
/// upstream event's state.</summary>
|
||
private readonly Dictionary<string, AlarmToldState> _alarmStateByRawPath = new(StringComparer.Ordinal);
|
||
|
||
/// <summary>#619 re-review: serialises the read-compute-emit over <see cref="_alarmStateByRawPath"/>. 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.</summary>
|
||
private readonly object _alarmStateLock = new();
|
||
|
||
/// <summary>#619 re-review: the ref set the last <see cref="SubscribeAlarmsAsync"/> carried. Survives
|
||
/// <see cref="ShutdownAsync"/> deliberately — see <see cref="BeginAlarmSubscription"/>.</summary>
|
||
private HashSet<string>? _lastAlarmRefSet;
|
||
|
||
/// <summary>Latched once the upstream server has refused ConditionRefresh as unsupported (logged once).</summary>
|
||
private int _conditionRefreshUnsupportedLogged;
|
||
private long _nextAlarmSubscriptionId;
|
||
|
||
/// <summary>Occurs when an alarm event is received from the remote OPC UA server.</summary>
|
||
public event EventHandler<AlarmEventArgs>? OnAlarmEvent;
|
||
|
||
// ---- ISubscribable + IHostConnectivityProbe state ----
|
||
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<long, RemoteSubscription> _subscriptions = new();
|
||
private long _nextSubscriptionId;
|
||
private readonly object _probeLock = new();
|
||
private HostState _hostState = HostState.Unknown;
|
||
private DateTime _hostStateChangedUtc = DateTime.UtcNow;
|
||
private KeepAliveEventHandler? _keepAliveHandler;
|
||
|
||
/// <summary>Occurs when a monitored item's data value changes on the remote OPC UA server.</summary>
|
||
public event EventHandler<DataChangeEventArgs>? OnDataChange;
|
||
/// <summary>Occurs when the host connectivity status of the remote OPC UA server changes.</summary>
|
||
public event EventHandler<HostStatusChangedEventArgs>? OnHostStatusChanged;
|
||
|
||
/// <inheritdoc />
|
||
/// <remarks>Gitea #676 — raised at most once per session, and only on a DEFINITIVE loss
|
||
/// (<see cref="IsDefinitiveSessionLoss"/>, or a tracked subscription the SERVER reported dropped — never the
|
||
/// publishing-stall heuristic). Re-armed when the session is released (re-initialise / shutdown). Known gap: the host
|
||
/// acts on it only while Connected, so a raise it receives before it has processed the successful initialise is
|
||
/// swallowed and that session falls back to the #527 watchdog.</remarks>
|
||
public event EventHandler<DriverConnectionLossEventArgs>? ConnectionLost;
|
||
|
||
// 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 const uint StatusBadNoCommunication = 0x80310000u;
|
||
private const uint StatusGood = 0x00000000u;
|
||
|
||
private readonly SemaphoreSlim _gate = new(1, 1);
|
||
|
||
/// <summary>Active OPC UA session. Null until <see cref="InitializeAsync"/> returns cleanly.</summary>
|
||
internal ISession? Session { get; private set; }
|
||
|
||
/// <summary>Per-connection gate. PRs 67+ serialize read/write/browse on this.</summary>
|
||
internal SemaphoreSlim Gate => _gate;
|
||
|
||
/// <summary>
|
||
/// Test-only seam to swap the active <see cref="Session"/> the way a re-initialise does at
|
||
/// runtime, so the stale-session race the read/history paths guard against (re-read Session
|
||
/// inside <c>_gate</c>, not before) can be exercised deterministically without a live connect.
|
||
/// Production code never calls this — <see cref="InitializeAsync"/> owns the real swap.
|
||
/// </summary>
|
||
/// <param name="session">The session instance to install, or <c>null</c> to clear it.</param>
|
||
internal void SetSessionForTest(ISession? session) => Session = session;
|
||
|
||
/// <summary>Test-only seam (#670): the most recent in-place recovery task.</summary>
|
||
internal Task RecoveryTaskForTest => _recoveryTask;
|
||
|
||
/// <summary>Test seam (#676): the trailing reconcile read scheduled by the last in-place recovery.</summary>
|
||
internal Task ReconcileTaskForTest => _reconcileTask;
|
||
|
||
/// <summary>Test-only seam (#670 review M1): raise the session's PublishError as the SDK publish loop does.</summary>
|
||
/// <param name="status">The publish error status.</param>
|
||
/// <param name="subscriptionId">The subscription id the error names (0 = none).</param>
|
||
internal void RaisePublishErrorForTest(StatusCode status, uint subscriptionId = 0) =>
|
||
HandlePublishError(Session, status, subscriptionId);
|
||
|
||
/// <summary>Test-only seam (#670): install a health snapshot as a completed connect would.</summary>
|
||
/// <param name="health">The health to install.</param>
|
||
internal void SetHealthForTest(DriverHealth health) => _health = health;
|
||
|
||
/// <summary>Test-only seam (#670): raise the session keep-alive callback as the SDK timer does.</summary>
|
||
/// <param name="status">The keep-alive status.</param>
|
||
/// <param name="sender">The raising session; defaults to the current one.</param>
|
||
internal void RaiseKeepAliveForTest(StatusCode status, ISession? sender = null) =>
|
||
OnKeepAlive(sender ?? Session!, new KeepAliveEventArgs(new ServiceResult(status), ServerState.Running, DateTime.UtcNow));
|
||
|
||
/// <summary>
|
||
/// Test-only seam (#670): register a data subscription over <paramref name="refs"/> as
|
||
/// <see cref="SubscribeAsync"/> would, without the wire create.
|
||
/// </summary>
|
||
/// <param name="refs">The RawPaths subscribed.</param>
|
||
/// <returns>The subscription handle.</returns>
|
||
internal ISubscriptionHandle AddSubscriptionForTest(IReadOnlyList<string> refs)
|
||
{
|
||
var id = Interlocked.Increment(ref _nextSubscriptionId);
|
||
var handle = new OpcUaSubscriptionHandle(id);
|
||
var subscription = new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"opcua-sub-{id}" });
|
||
var items = new List<MonitoredItemNotificationHandle>();
|
||
foreach (var r in refs)
|
||
{
|
||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = r }) { Handle = r };
|
||
MonitoredItemNotificationEventHandler h = (mi, args) => OnMonitoredItemNotification(handle, mi, args);
|
||
item.Notification += h;
|
||
items.Add(new MonitoredItemNotificationHandle(item, h));
|
||
}
|
||
_subscriptions[id] = new RemoteSubscription(subscription, handle, items);
|
||
return handle;
|
||
}
|
||
|
||
/// <summary>Test-only seam (#670): deliver one data notification as the SDK publish thread would.</summary>
|
||
/// <param name="handle">The subscription handle.</param>
|
||
/// <param name="fullRef">The RawPath.</param>
|
||
/// <param name="value">The delivered value.</param>
|
||
internal void DeliverNotificationForTest(ISubscriptionHandle handle, string fullRef, DataValue value) =>
|
||
PublishNotification((OpcUaSubscriptionHandle)handle, fullRef, value);
|
||
|
||
/// <summary>Test-only seam (#670): register an alarm subscription over <paramref name="refs"/> as
|
||
/// <see cref="SubscribeAlarmsAsync"/> would, without the wire create.</summary>
|
||
/// <param name="refs">The alarm RawPaths subscribed.</param>
|
||
internal void AddAlarmSubscriptionForTest(IReadOnlyList<string> refs)
|
||
{
|
||
var id = Interlocked.Increment(ref _nextAlarmSubscriptionId);
|
||
var handle = new OpcUaAlarmSubscriptionHandle(id);
|
||
BeginAlarmSubscription(refs);
|
||
var item = new MonitoredItem(telemetry: null!, new MonitoredItemOptions { DisplayName = "Server/Events" });
|
||
_alarmSubscriptions[id] = new RemoteAlarmSubscription(
|
||
new Subscription(telemetry: null!, new SubscriptionOptions { DisplayName = $"{AlarmSubscriptionNamePrefix}{id}" }),
|
||
handle, item, (_, _) => { });
|
||
}
|
||
|
||
/// <summary>
|
||
/// Test-only seam to install the <see cref="NamespaceMap"/> that connect builds at
|
||
/// runtime, so the v3 RawPath → upstream node id resolution can be exercised without a live
|
||
/// connect (which is what populates <see cref="_namespaceMap"/>). Production code never calls
|
||
/// this — <see cref="InitializeAsync"/> owns the real build.
|
||
/// </summary>
|
||
/// <param name="map">The namespace map to install, or <c>null</c> to clear it.</param>
|
||
internal void SetNamespaceMapForTest(NamespaceMap? map) => _namespaceMap = map;
|
||
|
||
/// <summary>Test-only view of the diagnostics counters (#276).</summary>
|
||
internal OpcUaClientDiagnostics DiagnosticsForTest => _diag;
|
||
|
||
private DriverHealth _health = new(DriverState.Unknown, null, null);
|
||
|
||
/// <summary>Per-instance event counters published as OTel metrics (#276).</summary>
|
||
private readonly OpcUaClientDiagnostics _diag;
|
||
|
||
private NotificationEventHandler? _notificationHandler;
|
||
private PublishErrorEventHandler? _publishErrorHandler;
|
||
private bool _disposed;
|
||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||
private string? _connectedEndpointUrl;
|
||
/// <summary>
|
||
/// Cert-validation delegate wired when <see cref="OpcUaClientDriverOptions.AutoAcceptCertificates"/>
|
||
/// is <c>true</c>. Stored so <see cref="Dispose"/> / <see cref="DisposeAsync"/> can
|
||
/// detach it from the (potentially process-shared) <see cref="CertificateValidator"/>
|
||
/// and avoid leaking the closure.
|
||
/// </summary>
|
||
private CertificateValidationEventHandler? _certValidationHandler;
|
||
/// <summary>The <see cref="CertificateValidator"/> that owns <see cref="_certValidationHandler"/>.</summary>
|
||
private CertificateValidator? _certValidatorRef;
|
||
/// <summary>
|
||
/// Gitea #670 — 1 while the CURRENT session's keep-alive is bad (an outage is in progress), else 0. The
|
||
/// 0→1 edge publishes the outage once (Bad per subscribed ref + a quality-only alarm annotation); the 1→0
|
||
/// edge on a good keep-alive recovers in place. Reset whenever the session is released, so a fresh session
|
||
/// reports its own loss. There is deliberately no in-driver reconnect any more: a session that never comes
|
||
/// back keeps health Degraded with <see cref="DriverHealth.LastSuccessfulRead"/> frozen, which is exactly what
|
||
/// the host's #527 comms-loss watchdog trips on (→ ShutdownAsync + InitializeAsync + re-subscribe).
|
||
/// </summary>
|
||
private int _sessionLost;
|
||
|
||
/// <summary>
|
||
/// #670 review M1 — 1 once a tracked subscription is known dead while the SESSION lives (it stopped
|
||
/// publishing past its keep-alive window, or the server said BadNoSubscription / BadSubscriptionIdInvalid —
|
||
/// e.g. an outage longer than the subscription lifetime but shorter than the session timeout). Sticky until
|
||
/// the session is released: health stays Degraded and NOTHING advances <see cref="DriverHealth.LastSuccessfulRead"/>
|
||
/// (good keep-alives would otherwise keep a driver whose data plane is dead looking alive forever), so the
|
||
/// host's #527 watchdog re-initialises the driver and the re-subscribe recreates the subscription.
|
||
/// </summary>
|
||
private int _subscriptionLost;
|
||
|
||
/// <summary>#670 review M1 — ticks of the first good keep-alive that saw a tracked subscription not publishing
|
||
/// (0 = none stalled). Only the keep-alive thread and the session release touch it.</summary>
|
||
private long _publishingStoppedSinceTicks;
|
||
|
||
/// <summary>The last in-place recovery started (#670). Test seam: lets a test await it deterministically.</summary>
|
||
private Task _recoveryTask = Task.CompletedTask;
|
||
|
||
/// <summary>#676 — 1 once <see cref="ConnectionLost"/> has been raised for the CURRENT session (once per session);
|
||
/// cleared when the session is released, so a re-initialised driver reports its next loss.</summary>
|
||
private int _connectionLostRaised;
|
||
|
||
/// <summary>#676 — the trailing reconcile read scheduled by the last in-place recovery. Test seam.</summary>
|
||
private Task _reconcileTask = Task.CompletedTask;
|
||
|
||
/// <summary>#676 — late notifications the replay guard dropped since it was armed, and the refs they were for;
|
||
/// summarised in ONE Information line when the window disarms. Guarded by <see cref="_replayGuardLock"/>.</summary>
|
||
private int _replayDrops;
|
||
private readonly HashSet<string> _replayDropRefs = new(StringComparer.Ordinal);
|
||
|
||
/// <summary>
|
||
/// Test seam (#676): how long after an in-place recovery the trailing reconcile read runs. Production: the
|
||
/// operation timeout plus one keep-alive interval — by then every request that was in flight across the stall
|
||
/// (including the Publish responses whose notifications the SDK can lose) has completed or timed out.
|
||
/// </summary>
|
||
internal Func<TimeSpan> PostRecoveryReconcileDelay { get; set; }
|
||
|
||
/// <summary>Test seam (#670 review M1): whether a subscription has stopped publishing. Production asks the SDK.</summary>
|
||
internal Func<Subscription, bool> IsPublishingStopped { get; set; } = static s => s.Created && s.PublishingStopped;
|
||
|
||
/// <summary>Test seam (#670 review M1): how long a subscription may keep reporting stopped (measured on the
|
||
/// driver's keep-alive cadence) before it is declared dead. Production: one more subscription keep-alive window
|
||
/// plus 5 s, on top of the window the SDK's own <c>PublishingStopped</c> already waits.</summary>
|
||
internal Func<Subscription, TimeSpan> PublishingStallGrace { get; set; } = static s =>
|
||
TimeSpan.FromMilliseconds(Math.Max(s.CurrentPublishingInterval, 1) * (s.CurrentKeepAliveCount + 1d))
|
||
+ TimeSpan.FromSeconds(5);
|
||
|
||
/// <summary>#670 — the last value delivered per subscribed RawPath, so an outage can publish
|
||
/// <c>BadNoCommunication</c> with the value retained (the same shape the host's own comms-loss fan uses).</summary>
|
||
/// <summary>
|
||
/// #670 follow-up F2 — per RawPath, the source time (ticks) of the last Good value ACCEPTED from upstream (a
|
||
/// notification, or the recovery read). Last, not max: in steady state it simply follows the upstream clock,
|
||
/// so a clock that steps backwards is tracked rather than remembered as a high-water mark. It only seeds the
|
||
/// replay guard below; nothing is ever dropped against it outside that guard's window.
|
||
/// </summary>
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _lastGoodSourceTicks =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>
|
||
/// #670 follow-up F2 — the REPLAY guard. Armed only by a same-session recovery (keep-alive bad→good), when the
|
||
/// SDK was observed delivering a missed notification seconds late with an older source time. Per ref it holds a
|
||
/// HIGH-WATER MARK: seeded with the last Good upstream source time at arming, raised by the recovery read, and
|
||
/// advanced by EVERY notification accepted while the window is open. A notification whose source time is at or
|
||
/// below the mark is dropped — equal included, because OPC UA moves the source timestamp on every value or
|
||
/// status change, so an equal one is a replay of what was already accepted. The guard holds for the WHOLE
|
||
/// window: the F2 live re-gate saw fresh notifications ~1 s after recovery and the SDK's late replay 4–6 s
|
||
/// later, so the original "disarm a ref on its first in-order notification" let the replay through. A
|
||
/// notification with no source time passes and leaves the mark alone. The whole guard expires at
|
||
/// <see cref="_replayGuardUntilTicks"/>; outside it every notification is accepted whatever its timestamp — a
|
||
/// global "never go backwards" rule would freeze a node for as long as an upstream clock that stepped back
|
||
/// (NTP correction, a PLC with a reset clock) takes to catch up.
|
||
/// </summary>
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _replayGuard =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>Clock ticks at which the replay guard expires (0 = not armed).</summary>
|
||
private long _replayGuardUntilTicks;
|
||
|
||
/// <summary>Serialises arming against expiry, so an expiry racing a re-arm cannot wipe the new seeds.</summary>
|
||
private readonly object _replayGuardLock = new();
|
||
|
||
/// <summary>
|
||
/// Review of 3b88056f — serialises "decide whether this upstream value may be published" with "publish it" across
|
||
/// the three paths that publish upstream data: live notifications, the recovery read and the trailing reconcile
|
||
/// read. Without it a path could pass its newer-than check, lose the CPU to another path publishing a NEWER value,
|
||
/// and then deliver its older one last — regressing the node. It also covers the comms-loss Bad fan
|
||
/// (<see cref="PublishCommsLoss"/>). One driver-wide lock rather than one per ref: it also serialises delivery
|
||
/// ACROSS subscriptions — with several subscriptions the SDK dispatches each subscription's messages on its own
|
||
/// worker, so their notifications no longer run in parallel. That is the cost, and it is small: each critical
|
||
/// section is a dictionary update plus a subscriber call that only <c>Tell</c>s an actor (tens of nanoseconds to
|
||
/// a few microseconds), nothing awaits under it, and per-subscription message order is unaffected. Because
|
||
/// subscribers are invoked under it, a subscriber must never block on, or call back into, this driver.
|
||
/// </summary>
|
||
private readonly Lock _deliveryLock = new();
|
||
|
||
/// <summary>
|
||
/// Re-review of 30a9d5f0 — per RawPath, a counter bumped (under <see cref="_deliveryLock"/>) every time a value is
|
||
/// delivered for it. The trailing re-read captures it before issuing its read and skips any ref delivered to since:
|
||
/// without source timestamps the read's value cannot be ordered against a notification that landed while the read
|
||
/// was in flight, and publishing it would regress the node to the value the read sampled.
|
||
/// </summary>
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, long> _deliverySeq =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>Review of 3b88056f — cancels the post-recovery background work (trailing read, replay-guard disarm timer)
|
||
/// of the current session; cancelled and replaced whenever the session is released, and on dispose.</summary>
|
||
private CancellationTokenSource _sessionWorkCts = new();
|
||
|
||
/// <summary>Test seam (review of 3b88056f): invoked inside the trailing read's critical section, after it decided
|
||
/// to publish a ref and before it does — where a racing notification would otherwise overtake it.</summary>
|
||
internal Action<string>? BeforeReconcilePublishForTest { get; set; }
|
||
|
||
/// <summary>Floor of the replay-guard window; the live gate saw the late replay 4–5 s after recovery.</summary>
|
||
private static readonly TimeSpan ReplayGuardMinimum = TimeSpan.FromSeconds(30);
|
||
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, DataValueSnapshot> _lastValues =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>#670 — refs published Bad by the current outage and not yet re-published Good. A notification
|
||
/// clears its ref (fresh data beats the recovery read); the recovery read publishes only what is left.</summary>
|
||
private readonly System.Collections.Concurrent.ConcurrentDictionary<string, byte> _awaitingRecovery =
|
||
new(StringComparer.Ordinal);
|
||
|
||
/// <summary>Ticks of the last <see cref="DriverHealth.LastSuccessfulRead"/> advance from a notification —
|
||
/// throttles the per-notification health write to at most one per second.</summary>
|
||
private long _lastEvidenceStampTicks;
|
||
|
||
/// <summary>Clock for liveness stamps. Test seam (#670); production uses <see cref="TimeProvider.System"/>.</summary>
|
||
internal TimeProvider Clock { get; set; } = TimeProvider.System;
|
||
|
||
/// <summary>
|
||
/// SDK telemetry for this instance's sessions: SDK warnings (keep-alive / publish / channel errors) go to the
|
||
/// driver's own logger (#670 — the shared <see cref="SdkTelemetry.Context"/> has no provider, which made every
|
||
/// SDK-side session error invisible).
|
||
/// </summary>
|
||
private readonly ITelemetryContext _sdkTelemetry;
|
||
|
||
/// <summary>
|
||
/// Bidirectional namespace map built at connect time from <c>session.NamespaceUris</c>.
|
||
/// Stored NodeIds embed the server-stable namespace <b>URI</b> rather than the
|
||
/// session-relative <c>ns=N</c> 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 <see cref="InitializeAsync"/> returns cleanly.
|
||
/// </summary>
|
||
private NamespaceMap? _namespaceMap;
|
||
|
||
/// <inheritdoc />
|
||
public string DriverInstanceId => _driverInstanceId;
|
||
/// <inheritdoc />
|
||
public string DriverType => "OpcUaClient";
|
||
|
||
/// <summary>True when the supplied DriverConfig JSON carries a real body. The bootstrapper always
|
||
/// passes a populated document; some unit tests pass <c>"{}"</c> or an empty string to exercise
|
||
/// lifecycle shape without a config — those keep the constructor-supplied options.</summary>
|
||
private static bool HasConfigBody(string? driverConfigJson)
|
||
{
|
||
if (string.IsNullOrWhiteSpace(driverConfigJson)) return false;
|
||
var trimmed = driverConfigJson.Trim();
|
||
return trimmed is not "{}" and not "[]";
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task InitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
// #630: InitializeAsync is reachable on a live driver with no teardown first (the IDriver contract
|
||
// allows it, and a reconnect retry can overlap a running attempt); `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<string> discovered = [];
|
||
if (!string.IsNullOrWhiteSpace(_options.DiscoveryUrl))
|
||
{
|
||
IReadOnlyList<DiscoveredEndpoint> 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<string>(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 + health (#670). OPC UA keep-alives
|
||
// are authoritative for session liveness: the SDK pings on KeepAliveInterval. A good one is
|
||
// liveness evidence (advances LastSuccessfulRead); a bad one degrades health and publishes the
|
||
// outage. Recovery from a session that does not come back is the host's comms-loss watchdog
|
||
// (ShutdownAsync + InitializeAsync + re-subscribe) — there is no in-driver reconnect handler.
|
||
_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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a minimal in-memory <see cref="ApplicationConfiguration"/>. Certificates live
|
||
/// under the OS user profile — on Windows that's <c>%LocalAppData%\OtOpcUa\pki</c>
|
||
/// — so multiple driver instances in the same OtOpcUa server process share one
|
||
/// certificate store without extra config.
|
||
/// </summary>
|
||
private async Task<ApplicationConfiguration> BuildApplicationConfigurationAsync(CancellationToken ct)
|
||
{
|
||
// The ITelemetryContext overload is the non-obsolete ctor; SDK warnings reach the driver's
|
||
// logger (#670) when one was supplied.
|
||
var app = new ApplicationInstance(_sdkTelemetry)
|
||
{
|
||
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;
|
||
}
|
||
|
||
/// <summary>True for the two hard "this certificate (or its issuer) is revoked" results (#277).</summary>
|
||
/// <param name="status">The SDK's certificate-validation status.</param>
|
||
/// <returns><c>true</c> when the status is Revoked / IssuerRevoked.</returns>
|
||
internal static bool IsRevocationFailure(StatusCode status) =>
|
||
status.Code == StatusCodes.BadCertificateRevoked || status.Code == StatusCodes.BadCertificateIssuerRevoked;
|
||
|
||
/// <summary>
|
||
/// Resolve the ordered failover candidate list. <c>EndpointUrls</c> wins when
|
||
/// non-empty; otherwise fall back to <c>EndpointUrl</c> as a single-URL shortcut so
|
||
/// existing single-endpoint configs keep working without migration.
|
||
/// </summary>
|
||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||
internal static IReadOnlyList<string> ResolveEndpointCandidates(OpcUaClientDriverOptions opts) =>
|
||
ResolveEndpointCandidates(opts, []);
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
/// <param name="opts">Driver options containing endpoint configuration.</param>
|
||
/// <param name="discovered">URLs resolved via <see cref="DiscoverEndpointsAsync"/> (may be empty).</param>
|
||
/// <returns>The ordered list of endpoint URLs to try.</returns>
|
||
internal static IReadOnlyList<string> ResolveEndpointCandidates(
|
||
OpcUaClientDriverOptions opts, IReadOnlyList<string> discovered)
|
||
{
|
||
IReadOnlyList<string> staticList = opts.EndpointUrls is { Count: > 0 } ? opts.EndpointUrls : [opts.EndpointUrl];
|
||
if (discovered.Count == 0) return staticList;
|
||
|
||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||
var merged = new List<string>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// <c>FindServers</c> on <paramref name="discoveryUrl"/> (an LDS, or the server itself), then
|
||
/// <c>GetEndpoints</c> 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.
|
||
/// </summary>
|
||
/// <param name="appConfig">Application configuration (client identity for the discovery channel).</param>
|
||
/// <param name="discoveryUrl">The LDS / server discovery URL.</param>
|
||
/// <param name="policy">Required security policy.</param>
|
||
/// <param name="mode">Required security mode.</param>
|
||
/// <param name="ct">Cancellation token.</param>
|
||
/// <returns>Matching endpoint URLs in discovery order, de-duplicated.</returns>
|
||
internal static async Task<IReadOnlyList<DiscoveredEndpoint>> 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<DiscoveredEndpoint>();
|
||
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;
|
||
}
|
||
|
||
/// <summary>One endpoint URL found by discovery, with the ApplicationUri of the server advertising it.</summary>
|
||
/// <param name="ApplicationUri">The advertising server's ApplicationUri (empty when it published none).</param>
|
||
/// <param name="Url">The endpoint URL.</param>
|
||
internal readonly record struct DiscoveredEndpoint(string ApplicationUri, string Url);
|
||
|
||
/// <summary>
|
||
/// 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 <paramref name="serverUri"/> 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.
|
||
/// </summary>
|
||
/// <param name="found">Matching endpoints from every server the LDS listed.</param>
|
||
/// <param name="serverUri">Optional <c>DiscoveryServerUri</c> filter.</param>
|
||
/// <returns>The usable URLs, de-duplicated, in discovery order (possibly empty).</returns>
|
||
/// <exception cref="InvalidOperationException">Several servers discovered and no filter configured.</exception>
|
||
internal static IReadOnlyList<string> SelectDiscoveredUrls(
|
||
IReadOnlyList<DiscoveredEndpoint> found, string? serverUri)
|
||
{
|
||
IEnumerable<DiscoveredEndpoint> 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) ? "<no ApplicationUri>" : u))}). " +
|
||
"Set DiscoveryServerUri to the ApplicationUri of the server this driver should use.");
|
||
}
|
||
return chosen.Select(e => e.Url).Distinct(StringComparer.OrdinalIgnoreCase).ToList();
|
||
}
|
||
|
||
/// <summary>The endpoint URLs in <paramref name="endpoints"/> matching the wanted policy URI + mode.</summary>
|
||
/// <param name="endpoints">Endpoints advertised by a server.</param>
|
||
/// <param name="wantedPolicyUri">Required <c>SecurityPolicyUri</c>.</param>
|
||
/// <param name="wantedMode">Required message security mode.</param>
|
||
/// <returns>Matching <c>EndpointUrl</c>s, in advertised order.</returns>
|
||
internal static IEnumerable<string> FilterEndpointUrls(
|
||
IEnumerable<EndpointDescription> 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)),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Range checks for the subscription-tuning and discovery options (#273/#278). Throws at
|
||
/// <see cref="InitializeAsync"/> so a bad config fails like the namespace-kind check does rather
|
||
/// than surfacing later as a server-side <c>BadInvalidArgument</c> on every subscribe.
|
||
/// </summary>
|
||
/// <param name="opts">The options to validate.</param>
|
||
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.");
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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).
|
||
/// </summary>
|
||
/// <param name="opts">Driver options.</param>
|
||
/// <param name="requested">The per-call interval from <c>SubscribeAsync</c>.</param>
|
||
/// <returns>The publishing interval in milliseconds.</returns>
|
||
internal static int ResolvePublishingIntervalMs(OpcUaClientDriverOptions opts, TimeSpan requested)
|
||
{
|
||
var effective = opts.SubscriptionPublishingInterval ?? requested;
|
||
return effective < TimeSpan.FromMilliseconds(50) ? 50 : (int)effective.TotalMilliseconds;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build the user-identity token from the driver options. Split out of
|
||
/// <see cref="InitializeAsync"/> 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.
|
||
/// </summary>
|
||
/// <param name="options">Driver options containing authentication configuration.</param>
|
||
/// <returns>The constructed <see cref="UserIdentity"/> for session activation.</returns>
|
||
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()),
|
||
};
|
||
|
||
/// <summary>
|
||
/// Open a session against a single endpoint URL. Bounded by
|
||
/// <see cref="OpcUaClientDriverOptions.PerEndpointConnectTimeout"/> so the failover
|
||
/// sweep doesn't spend its full budget on one dead server. Moved out of
|
||
/// <see cref="InitializeAsync"/> so the failover loop body stays readable.
|
||
/// </summary>
|
||
private async Task<ISession> 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).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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||
/// only honours a boolean "use security" flag; we need policy-aware matching so an
|
||
/// operator asking for <c>Basic256Sha256</c> against a server that also offers
|
||
/// <c>Basic128Rsa15</c> doesn't silently end up on the weaker cipher.
|
||
/// </summary>
|
||
private static async Task<EndpointDescription> 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}");
|
||
}
|
||
// #676: connect where the operator pointed us, not where the server says it lives.
|
||
match.EndpointUrl = RewriteEndpointUrl(match.EndpointUrl, endpointUrl);
|
||
return match;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Gitea #676 — the address to open the session on. A server reports its endpoints with ITS view of its own
|
||
/// address; behind NAT, a port map, a container port publish or a proxy that address is unreachable from here
|
||
/// (or bypasses the proxy). When the advertised URL's scheme matches the configured one, the configured host
|
||
/// AND port (when it names one) replace the advertised ones and the advertised path is kept — the SDK's own
|
||
/// <c>CoreClientUtils.SelectEndpoint</c> rule, which this driver's policy-aware selection bypassed. A different
|
||
/// scheme is a different transport and is left alone; an unparseable advertised URL falls back to the
|
||
/// configured one.
|
||
/// </summary>
|
||
/// <param name="advertisedUrl">The <c>EndpointUrl</c> the server returned from GetEndpoints.</param>
|
||
/// <param name="configuredUrl">The URL the driver was configured with (the one GetEndpoints was sent to).</param>
|
||
/// <returns>The URL to connect the session to.</returns>
|
||
internal static string RewriteEndpointUrl(string? advertisedUrl, string configuredUrl)
|
||
{
|
||
if (!Uri.TryCreate(configuredUrl, UriKind.Absolute, out var configured)) return advertisedUrl ?? configuredUrl;
|
||
if (string.IsNullOrWhiteSpace(advertisedUrl)
|
||
|| !Uri.TryCreate(advertisedUrl, UriKind.Absolute, out var advertised))
|
||
return configuredUrl;
|
||
if (!string.Equals(advertised.Scheme, configured.Scheme, StringComparison.OrdinalIgnoreCase)) return advertisedUrl;
|
||
// A port-less configured URL (Uri.Port == -1 for opc.tcp) keeps the advertised port — copying -1 would drop it
|
||
// and the SDK would fall back to 4840 (review of 3b88056f; same fix as the client's EndpointSelector).
|
||
var builder = new UriBuilder(advertised)
|
||
{
|
||
Host = configured.DnsSafeHost,
|
||
Port = configured.Port > 0 ? configured.Port : advertised.Port,
|
||
};
|
||
return builder.Uri.ToString();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Build a <see cref="UserIdentity"/> carrying a client user-authentication
|
||
/// certificate loaded from <see cref="OpcUaClientDriverOptions.UserCertificatePath"/>.
|
||
/// 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).
|
||
/// </summary>
|
||
/// <param name="options">Driver options containing certificate configuration.</param>
|
||
/// <returns>The certificate-backed <see cref="UserIdentity"/>.</returns>
|
||
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);
|
||
}
|
||
|
||
/// <summary>Convert a driver <see cref="OpcUaSecurityPolicy"/> to the OPC UA policy URI.</summary>
|
||
/// <param name="policy">The driver security policy to map.</param>
|
||
/// <returns>The corresponding OPC UA security policy URI.</returns>
|
||
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)";
|
||
|
||
/// <inheritdoc />
|
||
public async Task ReinitializeAsync(string driverConfigJson, CancellationToken cancellationToken)
|
||
{
|
||
await ShutdownAsync(cancellationToken).ConfigureAwait(false);
|
||
await InitializeAsync(driverConfigJson, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Detaches the keep-alive handler and closes + disposes the current session. The session part of
|
||
/// <see cref="ShutdownAsync"/>; also run at the top of <see cref="InitializeAsync"/> so a re-initialise on a
|
||
/// live driver (#630) does not orphan the previous session. Subscriptions are NOT touched here. Clears the
|
||
/// #670 outage latch, so the next session reports its own loss.
|
||
/// </summary>
|
||
private async Task ReleaseSessionAsync(CancellationToken cancellationToken)
|
||
{
|
||
// Take the session reference under _probeLock and clear Session to null so any concurrent caller that
|
||
// checks inside _gate sees null immediately after release begins.
|
||
ISession? sessionToClose;
|
||
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;
|
||
}
|
||
|
||
// #670: bound the close — against a dead upstream (the comms-loss watchdog's whole use case) an unbounded
|
||
// CloseSession would sit out the full operation timeout on every reconnect attempt — and skip it outright
|
||
// when the keep-alive already says the session is unreachable (review L3).
|
||
var sessionKnownLost = Volatile.Read(ref _sessionLost) == 1;
|
||
try
|
||
{
|
||
if (sessionToClose is Session s && !sessionKnownLost)
|
||
{
|
||
using var closeCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
closeCts.CancelAfter(SessionCloseTimeout);
|
||
await s.CloseAsync(closeCts.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
catch { /* best-effort */ }
|
||
try { sessionToClose?.Dispose(); } catch { }
|
||
Interlocked.Exchange(ref _sessionLost, 0);
|
||
Interlocked.Exchange(ref _subscriptionLost, 0);
|
||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||
// #676: re-arm the definitive-loss signal for the next session.
|
||
Interlocked.Exchange(ref _connectionLostRaised, 0);
|
||
// Review of 3b88056f: the released session's post-recovery timers must not outlive it.
|
||
CancelSessionWork();
|
||
}
|
||
|
||
/// <summary>Cancel the current session's background work and start a fresh token for the next one.</summary>
|
||
private void CancelSessionWork()
|
||
{
|
||
// Cancelled but deliberately NOT disposed (re-review of 30a9d5f0): the keep-alive thread may be reading the old
|
||
// source's Token concurrently, and a disposed source throws ObjectDisposedException there. Nothing here is
|
||
// timer-backed (no CancelAfter), so an undisposed source holds no unmanaged resource.
|
||
var old = Interlocked.Exchange(ref _sessionWorkCts, new CancellationTokenSource());
|
||
old.Cancel();
|
||
}
|
||
|
||
/// <summary>Upper bound on the best-effort wire calls a teardown makes against a possibly-dead upstream.</summary>
|
||
private static readonly TimeSpan SessionCloseTimeout = TimeSpan.FromSeconds(5);
|
||
|
||
/// <inheritdoc />
|
||
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.
|
||
// #670: against a session already known lost (bad keep-alive) the wire deletes cannot succeed and would each
|
||
// sit out the operation timeout — skip them; disposing the session releases the local objects.
|
||
var sessionAlive = Volatile.Read(ref _sessionLost) == 0;
|
||
foreach (var rs in _subscriptions.Values)
|
||
{
|
||
DetachNotificationHandlers(rs.ItemHandlers);
|
||
if (sessionAlive) await DeleteSubscriptionBestEffortAsync(rs.Subscription, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
_subscriptions.Clear();
|
||
|
||
foreach (var ras in _alarmSubscriptions.Values)
|
||
{
|
||
try { ras.EventItem.Notification -= ras.Handler; }
|
||
catch { /* best-effort */ }
|
||
if (sessionAlive) await DeleteSubscriptionBestEffortAsync(ras.Subscription, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
_alarmSubscriptions.Clear();
|
||
// The host re-subscribes after a reinit and the new subscriptions deliver initial values; nothing of the
|
||
// previous connection's value bookkeeping applies to them.
|
||
_lastValues.Clear();
|
||
_lastGoodSourceTicks.Clear();
|
||
lock (_replayGuardLock)
|
||
{
|
||
_replayGuard.Clear();
|
||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||
_replayDrops = 0;
|
||
_replayDropRefs.Clear();
|
||
}
|
||
_awaitingRecovery.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);
|
||
}
|
||
|
||
/// <summary>Best-effort, bounded delete of one subscription (shutdown path).</summary>
|
||
private static async Task DeleteSubscriptionBestEffortAsync(Subscription subscription, CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
cts.CancelAfter(SessionCloseTimeout);
|
||
await subscription.DeleteAsync(silent: true, cts.Token).ConfigureAwait(false);
|
||
}
|
||
catch { /* best-effort */ }
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public DriverHealth GetHealth() => _health;
|
||
|
||
|
||
// ---- IReadable ----
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<DataValueSnapshot>> ReadAsync(
|
||
IReadOnlyList<string> 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, _) = await ReadCoreAsync(fullReferences, cancellationToken).ConfigureAwait(false);
|
||
return results;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The read itself, also used by the #670 in-place recovery. <c>TransportOk</c> is false when the wire read
|
||
/// did not happen or threw (every result is then a local <c>BadCommunicationError</c>, not upstream data).
|
||
/// <paramref name="expectedSession"/>, when given, must still be the current session inside the gate — a
|
||
/// recovery overtaken by a re-initialise neither reads on, nor writes the health of, the new session.
|
||
/// </summary>
|
||
private async Task<(DataValueSnapshot[] Results, bool TransportOk)> ReadCoreAsync(
|
||
IReadOnlyList<string> fullReferences, CancellationToken cancellationToken, ISession? expectedSession = null)
|
||
{
|
||
var results = new DataValueSnapshot[fullReferences.Count];
|
||
var now = Clock.GetUtcNow().UtcDateTime;
|
||
|
||
await _gate.WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
try
|
||
{
|
||
// Re-read Session inside the critical section: if a reconnect completed while we
|
||
// were blocked on _gate, a re-initialise 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 || (expectedSession is not null && !ReferenceEquals(session, expectedSession)))
|
||
{
|
||
for (var i = 0; i < fullReferences.Count; i++)
|
||
results[i] = new DataValueSnapshot(null, StatusBadCommunicationError, null, now);
|
||
return (results, false);
|
||
}
|
||
|
||
// 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<int>(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, true);
|
||
|
||
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);
|
||
return (results, false);
|
||
}
|
||
}
|
||
finally { _gate.Release(); }
|
||
return (results, true);
|
||
}
|
||
|
||
// ---- IWritable ----
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IReadOnlyList<WriteResult>> WriteAsync(
|
||
IReadOnlyList<Core.Abstractions.WriteRequest> 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<int>(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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// (v3) Resolve a tag's <b>RawPath</b> reference to a live <see cref="NodeId"/> against the
|
||
/// <paramref name="session"/>'s <i>current</i> namespace table. Two stages:
|
||
/// <list type="number">
|
||
/// <item>Map the RawPath (the wire reference handed to the driver under v3) to its
|
||
/// upstream node id string via the authored <c>RawTags</c> table on
|
||
/// <see cref="_namespaceMap"/> (<see cref="NamespaceMap.TryResolve(string, out string)"/>).</item>
|
||
/// <item>Re-bind that node id string — a server-stable <c>nsu=<uri>;…</c> form or a plain
|
||
/// <c>ns=2;s=…</c> serialized NodeId — against the current session so a remote
|
||
/// namespace-table reorder across a restart is transparently corrected (driver-specs.md §8).</item>
|
||
/// </list>
|
||
/// An unknown/un-authored RawPath, a missing map, or a node id that won't parse returns false;
|
||
/// the driver surfaces that as <see cref="StatusBadNodeIdInvalid"/> without a wire round-trip.
|
||
/// </summary>
|
||
/// <param name="session">The OPC UA session to resolve the node ID against.</param>
|
||
/// <param name="rawPath">The tag's RawPath reference to resolve.</param>
|
||
/// <param name="nodeId">The resolved live node ID when successful.</param>
|
||
/// <returns><c>true</c> if the RawPath resolved to a live node ID; otherwise <c>false</c>.</returns>
|
||
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);
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="DiscoverAsync"/> runs).
|
||
/// </summary>
|
||
private string StableReference(NodeId nodeId) =>
|
||
_namespaceMap?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty;
|
||
|
||
private ISession RequireSession() =>
|
||
Session ?? throw new InvalidOperationException("OpcUaClientDriver not initialized");
|
||
|
||
// ---- ITagDiscovery ----
|
||
|
||
/// <inheritdoc />
|
||
// 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;
|
||
|
||
/// <inheritdoc />
|
||
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<NodeId>();
|
||
var discovered = 0;
|
||
var pendingVariables = new List<PendingVariable>();
|
||
|
||
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(); }
|
||
}
|
||
|
||
/// <summary>
|
||
/// A variable collected during the browse pass, waiting for attribute enrichment
|
||
/// before being registered on the address-space builder.
|
||
/// </summary>
|
||
private readonly record struct PendingVariable(
|
||
IAddressSpaceBuilder ParentFolder,
|
||
string BrowseName,
|
||
string DisplayName,
|
||
NodeId NodeId);
|
||
|
||
private async Task BrowseRecursiveAsync(
|
||
ISession session, NodeId node, IAddressSpaceBuilder folder, HashSet<NodeId> visited,
|
||
int depth, Func<int> discovered, Action increment,
|
||
List<PendingVariable> 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();
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 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 <see cref="DriverAttributeInfo"/>.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// <para>
|
||
/// Attributes read: <c>DataType</c> (NodeId of the value type),
|
||
/// <c>ValueRank</c> (-1 = scalar, 1 = array), <c>UserAccessLevel</c> (the
|
||
/// effective access mask for our session — more accurate than AccessLevel which
|
||
/// is the server-side configured mask before user filtering), and
|
||
/// <c>Historizing</c> (server flags whether historian data is available).
|
||
/// </para>
|
||
/// <para>
|
||
/// 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.
|
||
/// </para>
|
||
/// </remarks>
|
||
private async Task EnrichAndRegisterVariablesAsync(
|
||
ISession session, IReadOnlyList<PendingVariable> 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));
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Map an upstream OPC UA built-in DataType NodeId (via <c>DataTypeIds.*</c>) to a
|
||
/// <see cref="DriverDataType"/>. Unknown / custom types fall through to
|
||
/// <see cref="DriverDataType.String"/> 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.
|
||
/// </summary>
|
||
/// <param name="dataType">The OPC UA data type NodeId to map.</param>
|
||
/// <returns>The mapped <see cref="DriverDataType"/>.</returns>
|
||
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;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Map an OPC UA AccessLevel/UserAccessLevel attribute value (<c>AccessLevels</c>
|
||
/// bitmask) to a <see cref="SecurityClassification"/> the local node-manager's ACL
|
||
/// layer can gate writes off. CurrentWrite-capable variables surface as
|
||
/// <see cref="SecurityClassification.Operate"/>; read-only as <see cref="SecurityClassification.ViewOnly"/>.
|
||
/// </summary>
|
||
/// <param name="accessLevel">The OPC UA access level bitmask.</param>
|
||
/// <returns>The mapped security classification.</returns>
|
||
internal static SecurityClassification MapAccessLevelToSecurityClass(byte accessLevel)
|
||
{
|
||
const byte CurrentWrite = 2; // AccessLevels.CurrentWrite = 0x02
|
||
return (accessLevel & CurrentWrite) != 0
|
||
? SecurityClassification.Operate
|
||
: SecurityClassification.ViewOnly;
|
||
}
|
||
|
||
// ---- ISubscribable ----
|
||
|
||
/// <inheritdoc />
|
||
public async Task<ISubscriptionHandle> SubscribeAsync(
|
||
IReadOnlyList<string> 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 a re-initialise.
|
||
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<MonitoredItemNotificationHandle>();
|
||
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;
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
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<MonitoredItemNotificationHandle> 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;
|
||
PublishNotification(handle, RefOf(item), dv);
|
||
}
|
||
|
||
private static string RefOf(MonitoredItem item) => (item.Handle as string) ?? item.DisplayName ?? string.Empty;
|
||
|
||
private void PublishNotification(OpcUaSubscriptionHandle handle, string fullRef, DataValue dv)
|
||
{
|
||
_diag.Record(OpcUaClientDiagnostics.DataChange);
|
||
if (StatusCode.IsBad(dv.StatusCode)) _diag.Record(OpcUaClientDiagnostics.BadStatusValue);
|
||
// #670: a delivered notification is liveness evidence — the subscription path never calls ReadAsync, so
|
||
// without this a busy subscription-only driver's LastSuccessfulRead stayed at its connect time.
|
||
RecordLivenessEvidence();
|
||
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);
|
||
// Review of 3b88056f: the replay check and the publish are one critical section shared with the recovery and
|
||
// trailing reads, so an older value can never be delivered after a newer one (see _deliveryLock).
|
||
lock (_deliveryLock)
|
||
{
|
||
// F2: inside the post-recovery replay window only, a late OLDER notification must not regress the node.
|
||
if (IsLateReplay(fullRef, snapshot.SourceTimestampUtc, out var newest))
|
||
{
|
||
_diag.Record(OpcUaClientDiagnostics.StaleNotificationDropped);
|
||
// #676: the OTel counter and the Debug line below are invisible on a default-configured node; the
|
||
// per-recovery Information summary logged at disarm is what makes the guard observable.
|
||
lock (_replayGuardLock)
|
||
{
|
||
_replayDrops++;
|
||
_replayDropRefs.Add(fullRef);
|
||
}
|
||
_logger.LogDebug(
|
||
"OpcUaClientDriver '{DriverInstanceId}': dropped a late replayed notification for {Ref} after a recovery " +
|
||
"(source {Source:O} is not newer than {Newest:O}).", _driverInstanceId, fullRef, snapshot.SourceTimestampUtc,
|
||
new DateTime(newest, DateTimeKind.Utc));
|
||
return;
|
||
}
|
||
RecordUpstreamGood(fullRef, snapshot);
|
||
_lastValues[fullRef] = snapshot;
|
||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||
_awaitingRecovery.TryRemove(fullRef, out _);
|
||
OnDataChange?.Invoke(this, new DataChangeEventArgs(handle, fullRef, snapshot));
|
||
}
|
||
}
|
||
|
||
/// <summary>#670 F2 — remember the last accepted Good upstream source time for <paramref name="fullRef"/>.</summary>
|
||
private void RecordUpstreamGood(string fullRef, DataValueSnapshot snapshot)
|
||
{
|
||
if (snapshot.SourceTimestampUtc is not { } src || !StatusCode.IsGood(snapshot.StatusCode)) return;
|
||
_lastGoodSourceTicks[fullRef] = src.Ticks;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 F2 — true when the replay window is open and <paramref name="source"/> is at or below
|
||
/// <paramref name="fullRef"/>'s high-water mark. Otherwise (inside the window) the notification is accepted and
|
||
/// its source time becomes the ref's new mark, so the ref stays guarded for the whole window. A notification
|
||
/// with no source time passes without moving the mark. An expired window disarms everything.
|
||
/// </summary>
|
||
private bool IsLateReplay(string fullRef, DateTime? source, out long newest)
|
||
{
|
||
newest = 0;
|
||
var until = Interlocked.Read(ref _replayGuardUntilTicks);
|
||
if (until == 0) return false;
|
||
if (Clock.GetUtcNow().UtcDateTime.Ticks >= until)
|
||
{
|
||
DisarmReplayGuard(until);
|
||
return false;
|
||
}
|
||
if (source is not { } src) return false;
|
||
var ticks = src.Ticks;
|
||
while (true)
|
||
{
|
||
if (_replayGuard.TryGetValue(fullRef, out newest))
|
||
{
|
||
if (ticks <= newest) return true;
|
||
if (_replayGuard.TryUpdate(fullRef, ticks, newest)) return false;
|
||
}
|
||
else if (_replayGuard.TryAdd(fullRef, ticks)) return false;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Expire the replay guard armed until <paramref name="until"/> — a no-op when it was re-armed or already disarmed
|
||
/// since — and log ONE Information summary of what it dropped (#676). The guard's OTel counter and per-drop Debug
|
||
/// line are invisible on a default-configured node, so before this a recovery's drops left no trace at all.
|
||
/// </summary>
|
||
/// <param name="until">The expiry the caller observed; only that arming is disarmed.</param>
|
||
private void DisarmReplayGuard(long until)
|
||
{
|
||
int drops, refs;
|
||
lock (_replayGuardLock)
|
||
{
|
||
if (Interlocked.Read(ref _replayGuardUntilTicks) != until) return;
|
||
Interlocked.Exchange(ref _replayGuardUntilTicks, 0);
|
||
_replayGuard.Clear();
|
||
drops = _replayDrops;
|
||
refs = _replayDropRefs.Count;
|
||
_replayDrops = 0;
|
||
_replayDropRefs.Clear();
|
||
}
|
||
LogReplaySummary(drops, refs);
|
||
}
|
||
|
||
/// <summary>One line per replay window: Information when the guard dropped something, Debug otherwise.</summary>
|
||
private void LogReplaySummary(int drops, int refs) =>
|
||
_logger.Log(drops > 0 ? LogLevel.Information : LogLevel.Debug,
|
||
"OpcUaClientDriver '{DriverInstanceId}': post-recovery replay window closed — dropped {Drops} late " +
|
||
"notifications across {Refs} refs after recovery.", _driverInstanceId, drops, refs);
|
||
|
||
/// <summary>
|
||
/// #670 F2 — arm the replay guard on a same-session recovery: every ref with a known Good upstream source time,
|
||
/// for max(30 s, 2 × the longest tracked subscription keep-alive window).
|
||
/// </summary>
|
||
/// <returns>The window's expiry in clock ticks.</returns>
|
||
private long ArmReplayGuard(DateTime now)
|
||
{
|
||
var window = ReplayGuardMinimum;
|
||
foreach (var sub in _subscriptions.Values.Select(r => r.Subscription))
|
||
{
|
||
var keepAliveWindow = TimeSpan.FromMilliseconds(sub.CurrentPublishingInterval * (double)sub.CurrentKeepAliveCount);
|
||
if (keepAliveWindow * 2 > window) window = keepAliveWindow * 2;
|
||
}
|
||
int supersededDrops, supersededRefs;
|
||
lock (_replayGuardLock)
|
||
{
|
||
// A re-arm inside an open window ends that window: report what it dropped and count afresh.
|
||
supersededDrops = _replayDrops;
|
||
supersededRefs = _replayDropRefs.Count;
|
||
_replayDrops = 0;
|
||
_replayDropRefs.Clear();
|
||
_replayGuard.Clear();
|
||
foreach (var kv in _lastGoodSourceTicks) _replayGuard[kv.Key] = kv.Value;
|
||
Interlocked.Exchange(ref _replayGuardUntilTicks, (now + window).Ticks);
|
||
}
|
||
if (supersededDrops > 0) LogReplaySummary(supersededDrops, supersededRefs);
|
||
return (now + window).Ticks;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #676 — disarm the replay guard (and log its summary) when its window ends, even if no notification arrives to
|
||
/// disarm it lazily in <see cref="IsLateReplay"/> — a quiet subscription would otherwise never log the summary.
|
||
/// </summary>
|
||
private async Task DisarmReplayGuardWhenDueAsync(long until, CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
var wait = new DateTime(until, DateTimeKind.Utc) - Clock.GetUtcNow().UtcDateTime;
|
||
if (wait > TimeSpan.Zero) await Task.Delay(wait + TimeSpan.FromMilliseconds(100), Clock, ct).ConfigureAwait(false);
|
||
if (Clock.GetUtcNow().UtcDateTime.Ticks >= until) DisarmReplayGuard(until);
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* session released */ }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogDebug(ex, "OpcUaClientDriver '{DriverInstanceId}': replay-guard disarm timer faulted.", _driverInstanceId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 — advance <see cref="DriverHealth.LastSuccessfulRead"/> on upstream traffic (a notification), at most
|
||
/// once per second. The state is left as it is: a notification is evidence of comms, but the keep-alive
|
||
/// is what declares an outage over.
|
||
/// </summary>
|
||
private void RecordLivenessEvidence()
|
||
{
|
||
// A dead subscription (M1) must keep the stamp frozen even while OTHER subscriptions still deliver.
|
||
if (Volatile.Read(ref _subscriptionLost) == 1) return;
|
||
var now = Clock.GetUtcNow().UtcDateTime;
|
||
var last = Interlocked.Read(ref _lastEvidenceStampTicks);
|
||
if (now.Ticks - last < TimeSpan.TicksPerSecond && last != 0) return;
|
||
Interlocked.Exchange(ref _lastEvidenceStampTicks, now.Ticks);
|
||
UpdateHealth(h => h.State is DriverState.Healthy or DriverState.Degraded ? h with { LastSuccessfulRead = now } : null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Read-modify-write of <see cref="_health"/> as a CAS loop (#670 review L1): the keep-alive timer, publish
|
||
/// threads and the recovery task all derive the next health from the current one.
|
||
/// </summary>
|
||
/// <param name="next">Maps the current health to the next one, or null to leave it unchanged.</param>
|
||
private void UpdateHealth(Func<DriverHealth, DriverHealth?> next)
|
||
{
|
||
while (true)
|
||
{
|
||
var current = Volatile.Read(ref _health);
|
||
var proposed = next(current);
|
||
if (proposed is null) return;
|
||
if (ReferenceEquals(Interlocked.CompareExchange(ref _health, proposed, current), current)) return;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Live data-change subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
|
||
/// the local handle, and the per-MonitoredItem (item, handler) pairs so
|
||
/// <see cref="UnsubscribeAsync"/> / <see cref="ShutdownAsync"/> can detach the
|
||
/// Notification delegates before the SDK disposes the subscription.
|
||
/// </summary>
|
||
private sealed record RemoteSubscription(
|
||
Subscription Subscription,
|
||
OpcUaSubscriptionHandle Handle,
|
||
IReadOnlyList<MonitoredItemNotificationHandle> ItemHandlers);
|
||
|
||
/// <summary>
|
||
/// 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 <c>OpcUaSubscriptionHandle</c>, 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.
|
||
/// </summary>
|
||
private sealed record MonitoredItemNotificationHandle(
|
||
MonitoredItem Item,
|
||
MonitoredItemNotificationEventHandler Handler);
|
||
|
||
private sealed record OpcUaSubscriptionHandle(long Id) : ISubscriptionHandle
|
||
{
|
||
/// <inheritdoc />
|
||
public string DiagnosticId => $"opcua-sub-{Id}";
|
||
}
|
||
|
||
// ---- IAlarmSource ----
|
||
|
||
/// <summary>
|
||
/// Field positions in the EventFilter SelectClauses below. Used to index into the
|
||
/// <c>EventFieldList.EventFields</c> Variant collection when an event arrives.
|
||
/// </summary>
|
||
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;
|
||
|
||
/// <inheritdoc />
|
||
public async Task<IAlarmSubscriptionHandle> SubscribeAlarmsAsync(
|
||
IReadOnlyList<string> 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 remote namespace reorder 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,
|
||
});
|
||
|
||
// Set by CreateAlarmEventItemAsync so a failure after the handler is attached can detach it (#662).
|
||
MonitoredItem? eventItem = null;
|
||
MonitoredItemNotificationEventHandler? notifHandler = null;
|
||
|
||
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 a re-initialise.
|
||
var session = Session
|
||
?? throw new InvalidOperationException(
|
||
"OpcUaClient session was lost before the alarm subscription could be created.");
|
||
session.AddSubscription(subscription);
|
||
try
|
||
{
|
||
await CreateAlarmEventItemAsync().ConfigureAwait(false);
|
||
}
|
||
catch
|
||
{
|
||
// #662: the host retries a failed alarm subscribe from its reconcile, so a half-built subscription
|
||
// left on the session would accumulate one per retry for the length of an outage. Detach the
|
||
// handler first — the remove is best-effort and can itself fail against a broken server — and
|
||
// bound it, since _gate is held. OnEventNotification also drops anything from an unregistered id.
|
||
if (eventItem is not null && notifHandler is not null)
|
||
{
|
||
try { eventItem.Notification -= notifHandler; }
|
||
catch { /* best-effort */ }
|
||
}
|
||
try
|
||
{
|
||
using var removeCts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
||
await session.RemoveSubscriptionAsync(subscription, removeCts.Token).ConfigureAwait(false);
|
||
}
|
||
catch { /* best-effort — the session may be the thing that failed */ }
|
||
throw;
|
||
}
|
||
|
||
// #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;
|
||
|
||
async Task CreateAlarmEventItemAsync()
|
||
{
|
||
await subscription.CreateAsync(cancellationToken).ConfigureAwait(false);
|
||
|
||
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.
|
||
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);
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
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(); }
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task AcknowledgeAsync(
|
||
IReadOnlyList<AlarmAcknowledgeRequest> 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 a re-initialise.
|
||
// 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<byte>();
|
||
}
|
||
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<string, string> sourceFilter,
|
||
MonitoredItem item,
|
||
MonitoredItemNotificationEventArgs args)
|
||
{
|
||
if (args.NotificationValue is not EventFieldList efl) return;
|
||
// #662 review: only a subscription SubscribeAlarmsAsync completed (and nothing has since released) may
|
||
// deliver. A half-built one whose cleanup failed against a broken server can survive on the session;
|
||
// without this it would feed RouteAlarmEvent alongside the real one.
|
||
if (!_alarmSubscriptions.ContainsKey(handle.Id)) return;
|
||
RecordLivenessEvidence();
|
||
RouteAlarmEvent(efl.EventFields, sourceFilter, handle);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #619 re-review: open (or re-open) the host's alarm subscription over <paramref name="sourceNodeIds"/>.
|
||
/// A CHANGED set means the host applied an alarm-bearing delta, so every last-told state is forgotten and the
|
||
/// post-subscribe ConditionRefresh re-reports as first sight. Since #641 a rebuild KEEPS native condition state,
|
||
/// so the host may hold ANY state here — which is why first sight is flagged (#656/#657) rather than assuming
|
||
/// an inactive+acked 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.
|
||
/// </summary>
|
||
/// <param name="sourceNodeIds">The refs the host is subscribing.</param>
|
||
internal void BeginAlarmSubscription(IReadOnlyList<string> sourceNodeIds)
|
||
{
|
||
var set = new HashSet<string>(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 _);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #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.
|
||
/// </summary>
|
||
/// <param name="fields">The event's fields, in select-clause order.</param>
|
||
/// <param name="sourceFilter">The subscription's upstream-key → RawPath index.</param>
|
||
/// <param name="handle">The subscription handle to stamp.</param>
|
||
internal void RouteAlarmEvent(
|
||
IReadOnlyList<Variant> fields, IReadOnlyDictionary<string, string> 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];
|
||
}
|
||
// #656: on first sight (a start or deploy respawn) this instance is RE-STATING upstream's current state,
|
||
// not reporting a transition it watched — flagged so the host writes no duplicate history row when its
|
||
// condition already shows exactly this. History only; the host still projects the state.
|
||
foreach (var kind in kinds)
|
||
OnAlarmEvent?.Invoke(this, e with { Kind = kind, IsRestatement = !priorKnown });
|
||
}
|
||
}
|
||
|
||
/// <summary>#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.</summary>
|
||
/// <param name="Active">ActiveState/Id last told.</param>
|
||
/// <param name="Acked">AckedState/Id last told.</param>
|
||
/// <param name="Message">Message last told (null before any event).</param>
|
||
/// <param name="Severity">Exact severity last told (null before any event).</param>
|
||
private readonly record struct AlarmToldState(bool Active, bool Acked, string? Message, int? Severity);
|
||
|
||
/// <summary>True for a <c>RefreshRequiredEventType</c> event (the server asks the client to ConditionRefresh).</summary>
|
||
/// <param name="fields">The event's fields.</param>
|
||
/// <returns>Whether the event is a RefreshRequired marker.</returns>
|
||
internal static bool IsRefreshRequired(IReadOnlyList<Variant> fields) =>
|
||
fields.Count > AlarmFieldEventType && fields[AlarmFieldEventType].Value is NodeId t
|
||
&& t == ObjectTypeIds.RefreshRequiredEventType;
|
||
|
||
/// <summary>True for the three ConditionRefresh marker event types — never alarms.</summary>
|
||
/// <param name="eventType">The event's EventType.</param>
|
||
/// <returns>Whether it is a refresh marker.</returns>
|
||
private static bool IsRefreshMarker(object? eventType) =>
|
||
eventType is NodeId t
|
||
&& (t == ObjectTypeIds.RefreshStartEventType || t == ObjectTypeIds.RefreshEndEventType
|
||
|| t == ObjectTypeIds.RefreshRequiredEventType);
|
||
|
||
/// <summary>A ConditionRefresh refusal meaning "this server does not do refresh" (tolerated, logged once).</summary>
|
||
/// <param name="code">The refusal status.</param>
|
||
/// <returns>Whether the server simply lacks ConditionRefresh.</returns>
|
||
internal static bool IsConditionRefreshUnsupported(StatusCode code) =>
|
||
code == StatusCodes.BadNotSupported || code == StatusCodes.BadMethodInvalid
|
||
|| code == StatusCodes.BadNotImplemented || code == StatusCodes.BadServiceUnsupported
|
||
|| code == StatusCodes.BadNodeIdUnknown;
|
||
|
||
/// <summary>
|
||
/// #619 — call ConditionRefresh for <paramref name="subscription"/> so conditions already active upstream
|
||
/// are reported. Never throws: an unsupported server is logged once, any other failure warns.
|
||
/// </summary>
|
||
/// <param name="subscription">The (created) alarm subscription.</param>
|
||
/// <param name="cancellationToken">Cancellation.</param>
|
||
/// <returns>A task completing when the refresh call returns.</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.
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #619 — ConditionRefresh every alarm subscription on the current session (matched by display name): after
|
||
/// a keep-alive outage the session survived (#670) and on a RefreshRequired event. Events missed during the
|
||
/// outage reach the host through the normal deduplicated path.
|
||
/// </summary>
|
||
/// <param name="cancellationToken">Cancellation.</param>
|
||
/// <returns>A task completing when every refresh call has returned.</returns>
|
||
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) { }
|
||
}
|
||
}
|
||
|
||
/// <summary>Display-name prefix of every alarm subscription (how a refresh sweep finds them on a session).</summary>
|
||
private const string AlarmSubscriptionNamePrefix = "opcua-alarm-sub-";
|
||
|
||
/// <summary>
|
||
/// #619 review — the <see cref="AlarmTransitionKind"/> sequence that moves the host's
|
||
/// <c>NativeAlarmProjector</c> (which knows only kinds: Raise ⇒ active+unacked — or, on an already-active condition, a re-statement keeping the ack (#647) — Acknowledge ⇒ acked,
|
||
/// ActiveAcknowledged ⇒ active+acked in one step (#656), InactiveUnacknowledged ⇒ inactive+unacked in one step (#657), Clear ⇒ inactive keeping acked) from <paramref name="prior"/> to <paramref name="target"/>. 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).
|
||
/// </summary>
|
||
/// <param name="prior">The state last told to the host.</param>
|
||
/// <param name="target">The upstream event's ActiveState/Id + AckedState/Id.</param>
|
||
/// <param name="priorKnown">
|
||
/// False when this driver instance has told the host nothing about the condition yet (first sight after a
|
||
/// start or a deploy respawn), so <paramref name="prior"/> 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 <i>unacknowledged</i> must then be told with <see cref="AlarmTransitionKind.Retrigger"/>
|
||
/// — the upstream's ack state is authoritative, and the host may be holding an ack from before the respawn.
|
||
/// </param>
|
||
/// <returns>The kinds to emit, in order.</returns>
|
||
internal static IReadOnlyList<AlarmTransitionKind> AlarmTransitions(
|
||
(bool Active, bool Acked) prior, (bool Active, bool Acked) target, bool priorKnown = true)
|
||
{
|
||
// Gitea #657: on first sight the host may hold ANY state (it retains a still-deployed condition across a
|
||
// respawn), so an inactive target is stated in kinds that land on it from every host state: inactive+unacked
|
||
// as ONE InactiveUnacknowledged; inactive+acked as Clear then Acknowledge (flagged by the caller, so a host
|
||
// already showing it writes no row, and a host retaining it Active gets a real "Cleared"). Pre-fix an
|
||
// inactive+acked first sight equalled the default prior and went out as Unspecified — an "Activated" row for
|
||
// an inactive alarm, and a retained-Active host condition was never cleared.
|
||
if (!priorKnown && !target.Active)
|
||
return target.Acked
|
||
? [AlarmTransitionKind.Clear, AlarmTransitionKind.Acknowledge]
|
||
: [AlarmTransitionKind.InactiveUnacknowledged];
|
||
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<AlarmTransitionKind> 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 from inactive: ONE atomic ActiveAcknowledged (Gitea #656) — Raise-then-Acknowledge showed
|
||
// clients an Active+UNacked intermediate event before the ack.
|
||
(true, true) => prior.Active
|
||
? [AlarmTransitionKind.Acknowledge]
|
||
: [AlarmTransitionKind.ActiveAcknowledged],
|
||
// 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): ONE
|
||
// atomic InactiveUnacknowledged (Gitea #657) — Raise/Retrigger-then-Clear showed clients the condition
|
||
// Active again for an instant. From Active+unacked a plain Clear already lands there.
|
||
(false, false) => prior is (true, false)
|
||
? [AlarmTransitionKind.Clear]
|
||
: [AlarmTransitionKind.InactiveUnacknowledged],
|
||
};
|
||
}
|
||
|
||
/// <summary>
|
||
/// #619 — index an alarm subscription's source refs by UPSTREAM source id. The host (v3) subscribes with
|
||
/// the alarm tags' <b>RawPaths</b>; 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
|
||
/// <paramref name="resolveUpstream"/> cannot resolve is kept verbatim (direct upstream-id use).
|
||
/// </summary>
|
||
/// <param name="refs">The refs handed to <c>SubscribeAlarmsAsync</c>.</param>
|
||
/// <param name="resolveUpstream">RawPath → serialized live upstream NodeId, or null when unknown.</param>
|
||
/// <returns>Upstream source id → ref to report.</returns>
|
||
internal static Dictionary<string, string> BuildAlarmSourceIndex(
|
||
IReadOnlyList<string> refs, Func<string, string?> resolveUpstream)
|
||
{
|
||
var index = new Dictionary<string, string>(StringComparer.Ordinal);
|
||
foreach (var r in refs)
|
||
index[resolveUpstream(r) ?? r] = r;
|
||
return index;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #619 — project one upstream event into an <see cref="AlarmEventArgs"/>. <c>ConditionId</c> and
|
||
/// <c>SourceNodeId</c> are the authored RawPath, because <c>DriverHostActor</c> routes native alarms on
|
||
/// (driver, RawPath); the upstream condition node (needed to acknowledge) is handed to
|
||
/// <paramref name="rememberCondition"/>. Null when the event's source is not an authored one.
|
||
/// </summary>
|
||
internal static AlarmEventArgs? ProjectEvent(
|
||
IReadOnlyList<Variant> fields,
|
||
IReadOnlyDictionary<string, string> sourceIndex,
|
||
IAlarmSubscriptionHandle handle,
|
||
Func<NodeId, string> keyOf,
|
||
Action<string, AlarmAckTarget> rememberCondition) =>
|
||
ProjectEvent(fields, sourceIndex, handle, keyOf, rememberCondition, out _);
|
||
|
||
/// <summary>As the five-argument overload, also returning the event's (ActiveState/Id, AckedState/Id) through
|
||
/// <c>state</c> when it is an alarm condition (null otherwise).</summary>
|
||
internal static AlarmEventArgs? ProjectEvent(
|
||
IReadOnlyList<Variant> fields,
|
||
IReadOnlyDictionary<string, string> sourceIndex,
|
||
IAlarmSubscriptionHandle handle,
|
||
Func<NodeId, string> keyOf,
|
||
Action<string, AlarmAckTarget> 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<byte>()));
|
||
|
||
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);
|
||
}
|
||
|
||
/// <summary>#619 — what Acknowledge needs for one RawPath: the upstream condition node (server-stable
|
||
/// reference) and the EventId of the event that named it.</summary>
|
||
/// <param name="ConditionRef">Upstream condition NodeId, rendered by <see cref="AlarmIndexKey"/>.</param>
|
||
/// <param name="EventId">EventId of the most recent event seen for that condition.</param>
|
||
internal readonly record struct AlarmAckTarget(string ConditionRef, byte[] EventId);
|
||
|
||
/// <summary>#619 — the key both sides of the alarm index use: server-stable (<c>nsu=</c>) when a map is
|
||
/// available, so a remote namespace-table reorder cannot orphan it.</summary>
|
||
/// <param name="map">The current session's namespace map, or null.</param>
|
||
/// <param name="nodeId">The NodeId to key.</param>
|
||
/// <returns>The index key.</returns>
|
||
internal static string AlarmIndexKey(NamespaceMap? map, NodeId nodeId) =>
|
||
map?.ToStableReference(nodeId) ?? nodeId.ToString() ?? string.Empty;
|
||
|
||
/// <summary>
|
||
/// Map an OPC UA <c>BaseEventType.Severity</c> (1..1000) to our coarse-grained
|
||
/// <see cref="AlarmSeverity"/> bucket. Thresholds match the OPC UA A&C Part 9
|
||
/// guidance: 1-200 Low, 201-500 Medium, 501-800 High, 801-1000 Critical.
|
||
/// </summary>
|
||
/// <param name="opcSeverity">The OPC UA severity value (1-1000).</param>
|
||
/// <returns>The mapped coarse-grained alarm severity bucket.</returns>
|
||
internal static AlarmSeverity MapSeverity(ushort opcSeverity) => opcSeverity switch
|
||
{
|
||
<= 200 => AlarmSeverity.Low,
|
||
<= 500 => AlarmSeverity.Medium,
|
||
<= 800 => AlarmSeverity.High,
|
||
_ => AlarmSeverity.Critical,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Live alarm-event subscription bookkeeping. Holds the SDK <see cref="Subscription"/>,
|
||
/// 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.
|
||
/// </summary>
|
||
private sealed record RemoteAlarmSubscription(
|
||
Subscription Subscription,
|
||
OpcUaAlarmSubscriptionHandle Handle,
|
||
MonitoredItem EventItem,
|
||
MonitoredItemNotificationEventHandler Handler);
|
||
|
||
private sealed record OpcUaAlarmSubscriptionHandle(long Id) : IAlarmSubscriptionHandle
|
||
{
|
||
/// <inheritdoc />
|
||
public string DiagnosticId => $"opcua-alarm-sub-{Id}";
|
||
}
|
||
|
||
// ---- IHostConnectivityProbe ----
|
||
|
||
/// <summary>
|
||
/// 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.
|
||
/// </summary>
|
||
public string HostName => _connectedEndpointUrl
|
||
?? ResolveEndpointCandidates(_options).FirstOrDefault()
|
||
?? _options.EndpointUrl;
|
||
|
||
/// <inheritdoc />
|
||
public IReadOnlyList<HostConnectivityStatus> GetHostStatuses()
|
||
{
|
||
lock (_probeLock)
|
||
return [new HostConnectivityStatus(HostName, _hostState, _hostStateChangedUtc)];
|
||
}
|
||
|
||
/// <summary>Hook the SDK's publish-side events into the diagnostics counters (#276).</summary>
|
||
/// <param name="session">The session to observe.</param>
|
||
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 ??= OnPublishError;
|
||
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,
|
||
};
|
||
|
||
/// <summary>
|
||
/// Session keep-alive handler (Gitea #670). A good keep-alive is liveness evidence: it advances
|
||
/// <see cref="DriverHealth.LastSuccessfulRead"/>, and after a bad one it ends the outage in place (the session
|
||
/// survived — e.g. a network blip): health back to Healthy and the outage's Bad refs re-read and re-published
|
||
/// Good, because a server whose subscription survived will not resend values that did not change. A bad
|
||
/// keep-alive degrades health (stamp preserved) and, once per outage, publishes <c>BadNoCommunication</c> for
|
||
/// every subscribed ref (value retained) plus a quality-only annotation on every subscribed alarm condition.
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// There is deliberately no <c>SessionReconnectHandler</c> any more. In SDK 1.5.378 it retried forever, its
|
||
/// session-recreate path cloned subscriptions the driver never re-tracked, and it kept health Healthy while
|
||
/// it did so — the #670 symptom. A session that does not come back keeps health Degraded with the read stamp
|
||
/// frozen, which is exactly what the host's #527 comms-loss watchdog trips on: ShutdownAsync + InitializeAsync
|
||
/// + re-subscribe, whose initial values come back Good. When the keep-alive status proves the session is gone
|
||
/// for good (<see cref="IsDefinitiveSessionLoss"/>) the driver also raises <see cref="ConnectionLost"/> (#676),
|
||
/// so the host runs that sequence at once instead of after the watchdog's silence window.
|
||
/// </remarks>
|
||
private void OnKeepAlive(ISession sender, KeepAliveEventArgs e)
|
||
{
|
||
// A late callback from a session already released (re-initialise) says nothing about the current one.
|
||
if (!ReferenceEquals(sender, Session)) return;
|
||
var now = Clock.GetUtcNow().UtcDateTime;
|
||
|
||
if (!ServiceResult.IsBad(e.Status))
|
||
{
|
||
TransitionTo(HostState.Running);
|
||
// M1: the session answers but a subscription is dead — no stamp, no recovery; the watchdog reinit is the fix.
|
||
if (Volatile.Read(ref _subscriptionLost) == 1)
|
||
{
|
||
Interlocked.Exchange(ref _sessionLost, 0);
|
||
return;
|
||
}
|
||
|
||
if (Interlocked.Exchange(ref _sessionLost, 0) == 1)
|
||
{
|
||
UpdateHealth(_ => new DriverHealth(DriverState.Healthy, now, null));
|
||
// A fresh grace: every subscription reports stopped right after an outage until its next message.
|
||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||
var guardUntil = ArmReplayGuard(now);
|
||
_diag.Record(OpcUaClientDiagnostics.KeepAliveRecovered);
|
||
_logger.LogInformation(
|
||
"OpcUaClientDriver '{DriverInstanceId}': upstream keep-alive recovered on the same session; " +
|
||
"re-reading the refs published Bad during the outage.", _driverInstanceId);
|
||
var recovery = Task.Run(() => RecoverAfterOutageAsync(sender));
|
||
_recoveryTask = recovery;
|
||
var sessionWork = _sessionWorkCts.Token;
|
||
_reconcileTask = ReconcileAfterRecoveryAsync(sender, recovery, PostRecoveryReconcileDelay(), sessionWork);
|
||
_ = DisarmReplayGuardWhenDueAsync(guardUntil, sessionWork);
|
||
return;
|
||
}
|
||
|
||
if (FindStalledSubscription(now) is { } stalled)
|
||
{
|
||
DeclareSubscriptionLost($"subscription '{stalled}' stopped publishing while the session stayed up", now,
|
||
serverReported: false);
|
||
return;
|
||
}
|
||
|
||
UpdateHealth(h => h.State is DriverState.Healthy or DriverState.Degraded ? h with { LastSuccessfulRead = now } : null);
|
||
return;
|
||
}
|
||
|
||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||
TransitionTo(HostState.Stopped);
|
||
_diag.Record(OpcUaClientDiagnostics.KeepAliveMiss);
|
||
UpdateHealth(h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, $"keep-alive: {e.Status}"));
|
||
// #676: the first bad keep-alive is often ambiguous and a definitive one follows, so this is checked on EVERY
|
||
// bad keep-alive — but only after the outage below has been published, so the Bad reaches subscribers first.
|
||
var keepAliveCode = e.Status?.StatusCode ?? StatusCodes.Good;
|
||
var definitive = IsDefinitiveSessionLoss(keepAliveCode);
|
||
// A subscription loss already published Bad for this connection.
|
||
if (Interlocked.Exchange(ref _sessionLost, 1) == 0 && Volatile.Read(ref _subscriptionLost) == 0)
|
||
{
|
||
_logger.LogWarning(
|
||
"OpcUaClientDriver '{DriverInstanceId}': upstream session keep-alive failed ({Status}); subscribed values " +
|
||
"are BadNoCommunication until it recovers or the host's comms-loss watchdog reconnects the driver.",
|
||
_driverInstanceId, e.Status);
|
||
PublishCommsLoss(now);
|
||
}
|
||
if (definitive)
|
||
RaiseConnectionLost($"upstream session keep-alive reported {StatusCodes.GetBrowseName(keepAliveCode.Code)}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// #676 — keep-alive statuses that prove the session can NEVER recover in place, so waiting out the #527
|
||
/// watchdog would only delay the inevitable re-initialise. The server forgot the session
|
||
/// (<c>BadSessionIdInvalid</c> — what a re-opened channel to a restarted server answers), closed it
|
||
/// (<c>BadSessionClosed</c>) or never activated it (<c>BadSessionNotActivated</c>); or the server HALTED
|
||
/// (<c>BadServerHalted</c>) — measured on the #676 restart wire test, the SDK never re-opens a channel the
|
||
/// server halted: 15 s after the server was back the same session still failed every request with
|
||
/// <c>BadServerHalted</c>, so a halted session is dead for good. Everything else — <c>BadNoCommunication</c>,
|
||
/// <c>BadConnectionClosed</c>, <c>BadNotConnected</c>, request timeouts — is AMBIGUOUS (a stalled link that
|
||
/// resumes keeps its session, see the stall wire test) and stays with the watchdog.
|
||
/// </summary>
|
||
/// <param name="status">A keep-alive status.</param>
|
||
/// <returns>True when the session is definitively gone.</returns>
|
||
internal static bool IsDefinitiveSessionLoss(StatusCode status) =>
|
||
status == StatusCodes.BadSessionIdInvalid
|
||
|| status == StatusCodes.BadSessionClosed
|
||
|| status == StatusCodes.BadSessionNotActivated
|
||
|| status == StatusCodes.BadServerHalted;
|
||
|
||
/// <summary>#676 — raise <see cref="ConnectionLost"/> once for the current session, isolating a faulting subscriber.</summary>
|
||
/// <param name="reason">Why the connection is definitively gone, for the host's log.</param>
|
||
private void RaiseConnectionLost(string reason)
|
||
{
|
||
if (Interlocked.Exchange(ref _connectionLostRaised, 1) == 1) return;
|
||
_logger.LogWarning(
|
||
"OpcUaClientDriver '{DriverInstanceId}': connection definitively lost ({Reason}); asking the host to " +
|
||
"re-initialise now instead of waiting for the comms-loss watchdog.", _driverInstanceId, reason);
|
||
try { ConnectionLost?.Invoke(this, new DriverConnectionLossEventArgs(reason)); }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': a ConnectionLost subscriber faulted.",
|
||
_driverInstanceId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 review M1 — the display name of a tracked subscription that has reported "publishing stopped" for
|
||
/// longer than its grace, measured across consecutive good keep-alives; null when none has.
|
||
/// </summary>
|
||
private string? FindStalledSubscription(DateTime now)
|
||
{
|
||
Subscription? worst = null;
|
||
var grace = TimeSpan.Zero;
|
||
foreach (var sub in _subscriptions.Values.Select(r => r.Subscription)
|
||
.Concat(_alarmSubscriptions.Values.Select(r => r.Subscription)))
|
||
{
|
||
if (!IsPublishingStopped(sub)) continue;
|
||
var g = PublishingStallGrace(sub);
|
||
if (worst is null || g > grace) { worst = sub; grace = g; }
|
||
}
|
||
|
||
if (worst is null)
|
||
{
|
||
Interlocked.Exchange(ref _publishingStoppedSinceTicks, 0);
|
||
return null;
|
||
}
|
||
|
||
var since = Interlocked.CompareExchange(ref _publishingStoppedSinceTicks, now.Ticks, 0);
|
||
if (since == 0) since = now.Ticks;
|
||
return now.Ticks - since >= grace.Ticks ? worst.DisplayName ?? $"#{worst.Id}" : null;
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 review M1 — the server dropped a subscription the driver still tracks. Degraded with the read stamp
|
||
/// frozen (sticky until the session is released), and the outage published once, so the host's watchdog
|
||
/// re-initialises and re-subscribes.
|
||
/// </summary>
|
||
/// <param name="reason">Why, for the log and health.</param>
|
||
/// <param name="now">Detection time.</param>
|
||
/// <param name="serverReported">True when the SERVER said the subscription is gone (BadNoSubscription /
|
||
/// BadSubscriptionIdInvalid) — definitive, so <see cref="ConnectionLost"/> is raised. False for the publishing-stall
|
||
/// heuristic, an inference from silence that stays with the #527 watchdog (review of 3b88056f).</param>
|
||
private void DeclareSubscriptionLost(string reason, DateTime now, bool serverReported)
|
||
{
|
||
if (Interlocked.Exchange(ref _subscriptionLost, 1) == 1) return;
|
||
UpdateHealth(h => new DriverHealth(DriverState.Degraded, h.LastSuccessfulRead, $"subscription lost: {reason}"));
|
||
_logger.LogWarning(
|
||
"OpcUaClientDriver '{DriverInstanceId}': {Reason}; subscribed values are BadNoCommunication until the " +
|
||
"host re-initialises the driver and re-subscribes.", _driverInstanceId, reason);
|
||
if (Volatile.Read(ref _sessionLost) == 0) PublishCommsLoss(now);
|
||
// #676: a subscription the SERVER says is gone never comes back on this session — only the re-subscribe after a
|
||
// re-initialise recreates it — so ask the host now rather than after the watchdog's 90 s. The stall heuristic is
|
||
// an inference, not a server statement, and keeps going through the watchdog.
|
||
if (serverReported) RaiseConnectionLost($"subscription lost: {reason}");
|
||
}
|
||
|
||
/// <summary>
|
||
/// Session PublishError: always counted (#276); a BadNoSubscription / BadSubscriptionIdInvalid naming a
|
||
/// subscription the driver tracks means the server dropped it while the session lived (#670 review M1).
|
||
/// </summary>
|
||
private void OnPublishError(object? sender, PublishErrorEventArgs e) =>
|
||
HandlePublishError(sender, e.Status?.StatusCode ?? StatusCodes.Good, e.SubscriptionId);
|
||
|
||
private void HandlePublishError(object? sender, StatusCode code, uint subscriptionId)
|
||
{
|
||
_diag.Record(OpcUaClientDiagnostics.PublishError);
|
||
if (sender is not null && !ReferenceEquals(sender, Session)) return;
|
||
bool tracked;
|
||
if (code == StatusCodes.BadNoSubscription)
|
||
tracked = !_subscriptions.IsEmpty || !_alarmSubscriptions.IsEmpty;
|
||
else if (code == StatusCodes.BadSubscriptionIdInvalid)
|
||
tracked = _subscriptions.Values.Any(r => r.Subscription.Id == subscriptionId)
|
||
|| _alarmSubscriptions.Values.Any(r => r.Subscription.Id == subscriptionId);
|
||
else
|
||
return;
|
||
if (tracked)
|
||
DeclareSubscriptionLost($"server reported {code} for a tracked subscription", Clock.GetUtcNow().UtcDateTime,
|
||
serverReported: true);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 — publish the start of an outage: <c>BadNoCommunication</c> once per (subscription, ref) with the last
|
||
/// delivered value retained, and a quality-only annotation on every subscribed alarm condition (no state
|
||
/// change — an outage says nothing about whether the alarm is active).
|
||
/// </summary>
|
||
private void PublishCommsLoss(DateTime now)
|
||
{
|
||
// Under the delivery lock (re-review of 30a9d5f0) so the Bad cannot interleave with a recovery publish.
|
||
lock (_deliveryLock)
|
||
{
|
||
foreach (var rs in _subscriptions.Values)
|
||
{
|
||
foreach (var pair in rs.ItemHandlers)
|
||
{
|
||
var fullRef = RefOf(pair.Item);
|
||
_lastValues.TryGetValue(fullRef, out var last);
|
||
_awaitingRecovery[fullRef] = 0;
|
||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||
// F1: stamped with the DETECTION time, as the host's own comms-loss fan does. The recorder historizes
|
||
// SourceTimestampUtc ?? ServerTimestampUtc, so the last Good's source time here put the Bad at the
|
||
// Good row's (TagId, SourceTimeUtc) key, where the insert-if-absent historian dropped it.
|
||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, new DataValueSnapshot(
|
||
last?.Value, StatusBadNoCommunication, now, now)));
|
||
}
|
||
}
|
||
}
|
||
AnnotateAlarmQuality(StatusBadNoCommunication, now);
|
||
}
|
||
|
||
/// <summary>
|
||
/// #670 — the session survived the outage: re-read the refs still awaiting recovery and publish them (a ref
|
||
/// a notification already refreshed is skipped — fresh data beats the read), restore alarm-condition quality
|
||
/// and ConditionRefresh so transitions missed during the outage are re-reported (deduplicated). A failed
|
||
/// read re-arms the outage so the next good keep-alive tries again.
|
||
/// </summary>
|
||
/// <param name="session">The session whose keep-alive recovered. Every effect — publishing, re-arming the
|
||
/// latch — is conditional on it still being the current session and still out of outage (#670 review M2/L2): a
|
||
/// recovery overtaken by a re-initialise must not leak its outcome onto the NEW session.</param>
|
||
private async Task RecoverAfterOutageAsync(ISession session)
|
||
{
|
||
bool Current() => ReferenceEquals(session, Session) && Volatile.Read(ref _sessionLost) == 0;
|
||
try
|
||
{
|
||
if (!Current()) return;
|
||
var refs = _awaitingRecovery.Keys.ToList();
|
||
if (refs.Count > 0)
|
||
{
|
||
var (results, transportOk) = await ReadCoreAsync(refs, CancellationToken.None, session).ConfigureAwait(false);
|
||
if (!transportOk)
|
||
{
|
||
// Re-arm only for the session this recovery belongs to.
|
||
if (ReferenceEquals(session, Session)) Interlocked.Exchange(ref _sessionLost, 1);
|
||
return;
|
||
}
|
||
for (var i = 0; i < refs.Count; i++)
|
||
{
|
||
if (!Current()) return;
|
||
// One critical section with the live notification path (_deliveryLock): a notification that
|
||
// removed the ref first wins, and none can overtake this publish once it has decided.
|
||
lock (_deliveryLock)
|
||
{
|
||
if (!_awaitingRecovery.TryRemove(refs[i], out _)) continue; // a notification got there first
|
||
PublishUpstreamLocked(refs[i], results[i]);
|
||
}
|
||
}
|
||
}
|
||
|
||
if (!Current()) return;
|
||
AnnotateAlarmQuality(StatusGood, Clock.GetUtcNow().UtcDateTime);
|
||
if (!_alarmSubscriptions.IsEmpty)
|
||
await RefreshAllAlarmSubscriptionsAsync(CancellationToken.None).ConfigureAwait(false);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': in-place recovery after an outage failed.",
|
||
_driverInstanceId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Publish an upstream value read (not notified) for <paramref name="fullRef"/> to every subscription carrying it,
|
||
/// recording it as the last delivered value and — when Good with a source time — as the replay guard's mark, so a
|
||
/// replay older than the read is late too. Caller holds <see cref="_deliveryLock"/>.
|
||
/// </summary>
|
||
private void PublishUpstreamLocked(string fullRef, DataValueSnapshot value)
|
||
{
|
||
_lastValues[fullRef] = value;
|
||
_deliverySeq.AddOrUpdate(fullRef, 1, (_, n) => n + 1);
|
||
RecordUpstreamGood(fullRef, value);
|
||
if (value.SourceTimestampUtc is { } readAt && StatusCode.IsGood(value.StatusCode)
|
||
&& Interlocked.Read(ref _replayGuardUntilTicks) != 0)
|
||
_replayGuard.AddOrUpdate(fullRef, readAt.Ticks, (_, prior) => Math.Max(prior, readAt.Ticks));
|
||
foreach (var rs in _subscriptions.Values)
|
||
if (rs.ItemHandlers.Any(p => RefOf(p.Item) == fullRef))
|
||
RaiseDataChange(new DataChangeEventArgs(rs.Handle, fullRef, value));
|
||
}
|
||
|
||
/// <summary>
|
||
/// #676 — the trailing reconcile read after an in-place recovery. Found by the TCP-stall wire test: a value change
|
||
/// the server published just after a stall ended rode a Publish response that had been in flight across the
|
||
/// stall; the SDK timed that request out (<c>BadTimeout</c>), discarded the response, and never republished the
|
||
/// message (the following keep-alives advanced the sequence number past it). The node stayed on the value from
|
||
/// the recovery read — stale, indefinitely, for a value that does not change again. Once every request that was
|
||
/// in flight across the stall has completed or timed out (<see cref="PostRecoveryReconcileDelay"/>), every
|
||
/// subscribed ref is read once more and published when <see cref="IsReconcileRepair"/> says the read differs from
|
||
/// what was last delivered and is not older than it. The decision and the publish are one critical section with
|
||
/// the live notification path (<see cref="_deliveryLock"/>), so a newer notification can never be overtaken.
|
||
/// Bound to the session's work token: a re-initialise or shutdown cancels it.
|
||
/// </summary>
|
||
/// <param name="session">The session that recovered; nothing happens if it is no longer current or is in outage.</param>
|
||
/// <param name="recovery">The recovery read, which runs first so this read is compared against its result.</param>
|
||
/// <param name="delay">How long to wait before reading (on <see cref="Clock"/>).</param>
|
||
/// <param name="ct">The session's background-work token.</param>
|
||
private async Task ReconcileAfterRecoveryAsync(ISession session, Task recovery, TimeSpan delay, CancellationToken ct)
|
||
{
|
||
bool Current() => !ct.IsCancellationRequested
|
||
&& ReferenceEquals(session, Session)
|
||
&& Volatile.Read(ref _sessionLost) == 0
|
||
&& Volatile.Read(ref _subscriptionLost) == 0;
|
||
try
|
||
{
|
||
if (delay > TimeSpan.Zero) await Task.Delay(delay, Clock, ct).ConfigureAwait(false);
|
||
await recovery.WaitAsync(ct).ConfigureAwait(false);
|
||
if (!Current()) return;
|
||
var refs = _subscriptions.Values
|
||
.SelectMany(rs => rs.ItemHandlers.Select(p => RefOf(p.Item)))
|
||
.Distinct(StringComparer.Ordinal)
|
||
.ToList();
|
||
if (refs.Count == 0) return;
|
||
// Captured BEFORE the read is issued: a ref delivered to while the read is in flight has a value at least as
|
||
// new as anything the read could have sampled, so the read must not overwrite it.
|
||
var seqAtIssue = refs.Select(r => _deliverySeq.GetValueOrDefault(r)).ToArray();
|
||
var (results, transportOk) = await ReadCoreAsync(refs, ct, session).ConfigureAwait(false);
|
||
if (!transportOk) return; // a new outage — the keep-alive reports it
|
||
var published = 0;
|
||
for (var i = 0; i < refs.Count; i++)
|
||
{
|
||
if (!Current()) return;
|
||
lock (_deliveryLock)
|
||
{
|
||
if (_deliverySeq.GetValueOrDefault(refs[i]) != seqAtIssue[i]) continue;
|
||
if (!IsReconcileRepair(refs[i], results[i])) continue;
|
||
BeforeReconcilePublishForTest?.Invoke(refs[i]);
|
||
_awaitingRecovery.TryRemove(refs[i], out _);
|
||
PublishUpstreamLocked(refs[i], results[i]);
|
||
}
|
||
published++;
|
||
}
|
||
if (published > 0)
|
||
_logger.LogInformation(
|
||
"OpcUaClientDriver '{DriverInstanceId}': trailing re-read after recovery found {Count} of {Total} refs " +
|
||
"whose latest change never arrived through the subscription; published them.",
|
||
_driverInstanceId, published, refs.Count);
|
||
}
|
||
catch (OperationCanceledException) when (ct.IsCancellationRequested) { /* session released */ }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': trailing re-read after a recovery failed.",
|
||
_driverInstanceId);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Whether the trailing read's <paramref name="read"/> repairs <paramref name="fullRef"/>: its (value, status)
|
||
/// differs from the last value delivered for the ref, and it is not OLDER than it. Any status counts — a Good→Bad
|
||
/// or Good→Uncertain change lost in the stalled pipeline is as stale as a lost value. "Older" is judged on source
|
||
/// timestamps only when both carry one; a read with no source timestamp (a server that does not send them) is the
|
||
/// server's CURRENT state, read after the stalled pipeline drained, so it counts as newer. An EQUAL source
|
||
/// timestamp with different content is published too: it is not a replay but the server's current state, and it
|
||
/// repairs the one change the replay guard drops by design (a status-only change that kept its source time inside
|
||
/// the window). Caller holds <see cref="_deliveryLock"/>.
|
||
/// </summary>
|
||
private bool IsReconcileRepair(string fullRef, DataValueSnapshot read)
|
||
{
|
||
if (!_lastValues.TryGetValue(fullRef, out var last)) return true;
|
||
if (last.StatusCode == read.StatusCode && Utils.IsEqual(last.Value, read.Value)) return false;
|
||
return read.SourceTimestampUtc is not { } src
|
||
|| last.SourceTimestampUtc is not { } lastSrc
|
||
|| src >= lastSrc;
|
||
}
|
||
|
||
/// <summary>Quality-only annotation (#626 pattern) of every subscribed alarm condition.</summary>
|
||
private void AnnotateAlarmQuality(uint quality, DateTime now)
|
||
{
|
||
if (_alarmSubscriptions.IsEmpty || OnAlarmEvent is null) return;
|
||
var handle = _alarmSubscriptions.Values.First().Handle;
|
||
lock (_alarmStateLock)
|
||
{
|
||
if (_lastAlarmRefSet is null) return;
|
||
foreach (var conditionId in _lastAlarmRefSet)
|
||
{
|
||
_alarmStateByRawPath.TryGetValue(conditionId, out var told);
|
||
var e = new AlarmEventArgs(handle,
|
||
SourceNodeId: conditionId,
|
||
ConditionId: conditionId,
|
||
AlarmType: "ConditionType",
|
||
Message: told.Message ?? string.Empty,
|
||
Severity: told.Severity is { } sev ? MapSeverity((ushort)sev) : AlarmSeverity.Low,
|
||
SourceTimestampUtc: now,
|
||
Kind: AlarmTransitionKind.Unspecified,
|
||
OpcUaSeverity: told.Severity,
|
||
QualityStatusCode: quality);
|
||
try { OnAlarmEvent?.Invoke(this, e); }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': alarm subscriber faulted on a quality " +
|
||
"annotation for {ConditionId}.", _driverInstanceId, conditionId);
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Raise <see cref="OnDataChange"/>, isolating a faulting subscriber from the SDK callback thread.</summary>
|
||
private void RaiseDataChange(DataChangeEventArgs args)
|
||
{
|
||
try { OnDataChange?.Invoke(this, args); }
|
||
catch (Exception ex)
|
||
{
|
||
_logger.LogWarning(ex, "OpcUaClientDriver '{DriverInstanceId}': data subscriber faulted for {Ref}.",
|
||
_driverInstanceId, args.FullReference);
|
||
}
|
||
}
|
||
|
||
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));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Synchronous disposal. 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).
|
||
/// </summary>
|
||
public void Dispose()
|
||
{
|
||
if (_disposed) return;
|
||
_disposed = true;
|
||
|
||
lock (_probeLock)
|
||
{
|
||
// 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;
|
||
}
|
||
|
||
// 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();
|
||
try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { }
|
||
SdkTelemetry.Release(_sdkTelemetry);
|
||
}
|
||
|
||
/// <summary>Asynchronously disposes the driver and releases all associated resources.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
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();
|
||
try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { }
|
||
SdkTelemetry.Release(_sdkTelemetry);
|
||
}
|
||
}
|