mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-20 08:41:51 +08:00
9b034f8291
# Description Nullable reference types are now enabled once, in `common.props`, and every project in the repository builds warning-clean with them. **Configuration** - `<Nullable>enable</Nullable>` lives in `common.props`. All per-project `<Nullable>` settings are removed: 178 project files (enable, annotations, and the `disable` in the three Aot test projects). - 419 per-file `#nullable enable`/`restore` directives and the 4 `#nullable disable` directives are removed. - Kept on purpose: - The 5 `<auto-generated>` schema files (`UANodeSet.cs`, `UA Type Dictionary.cs`, `OPCBinarySchema.cs`, `SecuredApplication.cs`, `PInvokeHelper.cs`) keep `#nullable enable`. The compiler ignores the project setting in generated code, so the directive is required there. - `tests/Opc.Ua.Interop.LegacyPeer` keeps `disable`. It builds against the 1.5.378 packages and does not import the repository props. - `docs/DeveloperGuide.md` and `.github/copilot-instructions.md` now describe the central setting. **Migration** (the 43 unset and 13 annotations-only projects, roughly 10k diagnostics) - **Production code: annotated at the declarations.** Covers `tools/Opc.Ua.SourceGeneration.Core`, `src/Opc.Ua.Lds.Server` and the LDS sample. - Members, parameters and returns are nullable where null actually flows. - Null-check helpers carry `[NotNullWhen]` (`IsNull`, `IsValidXmlName`, `IsMethodTypeDesign`). - `CoreUtils.Format`, `Utils.Format`, `ITemplateWriter.WriteLine` take `params object?[]`. `Template.AddReplacement` takes `string?` (rendering already skips null replacements). - `IRegisteredServerStore.RegisterAsync` returns `Task<RegistrationEntry?>`: it returns null when a registration is removed. - Dereferences backed by an invariant (for example, the validated model) use `!`, so runtime behaviour is unchanged. - **Behavioural changes in production code** (small, deliberate): - `NodeSetToModelDesign` throws `InvalidOperationException` when the file is not a NodeSet. Before, it threw a NullReferenceException on the next line. - `ModelDesignValidator` starts its lookup tables empty instead of null. A redundant `if (m_nodes == null) return;` guard is gone. - **Test code: minimal annotations.** - `?` on values that can be null, `!` where the test asserts non-null. - `= null!` for fixture members assigned in SetUp. - `TryGetStructure<T>(out T? x)` where the inferred `T?` broke the `IEncodeable` constraint. - A few redundant `== null` checks on structs (`NodeId`, `ArrayOf<T>`) folded into the adjacent `IsNull`/`Count` checks. **Notes for reviewers** - The diff is large (~1,500 files) but almost entirely mechanical: `?`, `!` and directive removal. The non-annotation changes are listed above. - Known follow-up, not done here: `IModelDesign.Namespaces/TargetNamespace/NamespaceUris` stay non-nullable even though the validator returns null before it loads a dictionary. Making them nullable would ripple through every generator call site. ## Related Issues - Fixes #4439 ## Checklist - [ ] I have signed the [CLA](https://opcfoundation.org/license/cla/ContributorLicenseAgreementv1.0.pdf) and read the [CONTRIBUTING](https://github.com/OPCFoundation/UA-.NETStandard/blob/master/CONTRIBUTING.md) doc. - [ ] I have added tests that prove my fix is effective or that my feature works and increased code coverage. - [x] I have added all necessary documentation. - [x] I have verified that my changes do not introduce (new) build or analyzer warnings. - [ ] I ran **all** tests locally using the **UA.slnx** solution against at least .net **framework** and .net **10**, and all passed. - [ ] I fixed **all** failing and flaky tests in the CI pipelines and **all** CodeQL warnings. - [ ] I have addressed **all** PR feedback received. The "no new warnings" box covers net10.0 and net48: full non-incremental builds of `UA.slnx`, `tools/SourceGeneration.slnx`, `tools/MigrationAnalyzer.slnx` and the Aot test projects finished with 0 warnings and 0 errors under the repository's warnings-as-errors settings. net9.0 and net8.0 also had no nullable diagnostics, but they were not rebuilt after the final fixes. Tests have not been run locally yet. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1757 lines
79 KiB
C#
1757 lines
79 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/
|
|
* ======================================================================*/
|
|
|
|
// CA2000: test code; many disposables are ownership-transferred to test fixtures or short-lived,
|
|
// making CA2000 noisy without a real leak risk. Disabled file-level for the suite.
|
|
using System;
|
|
using System.Collections.Generic;
|
|
using System.Diagnostics;
|
|
using System.Reflection;
|
|
using System.Threading;
|
|
using System.Threading.Tasks;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Time.Testing;
|
|
using Moq;
|
|
using NUnit.Framework;
|
|
using Opc.Ua.Client.TestFramework;
|
|
using Opc.Ua.Identity;
|
|
using Opc.Ua.Tests;
|
|
|
|
#pragma warning disable CA2000
|
|
|
|
namespace Opc.Ua.Client.Tests.ManagedSession
|
|
{
|
|
/// <summary>
|
|
/// Unit tests for ManagedSession components including
|
|
/// <see cref="ReconnectPolicy"/>, <see cref="ConnectionState"/>,
|
|
/// <see cref="ConnectionStateMachine"/>, and
|
|
/// <see cref="Client.ManagedSession"/> factory methods.
|
|
/// </summary>
|
|
[TestFixture]
|
|
public sealed class ManagedSessionTests
|
|
{
|
|
[Test]
|
|
public async Task DisposeAsyncRetainsRevalidationWorkerUntilItCompletesAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
using var inner = SessionMock.Create();
|
|
await using Client.ManagedSession managed = CreateManagedSessionWithInner(
|
|
CreateClientConfiguration(telemetry), CreateEndpoint(), inner, telemetry);
|
|
using var cancellation = new CancellationTokenSource();
|
|
var pending = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
typeof(Client.ManagedSession).GetField(
|
|
"m_revalidationCancellation", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.SetValue(managed, cancellation);
|
|
typeof(Client.ManagedSession).GetField(
|
|
"m_revalidationTask", BindingFlags.Instance | BindingFlags.NonPublic)!
|
|
.SetValue(managed, pending.Task);
|
|
|
|
Task disposal = managed.DisposeAsync().AsTask();
|
|
try
|
|
{
|
|
Assert.That(cancellation.IsCancellationRequested, Is.True);
|
|
Assert.That(disposal.IsCompleted, Is.False);
|
|
}
|
|
finally
|
|
{
|
|
pending.TrySetResult(true);
|
|
await disposal.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
}
|
|
Assert.That(() => cancellation.Token, Throws.TypeOf<ObjectDisposedException>());
|
|
}
|
|
|
|
[Test]
|
|
public void ExponentialBackoffIncreasesDelay()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Exponential,
|
|
InitialDelay = TimeSpan.FromSeconds(1),
|
|
MaxDelay = TimeSpan.FromMinutes(10),
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
TimeSpan? delay0 = policy.GetNextDelay(0);
|
|
TimeSpan? delay1 = policy.GetNextDelay(1);
|
|
TimeSpan? delay2 = policy.GetNextDelay(2);
|
|
|
|
Assert.That(delay0, Is.Not.Null);
|
|
Assert.That(delay1, Is.Not.Null);
|
|
Assert.That(delay2, Is.Not.Null);
|
|
|
|
// Exponential: 1s * 2^0 = 1s, 1s * 2^1 = 2s, 1s * 2^2 = 4s
|
|
Assert.That(delay0.Value.TotalSeconds, Is.EqualTo(1.0));
|
|
Assert.That(delay1.Value.TotalSeconds, Is.EqualTo(2.0));
|
|
Assert.That(delay2.Value.TotalSeconds, Is.EqualTo(4.0));
|
|
}
|
|
|
|
[Test]
|
|
public void LinearBackoffIncreasesLinearly()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Linear,
|
|
InitialDelay = TimeSpan.FromSeconds(2),
|
|
MaxDelay = TimeSpan.FromMinutes(10),
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
TimeSpan? delay0 = policy.GetNextDelay(0);
|
|
TimeSpan? delay1 = policy.GetNextDelay(1);
|
|
TimeSpan? delay2 = policy.GetNextDelay(2);
|
|
|
|
Assert.That(delay0, Is.Not.Null);
|
|
Assert.That(delay1, Is.Not.Null);
|
|
Assert.That(delay2, Is.Not.Null);
|
|
|
|
// Linear: 2s * (0+1) = 2s, 2s * (1+1) = 4s, 2s * (2+1) = 6s
|
|
Assert.That(delay0.Value.TotalSeconds, Is.EqualTo(2.0));
|
|
Assert.That(delay1.Value.TotalSeconds, Is.EqualTo(4.0));
|
|
Assert.That(delay2.Value.TotalSeconds, Is.EqualTo(6.0));
|
|
}
|
|
|
|
[Test]
|
|
public void ConstantBackoffReturnsSameDelay()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromSeconds(5),
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
TimeSpan? delay0 = policy.GetNextDelay(0);
|
|
TimeSpan? delay1 = policy.GetNextDelay(1);
|
|
TimeSpan? delay2 = policy.GetNextDelay(5);
|
|
|
|
Assert.That(delay0, Is.Not.Null);
|
|
Assert.That(delay1, Is.Not.Null);
|
|
Assert.That(delay2, Is.Not.Null);
|
|
|
|
Assert.That(delay0.Value.TotalSeconds, Is.EqualTo(5.0));
|
|
Assert.That(delay1.Value.TotalSeconds, Is.EqualTo(5.0));
|
|
Assert.That(delay2.Value.TotalSeconds, Is.EqualTo(5.0));
|
|
}
|
|
|
|
[Test]
|
|
public void MaxDelayIsCapped()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Exponential,
|
|
InitialDelay = TimeSpan.FromSeconds(1),
|
|
MaxDelay = TimeSpan.FromSeconds(10),
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
// Attempt 10: 1s * 2^10 = 1024s, should be capped to 10s
|
|
TimeSpan? delay = policy.GetNextDelay(10);
|
|
|
|
Assert.That(delay, Is.Not.Null);
|
|
Assert.That(
|
|
delay.Value.TotalSeconds,
|
|
Is.EqualTo(10.0));
|
|
}
|
|
|
|
[Test]
|
|
public void MaxRetriesStopsAfterLimit()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromSeconds(1),
|
|
MaxRetries = 3,
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
Assert.That(policy.GetNextDelay(0), Is.Not.Null);
|
|
Assert.That(policy.GetNextDelay(1), Is.Not.Null);
|
|
Assert.That(policy.GetNextDelay(2), Is.Not.Null);
|
|
|
|
// attempt >= MaxRetries => null
|
|
Assert.That(policy.GetNextDelay(3), Is.Null);
|
|
Assert.That(policy.GetNextDelay(4), Is.Null);
|
|
}
|
|
|
|
[Test]
|
|
public void UnlimitedRetriesNeverReturnsNull()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromSeconds(1),
|
|
MaxRetries = 0,
|
|
JitterFactor = 0.0
|
|
};
|
|
|
|
for (int i = 0; i < 100; i++)
|
|
{
|
|
Assert.That(
|
|
policy.GetNextDelay(i),
|
|
Is.Not.Null,
|
|
$"GetNextDelay returned null at attempt {i}");
|
|
}
|
|
}
|
|
|
|
[Test]
|
|
public void JitterAppliesVariation()
|
|
{
|
|
var policy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromSeconds(10),
|
|
MaxDelay = TimeSpan.FromSeconds(30),
|
|
JitterFactor = 0.5
|
|
};
|
|
|
|
var delays = new HashSet<double>();
|
|
for (int i = 0; i < 50; i++)
|
|
{
|
|
TimeSpan? delay = policy.GetNextDelay(0);
|
|
Assert.That(delay, Is.Not.Null);
|
|
delays.Add(delay.Value.TotalMilliseconds);
|
|
}
|
|
|
|
// With 50% jitter over 50 iterations we expect variation
|
|
Assert.That(delays, Has.Count.GreaterThan(1),
|
|
"Jitter should produce varying delays");
|
|
}
|
|
|
|
[Test]
|
|
public async Task ManagedSessionPropagatesBudgetOrUsesLegacyReconnectContractAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
var managedChannel = new Mock<IManagedTransportChannel>();
|
|
managedChannel.SetupGet(c => c.MessageContext).Returns(messageContext);
|
|
|
|
var channelManager = new Mock<IClientChannelManager>();
|
|
#if NET8_0_OR_GREATER
|
|
IRetryBudget? capturedBudget = null;
|
|
channelManager.Setup(m => m.ReconnectAsync(
|
|
managedChannel.Object,
|
|
It.IsAny<IRetryBudget>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<IManagedTransportChannel, IRetryBudget, CancellationToken>(
|
|
(_, budget, _) => capturedBudget = budget)
|
|
.Returns(new ValueTask());
|
|
#else
|
|
channelManager.Setup(m => m.ReconnectAsync(
|
|
managedChannel.Object, It.IsAny<CancellationToken>()))
|
|
.Returns(new ValueTask());
|
|
#endif
|
|
|
|
using var innerSession = new Session(
|
|
managedChannel.Object,
|
|
configuration,
|
|
endpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
|
innerSession.BindManagedChannel(channelManager.Object, managedChannel.Object);
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
endpoint,
|
|
innerSession,
|
|
telemetry);
|
|
var budget = new RetryBudget(TimeSpan.FromSeconds(30));
|
|
|
|
ServiceResult result = await InvokeHandleReconnectAsync(
|
|
managedSession,
|
|
budget)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(ServiceResult.IsGood(result), Is.True);
|
|
#if NET8_0_OR_GREATER
|
|
Assert.That(capturedBudget, Is.SameAs(budget));
|
|
#else
|
|
channelManager.Verify(m => m.ReconnectAsync(
|
|
managedChannel.Object, It.IsAny<CancellationToken>()), Times.Once);
|
|
#endif
|
|
}
|
|
|
|
[Test]
|
|
public async Task HandleReconnectAsyncUsesAlternateNetworkEndpointAfterBadSecureChannelClosedAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint primaryEndpoint = CreateEndpoint();
|
|
ConfiguredEndpoint alternateEndpoint = CreateEndpoint();
|
|
alternateEndpoint.Description.EndpointUrl = "opc.tcp://alternate:4840";
|
|
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
var managedChannel = new Mock<IManagedTransportChannel>();
|
|
managedChannel.SetupGet(c => c.MessageContext).Returns(messageContext);
|
|
managedChannel.SetupGet(c => c.State).Returns(ChannelState.Closed);
|
|
|
|
ConfiguredEndpoint? capturedEndpoint = null;
|
|
var channelManager = new Mock<IClientChannelManager>();
|
|
#if NET8_0_OR_GREATER
|
|
channelManager.Setup(m => m.ReconnectAsync(
|
|
managedChannel.Object,
|
|
It.IsAny<IRetryBudget>(),
|
|
It.IsAny<CancellationToken>()))
|
|
#else
|
|
channelManager.Setup(m => m.ReconnectAsync(
|
|
managedChannel.Object, It.IsAny<CancellationToken>()))
|
|
#endif
|
|
.Throws(new ServiceResultException(StatusCodes.BadSecureChannelClosed));
|
|
channelManager.Setup(m => m.GetAsync(
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<Func<IManagedTransportChannel, IReconnectParticipant>>(),
|
|
It.IsAny<ITransportWaitingConnection>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ConfiguredEndpoint, Func<IManagedTransportChannel, IReconnectParticipant>,
|
|
ITransportWaitingConnection?, CancellationToken>(
|
|
(endpoint, _, _, _) => capturedEndpoint = endpoint)
|
|
.Throws(new ServiceResultException(StatusCodes.BadUnexpectedError));
|
|
|
|
using var innerSession = new Session(
|
|
managedChannel.Object,
|
|
configuration,
|
|
primaryEndpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
|
innerSession.BindManagedChannel(channelManager.Object, managedChannel.Object);
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
primaryEndpoint,
|
|
innerSession,
|
|
telemetry,
|
|
new NetworkRedundancyOptions
|
|
{
|
|
AlternateEndpoints = new ArrayOf<ConfiguredEndpoint>(new[] { alternateEndpoint }.AsMemory())
|
|
});
|
|
var budget = new RetryBudget(TimeSpan.FromSeconds(30));
|
|
|
|
ServiceResult result = await InvokeHandleReconnectAsync(
|
|
managedSession,
|
|
budget)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(ServiceResult.IsBad(result), Is.True);
|
|
Assert.That(capturedEndpoint, Is.SameAs(alternateEndpoint));
|
|
}
|
|
|
|
[Test]
|
|
public async Task HandleFailoverAsyncUsesInnerSessionCurrentEndpointForTargetSelectionAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint initialEndpoint = CreateEndpoint();
|
|
initialEndpoint.Description.EndpointUrl = "opc.tcp://initial:4840";
|
|
ConfiguredEndpoint activeEndpoint = CreateEndpoint();
|
|
activeEndpoint.Description.EndpointUrl = "opc.tcp://active:4840";
|
|
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
var managedChannel = new Mock<IManagedTransportChannel>();
|
|
managedChannel.SetupGet(c => c.MessageContext).Returns(messageContext);
|
|
|
|
ConfiguredEndpoint? capturedCurrentEndpoint = null;
|
|
var redundancyHandler = new Mock<IServerRedundancyHandler>(MockBehavior.Strict);
|
|
redundancyHandler.Setup(h => h.SelectFailoverTarget(
|
|
It.IsAny<ServerRedundancyInfo>(),
|
|
It.IsAny<ConfiguredEndpoint>()))
|
|
.Callback<ServerRedundancyInfo, ConfiguredEndpoint>(
|
|
(_, currentEndpoint) => capturedCurrentEndpoint = currentEndpoint)
|
|
.Returns((ConfiguredEndpoint?)null);
|
|
|
|
using var innerSession = new Session(
|
|
managedChannel.Object,
|
|
configuration,
|
|
activeEndpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
initialEndpoint,
|
|
innerSession,
|
|
telemetry,
|
|
redundancyHandler: redundancyHandler.Object);
|
|
|
|
typeof(Client.ManagedSession)
|
|
.GetField(
|
|
"m_redundancyInfo",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.SetValue(managedSession, new ServerRedundancyInfo());
|
|
|
|
ServiceResult result = await InvokeHandleFailoverAsync(
|
|
managedSession,
|
|
new RetryBudget(TimeSpan.FromSeconds(30)))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
|
|
Assert.That(capturedCurrentEndpoint, Is.SameAs(activeEndpoint));
|
|
redundancyHandler.Verify(h => h.SelectFailoverTarget(
|
|
It.IsAny<ServerRedundancyInfo>(),
|
|
It.IsAny<ConfiguredEndpoint>()),
|
|
Times.Once);
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task ReconnectKeepsCachedFailoverAfterBoundedRedundancyRefreshAsync(bool ignoreCancellation)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
TimeProvider time = TimeProvider.System;
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(value => value.MessageContext).Returns(configuration.CreateMessageContext());
|
|
var manager = new Mock<IClientChannelManager>();
|
|
#if NET8_0_OR_GREATER
|
|
manager.Setup(value => value.ReconnectAsync(
|
|
channel.Object, It.IsAny<IRetryBudget>(), It.IsAny<CancellationToken>()))
|
|
#else
|
|
manager.Setup(value => value.ReconnectAsync(channel.Object, It.IsAny<CancellationToken>()))
|
|
#endif
|
|
.Returns(new ValueTask());
|
|
using var inner = new Session(channel.Object, configuration, endpoint);
|
|
inner.BindManagedChannel(manager.Object, channel.Object);
|
|
var cached = new ServerRedundancyInfo { Mode = RedundancySupport.Cold };
|
|
var refresh = new TaskCompletionSource<ServerRedundancyInfo>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
using var deadline = new CancellationTokenSource(TimeSpan.FromSeconds(5));
|
|
using CancellationTokenRegistration abort = deadline.Token.Register(() => refresh.TrySetCanceled());
|
|
var redundancy = new Mock<IServerRedundancyHandler>(MockBehavior.Strict);
|
|
redundancy.Setup(value => value.FetchRedundancyInfoAsync(
|
|
It.IsAny<ISession>(), It.IsAny<CancellationToken>()))
|
|
.Returns(() =>
|
|
{
|
|
if (!ignoreCancellation)
|
|
{
|
|
throw new ServiceResultException(StatusCodes.BadConnectionClosed);
|
|
}
|
|
return new ValueTask<ServerRedundancyInfo>(refresh.Task);
|
|
});
|
|
redundancy.Setup(value => value.SelectFailoverTarget(cached, endpoint))
|
|
.Returns((ConfiguredEndpoint?)null);
|
|
Client.ManagedSession managed = CreateManagedSessionWithInner(
|
|
configuration, endpoint, inner, telemetry, redundancyHandler: redundancy.Object, timeProvider: time);
|
|
typeof(Client.ManagedSession).GetField(
|
|
"m_redundancyInfo", BindingFlags.NonPublic | BindingFlags.Instance)!.SetValue(managed, cached);
|
|
var budget = new RetryBudget(TimeSpan.FromSeconds(30), time);
|
|
try
|
|
{
|
|
ServiceResult result = await InvokeHandleReconnectAsync(managed, budget).ConfigureAwait(false);
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.Good));
|
|
redundancy.Setup(value => value.FetchRedundancyInfoAsync(
|
|
It.IsAny<ISession>(), It.IsAny<CancellationToken>()))
|
|
.Throws(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
|
ServiceResult failover = await InvokeHandleFailoverAsync(managed, budget)
|
|
.WaitAsync(TimeSpan.FromSeconds(5)).ConfigureAwait(false);
|
|
Assert.That(failover.StatusCode, Is.EqualTo(StatusCodes.BadNothingToDo));
|
|
redundancy.Verify(value => value.SelectFailoverTarget(cached, endpoint), Times.Once);
|
|
}
|
|
finally
|
|
{
|
|
refresh.TrySetResult(cached);
|
|
await managed.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
}
|
|
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public async Task FailoverResolvesCachedPeersAndInvalidatesFailedPeerAsync(bool initiallyResolved)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint primary = CreateEndpoint();
|
|
primary.UpdateBeforeConnect = false;
|
|
primary.Description.Server.ApplicationUri = "urn:primary";
|
|
ConfiguredEndpoint oldPeer = CreateEndpoint();
|
|
oldPeer.Description.Server.ApplicationUri = "urn:backup";
|
|
oldPeer.Description.EndpointUrl = "opc.tcp://backup:4840";
|
|
ConfiguredEndpoint refreshedPeer = CreateEndpoint();
|
|
refreshedPeer.Description.Server.ApplicationUri = "urn:backup";
|
|
refreshedPeer.Description.EndpointUrl = "opc.tcp://backup:4841";
|
|
var resolver = new Mock<IRedundantServerEndpointResolver>();
|
|
int resolutions = 0;
|
|
resolver.Setup(value => value.ResolveAsync(
|
|
"urn:backup", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(() =>
|
|
{
|
|
resolutions++;
|
|
return resolutions == 1 ? initiallyResolved ? oldPeer : null : refreshedPeer;
|
|
});
|
|
var redundancy = new DefaultServerRedundancyHandler(resolver.Object);
|
|
var response = new ReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new DataValue((int)RedundancySupport.Hot),
|
|
new DataValue((byte)0),
|
|
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
|
new DataValue(ArrayOf.Create(["urn:backup"])),
|
|
new DataValue(new Variant(DateTime.MinValue))
|
|
]
|
|
};
|
|
var channel = new Mock<ITransportChannel>();
|
|
channel.SetupGet(value => value.MessageContext).Returns(configuration.CreateMessageContext());
|
|
channel.SetupSequence(value => value.SendRequestAsync(
|
|
It.IsAny<ReadRequest>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(response)
|
|
.Throws(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
|
using var inner = new SessionMock(channel, configuration, primary);
|
|
inner.SetConnected();
|
|
var lease = new Mock<IManagedTransportChannel>();
|
|
lease.SetupGet(value => value.MessageContext).Returns(inner.MessageContext);
|
|
var manager = new Mock<IClientChannelManager>();
|
|
ConfiguredEndpoint? attemptedPeer = null;
|
|
manager.Setup(value => value.GetAsync(
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<Func<IManagedTransportChannel, IReconnectParticipant>>(),
|
|
It.IsAny<ITransportWaitingConnection>(), It.IsAny<CancellationToken>()))
|
|
.Callback<ConfiguredEndpoint, Func<IManagedTransportChannel, IReconnectParticipant>,
|
|
ITransportWaitingConnection?, CancellationToken>((endpoint, _, _, _) => attemptedPeer = endpoint)
|
|
.Throws(new ServiceResultException(StatusCodes.BadServerHalted));
|
|
inner.BindManagedChannel(manager.Object, lease.Object);
|
|
var factory = new Mock<ISessionFactory>();
|
|
factory.SetupGet(value => value.Telemetry).Returns(telemetry);
|
|
factory.SetupGet(value => value.SubscriptionEngineFactory)
|
|
.Returns(DefaultSubscriptionEngineFactory.Instance);
|
|
factory.Setup(value => value.CreateAsync(
|
|
configuration, primary, false, false, It.IsAny<string>(), It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity>(), It.IsAny<ArrayOf<string>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
await using Client.ManagedSession managed = await Client.ManagedSession.CreateAsync(
|
|
configuration, primary, factory.Object, redundancyHandler: redundancy, ct: timeout.Token)
|
|
.ConfigureAwait(false);
|
|
Assert.That(managed.RedundancySupport, Is.EqualTo(RedundancySupport.Hot));
|
|
|
|
ServiceResult result = await managed.StateMachine.FailoverWithBudgetAsync!(
|
|
new RetryBudget(TimeSpan.FromSeconds(30)), timeout.Token).ConfigureAwait(false);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerHalted));
|
|
Assert.That(attemptedPeer, Is.SameAs(initiallyResolved ? oldPeer : refreshedPeer));
|
|
var source = new Mock<ISession>();
|
|
source.SetupGet(value => value.ConfiguredEndpoint).Returns(primary);
|
|
source.Setup(value => value.ReadAsync(
|
|
It.IsAny<RequestHeader>(), It.IsAny<double>(), It.IsAny<TimestampsToReturn>(),
|
|
It.IsAny<ArrayOf<ReadValueId>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(response);
|
|
ServerRedundancyInfo next = await redundancy.FetchRedundancyInfoAsync(source.Object, timeout.Token)
|
|
.ConfigureAwait(false);
|
|
Assert.That(next.RedundantServers[0].Endpoint, Is.SameAs(refreshedPeer));
|
|
Assert.That(resolutions, Is.EqualTo(initiallyResolved ? 2 : 3));
|
|
}
|
|
|
|
[TestCase(RedundancySupport.Cold, false)]
|
|
[TestCase(RedundancySupport.Cold, true)]
|
|
[TestCase(RedundancySupport.Warm, false)]
|
|
[TestCase(RedundancySupport.Warm, true)]
|
|
public async Task ConnectRetainsBasicRedundancyBeforeUnavailablePeerDiscoveryAsync(
|
|
RedundancySupport mode,
|
|
bool waitForPeerTimeout)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint primary = CreateEndpoint();
|
|
primary.UpdateBeforeConnect = false;
|
|
primary.Description.Server.ApplicationUri = "urn:primary";
|
|
ConfiguredEndpoint backup = CreateEndpoint();
|
|
backup.Description.Server.ApplicationUri = "urn:backup";
|
|
backup.Description.EndpointUrl = "opc.tcp://backup:4840";
|
|
var time = new FakeTimeProvider();
|
|
var peerEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var backupEntered = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var peerRetried = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var blocked = new TaskCompletionSource<ConfiguredEndpoint?>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
int offlineAttempts = 0;
|
|
bool backupAvailable = false;
|
|
var resolver = new Mock<IRedundantServerEndpointResolver>(MockBehavior.Strict);
|
|
resolver.Setup(value => value.ResolveAsync(
|
|
"urn:offline", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
|
|
.Returns(async (string _, ConfiguredEndpoint _, CancellationToken ct) =>
|
|
{
|
|
if (Interlocked.Increment(ref offlineAttempts) == 1)
|
|
{
|
|
peerEntered.TrySetResult(true);
|
|
}
|
|
else
|
|
{
|
|
peerRetried.TrySetResult(true);
|
|
}
|
|
return await blocked.Task.WaitAsync(ct).ConfigureAwait(false);
|
|
});
|
|
resolver.Setup(value => value.ResolveAsync(
|
|
"urn:backup", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()))
|
|
.Callback(() => backupEntered.TrySetResult(true))
|
|
.ReturnsAsync(() => backupAvailable ? backup : null);
|
|
var redundancy = new DefaultServerRedundancyHandler(resolver.Object, time);
|
|
var response = new ReadResponse
|
|
{
|
|
Results =
|
|
[
|
|
new DataValue((int)mode),
|
|
new DataValue(ServiceLevels.Maximum),
|
|
DataValue.FromStatusCode(StatusCodes.BadNodeIdUnknown),
|
|
new DataValue(ArrayOf.Create(["urn:offline", "urn:backup"])),
|
|
new DataValue(new Variant(DateTime.MinValue))
|
|
]
|
|
};
|
|
var channel = new Mock<ITransportChannel>();
|
|
channel.SetupGet(value => value.MessageContext).Returns(configuration.CreateMessageContext());
|
|
channel.SetupSequence(value => value.SendRequestAsync(
|
|
It.IsAny<ReadRequest>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(response)
|
|
.Throws(new ServiceResultException(StatusCodes.BadConnectionClosed));
|
|
using var inner = new SessionMock(channel, configuration, primary);
|
|
inner.SetConnected();
|
|
var lease = new Mock<IManagedTransportChannel>();
|
|
lease.SetupGet(value => value.MessageContext).Returns(inner.MessageContext);
|
|
var manager = new Mock<IClientChannelManager>();
|
|
ConfiguredEndpoint? attemptedPeer = null;
|
|
manager.Setup(value => value.GetAsync(
|
|
It.IsAny<ConfiguredEndpoint>(), It.IsAny<Func<IManagedTransportChannel, IReconnectParticipant>>(),
|
|
It.IsAny<ITransportWaitingConnection>(), It.IsAny<CancellationToken>()))
|
|
.Callback<ConfiguredEndpoint, Func<IManagedTransportChannel, IReconnectParticipant>,
|
|
ITransportWaitingConnection?, CancellationToken>((endpoint, _, _, _) => attemptedPeer = endpoint)
|
|
.Throws(new ServiceResultException(StatusCodes.BadServerHalted));
|
|
inner.BindManagedChannel(manager.Object, lease.Object);
|
|
var factory = new Mock<ISessionFactory>();
|
|
factory.SetupGet(value => value.Telemetry).Returns(telemetry);
|
|
factory.SetupGet(value => value.SubscriptionEngineFactory)
|
|
.Returns(DefaultSubscriptionEngineFactory.Instance);
|
|
factory.Setup(value => value.CreateAsync(
|
|
configuration, primary, false, false, It.IsAny<string>(), It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity>(), It.IsAny<ArrayOf<string>>(), It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(inner);
|
|
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(10));
|
|
Task<Client.ManagedSession> connecting = Client.ManagedSession.CreateAsync(
|
|
configuration, primary, factory.Object, redundancyHandler: redundancy, timeProvider: time,
|
|
ct: timeout.Token);
|
|
// Advancing before both parallel lookups enter can expire a peer before it is called.
|
|
await Task.WhenAll(peerEntered.Task, backupEntered.Task).WaitAsync(timeout.Token).ConfigureAwait(false);
|
|
if (waitForPeerTimeout)
|
|
{
|
|
time.Advance(TimeSpan.FromSeconds(2));
|
|
}
|
|
await using Client.ManagedSession managed = await connecting.WaitAsync(timeout.Token)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(managed.RedundancySupport, Is.EqualTo(mode));
|
|
var snapshot = (ServerRedundancyInfo?)typeof(Client.ManagedSession)
|
|
.GetField("m_redundancyInfo", BindingFlags.NonPublic | BindingFlags.Instance)!.GetValue(managed);
|
|
Assert.That(snapshot, Is.Not.Null);
|
|
Assert.That(snapshot!.RedundantServers.Count, Is.EqualTo(2));
|
|
Assert.That(snapshot.RedundantServers[0].ServerUri, Is.EqualTo("urn:offline"));
|
|
Assert.That(snapshot.RedundantServers[1].ServerUri, Is.EqualTo("urn:backup"));
|
|
Assert.That(snapshot.ServiceLevel, Is.EqualTo(ServiceLevels.Maximum));
|
|
Assert.That(snapshot.RedundantServers[0].Endpoint, Is.Null);
|
|
|
|
var discovery = (Task<ServerRedundancyInfo>?)typeof(Client.ManagedSession)
|
|
.GetField("m_redundancyEndpointRefreshTask", BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.GetValue(managed);
|
|
Assert.That(discovery, Is.Not.Null);
|
|
if (!waitForPeerTimeout)
|
|
{
|
|
Assert.That(discovery!.IsCompleted, Is.False, "Peer discovery must not gate the connected state.");
|
|
time.Advance(TimeSpan.FromSeconds(2));
|
|
}
|
|
await discovery!.WaitAsync(timeout.Token).ConfigureAwait(false);
|
|
backupAvailable = true;
|
|
// The offline peer's retry returns no endpoint without timing out the healthy peer alongside it.
|
|
blocked.TrySetResult(null);
|
|
Task<ServiceResult> failover = managed.StateMachine.FailoverWithBudgetAsync!(
|
|
new RetryBudget(TimeSpan.FromSeconds(30), time), timeout.Token);
|
|
await peerRetried.Task.WaitAsync(timeout.Token).ConfigureAwait(false);
|
|
ServiceResult result = await failover.WaitAsync(timeout.Token).ConfigureAwait(false);
|
|
|
|
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadServerHalted));
|
|
Assert.That(attemptedPeer, Is.SameAs(backup));
|
|
Assert.That(managed.RedundancySupport, Is.EqualTo(mode));
|
|
resolver.Verify(value => value.ResolveAsync(
|
|
"urn:backup", It.IsAny<ConfiguredEndpoint>(), It.IsAny<CancellationToken>()), Times.Exactly(2));
|
|
}
|
|
|
|
[Test]
|
|
public void ConnectionStateChangedEventArgsHasCorrectProperties()
|
|
{
|
|
var error = new ServiceResult(StatusCodes.BadCommunicationError);
|
|
var args = new ConnectionStateChangedEventArgs
|
|
{
|
|
PreviousState = ConnectionState.Connected,
|
|
NewState = ConnectionState.Reconnecting,
|
|
Error = error,
|
|
ReconnectAttempt = 3
|
|
};
|
|
|
|
Assert.That(args.PreviousState, Is.EqualTo(ConnectionState.Connected));
|
|
Assert.That(args.NewState, Is.EqualTo(ConnectionState.Reconnecting));
|
|
Assert.That(args.Error, Is.Not.Null);
|
|
Assert.That(args.Error.StatusCode, Is.EqualTo(StatusCodes.BadCommunicationError));
|
|
Assert.That(args.ReconnectAttempt, Is.EqualTo(3));
|
|
}
|
|
|
|
[Test]
|
|
public void InitialStateIsDisconnected()
|
|
{
|
|
var policy = new ReconnectPolicy();
|
|
ILogger logger = NUnitTelemetryContext.Create().CreateLogger("ManagedSession");
|
|
|
|
using var cts = new CancellationTokenSource();
|
|
var sm = new ConnectionStateMachine(policy, logger);
|
|
|
|
Assert.That(sm.State, Is.EqualTo(ConnectionState.Disconnected));
|
|
}
|
|
|
|
[Test]
|
|
public async Task DisposeTransitionsToClosedState()
|
|
{
|
|
var policy = new ReconnectPolicy();
|
|
ILogger logger = NUnitTelemetryContext.Create().CreateLogger("ManagedSession");
|
|
|
|
var sm = new ConnectionStateMachine(policy, logger);
|
|
sm.Start();
|
|
|
|
await sm.DisposeAsync().ConfigureAwait(false);
|
|
|
|
Assert.That(sm.State, Is.EqualTo(ConnectionState.Closed));
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateAsyncCancellationStopsStartedConnectAttemptAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
var gate = new BlockingConnectGate();
|
|
using var cancellation = new CancellationTokenSource();
|
|
|
|
Task<Client.ManagedSession> creation = Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
new DefaultSessionFactory(telemetry),
|
|
connectGate: gate,
|
|
ct: cancellation.Token);
|
|
|
|
Task startedTask = await Task.WhenAny(
|
|
gate.Started,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(startedTask, Is.SameAs(gate.Started));
|
|
|
|
cancellation.Cancel();
|
|
|
|
Task completedTask = await Task.WhenAny(
|
|
creation,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(completedTask, Is.SameAs(creation));
|
|
|
|
OperationCanceledException exception =
|
|
Assert.CatchAsync<OperationCanceledException>(async () =>
|
|
await creation.ConfigureAwait(false))!;
|
|
Assert.That(exception.CancellationToken, Is.EqualTo(cancellation.Token));
|
|
|
|
Task stoppedTask = await Task.WhenAny(
|
|
gate.Stopped,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(stoppedTask, Is.SameAs(gate.Stopped));
|
|
}
|
|
|
|
[Test]
|
|
public void CreateAsyncThrowsOnNullConfiguration()
|
|
{
|
|
var mockFactory = new Mock<ISessionFactory>();
|
|
mockFactory.Setup(f => f.Telemetry)
|
|
.Returns(new Mock<ITelemetryContext>().Object);
|
|
|
|
var endpoint = new ConfiguredEndpoint(
|
|
null!,
|
|
new EndpointDescription("opc.tcp://localhost:4840"));
|
|
|
|
Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
|
await Client.ManagedSession.CreateAsync(
|
|
configuration: null!,
|
|
endpoint: endpoint,
|
|
sessionFactory: mockFactory.Object).ConfigureAwait(false));
|
|
}
|
|
|
|
[Test]
|
|
public void CreateAsyncThrowsOnNullEndpoint()
|
|
{
|
|
var mockFactory = new Mock<ISessionFactory>();
|
|
mockFactory.Setup(f => f.Telemetry)
|
|
.Returns(new Mock<ITelemetryContext>().Object);
|
|
|
|
var config = new ApplicationConfiguration
|
|
{
|
|
ApplicationName = "TestApp",
|
|
ApplicationType = ApplicationType.Client,
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier()
|
|
}
|
|
};
|
|
|
|
Assert.ThrowsAsync<ArgumentNullException>(async () =>
|
|
await Client.ManagedSession.CreateAsync(
|
|
configuration: config,
|
|
endpoint: null!,
|
|
sessionFactory: mockFactory.Object).ConfigureAwait(false));
|
|
}
|
|
|
|
[Test]
|
|
public void ConnectConsultsTheProviderWhenNoTokenPoliciesAndNoFallbackIdentityExist()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpointWithoutTokenPolicies();
|
|
|
|
IUserIdentity? observed = null;
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
sessionFactory.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(), It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(), It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ApplicationConfiguration, ConfiguredEndpoint, bool, bool, string, uint,
|
|
IUserIdentity?, ArrayOf<string>, CancellationToken>(
|
|
(_, _, _, _, _, _, identity, _, _) => observed = identity)
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCertificateUntrusted));
|
|
|
|
var provider = new Mock<IClientIdentityProvider>();
|
|
|
|
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
reconnectPolicy: NoRetryPolicy(),
|
|
identityProvider: provider.Object).ConfigureAwait(false));
|
|
|
|
// Without a configured identity there is nothing to defer to, so the
|
|
// provider must be consulted. Deferring would hand the factory null,
|
|
// which the inner session silently turns into Anonymous and then
|
|
// rejects with BadIdentityTokenInvalid on a non-anonymous endpoint.
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenRejected));
|
|
Assert.That(observed, Is.Null);
|
|
});
|
|
sessionFactory.Verify(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(), It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(), It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
}
|
|
|
|
[Test]
|
|
public void ConnectKeepsTheConfiguredIdentityWhenTheEndpointAdvertisesNoTokenPolicies()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpointWithoutTokenPolicies();
|
|
var configured = new UserIdentity();
|
|
|
|
IUserIdentity? observed = null;
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
sessionFactory.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(), It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(), It.IsAny<bool>(), It.IsAny<string>(), It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(), It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback<ApplicationConfiguration, ConfiguredEndpoint, bool, bool, string, uint,
|
|
IUserIdentity?, ArrayOf<string>, CancellationToken>(
|
|
(_, _, _, _, _, _, identity, _, _) => observed = identity)
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCertificateUntrusted));
|
|
|
|
var provider = new Mock<IClientIdentityProvider>(MockBehavior.Strict);
|
|
|
|
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
identity: configured,
|
|
reconnectPolicy: NoRetryPolicy(),
|
|
identityProvider: provider.Object).ConfigureAwait(false));
|
|
|
|
// A fallback identity exists, so selection is deferred until discovery
|
|
// populates the policies and the attempt fails at the transport layer
|
|
// for the reconnect policy to retry.
|
|
Assert.Multiple(() =>
|
|
{
|
|
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadCertificateUntrusted));
|
|
Assert.That(observed, Is.SameAs(configured));
|
|
});
|
|
provider.VerifyNoOtherCalls();
|
|
}
|
|
|
|
private static ReconnectPolicy NoRetryPolicy()
|
|
{
|
|
return new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromMilliseconds(1),
|
|
MaxRetries = 0,
|
|
JitterFactor = 0.0,
|
|
MaxTotalReconnectTime = TimeSpan.FromSeconds(5)
|
|
};
|
|
}
|
|
|
|
private static ConfiguredEndpoint CreateEndpointWithoutTokenPolicies()
|
|
{
|
|
var description = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
TransportProfileUri = Profiles.UaTcpTransport
|
|
};
|
|
description.Server.ApplicationUri = description.EndpointUrl;
|
|
description.Server.ApplicationType = ApplicationType.Server;
|
|
|
|
return new ConfiguredEndpoint(
|
|
null,
|
|
description,
|
|
new EndpointConfiguration { OperationTimeout = 6000 })
|
|
{
|
|
UpdateBeforeConnect = false
|
|
};
|
|
}
|
|
|
|
[Test]
|
|
public void CreateAsyncThrowsInitialConnectErrorInsteadOfReturningUnconnectedSession()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
|
|
int connectAttempts = 0;
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
sessionFactory.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(),
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(),
|
|
It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Callback(() => Interlocked.Increment(ref connectAttempts))
|
|
.ThrowsAsync(new ServiceResultException(
|
|
StatusCodes.BadCertificateUntrusted));
|
|
|
|
var reconnectPolicy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromMilliseconds(5),
|
|
MaxRetries = 2,
|
|
JitterFactor = 0.0,
|
|
MaxTotalReconnectTime = TimeSpan.FromSeconds(10)
|
|
};
|
|
|
|
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
reconnectPolicy: reconnectPolicy).ConfigureAwait(false));
|
|
|
|
// The original connect error must survive, not the BadNotSupported
|
|
// reported by the failover that has no redundant server to try, and
|
|
// not the BadNotConnected of a session handed back half-built.
|
|
Assert.That(
|
|
exception.StatusCode,
|
|
Is.EqualTo(StatusCodes.BadCertificateUntrusted));
|
|
|
|
// A reconnect with no inner session runs a full connect, so the
|
|
// retry policy actually retries the initial connect.
|
|
Assert.That(connectAttempts, Is.GreaterThan(1));
|
|
}
|
|
|
|
[Test]
|
|
[TestCase(false)]
|
|
[TestCase(true)]
|
|
public void CreateAsyncConnectsThroughAFactoryThatCarriesTheSubscriptionEngine(
|
|
bool engineConfigured)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
ISubscriptionEngineFactory configuredEngine = new Mock<ISubscriptionEngineFactory>().Object;
|
|
|
|
var specialised = new Mock<ISessionFactory>();
|
|
specialised.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
var original = new Mock<ISessionFactory>();
|
|
original.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
original.SetupGet(f => f.SubscriptionEngineFactory)
|
|
.Returns(engineConfigured ? configuredEngine : null);
|
|
original.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(specialised.Object);
|
|
|
|
Mock<ISessionFactory> expected = engineConfigured ? original : specialised;
|
|
expected.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(),
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(),
|
|
It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ThrowsAsync(new ServiceResultException(StatusCodes.BadCertificateUntrusted));
|
|
|
|
var reconnectPolicy = new ReconnectPolicy
|
|
{
|
|
Strategy = BackoffStrategy.Constant,
|
|
InitialDelay = TimeSpan.FromMilliseconds(5),
|
|
MaxRetries = 0,
|
|
JitterFactor = 0.0,
|
|
MaxTotalReconnectTime = TimeSpan.FromSeconds(10)
|
|
};
|
|
|
|
Assert.ThrowsAsync<ServiceResultException>(
|
|
async () => await Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
original.Object,
|
|
reconnectPolicy: reconnectPolicy).ConfigureAwait(false));
|
|
|
|
// A factory without an engine is specialised with the V2 default
|
|
// and the copy connects; a factory that has one is used as-is.
|
|
original.Verify(
|
|
f => f.WithSubscriptionEngine(It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()),
|
|
engineConfigured ? Times.Never() : Times.Once());
|
|
expected.Verify(
|
|
f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(),
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(),
|
|
It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()),
|
|
Times.AtLeastOnce());
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateAsyncDisposesManagedSessionWhenInitialWaitIsCanceledAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(messageContext);
|
|
var innerSession = new Session(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
|
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
sessionFactory.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(),
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(),
|
|
It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(innerSession);
|
|
|
|
Client.ManagedSession? managedSession = null;
|
|
var fetchStarted = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var fetchCompletion = new TaskCompletionSource<ServerRedundancyInfo>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var redundancyHandler = new Mock<IServerRedundancyHandler>();
|
|
redundancyHandler.Setup(h => h.FetchRedundancyInfoAsync(
|
|
It.IsAny<ISession>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((ISession session, CancellationToken ct) =>
|
|
{
|
|
managedSession = (Client.ManagedSession)session;
|
|
fetchStarted.TrySetResult(true);
|
|
ct.Register(() => fetchCompletion.TrySetCanceled());
|
|
return new ValueTask<ServerRedundancyInfo>(fetchCompletion.Task);
|
|
});
|
|
|
|
using var cancellation = new CancellationTokenSource();
|
|
Task<Client.ManagedSession> createTask = Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
redundancyHandler: redundancyHandler.Object,
|
|
ct: cancellation.Token);
|
|
|
|
Task startedTask = await Task.WhenAny(
|
|
fetchStarted.Task,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(startedTask, Is.SameAs(fetchStarted.Task));
|
|
|
|
cancellation.Cancel();
|
|
|
|
Task completedTask = await Task.WhenAny(
|
|
createTask,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(completedTask, Is.SameAs(createTask));
|
|
Assert.CatchAsync<OperationCanceledException>(() => createTask);
|
|
Assert.That(fetchCompletion.Task.IsCanceled, Is.True);
|
|
Assert.That(managedSession, Is.Not.Null);
|
|
Assert.That(
|
|
managedSession!.StateMachine.State,
|
|
Is.EqualTo(ConnectionState.Closed));
|
|
Assert.Throws<ServiceResultException>(() => _ = managedSession.InnerSession);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CloseAsyncCompletesWhileConnectAttemptIsInFlightAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
IServiceMessageContext messageContext = configuration.CreateMessageContext();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(messageContext);
|
|
var innerSession = new Session(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance);
|
|
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
sessionFactory.Setup(f => f.CreateAsync(
|
|
It.IsAny<ApplicationConfiguration>(),
|
|
It.IsAny<ConfiguredEndpoint>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<bool>(),
|
|
It.IsAny<string>(),
|
|
It.IsAny<uint>(),
|
|
It.IsAny<IUserIdentity?>(),
|
|
It.IsAny<ArrayOf<string>>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.ReturnsAsync(innerSession);
|
|
|
|
Client.ManagedSession? managedSession = null;
|
|
var fetchStarted = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var fetchCompletion = new TaskCompletionSource<ServerRedundancyInfo>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
// Models a peer that never answers: the connect attempt in flight
|
|
// only ends when its token is cancelled.
|
|
var redundancyHandler = new Mock<IServerRedundancyHandler>();
|
|
redundancyHandler.Setup(h => h.FetchRedundancyInfoAsync(
|
|
It.IsAny<ISession>(),
|
|
It.IsAny<CancellationToken>()))
|
|
.Returns((ISession session, CancellationToken ct) =>
|
|
{
|
|
managedSession = (Client.ManagedSession)session;
|
|
fetchStarted.TrySetResult(true);
|
|
ct.Register(() => fetchCompletion.TrySetCanceled());
|
|
return new ValueTask<ServerRedundancyInfo>(fetchCompletion.Task);
|
|
});
|
|
|
|
Task<Client.ManagedSession> createTask = Client.ManagedSession.CreateAsync(
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
redundancyHandler: redundancyHandler.Object);
|
|
|
|
Task startedTask = await Task.WhenAny(
|
|
fetchStarted.Task,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(startedTask, Is.SameAs(fetchStarted.Task));
|
|
Assert.That(managedSession, Is.Not.Null);
|
|
|
|
Task<StatusCode> closeTask = managedSession!.CloseAsync(
|
|
10_000,
|
|
closeChannel: true);
|
|
|
|
Task completedClose = await Task.WhenAny(
|
|
closeTask,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(
|
|
completedClose,
|
|
Is.SameAs(closeTask),
|
|
"CloseAsync must not wait for the attempt in flight.");
|
|
|
|
Assert.That(
|
|
await closeTask.ConfigureAwait(false),
|
|
Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(
|
|
managedSession.StateMachine.State,
|
|
Is.EqualTo(ConnectionState.Closed));
|
|
|
|
Assert.CatchAsync<ServiceResultException>(() => createTask);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CloseAsyncReturnsBadTimeoutWhenInnerCloseDoesNotCompleteAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(
|
|
configuration.CreateMessageContext());
|
|
|
|
var release = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var innerSession = new BlockingCloseSession(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint)
|
|
{
|
|
CloseBlocker = release.Task
|
|
};
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
endpoint,
|
|
innerSession,
|
|
telemetry);
|
|
managedSession.StateMachine.Start();
|
|
|
|
StatusCode result = await managedSession
|
|
.CloseAsync(250, closeChannel: true)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.BadTimeout));
|
|
|
|
// The close continues in the background: let it finish so the
|
|
// session is torn down before the fixture completes.
|
|
release.TrySetResult(true);
|
|
await managedSession.StateMachine
|
|
.WaitForClosedAsync(default)
|
|
.ConfigureAwait(false);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CloseAsyncPropagatesInnerCloseStatusAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(
|
|
configuration.CreateMessageContext());
|
|
|
|
var innerSession = new BlockingCloseSession(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint)
|
|
{
|
|
CloseResult = StatusCodes.BadSessionClosed
|
|
};
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
endpoint,
|
|
innerSession,
|
|
telemetry);
|
|
managedSession.StateMachine.Start();
|
|
|
|
StatusCode result = await managedSession
|
|
.CloseAsync(10_000, closeChannel: true)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.BadSessionClosed));
|
|
Assert.That(innerSession.CloseChannelRequested, Is.True);
|
|
channel.Verify(c => c.Dispose(), Times.Once);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CloseAsyncWithCloseChannelFalseKeepsTransportChannelAsync()
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(
|
|
configuration.CreateMessageContext());
|
|
|
|
var innerSession = new BlockingCloseSession(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint);
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
endpoint,
|
|
innerSession,
|
|
telemetry);
|
|
managedSession.StateMachine.Start();
|
|
|
|
StatusCode result = await managedSession
|
|
.CloseAsync(10_000, closeChannel: false)
|
|
.ConfigureAwait(false);
|
|
|
|
Assert.That(result, Is.EqualTo(StatusCodes.Good));
|
|
Assert.That(innerSession.CloseChannelRequested, Is.False);
|
|
|
|
// The channel stays owned by the caller: neither closed nor
|
|
// disposed by the session teardown.
|
|
channel.Verify(
|
|
c => c.CloseAsync(It.IsAny<CancellationToken>()),
|
|
Times.Never);
|
|
channel.Verify(c => c.Dispose(), Times.Never);
|
|
}
|
|
|
|
/// <summary>
|
|
/// G6 (review of L1-3): disposing or closing a managed session from a
|
|
/// Notification handler of its inner session made the state machine
|
|
/// worker dispose the inner session, which waited the full drain
|
|
/// timeout (30 s) for the very handler that waited for the close.
|
|
/// </summary>
|
|
[TestCase(true)]
|
|
[TestCase(false)]
|
|
public async Task CloseFromInnerNotificationHandlerDoesNotWaitForItsDrainAsync(bool dispose)
|
|
{
|
|
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
|
|
ApplicationConfiguration configuration = CreateClientConfiguration(telemetry);
|
|
ConfiguredEndpoint endpoint = CreateEndpoint();
|
|
|
|
var channel = new Mock<IManagedTransportChannel>();
|
|
channel.SetupGet(c => c.MessageContext).Returns(
|
|
configuration.CreateMessageContext());
|
|
|
|
var innerSession = new BlockingCloseSession(
|
|
channel.Object,
|
|
configuration,
|
|
endpoint);
|
|
|
|
using Client.ManagedSession managedSession = CreateManagedSessionWithInner(
|
|
configuration,
|
|
endpoint,
|
|
innerSession,
|
|
telemetry);
|
|
managedSession.StateMachine.Start();
|
|
|
|
var closed = new TaskCompletionSource<TimeSpan>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
Assert.That(innerSession.RunBackgroundWork("Notification", () =>
|
|
{
|
|
var watch = Stopwatch.StartNew();
|
|
if (dispose)
|
|
{
|
|
managedSession.Dispose();
|
|
}
|
|
else
|
|
{
|
|
managedSession.CloseAsync(0, closeChannel: true)
|
|
.GetAwaiter().GetResult();
|
|
}
|
|
closed.SetResult(watch.Elapsed);
|
|
}), Is.True);
|
|
|
|
TimeSpan elapsed = await closed.Task
|
|
.WaitAsync(TimeSpan.FromSeconds(60))
|
|
.ConfigureAwait(false);
|
|
Assert.That(elapsed, Is.LessThan(TimeSpan.FromSeconds(8)));
|
|
Assert.That(innerSession.Disposed, Is.True);
|
|
}
|
|
|
|
[Test]
|
|
public async Task CreateAsyncLogsDisposeFailureAndPreservesCancellationAsync()
|
|
{
|
|
var logger = new Mock<ILogger>();
|
|
logger.Setup(l => l.IsEnabled(It.IsAny<LogLevel>())).Returns(true);
|
|
var disposeFailure = new InvalidOperationException("Test cleanup failure.");
|
|
|
|
// The stack emits source-generated log messages whose state has an empty
|
|
// ToString(); match on the EventId name (the log method name) instead.
|
|
#pragma warning disable CA1873 // Moq Setup on ILogger.Log is an expression tree, not an executed logging call.
|
|
logger.Setup(l => l.Log(
|
|
It.IsAny<LogLevel>(),
|
|
It.Is<EventId>(eventId => eventId.Name == "ConnectionStateMachineWorkerExiting"),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.IsAny<Exception?>(),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()))
|
|
.Throws(disposeFailure);
|
|
#pragma warning restore CA1873
|
|
|
|
var loggerFactory = new Mock<ILoggerFactory>();
|
|
loggerFactory.Setup(f => f.CreateLogger(It.IsAny<string>()))
|
|
.Returns(logger.Object);
|
|
var telemetry = new Mock<ITelemetryContext>();
|
|
telemetry.SetupGet(t => t.LoggerFactory).Returns(loggerFactory.Object);
|
|
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry.Object);
|
|
sessionFactory.Setup(f => f.WithSubscriptionEngine(
|
|
It.IsAny<ISubscriptionEngineFactory>(), It.IsAny<TimeProvider?>()))
|
|
.Returns(sessionFactory.Object);
|
|
var connectStarted = new TaskCompletionSource<bool>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var connectCompletion = new TaskCompletionSource<IDisposable>(
|
|
TaskCreationOptions.RunContinuationsAsynchronously);
|
|
var connectGate = new Mock<IClientConnectGate>();
|
|
connectGate.Setup(g => g.AcquireAsync(It.IsAny<CancellationToken>()))
|
|
.Returns((CancellationToken ct) =>
|
|
{
|
|
connectStarted.TrySetResult(true);
|
|
ct.Register(() => connectCompletion.TrySetCanceled());
|
|
return new ValueTask<IDisposable>(connectCompletion.Task);
|
|
});
|
|
|
|
using var cancellation = new CancellationTokenSource();
|
|
Task<Client.ManagedSession> createTask = Client.ManagedSession.CreateAsync(
|
|
CreateClientConfiguration(telemetry.Object),
|
|
CreateEndpoint(),
|
|
sessionFactory.Object,
|
|
connectGate: connectGate.Object,
|
|
ct: cancellation.Token);
|
|
|
|
Task startedTask = await Task.WhenAny(
|
|
connectStarted.Task,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(startedTask, Is.SameAs(connectStarted.Task));
|
|
|
|
cancellation.Cancel();
|
|
|
|
Task completedTask = await Task.WhenAny(
|
|
createTask,
|
|
Task.Delay(TimeSpan.FromSeconds(5)))
|
|
.ConfigureAwait(false);
|
|
Assert.That(completedTask, Is.SameAs(createTask));
|
|
Assert.CatchAsync<OperationCanceledException>(() => createTask);
|
|
#pragma warning disable CA1873 // Moq Verify on ILogger.Log is an expression tree, not an executed logging call.
|
|
logger.Verify(
|
|
l => l.Log(
|
|
LogLevel.Error,
|
|
It.Is<EventId>(eventId =>
|
|
eventId.Name == "ManagedSessionDisposalAfterConnectionFailureFailed"),
|
|
It.IsAny<It.IsAnyType>(),
|
|
It.Is<Exception?>(exception => ReferenceEquals(exception, disposeFailure)),
|
|
It.IsAny<Func<It.IsAnyType, Exception?, string>>()),
|
|
Times.Once);
|
|
#pragma warning restore CA1873
|
|
}
|
|
|
|
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for reconnect-path tests that need the private handler.
|
|
private static Task<ServiceResult> InvokeHandleReconnectAsync(
|
|
Client.ManagedSession managedSession,
|
|
IRetryBudget budget)
|
|
{
|
|
MethodInfo? method = typeof(Client.ManagedSession).GetMethod(
|
|
"HandleReconnectAsync",
|
|
BindingFlags.NonPublic | BindingFlags.Instance,
|
|
null,
|
|
[typeof(IRetryBudget), typeof(CancellationToken)],
|
|
null);
|
|
|
|
Assert.That(method, Is.Not.Null);
|
|
|
|
var task = (Task<ServiceResult>?)method!.Invoke(
|
|
managedSession,
|
|
[budget, CancellationToken.None]);
|
|
|
|
Assert.That(task, Is.Not.Null);
|
|
return task!;
|
|
}
|
|
#pragma warning restore IDE0051, RCS1213
|
|
|
|
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for failover-path tests that need the private handler.
|
|
private static Task<ServiceResult> InvokeHandleFailoverAsync(
|
|
Client.ManagedSession managedSession,
|
|
IRetryBudget budget)
|
|
{
|
|
MethodInfo? method = typeof(Client.ManagedSession).GetMethod(
|
|
"HandleFailoverAsync",
|
|
BindingFlags.NonPublic | BindingFlags.Instance,
|
|
null,
|
|
[typeof(IRetryBudget), typeof(CancellationToken)],
|
|
null);
|
|
|
|
Assert.That(method, Is.Not.Null);
|
|
|
|
var task = (Task<ServiceResult>?)method!.Invoke(
|
|
managedSession,
|
|
[budget, CancellationToken.None]);
|
|
|
|
Assert.That(task, Is.Not.Null);
|
|
return task!;
|
|
}
|
|
#pragma warning restore IDE0051, RCS1213
|
|
|
|
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for tests that inject an already-created inner Session.
|
|
private static Client.ManagedSession CreateManagedSessionWithInner(
|
|
ApplicationConfiguration configuration,
|
|
ConfiguredEndpoint endpoint,
|
|
Session innerSession,
|
|
ITelemetryContext telemetry,
|
|
NetworkRedundancyOptions? networkRedundancy = null,
|
|
IServerRedundancyHandler? redundancyHandler = null,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
ILogger<Client.ManagedSession> logger = telemetry.CreateLogger<Client.ManagedSession>();
|
|
var sessionFactory = new Mock<ISessionFactory>();
|
|
sessionFactory.SetupGet(f => f.Telemetry).Returns(telemetry);
|
|
var reconnectPolicy = new ReconnectPolicy(new ReconnectPolicyOptions
|
|
{
|
|
MaxTotalReconnectTime = TimeSpan.FromSeconds(30)
|
|
});
|
|
|
|
ConstructorInfo? ctor = typeof(Client.ManagedSession).GetConstructor(
|
|
BindingFlags.NonPublic | BindingFlags.Instance,
|
|
null,
|
|
[
|
|
typeof(ApplicationConfiguration),
|
|
typeof(ConfiguredEndpoint),
|
|
typeof(ISessionFactory),
|
|
typeof(IReconnectPolicy),
|
|
typeof(IServerRedundancyHandler),
|
|
typeof(ILogger),
|
|
typeof(IUserIdentity),
|
|
typeof(IClientIdentityProvider),
|
|
typeof(TimeProvider),
|
|
typeof(ArrayOf<string>),
|
|
typeof(string),
|
|
typeof(uint),
|
|
typeof(bool),
|
|
typeof(bool),
|
|
typeof(bool),
|
|
typeof(bool),
|
|
typeof(NetworkRedundancyOptions),
|
|
typeof(ServerRedundancyOptions),
|
|
typeof(IClientChannelManager),
|
|
typeof(IClientConnectGate)
|
|
],
|
|
null);
|
|
|
|
Assert.That(ctor, Is.Not.Null);
|
|
|
|
var managedSession = (Client.ManagedSession)ctor!.Invoke(
|
|
[
|
|
configuration,
|
|
endpoint,
|
|
sessionFactory.Object,
|
|
reconnectPolicy,
|
|
redundancyHandler,
|
|
logger,
|
|
null,
|
|
null,
|
|
timeProvider,
|
|
default(ArrayOf<string>),
|
|
"TestManagedSession",
|
|
60000u,
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
networkRedundancy,
|
|
null,
|
|
null,
|
|
null
|
|
]);
|
|
|
|
typeof(Client.ManagedSession)
|
|
.GetField(
|
|
"m_session",
|
|
BindingFlags.NonPublic | BindingFlags.Instance)!
|
|
.SetValue(managedSession, innerSession);
|
|
|
|
return managedSession;
|
|
}
|
|
#pragma warning restore IDE0051, RCS1213
|
|
|
|
/// <summary>
|
|
/// Inner session whose close can be observed and delayed by the test.
|
|
/// </summary>
|
|
private sealed class BlockingCloseSession : Session
|
|
{
|
|
public BlockingCloseSession(
|
|
ITransportChannel channel,
|
|
ApplicationConfiguration configuration,
|
|
ConfiguredEndpoint endpoint)
|
|
: base(
|
|
channel,
|
|
configuration,
|
|
endpoint,
|
|
engineFactory: DefaultSubscriptionEngineFactory.Instance)
|
|
{
|
|
}
|
|
|
|
/// <summary>
|
|
/// The status the inner close reports.
|
|
/// </summary>
|
|
public StatusCode CloseResult { get; set; } = StatusCodes.Good;
|
|
|
|
/// <summary>
|
|
/// Completes when the inner close is allowed to return.
|
|
/// </summary>
|
|
public Task CloseBlocker { get; set; } = Task.CompletedTask;
|
|
|
|
/// <summary>
|
|
/// The value the managed session forwarded for closeChannel.
|
|
/// </summary>
|
|
public bool? CloseChannelRequested { get; private set; }
|
|
|
|
public override async Task<StatusCode> CloseAsync(
|
|
int timeout,
|
|
bool closeChannel,
|
|
CancellationToken ct = default)
|
|
{
|
|
CloseChannelRequested = closeChannel;
|
|
await CloseBlocker.ConfigureAwait(false);
|
|
return CloseResult;
|
|
}
|
|
}
|
|
|
|
private sealed class BlockingConnectGate : IClientConnectGate
|
|
{
|
|
public Task Started => m_started.Task;
|
|
|
|
public Task Stopped => m_stopped.Task;
|
|
|
|
public async ValueTask<IDisposable> AcquireAsync(
|
|
CancellationToken ct = default)
|
|
{
|
|
m_started.TrySetResult(null);
|
|
try
|
|
{
|
|
await Task.Delay(Timeout.Infinite, ct).ConfigureAwait(false);
|
|
}
|
|
finally
|
|
{
|
|
m_stopped.TrySetResult(null);
|
|
}
|
|
|
|
throw new InvalidOperationException(
|
|
"The blocking connect gate unexpectedly completed without cancellation.");
|
|
}
|
|
|
|
private readonly TaskCompletionSource<object?> m_started =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
|
|
private readonly TaskCompletionSource<object?> m_stopped =
|
|
new(TaskCreationOptions.RunContinuationsAsynchronously);
|
|
}
|
|
|
|
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for ManagedSession construction tests.
|
|
private static ApplicationConfiguration CreateClientConfiguration(
|
|
ITelemetryContext telemetry)
|
|
{
|
|
return new ApplicationConfiguration(telemetry)
|
|
{
|
|
ApplicationName = "ManagedSessionTests",
|
|
ApplicationType = ApplicationType.Client,
|
|
ApplicationUri = "urn:localhost:ManagedSessionTests",
|
|
ProductUri = "urn:localhost:ManagedSessionTests",
|
|
ClientConfiguration = new ClientConfiguration
|
|
{
|
|
DefaultSessionTimeout = 60000,
|
|
MinSubscriptionLifetime = 10000
|
|
},
|
|
SecurityConfiguration = new SecurityConfiguration
|
|
{
|
|
ApplicationCertificate = new CertificateIdentifier()
|
|
},
|
|
TransportQuotas = new TransportQuotas
|
|
{
|
|
OperationTimeout = 6000
|
|
}
|
|
};
|
|
}
|
|
#pragma warning restore IDE0051, RCS1213
|
|
|
|
#pragma warning disable IDE0051, RCS1213 // Test scaffold kept for ManagedSession endpoint construction tests.
|
|
private static ConfiguredEndpoint CreateEndpoint()
|
|
{
|
|
var description = new EndpointDescription
|
|
{
|
|
EndpointUrl = "opc.tcp://localhost:4840",
|
|
SecurityMode = MessageSecurityMode.None,
|
|
SecurityPolicyUri = SecurityPolicies.None,
|
|
TransportProfileUri = Profiles.UaTcpTransport,
|
|
UserIdentityTokens =
|
|
[
|
|
new UserTokenPolicy
|
|
{
|
|
PolicyId = "anonymous",
|
|
TokenType = UserTokenType.Anonymous,
|
|
SecurityPolicyUri = SecurityPolicies.None
|
|
}
|
|
]
|
|
};
|
|
description.Server.ApplicationUri = description.EndpointUrl;
|
|
description.Server.ApplicationType = ApplicationType.Server;
|
|
|
|
return new ConfiguredEndpoint(
|
|
null,
|
|
description,
|
|
new EndpointConfiguration { OperationTimeout = 6000 })
|
|
{
|
|
UpdateBeforeConnect = false
|
|
};
|
|
}
|
|
#pragma warning restore IDE0051, RCS1213
|
|
}
|
|
}
|