/* ========================================================================
* Copyright (c) 2005-2026 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;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Client.Tests.Stack.Client.Fakes;
namespace Opc.Ua.Client.Tests.SessionContexts
{
///
/// Proves that the borrowed client's actual service dispatch cannot adopt a replacement session.
///
[TestFixture]
[Parallelizable(ParallelScope.All)]
public sealed class SessionRequestContextTests
{
///
/// Identity changes notify old contexts before another capture can subscribe for the new generation.
///
[TestCase(false)]
[TestCase(true)]
public async Task SessionIdentityNotificationExcludesNewCapturesAsync(bool reusedIdentity)
{
await using var fixture = new SessionRequestContextTestContext();
var identity = (Lock)typeof(Session)
.GetField("m_requestContextIdentity", BindingFlags.NonPublic | BindingFlags.Instance)!
.GetValue(fixture.InnerSession)!;
bool notified = false;
bool captureExcluded = false;
void ObserveChange()
{
notified = true;
captureExcluded = identity.IsHeldByCurrentThread;
}
fixture.InnerSession.RequestContextChanged += ObserveChange;
try
{
await using SessionRequestContext original =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
if (reusedIdentity)
{
fixture.InnerSession.SessionCreated(
NodeId.Parse("s=original-session"), NodeId.Parse("s=original-token"));
}
else
{
fixture.Recreate();
}
Assert.That(notified, Is.True);
Assert.That(captureExcluded, Is.True,
"A new capture must not enter between the identity change and its invalidation notification.");
Assert.That(original.Invalidated.IsCompleted, Is.True);
await using SessionRequestContext current =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
Assert.That(current.Invalidated.IsCompleted, Is.False);
Assert.That(current.SessionId, Is.EqualTo(fixture.InnerSession.SessionId));
fixture.Handler = (_, _) => new ValueTask(new ReadResponse
{
Results = [new DataValue(Variant.From(19))]
});
ReadResponse response = await current.Session.ReadAsync(null, 0, TimestampsToReturn.Neither,
[new ReadValueId { NodeId = new NodeId(10u), AttributeId = Attributes.Value }],
CancellationToken.None)
.ConfigureAwait(false);
Assert.That(response.Results[0].WrappedValue.TryGetValue(out int value), Is.True);
Assert.That(value, Is.EqualTo(19));
Assert.That(fixture.Requests, Has.Count.EqualTo(1));
Assert.That(fixture.Requests[0].RequestHeader.AuthenticationToken, Is.EqualTo(NodeId.Parse(
reusedIdentity ? "s=original-token" : "s=replacement-token")));
Assert.That(current.Invalidated.IsCompleted, Is.False);
}
finally
{
fixture.InnerSession.RequestContextChanged -= ObserveChange;
}
}
///
/// Queues a request behind recovery after the caller observed the original identity.
///
[Test]
public async Task DispatchRejectsGenerationChangedWhileWaitingForGate()
{
await using var fixture = new SessionRequestContextTestContext();
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
ISession borrowed = context.Session;
Session original = fixture.InnerSession;
Assert.That(borrowed.SessionId, Is.EqualTo(original.SessionId));
Task request;
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
{
request = ReadHistoryAsync(borrowed).AsTask();
Assert.That(request.IsCompleted, Is.False);
fixture.Recreate();
}
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await request.ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.InnerSession, Is.SameAs(original));
Assert.That(fixture.Requests, Is.Empty);
Assert.That(context.Invalidated.IsCompleted, Is.True);
Assert.That(context.SessionId, Is.EqualTo(NodeId.Parse("s=original-session")));
}
///
/// A reader whose drain is bypassed cannot cause the next call to use the new generation.
///
[Test]
public async Task RecoveryDrainBypassDoesNotRetargetLaterDispatch()
{
await using var fixture = new SessionRequestContextTestContext();
var reply = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
fixture.Handler = (_, _) => new ValueTask(reply.Task);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
Task first = ReadHistoryAsync(context.Session).AsTask();
Task recovery = fixture.ServiceGate.WriterLockAsync(
TimeSpan.FromSeconds(1), fixture.TimeProvider, static () => true, CancellationToken.None).AsTask();
try
{
Assert.That(first.IsCompleted, Is.False);
Assert.That(recovery.IsCompleted, Is.False);
fixture.TimeProvider.Advance(TimeSpan.FromSeconds(1));
using (await recovery.ConfigureAwait(false))
{
fixture.Recreate();
}
}
finally
{
reply.TrySetResult(new HistoryReadResponse { Results = [] });
}
_ = await first.ConfigureAwait(false);
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await ReadHistoryAsync(context.Session).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.Requests, Has.Count.EqualTo(1));
Assert.That(fixture.Requests[0].RequestHeader.AuthenticationToken,
Is.EqualTo(NodeId.Parse("s=original-token")));
}
///
/// Namespace metadata stays captured and remapping is rejected before sending the request.
///
[Test]
public async Task BorrowedSessionKeepsOriginalNamespaceMapping()
{
await using var fixture = new SessionRequestContextTestContext();
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
Assert.That(context.Session.NamespaceUris.GetString(1), Is.EqualTo("urn:request-context:original"));
Task request;
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
{
request = ReadHistoryAsync(context.Session).AsTask();
fixture.InnerSession.NamespaceUris.Update(
["http://opcfoundation.org/UA/", "urn:request-context:replacement"]);
}
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await request.ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(context.Session.NamespaceUris.GetString(1), Is.EqualTo("urn:request-context:original"));
Assert.That(fixture.Requests, Is.Empty);
}
///
/// Public identifiers alone cannot make a captured context valid on another physical connection.
///
[Test]
public async Task PhysicalChannelReplacementRejectsReusedSessionIdentity()
{
await using var fixture = new SessionRequestContextTestContext();
var replacement = new Mock();
replacement.SetupGet(channel => channel.MessageContext).Returns(fixture.MessageContext);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
Task request;
using (await fixture.ServiceGate.WriterLockAsync().ConfigureAwait(false))
{
request = ReadHistoryAsync(context.Session).AsTask();
fixture.ReplaceTransport(replacement.Object);
}
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await request.ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.InnerSession.SessionId, Is.EqualTo(context.SessionId));
Assert.That(fixture.Requests, Is.Empty);
replacement.Verify(channel => channel.SendRequestAsync(
It.IsAny(), It.IsAny()), Times.Never);
}
///
/// A recreation is still a new generation if a server reuses both externally visible identifiers.
///
[Test]
public async Task GenerationCounterRejectsReusedIdentifiers()
{
await using var fixture = new SessionRequestContextTestContext();
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
fixture.InnerSession.SessionCreated(NodeId.Parse("s=original-session"), NodeId.Parse("s=original-token"));
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await ReadHistoryAsync(context.Session).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(context.Invalidated.IsCompleted, Is.True);
Assert.That(fixture.Requests, Is.Empty);
}
///
/// A caller-supplied header cannot replace the captured authentication context.
///
[Test]
public async Task ForeignAuthenticationHeaderIsRejectedBeforeDispatch()
{
await using var fixture = new SessionRequestContextTestContext();
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
ServiceResultException failure = (await Assert.ThrowsAsync(async () =>
await context.Session.ReadAsync(
new RequestHeader { AuthenticationToken = NodeId.Parse("s=foreign-token") },
0, TimestampsToReturn.Neither,
[new ReadValueId { NodeId = new NodeId(10u), AttributeId = Attributes.Value }],
CancellationToken.None).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(fixture.Requests, Is.Empty);
}
///
/// Borrowed generated services keep the owner's negotiated batching limits without sharing mutable options.
///
[Test]
public async Task BorrowedSessionPreservesNegotiatedBatchingLimits()
{
await using var fixture = new SessionRequestContextTestContext();
fixture.InnerSession.OperationLimits.MaxNodesPerRead = 1;
fixture.InnerSession.ServerCapabilities.MaxBrowseContinuationPoints = 7;
fixture.Handler = (request, _) =>
{
var read = (ReadRequest)request;
return new ValueTask(new ReadResponse
{
Results = read.NodesToRead.ConvertAll(static _ => new DataValue(Variant.From(42)))
});
};
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
fixture.InnerSession.OperationLimits.MaxNodesPerRead = 10;
ReadResponse response = await context.Session.ReadAsync(
null, 0, TimestampsToReturn.Neither,
[
new ReadValueId { NodeId = new NodeId(10u), AttributeId = Attributes.Value },
new ReadValueId { NodeId = new NodeId(11u), AttributeId = Attributes.Value }
], CancellationToken.None).ConfigureAwait(false);
Assert.That(fixture.Requests, Has.Count.EqualTo(2));
Assert.That(response.Results.Count, Is.EqualTo(2));
Assert.That(context.Session.OperationLimits.MaxNodesPerRead, Is.EqualTo(1));
Assert.That(context.Session.ServerCapabilities.MaxBrowseContinuationPoints, Is.EqualTo(7));
Assert.That(context.Session.SystemContext.NamespaceUris.GetString(1),
Is.EqualTo("urn:request-context:original"));
}
///
/// Disposal and a direct Close on the borrowed session affect only its local resources.
///
[Test]
public async Task ScopedDisposalDoesNotCloseOwner()
{
await using var fixture = new SessionRequestContextTestContext();
SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
await using (context.ConfigureAwait(false))
{
StatusCode closed = await context.Session.CloseAsync().ConfigureAwait(false);
Assert.That(closed, Is.EqualTo(StatusCodes.Good));
}
Assert.That(fixture.InnerSession.Connected, Is.True);
Assert.That(fixture.ManagedSession.SessionId, Is.EqualTo(NodeId.Parse("s=original-session")));
Assert.That(fixture.Requests, Is.Empty);
fixture.Channel.Verify(channel => channel.CloseAsync(It.IsAny()), Times.Never);
fixture.Channel.Verify(channel => channel.Dispose(), Times.Never);
}
///
/// A completed method result is not replaced by a later caller cancellation or session change.
///
[Test]
public async Task ScopedDispatchPreservesCommittedResponseAfterCancellation()
{
await using var fixture = new SessionRequestContextTestContext();
using var cancellation = new CancellationTokenSource();
var expected = new CallMethodResult { StatusCode = StatusCodes.GoodCompletesAsynchronously };
fixture.Handler = async (_, _) =>
{
fixture.Recreate();
await cancellation.CancelAsync().ConfigureAwait(false);
return new CallResponse { Results = [expected] };
};
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
CallResponse response = await context.ExecuteAsync(
(session, ct) => session.CallAsync(null,
[new CallMethodRequest { ObjectId = new NodeId(1u), MethodId = new NodeId(2u) }], ct),
cancellation.Token).ConfigureAwait(false);
Assert.That(response.Results[0], Is.SameAs(expected));
Assert.That(response.Results[0].StatusCode,
Is.EqualTo(StatusCodes.GoodCompletesAsynchronously));
Assert.That(cancellation.IsCancellationRequested, Is.True);
Assert.That(fixture.Requests, Has.Count.EqualTo(1));
}
///
/// The real managed transport path preserves a known result and does not follow channel recovery.
///
[Test]
public async Task ManagedTransportContextPreservesCommittedResultAndExpiresOnRecovery()
{
await using var channels = new SessionChannelHarness();
await using Client.ManagedSession owner = await Client.ManagedSession.CreateAsync(
new ManagedSessionOptions { Endpoint = SessionChannelHarness.CreateEndpoint() },
channels.Configuration,
new DefaultSessionFactory(channels.Telemetry),
telemetry: channels.Telemetry,
channelManager: channels.Manager,
updateBeforeConnect: false).WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
Assert.That(owner.TransportChannel, Is.InstanceOf());
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(owner).ConfigureAwait(false);
using var cancellation = new CancellationTokenSource();
int calls = 0;
var expected = new CallMethodResult { StatusCode = StatusCodes.GoodCompletesAsynchronously };
ScriptedChannel transport = channels.CreatedChannels[0];
transport.RequestHandler = async (request, _) =>
{
if (request is not CallRequest)
{
return transport.CreateResponse(request);
}
calls++;
await cancellation.CancelAsync().ConfigureAwait(false);
return new CallResponse
{
ResponseHeader = transport.CreateGoodHeader(),
Results = [expected]
};
};
CallResponse response = await context.Session.CallAsync(
null, [new CallMethodRequest { ObjectId = new NodeId(1u), MethodId = new NodeId(2u) }],
cancellation.Token).ConfigureAwait(false);
Assert.That(response.Results[0], Is.SameAs(expected));
Assert.That(calls, Is.EqualTo(1));
Assert.That(cancellation.IsCancellationRequested, Is.True);
await channels.Manager.ReconnectAsync((IManagedTransportChannel)owner.TransportChannel)
.AsTask().WaitAsync(TimeSpan.FromSeconds(10)).ConfigureAwait(false);
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await ReadHistoryAsync(context.Session).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadSessionIdInvalid));
Assert.That(context.Invalidated.IsCompleted, Is.True);
Assert.That(calls, Is.EqualTo(1));
}
///
/// Dispatch returns the original service failure without replaying the request.
///
[Test]
public async Task ScopedDispatchPreservesServiceFailureWithoutRetry()
{
await using var fixture = new SessionRequestContextTestContext();
fixture.Handler = static (_, _) => throw new ServiceResultException(StatusCodes.BadUserAccessDenied);
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await ReadHistoryAsync(context.Session).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadUserAccessDenied));
Assert.That(fixture.Requests, Has.Count.EqualTo(1));
}
///
/// Local disposal callbacks still run after invalidation even when the supplied token is cancelled.
///
[Test]
public async Task StaleExecuteStillRunsLocalCleanup()
{
await using var fixture = new SessionRequestContextTestContext();
await using SessionRequestContext context =
await SessionRequestContext.CaptureAsync(fixture.ManagedSession).ConfigureAwait(false);
fixture.Recreate();
bool cleaned = false;
using var cancellation = new CancellationTokenSource();
await cancellation.CancelAsync().ConfigureAwait(false);
int result = await context.ExecuteAsync((session, _) =>
{
Assert.That(session, Is.SameAs(context.Session));
cleaned = true;
return new ValueTask(12);
}, cancellation.Token).ConfigureAwait(false);
Assert.That(cleaned, Is.True);
Assert.That(result, Is.EqualTo(12));
Assert.That(fixture.Requests, Is.Empty);
}
///
/// No fixed-generation guarantee is fabricated for an arbitrary ISession implementation.
///
[Test]
public async Task CapturingCustomSessionFailsClosed()
{
var custom = new Mock();
ServiceResultException failure = (await Assert.ThrowsAsync(
async () => await SessionRequestContext.CaptureAsync(custom.Object).ConfigureAwait(false)))!;
Assert.That(failure.StatusCode, Is.EqualTo(StatusCodes.BadNotSupported));
custom.VerifyNoOtherCalls();
}
///
/// Sends an actual history request carrying a representative retained continuation point.
///
private static ValueTask ReadHistoryAsync(ISession session)
{
return session.HistoryReadAsync(
null, new ExtensionObject(new ReadRawModifiedDetails()),
TimestampsToReturn.Both, false,
[new HistoryReadValueId { NodeId = new NodeId(3u, 1), ContinuationPoint = ByteString.From("point"u8) }],
CancellationToken.None);
}
}
}