perf(opcuaclient): split the read gate from subscription management; no gate on writes or the discovery browse (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 would block the data plane for its full duration. Reads/acks keep the read gate; writes and DiscoverAsync are ungated (the SDK session is safe for concurrent service calls, the host bounds write concurrency); subscription management takes its own gate. Dispose drains both (DisposeAsync's drain no longer over-releases on a timed-out wait). Refs #743 Claude-Session: https://claude.ai/code/session_01Ub9hGmYLQvn9XETzw7CMsT
This commit is contained in:
@@ -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:
|
||||
|
||||
@@ -158,9 +158,20 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
/// <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>
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
private readonly SemaphoreSlim _subscriptionGate = new(1, 1);
|
||||
|
||||
/// <summary>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 <see cref="SubscriptionGate"/>.</summary>
|
||||
internal SemaphoreSlim Gate => _gate;
|
||||
|
||||
/// <summary>Test seam: the subscription-management gate.</summary>
|
||||
internal SemaphoreSlim SubscriptionGate => _subscriptionGate;
|
||||
|
||||
/// <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
|
||||
@@ -1420,80 +1431,76 @@ public sealed class OpcUaClientDriver : IDriver, ITagDiscovery, IReadable, IWrit
|
||||
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.
|
||||
// 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<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 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<int>(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<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;
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -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<MonitoredItemNotificationHandle> 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(); }
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+153
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Review 2026-10-21 R06:O-6 — one <c>SemaphoreSlim</c> 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.
|
||||
/// </summary>
|
||||
[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<RawTagEntry> Tags =
|
||||
[new(SpeedRef, JsonSerializer.Serialize(new { nodeId = "ns=0;s=Speed" }), WriteIdempotent: true)];
|
||||
|
||||
private static (OpcUaClientDriver Driver, Mock<ISession> Session) NewConnectedDriver()
|
||||
{
|
||||
var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions { RawTags = Tags }, "opcua-o6");
|
||||
var session = new Mock<ISession>(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<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
||||
It.IsAny<ReadValueIdCollection>(), It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <summary>A write stalled on the wire does not hold up a read.</summary>
|
||||
[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<RequestHeader>(), It.IsAny<WriteValueCollection>(), It.IsAny<CancellationToken>()))
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <summary>Subscription management queues on its own gate, not the read gate.</summary>
|
||||
[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<Exception>(() => subscribe.WaitAsync(Bound, TestContext.Current.CancellationToken));
|
||||
}
|
||||
|
||||
/// <summary>A discovery browse in progress does not hold the read gate.</summary>
|
||||
[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<RequestHeader>(), It.IsAny<ViewDescription>(), It.IsAny<uint>(),
|
||||
It.IsAny<BrowseDescriptionCollection>(), It.IsAny<CancellationToken>()))
|
||||
.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();
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user