diff --git a/docs/drivers/OpcUaClient.md b/docs/drivers/OpcUaClient.md index d2d7b523..aa9d4e3f 100644 --- a/docs/drivers/OpcUaClient.md +++ b/docs/drivers/OpcUaClient.md @@ -70,6 +70,21 @@ A single `Session` per driver instance; subscriptions multiplex onto it. Stored session-relative `ns=N` index) so a remote namespace-table reorder across a restart doesn't silently re-point references at the wrong namespace. +**Concurrency (review 2026-10-21 O-6).** Reads (including the post-outage recovery / reconcile reads) and acks +share one gate; **writes and the discovery browse take none** (the SDK session is safe for concurrent service +calls, and the host already bounds write concurrency and serialises per tag); subscription management — data and +alarm subscribe/unsubscribe and ConditionRefresh sweeps — has its own gate. One gate used to serialise all of it, +so a write stalled for the operation timeout blocked every read and subscription create, and a discovery browse +would have blocked the data plane for its whole duration. + +**Lifecycle generation.** Every `InitializeAsync` / `ShutdownAsync` takes a new generation; the session, namespace +map and every health write land only while their generation is current. An init the host abandoned that finishes +after the next shutdown discards (closes + disposes) its session instead of installing it, and a read whose +response arrives after a shutdown does not write `Healthy` over it. A throw after the session activated closes +that local session (O-2). `ShutdownAsync` spends **one** 4 s budget on wire calls — every subscription delete +concurrently, then the close with what is left — and abandons waits the SDK does not cancel (O-9; it used to be +5 s per subscription, serially, inside the actor's `PostStop`). + ### Upstream outage and recovery (Gitea #670) The session keep-alive (`KeepAliveInterval`, default 5 s) is the liveness signal: diff --git a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs index 9a79ac3c..4e757d6d 100644 --- a/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs +++ b/src/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient/OpcUaClientDriver.cs @@ -158,9 +158,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit /// Active OPC UA session. Null until returns cleanly. internal ISession? Session { get; private set; } - /// Per-connection gate. PRs 67+ serialize read/write/browse on this. + /// + /// R06:O-6 — subscription management (data + alarm subscribe/unsubscribe, ConditionRefresh sweeps) has its own + /// gate, so a slow subscription create no longer queues reads, and the reconcile reads never wait on it. + /// + private readonly SemaphoreSlim _subscriptionGate = new(1, 1); + + /// Read gate: serialises reads (incl. the recovery / reconcile reads) and acknowledges. Writes and the + /// discovery browse take no gate (the SDK session is safe for concurrent service calls); subscription management + /// takes . internal SemaphoreSlim Gate => _gate; + /// Test seam: the subscription-management gate. + internal SemaphoreSlim SubscriptionGate => _subscriptionGate; + /// /// Test-only seam to swap the active the way a re-initialise does at /// runtime, so the stale-session race the read/history paths guard against (re-read Session @@ -1420,80 +1431,76 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit public async Task> WriteAsync( IReadOnlyList writes, CancellationToken cancellationToken) { - // See ReadAsync — the wire call must use the session current inside the gate, not a - // reference captured before WaitAsync. + // R06:O-6 — writes take NO gate: a write stalled for the operation timeout must not hold up every read + // and subscription create behind it (the host already bounds write concurrency and serialises per tag; + // the SDK session is safe for concurrent service calls). The session is read once, here. _ = RequireSession(); var results = new WriteResult[writes.Count]; - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + var session = Session; + if (session is null) + { + // Writes are non-idempotent — but here the request never reached the wire, so + // BadCommunicationError ("definitely did not happen") is the honest code. + for (var i = 0; i < writes.Count; i++) + results[i] = new WriteResult(StatusBadCommunicationError); + return results; + } + + var toSend = new WriteValueCollection(); + var indexMap = new List(writes.Count); + for (var i = 0; i < writes.Count; i++) + { + if (!TryResolveTagNode(session, writes[i].FullReference, out var nodeId)) + { + results[i] = new WriteResult(StatusBadNodeIdInvalid); + continue; + } + toSend.Add(new WriteValue + { + NodeId = nodeId, + AttributeId = Attributes.Value, + Value = new DataValue(new Variant(writes[i].Value)), + }); + indexMap.Add(i); + } + + if (toSend.Count == 0) return results; + try { - var 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 resp = await session.WriteAsync( + requestHeader: null, + nodesToWrite: toSend, + ct: cancellationToken).ConfigureAwait(false); - var toSend = new WriteValueCollection(); - var indexMap = new List(writes.Count); - for (var i = 0; i < writes.Count; i++) + var codes = resp.Results; + for (var w = 0; w < codes.Count; w++) { - 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); + 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); } } - finally { _gate.Release(); } + 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); + } return results; } @@ -1550,46 +1557,38 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit 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(); + // R06:O-6 — no gate across the recursive browse: it can run for as long as the remote tree takes to walk, + // and holding the read gate for that long would stall the data plane behind it. The browse reads the session + // once and uses it throughout (the SDK session is safe for concurrent service calls). + var session = Session + ?? throw new InvalidOperationException("OpcUaClientDriver not initialized"); var rootFolder = builder.Folder("Remote", "Remote"); var visited = new HashSet(); var discovered = 0; var pendingVariables = new List(); - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); - try - { - var session = Session - ?? throw new InvalidOperationException( - "OpcUaClient session was lost before discovery could browse the remote server."); + var root = !string.IsNullOrEmpty(_options.BrowseRoot) + ? NodeId.Parse(session.MessageContext, _options.BrowseRoot) + : ObjectIds.ObjectsFolder; - 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 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(); } + // 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); } /// @@ -1904,7 +1903,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit TimestampsToReturn = TimestampsToReturn.Both, }); - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _subscriptionGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: a reconnect completing while we were @@ -1939,7 +1938,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit } _subscriptions[id] = new RemoteSubscription(subscription, handle, itemHandlers); } - finally { _gate.Release(); } + finally { _subscriptionGate.Release(); } return handle; @@ -1982,7 +1981,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit if (handle is not OpcUaSubscriptionHandle h) return; if (!_subscriptions.TryRemove(h.Id, out var rs)) return; - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _subscriptionGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Detach Notification handlers BEFORE deleting the subscription so the SDK's @@ -1994,7 +1993,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort — the subscription may already be gone on reconnect */ } } - finally { _gate.Release(); } + finally { _subscriptionGate.Release(); } } private static void DetachNotificationHandlers(IReadOnlyList items) @@ -2384,7 +2383,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit MonitoredItem? eventItem = null; MonitoredItemNotificationEventHandler? notifHandler = null; - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _subscriptionGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Re-read Session inside the critical section: a reconnect completing while we were @@ -2422,7 +2421,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // current state through the normal path (deduplicated against the last-told state). await TryConditionRefreshAsync(subscription, cancellationToken).ConfigureAwait(false); } - finally { _gate.Release(); } + finally { _subscriptionGate.Release(); } return handle; @@ -2463,7 +2462,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit if (handle is not OpcUaAlarmSubscriptionHandle h) return; if (!_alarmSubscriptions.TryRemove(h.Id, out var rs)) return; - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _subscriptionGate.WaitAsync(cancellationToken).ConfigureAwait(false); try { // Detach the Notification handler before deleting the subscription so the SDK's @@ -2474,7 +2473,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit try { await rs.Subscription.DeleteAsync(silent: true, cancellationToken).ConfigureAwait(false); } catch { /* best-effort — session may already be gone across a reconnect */ } } - finally { _gate.Release(); } + finally { _subscriptionGate.Release(); } } /// @@ -2761,7 +2760,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit if (_alarmSubscriptions.IsEmpty) return; try { - await _gate.WaitAsync(cancellationToken).ConfigureAwait(false); + await _subscriptionGate.WaitAsync(cancellationToken).ConfigureAwait(false); } catch (ObjectDisposedException) { return; } try @@ -2781,7 +2780,7 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit } finally { - try { _gate.Release(); } catch (ObjectDisposedException) { } + try { _subscriptionGate.Release(); } catch (ObjectDisposedException) { } } } @@ -3560,13 +3559,16 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // DiscoverAsync) has definitely released before we dispose the gate. Without this // drain, a background read that calls _gate.Release() after Dispose throws // ObjectDisposedException. - try + foreach (var gate in new[] { _gate, _subscriptionGate }) { - if (_gate.Wait(TimeSpan.FromSeconds(2))) - _gate.Release(); + try + { + if (gate.Wait(TimeSpan.FromSeconds(2))) + gate.Release(); + } + catch { /* timeout or already disposed — proceed */ } + gate.Dispose(); } - catch { /* timeout or already disposed — proceed */ } - _gate.Dispose(); try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { } SdkTelemetry.Release(_sdkTelemetry); } @@ -3590,13 +3592,18 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit // Drain the gate before disposal so no in-flight _gate.Release() fires after // Dispose. - try + foreach (var gate in new[] { _gate, _subscriptionGate }) { - await _gate.WaitAsync(TimeSpan.FromSeconds(2)).ConfigureAwait(false); - _gate.Release(); + try + { + // Short: the host's PostStop gives shutdown + dispose 5 s together, and ShutdownAsync above + // already spent its own budget; an op still holding a gate finishes on its own token. + if (await gate.WaitAsync(TimeSpan.FromMilliseconds(500)).ConfigureAwait(false)) + gate.Release(); + } + catch { /* timeout or already disposed */ } + gate.Dispose(); } - catch { /* timeout or already disposed */ } - _gate.Dispose(); try { _sessionWorkCts.Cancel(); } catch (ObjectDisposedException) { } SdkTelemetry.Release(_sdkTelemetry); } diff --git a/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs new file mode 100644 index 00000000..b78ba971 --- /dev/null +++ b/tests/Drivers/ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests/OpcUaClientGateSplitTests.cs @@ -0,0 +1,153 @@ +using System.Text.Json; +using Moq; +using Opc.Ua; +using Opc.Ua.Client; +using Shouldly; +using Xunit; +using ZB.MOM.WW.OtOpcUa.Core.Abstractions; + +namespace ZB.MOM.WW.OtOpcUa.Driver.OpcUaClient.Tests; + +/// +/// Review 2026-10-21 R06:O-6 — one SemaphoreSlim serialised reads, writes, subscribes, acks AND the +/// whole recursive discovery browse, so a stalled write (up to the operation timeout) blocked every read and +/// subscription create, and a discovery browse blocked the data plane for its full duration. Reads (and acks) +/// keep their gate; writes and the discovery browse take none (the SDK session is thread-safe for concurrent +/// service calls); subscription management has its own gate. +/// +[Trait("Category", "Unit")] +public sealed class OpcUaClientGateSplitTests +{ + private const string SpeedRef = "Plant/dev/Speed"; + private static readonly TimeSpan Bound = TimeSpan.FromSeconds(10); + + private static readonly IReadOnlyList Tags = + [new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)]; + + private static (OpcUaClientDriver Driver, Mock Session) NewConnectedDriver() + { + var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6"); + var session = new Mock(MockBehavior.Loose); + session.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext(SdkTelemetry.Context)); + session.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable()); + session.Setup(s => s.ReadAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .ReturnsAsync((RequestHeader _, double _, TimestampsToReturn _, ReadValueIdCollection nodes, CancellationToken _) => + new ReadResponse + { + ResponseHeader = new ResponseHeader(), + Results = [.. nodes.Select(_ => new DataValue(new Variant(42), StatusCodes.Good, DateTime.UtcNow, DateTime.UtcNow))], + DiagnosticInfos = [], + }); + drv.SetSessionForTest(session.Object); + drv.SetNamespaceMapForTest(NamespaceMap.FromTable(new NamespaceTable(), Tags)); + drv.SetHealthForTest(new DriverHealth(DriverState.Healthy, DateTime.UtcNow, null)); + return (drv, session); + } + + private static async Task ReadCompletes(OpcUaClientDriver drv, string because) + { + var result = await drv.ReadAsync([SpeedRef], TestContext.Current.CancellationToken).WaitAsync(Bound, TestContext.Current.CancellationToken); + result.Single().Value.ShouldBe(42, because); + } + + /// A write stalled on the wire does not hold up a read. + [Fact] + public async Task A_stalled_write_does_not_block_a_read() + { + var (drv, session) = NewConnectedDriver(); + using var _ = drv; + var writeOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseWrite = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + session.Setup(s => s.WriteAsync(It.IsAny(), It.IsAny(), It.IsAny())) + .Returns(async (RequestHeader _, WriteValueCollection nodes, CancellationToken _) => + { + writeOnWire.TrySetResult(); + await releaseWrite.Task; + return new WriteResponse + { + ResponseHeader = new ResponseHeader(), + Results = [.. nodes.Select(_ => (StatusCode)StatusCodes.Good)], + DiagnosticInfos = [], + }; + }); + + var write = drv.WriteAsync([new ZB.MOM.WW.OtOpcUa.Core.Abstractions.WriteRequest(SpeedRef, 7)], TestContext.Current.CancellationToken); + await writeOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + await ReadCompletes(drv, "a read must not queue behind a stalled write"); + + releaseWrite.SetResult(); + (await write).Single().StatusCode.ShouldBe(0u); + } + + /// Subscription management queues on its own gate, not the read gate. + [Fact] + public async Task Subscription_management_does_not_share_the_read_gate() + { + var (drv, _) = NewConnectedDriver(); + using var __ = drv; + + await drv.SubscriptionGate.WaitAsync(TestContext.Current.CancellationToken); + var subscribe = drv.SubscribeAsync([SpeedRef], TimeSpan.FromSeconds(1), TestContext.Current.CancellationToken); + + // Positive control: the subscribe really is parked on the subscription gate. + subscribe.IsCompleted.ShouldBeFalse(); + await ReadCompletes(drv, "a read must not queue behind subscription management"); + + drv.SubscriptionGate.Release(); + // The mock never attaches the subscription, so the create itself throws — what matters is that it ran. + await Should.ThrowAsync(() => subscribe.WaitAsync(Bound, TestContext.Current.CancellationToken)); + } + + /// A discovery browse in progress does not hold the read gate. + [Fact] + public async Task A_discovery_browse_in_progress_does_not_block_a_read() + { + var (drv, session) = NewConnectedDriver(); + using var _ = drv; + var browseOnWire = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + var releaseBrowse = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + session.Setup(s => s.BrowseAsync( + It.IsAny(), It.IsAny(), It.IsAny(), + It.IsAny(), It.IsAny())) + .Returns(async (RequestHeader _, ViewDescription _, uint _, BrowseDescriptionCollection _, CancellationToken _) => + { + browseOnWire.TrySetResult(); + await releaseBrowse.Task; + return new BrowseResponse + { + ResponseHeader = new ResponseHeader(), + Results = [new BrowseResult { References = [], ContinuationPoint = [] }], + DiagnosticInfos = [], + }; + }); + + var discover = drv.DiscoverAsync(new NullAddressSpaceBuilder(), TestContext.Current.CancellationToken); + await browseOnWire.Task.WaitAsync(Bound, TestContext.Current.CancellationToken); + + await ReadCompletes(drv, "a read must not queue behind a recursive discovery browse"); + + releaseBrowse.SetResult(); + await discover.WaitAsync(Bound, TestContext.Current.CancellationToken); + } + + private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder + { + public IAddressSpaceBuilder Folder(string browseName, string displayName) => this; + + public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo) + => new StubHandle(); + + public void AddProperty(string browseName, DriverDataType dataType, object? value) { } + + public void AttachAlarmCondition(IVariableHandle sourceVariable, string alarmName, DriverAttributeInfo alarmInfo) { } + + private sealed class StubHandle : IVariableHandle + { + public string FullReference => "stub"; + public IAlarmConditionSink MarkAsAlarmCondition(AlarmConditionInfo info) => throw new NotSupportedException(); + } + } +}