be272d960f
Re-review at 7286d320. -016: BrowseRecursiveAsync now releases the server-side continuation
point on OperationCanceledException (BrowseNext releaseContinuationPoints:true) before
rethrowing (resolves the Browser-002 cross-cutting leak) + TDD.
227 lines
10 KiB
C#
227 lines
10 KiB
C#
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>
|
|
/// Regression coverage for Driver.OpcUaClient-016: when the caller cancels during
|
|
/// the <c>BrowseNextAsync</c> pagination loop inside <c>BrowseRecursiveAsync</c>, the
|
|
/// server-side continuation point must be released via
|
|
/// <c>BrowseNextAsync(releaseContinuationPoints: true)</c> before the
|
|
/// <see cref="OperationCanceledException"/> propagates. Without the release the server
|
|
/// retains the cursor open until the session is closed (a resource leak). This is the
|
|
/// same pattern the Browser subagent recorded as Driver.OpcUaClient.Browser-002 and
|
|
/// noted also exists in the runtime pagination loop.
|
|
/// </summary>
|
|
[Trait("Category", "Unit")]
|
|
public sealed class OpcUaClientContinuationPointReleaseTests
|
|
{
|
|
// A fake non-empty continuation point so the pagination loop can be triggered.
|
|
private static readonly byte[] FakeContinuationPoint = [0x01, 0x02, 0x03];
|
|
|
|
/// <summary>
|
|
/// Builds a loose <see cref="ISession"/> mock where:
|
|
/// <list type="bullet">
|
|
/// <item><c>BrowseAsync</c> returns a result with one Object-class child and a
|
|
/// non-empty continuation point so the pagination loop is entered.</item>
|
|
/// <item><c>BrowseNextAsync(releaseContinuationPoints: false)</c> throws
|
|
/// <see cref="OperationCanceledException"/> — simulating cancellation mid-page.</item>
|
|
/// <item><c>BrowseNextAsync(releaseContinuationPoints: true)</c> returns an empty
|
|
/// response — the release call the fixed driver should issue before rethrowing.</item>
|
|
/// </list>
|
|
/// </summary>
|
|
private static Mock<ISession> BuildCancellingSessionMock()
|
|
{
|
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
|
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
|
|
// BrowseAsync — return one Object child plus a non-empty continuation point so
|
|
// the pagination loop is entered. The child node is irrelevant; the loop is
|
|
// what matters.
|
|
var firstRef = new ReferenceDescription
|
|
{
|
|
NodeId = new ExpandedNodeId(new NodeId(1000, 2), "opc.tcp://test"),
|
|
BrowseName = new QualifiedName("TestObject", 2),
|
|
DisplayName = new LocalizedText("TestObject"),
|
|
NodeClass = NodeClass.Object,
|
|
};
|
|
var browseResult = new BrowseResult
|
|
{
|
|
ContinuationPoint = FakeContinuationPoint,
|
|
References = new ReferenceDescriptionCollection { firstRef },
|
|
};
|
|
var browseResp = new BrowseResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = new BrowseResultCollection { browseResult },
|
|
DiagnosticInfos = new DiagnosticInfoCollection(),
|
|
};
|
|
mock.Setup(s => s.BrowseAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ViewDescription>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<BrowseDescriptionCollection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(browseResp);
|
|
|
|
// BrowseNextAsync(release=false) — pagination call: throw cancellation.
|
|
mock.Setup(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.Is<bool>(release => !release),
|
|
It.IsAny<ByteStringCollection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new OperationCanceledException("ct cancelled during BrowseNext"));
|
|
|
|
// BrowseNextAsync(release=true) — the release call the fixed driver must make.
|
|
// BrowseNextResponse.Results shares BrowseResult (same as BrowseResponse.Results);
|
|
// there is no separate BrowseNextResult type in the OPC UA SDK.
|
|
var emptyReleaseResult = new BrowseResult
|
|
{
|
|
ContinuationPoint = Array.Empty<byte>(),
|
|
References = new ReferenceDescriptionCollection(),
|
|
};
|
|
var releaseResp = new BrowseNextResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = new BrowseResultCollection { emptyReleaseResult },
|
|
DiagnosticInfos = new DiagnosticInfoCollection(),
|
|
};
|
|
mock.Setup(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.Is<bool>(release => release),
|
|
It.IsAny<ByteStringCollection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(releaseResp);
|
|
|
|
return mock;
|
|
}
|
|
|
|
/// <summary>
|
|
/// When the caller's <see cref="CancellationToken"/> fires mid-pagination,
|
|
/// <c>DiscoverAsync</c> must call <c>BrowseNextAsync(releaseContinuationPoints: true)</c>
|
|
/// before the exception propagates, so the server-side cursor is freed rather than
|
|
/// held open until session close.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_releases_continuation_point_when_cancelled_mid_pagination()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-release");
|
|
|
|
var sessionMock = BuildCancellingSessionMock();
|
|
drv.SetSessionForTest(sessionMock.Object);
|
|
|
|
var builder = new NullAddressSpaceBuilder();
|
|
|
|
// DiscoverAsync should throw (OperationCanceledException propagates after release),
|
|
// but the release BrowseNextAsync(release=true) must be called first.
|
|
await Should.ThrowAsync<OperationCanceledException>(async () =>
|
|
await drv.DiscoverAsync(builder, ct));
|
|
|
|
// Verify the release call was made exactly once with releaseContinuationPoints=true.
|
|
sessionMock.Verify(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.Is<bool>(release => release),
|
|
It.IsAny<ByteStringCollection>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Once,
|
|
"DiscoverAsync must release the server-side continuation point on cancellation " +
|
|
"(Driver.OpcUaClient-016 / Browser-002 cross-module finding)");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Transient browse failure (non-cancellation exception) must NOT attempt a release —
|
|
/// the server already cleaned up (the request never reached the page-fetch state), and
|
|
/// calling BrowseNext on a bad continuation point would add noise to the server log.
|
|
/// <c>BrowseRecursiveAsync</c>'s existing catch swallows transient failures silently;
|
|
/// we should not change that behaviour.
|
|
/// </summary>
|
|
[Fact]
|
|
public async Task DiscoverAsync_does_not_release_continuation_point_on_non_cancel_browse_failure()
|
|
{
|
|
var ct = TestContext.Current.CancellationToken;
|
|
using var drv = new OpcUaClientDriver(new OpcUaClientDriverOptions(), "opcua-cp-norelease");
|
|
|
|
var mock = new Mock<ISession>(MockBehavior.Loose);
|
|
mock.SetupGet(s => s.MessageContext).Returns(new ServiceMessageContext());
|
|
mock.SetupGet(s => s.NamespaceUris).Returns(new NamespaceTable());
|
|
|
|
// BrowseAsync — return a result with a continuation point so the loop is entered.
|
|
var firstRef = new ReferenceDescription
|
|
{
|
|
NodeId = new ExpandedNodeId(new NodeId(1001, 2), "opc.tcp://test"),
|
|
BrowseName = new QualifiedName("TestObj2", 2),
|
|
DisplayName = new LocalizedText("TestObj2"),
|
|
NodeClass = NodeClass.Object,
|
|
};
|
|
var browseResult = new BrowseResult
|
|
{
|
|
ContinuationPoint = FakeContinuationPoint,
|
|
References = new ReferenceDescriptionCollection { firstRef },
|
|
};
|
|
mock.Setup(s => s.BrowseAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.IsAny<ViewDescription>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<BrowseDescriptionCollection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(new BrowseResponse
|
|
{
|
|
ResponseHeader = new ResponseHeader(),
|
|
Results = new BrowseResultCollection { browseResult },
|
|
DiagnosticInfos = new DiagnosticInfoCollection(),
|
|
});
|
|
|
|
// BrowseNextAsync — throw a non-cancellation transport error.
|
|
mock.Setup(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.Is<bool>(release => !release),
|
|
It.IsAny<ByteStringCollection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCommunicationError));
|
|
|
|
drv.SetSessionForTest(mock.Object);
|
|
|
|
// DiscoverAsync catches the transient failure and continues (returns normally since
|
|
// the root sub-tree failure just skips that branch).
|
|
await drv.DiscoverAsync(new NullAddressSpaceBuilder(), ct);
|
|
|
|
// The release call must NOT be made for a transport error — only for cancellation.
|
|
mock.Verify(s => s.BrowseNextAsync(
|
|
It.IsAny<RequestHeader>(),
|
|
It.Is<bool>(release => release),
|
|
It.IsAny<ByteStringCollection>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Never,
|
|
"A transient transport failure must not trigger a release BrowseNext call");
|
|
}
|
|
|
|
/// <summary>Minimal no-op address space builder for discovery tests.</summary>
|
|
private sealed class NullAddressSpaceBuilder : IAddressSpaceBuilder
|
|
{
|
|
/// <inheritdoc />
|
|
public IAddressSpaceBuilder Folder(string browseName, string displayName) => this;
|
|
|
|
/// <inheritdoc />
|
|
public IVariableHandle Variable(string browseName, string displayName, DriverAttributeInfo attributeInfo)
|
|
=> new StubHandle();
|
|
|
|
/// <inheritdoc />
|
|
public void AddProperty(string browseName, DriverDataType dataType, object? value) { }
|
|
|
|
/// <inheritdoc />
|
|
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();
|
|
}
|
|
}
|
|
}
|