Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Session/SessionRecoveryAuditTests.cs
romanett 9b034f8291 Enable nullable reference types centrally; migrate all unset and annotations-only projects (#4700)
# 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>
2026-10-08 08:48:13 +02:00

1017 lines
55 KiB
C#

/* ========================================================================
* 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.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading;
using System.Threading.Tasks;
using Moq;
using NUnit.Framework;
using Opc.Ua.Bindings;
using Opc.Ua.Client.Tests.Stack.Client.Fakes;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// Exercises session recovery admission, publishing ownership, and certificate reload exclusion.
/// </summary>
[TestFixture]
[Category("Client")]
public sealed class SessionRecoveryAuditTests
{
[Test]
public async Task RequestHandlesAreDistinctAcrossSessionsAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
await using var first = new Session(
harness.CreateOpenedStandaloneChannel(endpoint).Channel, harness.Configuration, endpoint);
await using var second = new Session(
harness.CreateOpenedStandaloneChannel(endpoint).Channel, harness.Configuration, endpoint);
Assert.That(second.NewRequestHandle(), Is.Not.EqualTo(first.NewRequestHandle()));
}
[Test]
public async Task PublishAndKeepAliveUseDistinctRequestHandlesAsync()
{
await using var harness = new ManagedSessionReconnectHarness();
await harness.ConnectAsync(false).ConfigureAwait(false);
harness.Clock.Advance(TimeSpan.Zero);
await harness.InitialKeepAliveRead.WaitAsync(s_timeout).ConfigureAwait(false);
await using Subscriptions.ISubscription subscription = harness.AddSubscription();
ManagedSessionReconnectHarness.WirePublish publish = await harness.NextWirePublishAsync()
.AsTask().WaitAsync(s_timeout).ConfigureAwait(false);
ReadRequest keepAlive = harness.Requests.ToList().OfType<ReadRequest>().First(request =>
request.NodesToRead.Count == 1 &&
request.NodesToRead[0].NodeId == VariableIds.Server_ServerStatus_State);
Assert.That(
publish.Request.RequestHeader.RequestHandle, Is.Not.EqualTo(keepAlive.RequestHeader.RequestHandle));
}
[TestCaseSource(nameof(InitialConnectFailures))]
public async Task InitialConnectRotatesOnlyForConnectivityFailuresAsync(StatusCode failure, bool rotate)
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint primary = SessionChannelHarness.CreateEndpoint();
ConfiguredEndpoint alternate = SessionChannelHarness.CreateEndpoint("opc.tcp://alternate:4840");
alternate.Description.Server.ApplicationUri = primary.Description.Server.ApplicationUri;
var attempted = new List<ConfiguredEndpoint>();
var factory = new Mock<ISessionFactory>();
factory.SetupGet(value => value.Telemetry).Returns(harness.Telemetry);
factory.SetupGet(value => value.SubscriptionEngineFactory)
.Returns(DefaultSubscriptionEngineFactory.Instance);
factory.Setup(value => value.CreateAsync(
harness.Configuration, It.IsAny<ConfiguredEndpoint>(), false, false,
It.IsAny<string>(), It.IsAny<uint>(), It.IsAny<IUserIdentity>(),
It.IsAny<ArrayOf<string>>(), It.IsAny<CancellationToken>()))
.Returns(async (ApplicationConfiguration _, ConfiguredEndpoint endpoint, bool _, bool _,
string _, uint _, IUserIdentity _, ArrayOf<string> _, CancellationToken _) =>
{
attempted.Add(endpoint);
if (ReferenceEquals(endpoint, primary))
{
throw new ServiceResultException(failure);
}
return await harness.CreateSessionAsync(endpoint).ConfigureAwait(false);
});
var policy = new Mock<IReconnectPolicy>();
policy.Setup(value => value.GetNextDelay(It.IsAny<int>(), It.IsAny<CancellationToken>()))
.Returns((TimeSpan?)null);
using var cancellation = new CancellationTokenSource(s_timeout);
Task<Client.ManagedSession> connecting = Client.ManagedSession.CreateAsync(
harness.Configuration, primary, factory.Object,
reconnectPolicy: policy.Object,
networkRedundancy: new NetworkRedundancyOptions { AlternateEndpoints = [alternate] },
ct: cancellation.Token);
if (rotate)
{
await using Client.ManagedSession session = await connecting.ConfigureAwait(false);
Assert.That(attempted, Is.EqualTo([primary, alternate]));
Assert.That(session.StateMachine.State, Is.EqualTo(ConnectionState.Connected));
}
else
{
Assert.ThrowsAsync<ServiceResultException>(() => connecting);
Assert.That(attempted, Is.EqualTo([primary]));
}
}
[TestCase(false)]
[TestCase(true)]
public async Task NetworkPathRecoveryReactivatesBeforeRecreatingAsync(bool rejectSession)
{
var alternateRequests = new ConcurrentQueue<Type>();
var clock = new ObservableFakeTimeProvider();
using var alternateKeepAlive = new SemaphoreSlim(0);
using var keepAliveResponse = new SemaphoreSlim(0);
var completedKeepAlives = System.Threading.Channels.Channel.CreateUnbounded<KeepAliveEventArgs>();
int transports = 0;
int activations = 0;
await using var harness = new SessionChannelHarness(timeProvider: clock, configureChannel: channel =>
{
int index = Interlocked.Increment(ref transports);
channel.SupportedFeatures = TransportChannelFeatures.Reconnect;
if (index == 1)
{
channel.ReconnectHandler = _ =>
throw new ServiceResultException(StatusCodes.BadCommunicationError);
}
else
{
channel.Mock.As<ISecureChannel>().Setup(value => value.OpenAsync(
It.Is<Uri>(uri => uri.Host == "localhost"),
It.IsAny<TransportChannelSettings>(), It.IsAny<CancellationToken>()))
.Throws(new ServiceResultException(StatusCodes.BadCommunicationError));
channel.RequestHandler = async (request, ct) =>
{
if (request is ActivateSessionRequest or CreateSessionRequest)
{
alternateRequests.Enqueue(request.GetType());
}
if (request is ActivateSessionRequest &&
Interlocked.Increment(ref activations) == 1 &&
rejectSession)
{
throw new ServiceResultException(StatusCodes.BadSessionIdInvalid);
}
if (request is ReadRequest read &&
read.NodesToRead.Count == 1 &&
read.NodesToRead[0].NodeId == VariableIds.Server_ServerStatus_State)
{
alternateKeepAlive.Release();
await keepAliveResponse.WaitAsync(ct).ConfigureAwait(false);
}
return channel.CreateResponse(request);
};
}
});
ConfiguredEndpoint primary = SessionChannelHarness.CreateEndpoint();
ConfiguredEndpoint alternate = SessionChannelHarness.CreateEndpoint("opc.tcp://alternate:4840");
alternate.Description.Server.ApplicationUri = primary.Description.Server.ApplicationUri;
var factory = new ChannelManagerSessionFactory(harness.Manager, harness.Telemetry, timeProvider: clock);
using var cancellation = new CancellationTokenSource(s_timeout);
await using Client.ManagedSession session = await Client.ManagedSession.CreateAsync(
harness.Configuration, primary, factory,
reconnectPolicy: new ReconnectPolicy { InitialDelay = TimeSpan.Zero, MaxRetries = 2 },
timeProvider: clock,
networkRedundancy: new NetworkRedundancyOptions { AlternateEndpoints = [alternate] },
ct: cancellation.Token).ConfigureAwait(false);
NodeId initialId = session.SessionId;
session.KeepAlive += (current, args) =>
{
if (current.TransportChannel.EndpointDescription.EndpointUrl == alternate.Description.EndpointUrl)
{
completedKeepAlives.Writer.TryWrite(args);
}
};
session.StateMachine.TriggerReconnect();
await session.StateMachine.WaitForConnectedAsync(cancellation.Token).ConfigureAwait(false);
Assert.That(alternateRequests.ToArray(), Is.EqualTo(rejectSession
? new[]
{
typeof(ActivateSessionRequest), typeof(CreateSessionRequest), typeof(ActivateSessionRequest)
}
: [typeof(ActivateSessionRequest)]));
if (!rejectSession)
{
Assert.That(session.SessionId, Is.EqualTo(initialId));
}
Assert.That(session.TransportChannel.EndpointDescription.EndpointUrl,
Is.EqualTo(alternate.Description.EndpointUrl));
for (int keepAlive = 0; keepAlive < 2; keepAlive++)
{
clock.Advance(TimeSpan.FromMilliseconds(session.KeepAliveInterval));
Assert.That(await alternateKeepAlive.WaitAsync(s_timeout).ConfigureAwait(false), Is.True,
"A successful recovery must continue reading server state on the alternate path.");
Assert.That(completedKeepAlives.Reader.TryPeek(out _), Is.False,
"Sending the keepalive read must not count as processing its response.");
keepAliveResponse.Release();
// RequestCompleted resets the timer before KeepAlive is raised. Do not advance
// fake time while that reset is still in flight.
KeepAliveEventArgs completed = await completedKeepAlives.Reader.ReadAsync(cancellation.Token)
.AsTask().WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(completed.Status, Is.Null);
Assert.That(completed.CurrentState, Is.EqualTo(ServerState.Running));
}
}
[Test]
public async Task ReusedSessionRestartsClassicPublishAndKeepAliveAsync(
[Values("network", "mirrored", "token")] string recovery,
[Values] bool observePublishing)
{
var clock = new ObservableFakeTimeProvider();
using var keepAlive = new SemaphoreSlim(0);
var originalPublish = new AsyncOperationGate();
var activation = new AsyncOperationGate();
var resumedPublish = new AsyncOperationGate();
var ongoingPublish = new AsyncOperationGate();
var initialProcessed = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously);
NodeId originalToken = default;
NodeId reusedToken = default;
int channels = 0;
int createSessions = 0;
int createSubscriptions = 0;
int resumedPublishes = 0;
await using var harness = new SessionChannelHarness(timeProvider: clock, configureChannel: channel =>
{
int index = Interlocked.Increment(ref channels);
channel.RequestHandler = async (request, ct) =>
{
switch (request)
{
case CreateSessionRequest:
Interlocked.Increment(ref createSessions);
break;
case ActivateSessionRequest activate when index == 1:
originalToken = activate.RequestHeader.AuthenticationToken;
break;
case ActivateSessionRequest activate:
reusedToken = activate.RequestHeader.AuthenticationToken;
await activation.WaitAsync(ct).ConfigureAwait(false);
break;
case CreateSubscriptionRequest create:
Interlocked.Increment(ref createSubscriptions);
return new CreateSubscriptionResponse
{
ResponseHeader = channel.CreateGoodHeader(),
SubscriptionId = 1,
RevisedPublishingInterval = create.RequestedPublishingInterval,
RevisedLifetimeCount = create.RequestedLifetimeCount,
RevisedMaxKeepAliveCount = create.RequestedMaxKeepAliveCount
};
case PublishRequest publish:
if (index == 1)
{
await originalPublish.WaitAsync(ct).ConfigureAwait(false);
}
else if (Interlocked.Increment(ref resumedPublishes) == 1)
{
await resumedPublish.WaitAsync(ct).ConfigureAwait(false);
}
else
{
await ongoingPublish.WaitAsync(ct).ConfigureAwait(false);
throw new ServiceResultException(StatusCodes.BadSessionClosed);
}
return new PublishResponse
{
ResponseHeader = channel.CreateGoodHeader(),
SubscriptionId = 1,
Results = publish.SubscriptionAcknowledgements.ConvertAll(_ => StatusCodes.Good),
NotificationMessage = new NotificationMessage
{
SequenceNumber = (uint)index,
PublishTime = new DateTimeUtc(clock.GetUtcNow().UtcDateTime)
}
};
case DeleteSubscriptionsRequest delete:
return new DeleteSubscriptionsResponse
{
ResponseHeader = channel.CreateGoodHeader(),
Results = delete.SubscriptionIds.ConvertAll(_ => StatusCodes.Good)
};
case SetPublishingModeRequest publishing:
return new SetPublishingModeResponse
{
ResponseHeader = channel.CreateGoodHeader(),
Results = publishing.SubscriptionIds.ConvertAll(_ => StatusCodes.Good)
};
case ReadRequest read when index > 1 &&
read.NodesToRead.Count == 1 &&
read.NodesToRead[0].NodeId == VariableIds.Server_ServerStatus_State:
keepAlive.Release();
break;
}
return channel.CreateResponse(request);
};
});
ConfiguredEndpoint primary = SessionChannelHarness.CreateEndpoint();
ConfiguredEndpoint alternate = SessionChannelHarness.CreateEndpoint("opc.tcp://alternate:4840");
alternate.Description.Server.ApplicationUri = primary.Description.Server.ApplicationUri;
using var cancellation = new CancellationTokenSource(s_timeout);
await using Session session = await Session.CreateAsync(
harness.Manager, harness.Configuration, primary, updateBeforeConnect: false,
engineFactory: ClassicSubscriptionEngineFactory.Instance, timeProvider: clock,
ct: cancellation.Token).ConfigureAwait(false);
session.MinPublishRequestCount = 1;
session.MaxPublishRequestCount = 1;
session.EnableTokenReuseFailover = recovery == "token";
using var subscription = new Subscription(session.DefaultSubscription)
{
PublishingInterval = 1000,
KeepAliveCount = 10,
LifetimeCount = 100,
PublishingEnabled = true,
FastKeepAliveCallback = (_, _) => initialProcessed.TrySetResult(true)
};
Assert.That(session.AddSubscription(subscription), Is.True);
await subscription.CreateAsync(cancellation.Token).ConfigureAwait(false);
await originalPublish.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
NodeId sessionId = session.SessionId;
Task recovering = recovery switch
{
"network" => session.ReactivateOnNetworkEndpointAsync(
alternate, null, new RetryBudget(s_timeout, clock), cancellation.Token),
"mirrored" => session.ReactivateMirroredSessionAsync(alternate, cancellation.Token),
"token" => session.RecreateInPlaceAsync(
alternate, new RetryBudget(s_timeout, clock), cancellation.Token),
_ => throw new ArgumentOutOfRangeException(nameof(recovery))
};
try
{
await activation.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
originalPublish.Release();
await initialProcessed.Task.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.Reconnecting, Is.True);
Assert.That(session.GoodPublishRequestCount, Is.Zero,
"The old Publish must finish while recovery owns admission, without refilling itself.");
activation.Release();
await recovering.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.SessionId, Is.EqualTo(sessionId));
Assert.That(reusedToken, Is.EqualTo(originalToken));
Assert.That(createSessions, Is.EqualTo(1));
Assert.That(createSubscriptions, Is.EqualTo(1));
Assert.That(subscription.Id, Is.EqualTo(1));
Assert.That(session.TransportChannel.EndpointDescription.EndpointUrl,
Is.EqualTo(alternate.Description.EndpointUrl));
if (observePublishing)
{
await resumedPublish.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
resumedPublish.Release();
await ongoingPublish.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(resumedPublishes, Is.EqualTo(2));
}
else
{
for (int interval = 0; interval < 2; interval++)
{
clock.Advance(TimeSpan.FromMilliseconds(session.KeepAliveInterval));
Assert.That(await keepAlive.WaitAsync(s_timeout).ConfigureAwait(false), Is.True,
"Reusing a session must restart ongoing keepalive reads.");
}
}
}
finally
{
originalPublish.Release();
activation.Release();
resumedPublish.Release();
ongoingPublish.Release();
await recovering.WaitAsync(s_timeout).ConfigureAwait(false);
}
}
[Test]
public async Task SingleUseReverseManagedSessionNeverFallsBackToOutboundAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
var initial = new Mock<ITransportWaitingConnection>();
initial.SetupGet(value => value.EndpointUrl).Returns(endpoint.EndpointUrl!);
var factory = new ChannelManagerSessionFactory(harness.Manager, harness.Telemetry);
using var cancellation = new CancellationTokenSource(s_timeout);
await using Client.ManagedSession session = await Client.ManagedSession.CreateAsync(
harness.Configuration, endpoint, factory,
reconnectPolicy: new ReconnectPolicy { InitialDelay = TimeSpan.Zero, MaxRetries = 1 },
connection: initial.Object,
ct: cancellation.Token).ConfigureAwait(false);
ServiceResult result = await session.StateMachine.ReconnectWithBudgetAsync!(
new RetryBudget(s_timeout), cancellation.Token).ConfigureAwait(false);
Assert.That(result.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
Assert.That(harness.CreatedChannels, Has.Count.EqualTo(1));
Assert.That(harness.CreatedChannels[0].ReconnectCount, Is.Zero);
}
[TestCase(false)]
[TestCase(true)]
public async Task RestoredUserNameCredentialsAreResuppliedWithoutSerializationAsync(bool renewal)
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
endpoint.Description.UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "user", TokenType = UserTokenType.UserName, SecurityPolicyUri = SecurityPolicies.None
}
];
byte[] secret = Guid.NewGuid().ToByteArray();
ScriptedChannel original = harness.CreateOpenedStandaloneChannel(endpoint);
await using var source = new Session(original.Channel, harness.Configuration, endpoint);
source.Restore(new SessionConfiguration
{
SessionName = "snapshot",
SessionId = new NodeId("restored", 1),
AuthenticationToken = new NodeId("token", 1),
Identity = new UserIdentity("snapshot-user", secret),
UserIdentityTokenPolicy = SecurityPolicies.None
});
using var stream = new MemoryStream();
source.SaveSessionConfiguration(stream);
Assert.That(stream.ToArray().AsSpan().IndexOf(secret), Is.EqualTo(-1));
stream.Position = 0;
SessionConfiguration saved = SessionConfiguration.Create(stream, harness.Telemetry)!;
ScriptedChannel restored = harness.CreateOpenedStandaloneChannel(endpoint);
await using var target = new Session(restored.Channel, harness.Configuration, endpoint);
var supplied = new UserIdentity("snapshot-user", secret);
if (renewal)
{
target.RenewUserIdentity += (_, _) => supplied;
}
else
{
saved.Identity = supplied;
}
target.Restore(saved);
await target.ReconnectAsync(null, restored.Channel, CancellationToken.None).ConfigureAwait(false);
ScriptedChannel.RequestOperation activation = await restored
.NextOperationAsync<ActivateSessionRequest>(CancellationToken.None).ConfigureAwait(false);
Assert.That(((ActivateSessionRequest)activation.Request).UserIdentityToken.TryGetValue(
out UserNameIdentityToken? token), Is.True);
Assert.That(token!.UserName, Is.EqualTo("snapshot-user"));
Assert.That(token.Password.ToArray(), Is.EqualTo(secret));
Assert.That(target.SessionId, Is.EqualTo(source.SessionId));
}
[Test]
public async Task RestoredUserNameWithoutCredentialsFailsBeforeActivationAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
endpoint.Description.UserIdentityTokens =
[
new UserTokenPolicy
{
PolicyId = "user", TokenType = UserTokenType.UserName, SecurityPolicyUri = SecurityPolicies.None
}
];
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
await using var session = new Session(channel.Channel, harness.Configuration, endpoint);
session.Restore(new SessionConfiguration
{
SessionId = new NodeId("restored", 1),
AuthenticationToken = new NodeId("token", 1),
IdentityToken = new UserNameIdentityToken { UserName = "snapshot-user", PolicyId = "user" },
UserIdentityTokenPolicy = SecurityPolicies.None
});
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => session.ReconnectAsync(null, channel.Channel, CancellationToken.None))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadIdentityTokenInvalid));
Assert.That(error.Message, Does.Contain("credentials"));
channel.Mock.Verify(value => value.SendRequestAsync(
It.Is<IServiceRequest>(request => request is ActivateSessionRequest),
It.IsAny<CancellationToken>()), Times.Never);
}
[Test]
public async Task ManagedCertificateRevalidationUsesEntireServerChainAsync()
{
await using var harness = new ManagedSessionReconnectHarness(TimeSpan.Zero);
await harness.ConnectAsync(false).ConfigureAwait(false);
using Certificate issuer = CertificateBuilder.Create("CN=RevalidationIssuer").SetCAConstraint()
.CreateForRSA();
using Certificate leaf = CertificateBuilder.Create("CN=RevalidationLeaf").SetIssuer(issuer).CreateForRSA();
harness.Session.ConfiguredEndpoint.Description.ServerCertificate =
ByteString.From([.. leaf.RawData, .. issuer.RawData]);
var validator = new Mock<ICertificateManager>();
string[] validated = [];
validator.Setup(value => value.ValidateAsync(
It.IsAny<CertificateCollection>(), It.IsAny<TrustListIdentifier>(),
It.IsAny<Security.Certificates.CertificateValidationOptions>(), It.IsAny<CancellationToken>()))
.Callback<CertificateCollection, TrustListIdentifier,
Security.Certificates.CertificateValidationOptions, CancellationToken>(
(chain, _, _, _) => validated = [.. chain.Select(certificate => certificate.Thumbprint)])
.ReturnsAsync(CertificateValidationResult.Success);
validator.Setup(value => value.ValidateAsync(
It.IsAny<Certificate>(), It.IsAny<TrustListIdentifier>(), It.IsAny<CancellationToken>()))
.ReturnsAsync(CertificateValidationResult.Success);
harness.Channels.Configuration.CertificateManager = validator.Object;
await harness.Session.RevalidateServerCertificateAsync(CancellationToken.None).ConfigureAwait(false);
Assert.That(validated, Is.EqualTo([leaf.Thumbprint, issuer.Thumbprint]));
validator.Verify(value => value.ValidateAsync(
It.IsAny<Certificate>(), It.IsAny<TrustListIdentifier>(), It.IsAny<CancellationToken>()), Times.Never);
Assert.That(harness.Session.StateMachine.State, Is.EqualTo(ConnectionState.Connected));
}
[Test]
public async Task ManagedReverseReconnectUsesFreshConnectionWithoutSessionReentryAsync()
{
await using var harness = new SessionChannelHarness(configureChannel: channel =>
channel.SupportedFeatures = TransportChannelFeatures.Reconnect);
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
var initial = new Mock<ITransportWaitingConnection>();
initial.SetupGet(value => value.EndpointUrl).Returns(endpoint.EndpointUrl!);
var replacement = new Mock<ITransportWaitingConnection>();
replacement.SetupGet(value => value.EndpointUrl).Returns(endpoint.EndpointUrl!);
var factory = new ChannelManagerSessionFactory(harness.Manager, harness.Telemetry);
using var cancellation = new CancellationTokenSource(s_timeout);
await using var session = (Session)await factory.CreateAsync(
harness.Configuration, initial.Object, endpoint, false, false,
"reverse", 60000, new UserIdentity(), default, cancellation.Token).ConfigureAwait(false);
NodeId original = session.SessionId;
await session.ReconnectAsync(replacement.Object, null, cancellation.Token).ConfigureAwait(false);
Assert.That(session.SessionId, Is.EqualTo(original));
Assert.That(session.Reconnecting, Is.False);
Assert.That(harness.CreatedChannels, Has.Count.EqualTo(1));
Assert.That(harness.CreatedChannels[0].CreateSessionCount, Is.EqualTo(1));
harness.CreatedChannels[0].Mock.Verify(value =>
value.ReconnectAsync(replacement.Object, It.IsAny<CancellationToken>()), Times.Once);
}
[Test]
public async Task ManagedReverseReconnectWithoutFreshConnectionFailsBeforeTransportAsync()
{
await using var harness = new SessionChannelHarness(configureChannel: channel =>
channel.SupportedFeatures = TransportChannelFeatures.Reconnect);
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
var initial = new Mock<ITransportWaitingConnection>();
initial.SetupGet(value => value.EndpointUrl).Returns(endpoint.EndpointUrl!);
var factory = new ChannelManagerSessionFactory(harness.Manager, harness.Telemetry);
using var cancellation = new CancellationTokenSource(s_timeout);
await using var session = (Session)await factory.CreateAsync(
harness.Configuration, initial.Object, endpoint, false, false,
"reverse", 60000, new UserIdentity(), default, cancellation.Token).ConfigureAwait(false);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(
() => session.ReconnectAsync(null, null, cancellation.Token))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
harness.CreatedChannels[0].Mock.Verify(value => value.ReconnectAsync(
It.IsAny<ITransportWaitingConnection>(), It.IsAny<CancellationToken>()), Times.Never);
Assert.That(harness.CreatedChannels, Has.Count.EqualTo(1));
}
[TestCase(false)]
[TestCase(true)]
public async Task CloneRecreateUsesInjectedPolicyAtChannelConstructionAsync(bool reverse)
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint("opc.tcp://127.0.0.1:0");
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var registry = new Mock<ISecurityPolicyRegistry>();
var marker = new ServiceResultException(
StatusCodes.BadSecurityPolicyRejected, "Injected registry reached.");
registry.Setup(value => value.GetInfo(SecurityPolicies.None)).Throws(marker);
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, securityPolicies: registry.Object);
var connection = new Mock<ITransportWaitingConnection>();
connection.SetupGet(value => value.EndpointUrl).Returns(endpoint.EndpointUrl!);
connection.SetupGet(value => value.Handle).Returns(Mock.Of<IUaSCByteTransport>());
using var cancellation = new CancellationTokenSource(s_timeout);
ServiceResultException error = Assert.ThrowsAsync<ServiceResultException>(() => reverse
? session.RecreateAsync(connection.Object, cancellation.Token)
: session.RecreateAsync(cancellation.Token))!;
Assert.That(error.StatusCode, Is.EqualTo(StatusCodes.BadSecurityPolicyRejected));
registry.Verify(value => value.GetInfo(SecurityPolicies.None), Times.AtLeastOnce);
}
/// <summary>
/// Cancelling a recreate waiting for admission cannot leave the subscription engine paused.
/// </summary>
[Test]
public async Task CancelledRecreateAdmissionDoesNotLeavePublishingPausedAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var engine = new Mock<ISubscriptionEngine>();
var factory = new Mock<ISubscriptionEngineFactory>();
ISubscriptionEngineContext context = null!;
factory.Setup(value => value.Create(It.IsAny<ISubscriptionEngineContext>()))
.Returns<ISubscriptionEngineContext>(value =>
{
context = value;
return engine.Object;
});
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, engineFactory: factory.Object);
int pauseBalance = 0;
engine.Setup(value => value.PausePublishing()).Callback(() => pauseBalance++);
engine.Setup(value => value.ResumePublishing()).Callback(() => pauseBalance--);
using var cancellation = new CancellationTokenSource();
await context.ReconnectLock.WaitAsync().ConfigureAwait(false);
try
{
Task recreate = session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token);
cancellation.Cancel();
Assert.That(async () => await recreate.WaitAsync(s_timeout).ConfigureAwait(false),
Throws.InstanceOf<OperationCanceledException>());
Assert.That(pauseBalance, Is.Zero, "A rejected admission must preserve the publishing state.");
}
finally
{
context.ReconnectLock.Release();
}
}
/// <summary>
/// Recreation owns admission until activation finishes, and a losing caller cannot resume its publishing.
/// </summary>
[Test]
public async Task RecreateKeepsOwnershipAndRejectsConcurrentCallsAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var create = new AsyncOperationGate();
channel.RequestHandler = async (request, ct) =>
{
if (request is CreateSessionRequest)
{
await create.WaitAsync(ct).ConfigureAwait(false);
}
return channel.CreateResponse(request);
};
var engine = new Mock<ISubscriptionEngine>();
var factory = new Mock<ISubscriptionEngineFactory>();
factory.Setup(value => value.Create(It.IsAny<ISubscriptionEngineContext>())).Returns(engine.Object);
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, engineFactory: factory.Object);
using var cancellation = new CancellationTokenSource();
Task owner = session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token);
try
{
await create.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.Reconnecting, Is.True, "CreateSession must remain inside recovery admission.");
ServiceResultException exception = Assert.ThrowsAsync<ServiceResultException>(
async () => await session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token)
.WaitAsync(s_timeout).ConfigureAwait(false))!;
Assert.That(exception.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
engine.Verify(value => value.PausePublishing(), Times.Once);
engine.Verify(value => value.ResumePublishing(), Times.Never);
}
finally
{
cancellation.Cancel();
create.Release();
await ObserveCancelledRecoveryAsync(owner).ConfigureAwait(false);
}
Assert.That(session.Reconnecting, Is.False);
}
[TestCase(false)]
[TestCase(true)]
public async Task CancelledRecreateRetainsAdmissionUntilPublishUnwindAsync(bool pauseDuringCleanup)
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var publishUnwind = new AsyncOperationGate { IgnoreCancellation = true };
var resumedPublish = new AsyncOperationGate();
var create = new AsyncOperationGate();
int publishCount = 0;
int sessionCount = 0;
TaskCompletionSource<bool>? successorPublishEntered = null;
channel.RequestHandler = async (request, ct) =>
{
switch (request)
{
case CreateSessionRequest when Interlocked.Increment(ref sessionCount) > 1:
await create.WaitAsync(ct).ConfigureAwait(false);
break;
case CreateSubscriptionRequest subscription:
return new CreateSubscriptionResponse
{
ResponseHeader = channel.CreateGoodHeader(),
SubscriptionId = 1,
RevisedPublishingInterval = subscription.RequestedPublishingInterval,
RevisedLifetimeCount = subscription.RequestedLifetimeCount,
RevisedMaxKeepAliveCount = subscription.RequestedMaxKeepAliveCount
};
case DeleteSubscriptionsRequest delete:
return new DeleteSubscriptionsResponse
{
ResponseHeader = channel.CreateGoodHeader(),
Results = delete.SubscriptionIds.ConvertAll(_ => StatusCodes.Good)
};
case TransferSubscriptionsRequest transfer:
return new TransferSubscriptionsResponse
{
ResponseHeader = channel.CreateGoodHeader(),
Results = transfer.SubscriptionIds.ConvertAll(_ => new TransferResult
{
StatusCode = StatusCodes.BadSubscriptionIdInvalid
})
};
case PublishRequest:
int attemptNumber = Interlocked.Increment(ref publishCount);
if (attemptNumber > 1)
{
successorPublishEntered?.TrySetResult(true);
}
AsyncOperationGate gate = attemptNumber == 1
? publishUnwind
: resumedPublish;
await gate.WaitAsync(ct).ConfigureAwait(false);
ct.ThrowIfCancellationRequested();
throw new InvalidOperationException("The publish gate must end through cancellation.");
}
return channel.CreateResponse(request);
};
var engineFactory = new ObservingSubscriptionEngineFactory(TimeProvider.System);
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, engineFactory: engineFactory);
await session.OpenAsync("final-drain", new UserIdentity(), CancellationToken.None).ConfigureAwait(false);
var manager = (Subscriptions.SubscriptionManager)engineFactory.Engine.SubscriptionManager;
await using Subscriptions.ISubscription subscription = manager.Add(
Mock.Of<Subscriptions.ISubscriptionNotificationHandler>(),
OptionsFactory.Create(new Subscriptions.SubscriptionOptions
{
DisableUnboundedItemMode = true,
PublishingEnabled = true,
PublishingInterval = TimeSpan.FromSeconds(1)
}));
using var cancellation = new CancellationTokenSource();
using var contenderCancellation = new CancellationTokenSource();
using var successorCancellation = new CancellationTokenSource();
Task? owner = null;
Task? contender = null;
Task? successor = null;
try
{
await publishUnwind.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
ObservingSubscriptionEngineFactory.PublishAttempt attempt = await engineFactory
.NextAttemptAsync(CancellationToken.None).AsTask().WaitAsync(s_timeout).ConfigureAwait(false);
var publishing = (AsyncManualResetEvent)typeof(Subscriptions.SubscriptionManager)
.GetField("m_running", BindingFlags.Instance | BindingFlags.NonPublic)!.GetValue(manager)!;
// Cancelling inside DrainAsync's abort makes its first wait cancel synchronously.
// Recreate can return its task only after entering the real uncancellable final drain.
using CancellationTokenRegistration registration = attempt.CancellationToken.Register(
cancellation.Cancel);
owner = session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token);
bool retainedAdmission = session.Reconnecting;
if (pauseDuringCleanup)
{
engineFactory.Engine.PausePublishing();
}
contender = session.RecreateInPlaceAsync(
channel: channel.Channel, ct: contenderCancellation.Token);
using (Assert.EnterMultipleScope())
{
Assert.That(cancellation.IsCancellationRequested, Is.True);
Assert.That(attempt.CancellationToken.IsCancellationRequested, Is.True);
Assert.That(attempt.Operation.IsCompleted, Is.False);
Assert.That(owner.IsCompleted, Is.False);
Assert.That(retainedAdmission, Is.True, "The final drain must retain recovery admission.");
Assert.That(contender.IsCompleted, Is.True, "A contender must reject, not join the old drain.");
Assert.That(publishing.IsSet, Is.False);
Assert.That(Volatile.Read(ref publishCount), Is.EqualTo(1));
}
ServiceResultException rejected = Assert.ThrowsAsync<ServiceResultException>(
async () => await contender.WaitAsync(s_timeout).ConfigureAwait(false))!;
Assert.That(rejected.StatusCode, Is.EqualTo(StatusCodes.BadInvalidState));
publishUnwind.Release();
Assert.CatchAsync<OperationCanceledException>(
async () => await owner.WaitAsync(s_timeout).ConfigureAwait(false));
Assert.That(attempt.Operation.IsCanceled, Is.True);
Assert.That(session.Reconnecting, Is.False);
Assert.That(publishing.IsSet, Is.EqualTo(!pauseDuringCleanup));
engineFactory.Engine.PausePublishing();
await manager.DrainAsync(CancellationToken.None).WaitAsync(s_timeout).ConfigureAwait(false);
int publishesBeforeSuccessor = Volatile.Read(ref publishCount);
successorPublishEntered = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
successor = session.RecreateInPlaceAsync(
channel: channel.Channel, ct: successorCancellation.Token);
await create.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
using (Assert.EnterMultipleScope())
{
Assert.That(session.Reconnecting, Is.True);
Assert.That(publishing.IsSet, Is.False, "The old owner cannot resume a successor's pause.");
Assert.That(Volatile.Read(ref publishCount), Is.EqualTo(publishesBeforeSuccessor));
Assert.That(successorPublishEntered.Task.IsCompleted, Is.False);
}
create.Release();
await successor.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.Reconnecting, Is.False);
Assert.That(publishing.IsSet, Is.False);
engineFactory.Engine.ResumePublishing();
await successorPublishEntered.Task.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(Volatile.Read(ref publishCount), Is.EqualTo(publishesBeforeSuccessor + 1));
Assert.That(engineFactory.CreateCount, Is.EqualTo(1));
}
finally
{
engineFactory.Engine.PausePublishing();
cancellation.Cancel();
contenderCancellation.Cancel();
successorCancellation.Cancel();
publishUnwind.Release();
create.Release();
foreach (Task recovery in new[] { owner, contender, successor }.OfType<Task>())
{
try
{
await ObserveCancelledRecoveryAsync(recovery).ConfigureAwait(false);
}
catch (ServiceResultException exception) when (exception.StatusCode == StatusCodes.BadInvalidState)
{
}
}
await manager.DrainAsync(CancellationToken.None).WaitAsync(s_timeout).ConfigureAwait(false);
resumedPublish.Release();
}
}
[Test]
public async Task RecreateReleasesAdmissionWhenPublishingCleanupFailsAsync()
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var engine = new Mock<ISubscriptionEngine>();
var factory = new Mock<ISubscriptionEngineFactory>();
factory.Setup(value => value.Create(It.IsAny<ISubscriptionEngineContext>())).Returns(engine.Object);
var failure = new InvalidOperationException("Injected publishing cleanup failure.");
engine.Setup(value => value.ResumePublishing()).Throws(failure);
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, engineFactory: factory.Object);
InvalidOperationException error = Assert.ThrowsAsync<InvalidOperationException>(
async () => await session.RecreateInPlaceAsync(channel: channel.Channel)
.WaitAsync(s_timeout).ConfigureAwait(false))!;
Assert.That(error, Is.SameAs(failure));
Assert.That(session.Reconnecting, Is.False, "A cleanup failure must still release recovery admission.");
engine.Setup(value => value.ResumePublishing()).Callback(static () => { });
await session.RecreateInPlaceAsync(channel: channel.Channel).WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.Reconnecting, Is.False);
Assert.That(session.Connected, Is.True);
Assert.That(channel.CreateSessionCount, Is.EqualTo(2));
}
/// <summary>
/// Certificate reload cannot replace certificate material while session creation still owns recovery.
/// </summary>
[Test]
public async Task CertificateReloadWaitsForSessionRecoveryAsync()
{
var clock = new ObservableFakeTimeProvider();
await using var harness = new SessionChannelHarness(timeProvider: clock);
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var create = new AsyncOperationGate();
channel.RequestHandler = async (request, ct) =>
{
if (request is CreateSessionRequest)
{
await create.WaitAsync(ct).ConfigureAwait(false);
}
return channel.CreateResponse(request);
};
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, timeProvider: clock);
using var cancellation = new CancellationTokenSource();
Task owner = session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token);
Task? reload = null;
try
{
await create.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
Task waiting = clock.WaitForTimerCreatedAsync(TimeSpan.FromMilliseconds(100));
reload = session.ReloadInstanceCertificateAsync(cancellation.Token);
Task observed = await Task.WhenAny(reload, waiting).WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(observed, Is.SameAs(waiting), "Reload must wait for the active recovery owner.");
Assert.That(reload.IsCompleted, Is.False);
create.Release();
await owner.WaitAsync(s_timeout).ConfigureAwait(false);
clock.Advance(TimeSpan.FromMilliseconds(100));
await reload.WaitAsync(s_timeout).ConfigureAwait(false);
Assert.That(session.Reconnecting, Is.False);
}
finally
{
cancellation.Cancel();
create.Release();
await ObserveCancelledRecoveryAsync(owner).ConfigureAwait(false);
if (reload != null)
{
await ObserveCancelledRecoveryAsync(reload).ConfigureAwait(false);
}
}
}
[TestCase(false)]
[TestCase(true)]
public async Task RecreationPreservesExistingPublishingPauseAsync(bool cancel)
{
await using var harness = new SessionChannelHarness();
ConfiguredEndpoint endpoint = SessionChannelHarness.CreateEndpoint();
ScriptedChannel channel = harness.CreateOpenedStandaloneChannel(endpoint);
var create = new AsyncOperationGate();
channel.RequestHandler = async (request, ct) =>
{
if (request is CreateSessionRequest)
{
await create.WaitAsync(ct).ConfigureAwait(false);
}
return channel.CreateResponse(request);
};
var engineFactory = new ObservingSubscriptionEngineFactory(TimeProvider.System);
await using var session = new Session(
channel.Channel, harness.Configuration, endpoint, engineFactory: engineFactory);
engineFactory.Engine.PausePublishing();
using var cancellation = new CancellationTokenSource();
Task recreate = session.RecreateInPlaceAsync(channel: channel.Channel, ct: cancellation.Token);
try
{
await create.Entered.WaitAsync(s_timeout).ConfigureAwait(false);
if (cancel)
{
cancellation.Cancel();
Assert.CatchAsync<OperationCanceledException>(
async () => await recreate.WaitAsync(s_timeout).ConfigureAwait(false));
}
else
{
create.Release();
await recreate.WaitAsync(s_timeout).ConfigureAwait(false);
}
Assert.That(typeof(Subscriptions.SubscriptionManager)
.GetField("m_running", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(engineFactory.Engine.SubscriptionManager), Is.TypeOf<AsyncManualResetEvent>());
var publishing = (AsyncManualResetEvent)typeof(Subscriptions.SubscriptionManager)
.GetField("m_running", BindingFlags.Instance | BindingFlags.NonPublic)!
.GetValue(engineFactory.Engine.SubscriptionManager)!;
Assert.That(publishing.IsSet, Is.False, "Recovery must not replace the caller's publishing intent.");
Assert.That(session.Reconnecting, Is.False);
}
finally
{
cancellation.Cancel();
create.Release();
await ObserveCancelledRecoveryAsync(recreate).ConfigureAwait(false);
}
}
private static async Task ObserveCancelledRecoveryAsync(Task task)
{
try
{
await task.WaitAsync(s_timeout).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
}
}
private static readonly TimeSpan s_timeout = TimeSpan.FromSeconds(10);
private static IEnumerable<TestCaseData> InitialConnectFailures =>
[
new(StatusCodes.BadCommunicationError, true),
new(StatusCodes.BadTimeout, true),
new(StatusCodes.BadUserAccessDenied, false),
new(StatusCodes.BadCertificateUntrusted, false)
];
}
}