mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-29 07:07:24 +08:00
### Summary This PR scopes `Cancel` request matching to the caller's session and adds regression tests that verify a request handle can no longer cancel work that belongs to another session. ### Problem According to OPC UA Part 4, `Cancel` applies to a request handle from the same session that issued the original request. The server should not use a client-supplied `requestHandle` to match outstanding requests across all sessions. Previously, `StandardServer.CancelAsync()` validated the caller's session but then forwarded only the raw `requestHandle` to `RequestManager.CancelRequests()`. `RequestManager` iterated the global outstanding-request table and canceled every request whose `ClientHandle` matched, without checking the owning session. Since request handles are only session-scoped and different sessions can legitimately reuse the same value, one session could cancel another session's in-flight request. ### Changes - Pass the validated caller session id from `StandardServer.CancelAsync()` into `RequestManager.CancelRequests()`. - Restrict `RequestManager.CancelRequests()` to requests whose `SessionId` and `ClientHandle` both match the `Cancel` request. - Update existing request-manager and publish-queue tests to pass the session id and add a regression test that proves matching request handles in different sessions no longer interfere with each other.
266 lines
9.8 KiB
C#
266 lines
9.8 KiB
C#
/* ========================================================================
|
|
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
|
|
*
|
|
* OPC Foundation MIT License 1.00
|
|
*
|
|
* Permission is hereby granted, free of charge, to any person
|
|
* obtaining a copy of this software and associated documentation
|
|
* files (the "Software"), to deal in the Software without
|
|
* restriction, including without limitation the rights to use,
|
|
* copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
* copies of the Software, and to permit persons to whom the
|
|
* Software is furnished to do so, subject to the following
|
|
* conditions:
|
|
*
|
|
* The above copyright notice and this permission notice shall be
|
|
* included in all copies or substantial portions of the Software.
|
|
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
|
|
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
|
|
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
|
|
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
|
|
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
|
|
* OTHER DEALINGS IN THE SOFTWARE.
|
|
*
|
|
* The complete license agreement can be found here:
|
|
* http://opcfoundation.org/License/MIT/1.00/
|
|
* ======================================================================*/
|
|
|
|
using System.Threading.Tasks;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Tests;
|
|
|
|
namespace Opc.Ua.Server.Tests
|
|
{
|
|
[TestFixture]
|
|
public class RequestManagerTests
|
|
{
|
|
private Mock<IServerInternal> m_mockServer;
|
|
private RequestManager m_requestManager;
|
|
|
|
[SetUp]
|
|
public void SetUp()
|
|
{
|
|
m_mockServer = new Mock<IServerInternal>();
|
|
m_mockServer.Setup(s => s.Telemetry).Returns(NUnitTelemetryContext.Create());
|
|
m_requestManager = new RequestManager(m_mockServer.Object);
|
|
}
|
|
|
|
[TearDown]
|
|
public void TearDown()
|
|
{
|
|
m_requestManager?.Dispose();
|
|
}
|
|
|
|
[Test]
|
|
public void ConstructorThrowsArgumentNullExceptionWhenServerNull()
|
|
{
|
|
Assert.That(() => new RequestManager(null), Throws.ArgumentNullException);
|
|
}
|
|
|
|
[Test]
|
|
public void RequestReceivedThrowsArgumentNullExceptionWhenContextNull()
|
|
{
|
|
Assert.That(() => m_requestManager.RequestReceived(null), Throws.ArgumentNullException);
|
|
}
|
|
|
|
[Test]
|
|
public void RequestCompletedThrowsArgumentNullExceptionWhenContextNull()
|
|
{
|
|
Assert.That(() => m_requestManager.RequestCompleted(null), Throws.ArgumentNullException);
|
|
}
|
|
|
|
[Test]
|
|
public void CancelRequestsCancelsMatchingRequestsAndFiresEvent()
|
|
{
|
|
// Arrange
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession.Setup(s => s.Id).Returns(new NodeId(1));
|
|
|
|
var requestHeader = new RequestHeader { RequestHandle = 42, TimeoutHint = 0 };
|
|
using var requestLifetime = new RequestLifetime();
|
|
var context = new OperationContext(
|
|
requestHeader,
|
|
null,
|
|
RequestType.Read,
|
|
requestLifetime,
|
|
mockSession.Object);
|
|
|
|
m_requestManager.RequestReceived(context);
|
|
|
|
bool eventFired = false;
|
|
uint cancelledRequestId = 0;
|
|
m_requestManager.RequestCancelled += (sender, reqId, status) =>
|
|
{
|
|
eventFired = true;
|
|
cancelledRequestId = reqId;
|
|
};
|
|
|
|
// Act
|
|
m_requestManager.CancelRequests(context.SessionId, 42, out uint cancelCount);
|
|
|
|
// Assert
|
|
Assert.That(cancelCount, Is.EqualTo(1));
|
|
Assert.That(eventFired, Is.True);
|
|
Assert.That(cancelledRequestId, Is.EqualTo(context.RequestId));
|
|
Assert.That(requestLifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void CancelRequestsShouldCancelActivateSessionRequestWithoutSession()
|
|
{
|
|
const uint requestHandle = 1234;
|
|
using var requestLifetime = new RequestLifetime();
|
|
var context = new OperationContext(
|
|
new RequestHeader { RequestHandle = requestHandle },
|
|
null,
|
|
RequestType.ActivateSession,
|
|
requestLifetime);
|
|
|
|
m_requestManager.RequestReceived(context);
|
|
|
|
uint cancelCount = 0;
|
|
Assert.DoesNotThrow(
|
|
() => m_requestManager.CancelRequests(context.SessionId, requestHandle, out cancelCount));
|
|
|
|
Assert.That(cancelCount, Is.EqualTo(1));
|
|
Assert.That(
|
|
context.OperationStatus.Code,
|
|
Is.EqualTo(StatusCodes.BadRequestCancelledByRequest));
|
|
}
|
|
|
|
[Test]
|
|
public void CancelRequestsDoesNotCancelMatchingHandleFromDifferentSession()
|
|
{
|
|
var cancellingSession = new Mock<ISession>();
|
|
cancellingSession.Setup(s => s.Id).Returns(new NodeId(1));
|
|
|
|
var otherSession = new Mock<ISession>();
|
|
otherSession.Setup(s => s.Id).Returns(new NodeId(2));
|
|
|
|
const uint requestHandle = 42;
|
|
using var ownRequestLifetime = new RequestLifetime();
|
|
using var otherRequestLifetime = new RequestLifetime();
|
|
|
|
var ownContext = new OperationContext(
|
|
new RequestHeader { RequestHandle = requestHandle },
|
|
null,
|
|
RequestType.Read,
|
|
ownRequestLifetime,
|
|
cancellingSession.Object);
|
|
var otherContext = new OperationContext(
|
|
new RequestHeader { RequestHandle = requestHandle },
|
|
null,
|
|
RequestType.Read,
|
|
otherRequestLifetime,
|
|
otherSession.Object);
|
|
|
|
m_requestManager.RequestReceived(ownContext);
|
|
m_requestManager.RequestReceived(otherContext);
|
|
|
|
m_requestManager.CancelRequests(cancellingSession.Object.Id, requestHandle, out uint cancelCount);
|
|
|
|
Assert.That(cancelCount, Is.EqualTo(1));
|
|
Assert.That(ownRequestLifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
Assert.That(otherRequestLifetime.CancellationToken.IsCancellationRequested, Is.False);
|
|
Assert.That(
|
|
otherContext.OperationStatus.Code,
|
|
Is.EqualTo(StatusCodes.Good));
|
|
}
|
|
|
|
[Test]
|
|
public void RequestCompletedRemovesRequestAndCompletesLifetime()
|
|
{
|
|
// Arrange
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession.Setup(s => s.Id).Returns(new NodeId(1));
|
|
|
|
var requestHeader = new RequestHeader { RequestHandle = 42, TimeoutHint = 0 };
|
|
using var requestLifetime = new RequestLifetime();
|
|
var context = new OperationContext(
|
|
requestHeader,
|
|
null,
|
|
RequestType.Read,
|
|
requestLifetime,
|
|
mockSession.Object);
|
|
|
|
m_requestManager.RequestReceived(context);
|
|
|
|
// Act
|
|
m_requestManager.RequestCompleted(context);
|
|
|
|
// Assert
|
|
// To ensure it is removed, cancelling it will yield 0 count
|
|
m_requestManager.CancelRequests(context.SessionId, 42, out uint cancelCount);
|
|
Assert.That(cancelCount, Is.Zero);
|
|
// Assert that lifetime is completed (disposed), which means TryCancel returns false
|
|
Assert.That(requestLifetime.TryCancel(StatusCodes.BadTimeout), Is.False);
|
|
}
|
|
|
|
[Test]
|
|
public async Task TimerCancelsExpiredRequestsAndFiresEventAsync()
|
|
{
|
|
// Arrange
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession.Setup(s => s.Id).Returns(new NodeId(1));
|
|
|
|
// TimeoutHint is small to ensure it expires quickly
|
|
var requestHeader = new RequestHeader { RequestHandle = 43, TimeoutHint = 100 };
|
|
using var requestLifetime = new RequestLifetime();
|
|
var context = new OperationContext(
|
|
requestHeader,
|
|
null,
|
|
RequestType.Read,
|
|
requestLifetime,
|
|
mockSession.Object);
|
|
|
|
bool eventFired = false;
|
|
m_requestManager.RequestCancelled += (sender, reqId, status) =>
|
|
{
|
|
if (reqId == context.RequestId && status == StatusCodes.BadTimeout)
|
|
{
|
|
eventFired = true;
|
|
}
|
|
};
|
|
|
|
m_requestManager.RequestReceived(context);
|
|
|
|
// Act
|
|
// Wait for timer to expire since TimeoutHint = 100ms. Note the original timer runs every 1000ms.
|
|
// We need to wait a bit more than 1000ms.
|
|
await Task.Delay(1200).ConfigureAwait(false);
|
|
|
|
// Assert
|
|
Assert.That(eventFired, Is.True);
|
|
Assert.That(requestLifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public void DisposeCancelsPendingRequests()
|
|
{
|
|
// Arrange
|
|
var mockSession = new Mock<ISession>();
|
|
mockSession.Setup(s => s.Id).Returns(new NodeId(1));
|
|
|
|
var requestHeader = new RequestHeader { RequestHandle = 44, TimeoutHint = 0 };
|
|
using var requestLifetime = new RequestLifetime();
|
|
var context = new OperationContext(
|
|
requestHeader,
|
|
null,
|
|
RequestType.Read,
|
|
requestLifetime,
|
|
mockSession.Object);
|
|
|
|
m_requestManager.RequestReceived(context);
|
|
|
|
// Act
|
|
m_requestManager.Dispose();
|
|
|
|
// Assert
|
|
Assert.That(requestLifetime.CancellationToken.IsCancellationRequested, Is.True);
|
|
}
|
|
}
|
|
}
|