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();
+ }
+ }
+}