Fix concurrent session creation on a shared ClientChannelManager channel (#4739)

# Description

Sessions created concurrently through one `ClientChannelManager` against
the same endpoint (`Session.CreateAsync(IClientChannelManager, ...)`)
failed with `BadSecureChannelClosed 'The managed transport is closed.'`,
and the log showed `Background operation OpenChannel started by
ClientChannelManager failed. Channel is Closed.` Creating the same
sessions one after another worked.

## Root cause

A caller that finds an existing shared entry gets a lease right away,
even while the entry's creator is still opening the transport:

1. That session's `OpenAsync` called
`lease.Entry.SnapshotClientCertificate()` before any transport was
installed and threw *"The managed transport is closed."*
2. The failed session disposed its lease. The refcount dropped to 0 and
the entry was torn down.
3. The creator's in-flight `OpenInitialAsync` then found the entry
closing and threw *"Channel is Closed."* Every session attached to that
entry failed.

## Changes

- **`Session.OpenAsync`** waits until the shared entry has an installed
transport (new `ChannelEntry.WaitForTransportAsync`) before it reads the
certificate. The signal fires when the initial open installs a
transport, or when a reconnect that started during the initial open
does. It does not wait for `Ready`: `OpenCoreAsync` also runs for
session recreate inside a reconnect cycle, and the cycle installs the
transport before participants reactivate.
- **`ClientChannelManager.GetCoreAsync`**: the creator holds no lease
while it opens the entry, so other participants' releases can close the
entry under the open. The creator now retries on a fresh entry instead
of failing. When the initial open fails on its own, the entry records it
(`InitialOpenFaulted`, set under the entry lock at failure time, because
the later lifecycle state cannot tell the two cases apart). A faulted
open, or one closed by manager disposal, still fails the creator.

## Tests

-
`SharedChannelConcurrentSessionTests.SessionAttachedWhileChannelOpensWaitsForTransportAsync`
(Client.Tests): holds the creator's transport through gated channel
bindings until a second session has attached to the opening entry, then
checks that both sessions open and share the channel. Fails
deterministically without the fix.
-
`ClientChannelManagerManagedTests.WaitForTransportWaitsForReconnectStartedDuringInitialOpenAsync`
and `CreatorDoesNotRetryInitialOpenThatFaultedAsync` (Core.Tests): cover
the reconnect-during-open wait and the no-retry-on-fault path.
-
`SharedChannelConcurrentSessionTests.ConcurrentlyCreatedSessionsShareOneChannelAsync`
(Client.Tests): creates 20 sessions concurrently through one manager
against the reference server. It checks that they share one channel with
refcount 20 and that every session can read. Fails without the fix.
-
`ClientChannelManagerManagedTests.CreatorRetriesWhenLeaseReleasedDuringInitialOpenAsync`
(Core.Tests): holds the initial open with a mock transport, then
attaches and releases a second lease during it. It checks that the
creator gets a lease on a fresh entry. Fails without the fix with
"Channel is Closed."

Verification:
- **Local runs (net10.0, sequential):** Opc.Ua.Client.Tests 2778 passed;
Opc.Ua.Sessions.Tests 851 passed, 379 skipped; Opc.Ua.Core.Tests 6241
passed, 88 skipped.
- **net48:** Client.Tests and Core.Tests compile cleanly.
- **ServerLoadHarness repro:** `--client shared --sessions 20
--connect-parallel 4/20` now connects every run. The same build without
the fix still fails.

Note: when the creator's open is cut short this way, the `OpenChannel`
background failure is still logged at `fail` level even though the
creator now recovers.

## Related Issues

- None filed yet. Found while load-testing the shared-channel client
with the ServerLoadHarness.

## Checklist

- [x] 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.
- [x] 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.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
romanett
2026-10-12 07:04:41 +02:00
committed by GitHub
parent f5062be349
commit 0716603543
5 changed files with 592 additions and 5 deletions
+4
View File
@@ -1414,6 +1414,10 @@ namespace Opc.Ua.Client
if (TransportChannel is ManagedTransportChannelLease lease)
{
// A session attaching to a shared channel that another caller
// is still opening has no installed transport to read the
// certificate from yet.
await lease.Entry.WaitForTransportAsync(ct).ConfigureAwait(false);
using ClientChannelCertificateSnapshot certificates = lease.Entry.SnapshotClientCertificate();
CertificateEntry? replacement = BuildInstanceCertificateEntry(
certificates.Certificate,
@@ -1037,13 +1037,18 @@ namespace Opc.Ua
return entry.AcquireLease(participantFactory);
}
catch (ServiceResultException sre) when (
!created &&
sre.StatusCode == StatusCodes.BadSecureChannelClosed &&
entry.IsClosing)
entry.IsClosing &&
(!created ||
(!entry.InitialOpenFaulted && Volatile.Read(ref m_disposed) == 0)))
{
// The last lease on the shared entry was released and its
// teardown began after the lookup: the next lookup replaces
// the closing entry with a fresh one.
// the closing entry with a fresh one. The creator holds no
// lease while it opens the entry, so other participants that
// attached meanwhile can release theirs and close the entry
// under the open; an open that faulted on its own, or that
// manager disposal closed, fails the creator.
}
catch
{
@@ -58,6 +58,8 @@ namespace Opc.Ua
m_lastStateChange = host.TimeProvider.GetUtcNow();
m_readyGate = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
m_transportInstalled = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
}
public ManagedChannelKey Key { get; }
@@ -242,6 +244,7 @@ namespace Opc.Ua
{
m_underlying = channel;
m_clientCertificateVersion = clientCertificateVersion;
m_transportInstalled.TrySetResult(true);
}
recordActiveMetric = !entryClosed && !m_activeMetricRecorded;
@@ -281,14 +284,22 @@ namespace Opc.Ua
}
catch (Exception ex)
{
lock (m_lock)
{
// Only a failure of the open itself, not a teardown that
// closed the entry under the open, counts as a fault.
m_initialOpenFaulted = !IsClosingLocked;
}
TransitionTo(
ChannelState.Faulted,
new ServiceResult(ex),
attempt: 0);
FailReady(ServiceResultException.Create(
ServiceResultException failure = ServiceResultException.Create(
StatusCodes.BadSecureChannelClosed,
ex,
"The shared channel failed during initial opening."));
"The shared channel failed during initial opening.");
FailReady(failure);
FailTransportInstalled(failure);
throw;
}
}
@@ -769,6 +780,50 @@ namespace Opc.Ua
return WaitForReadyCoreAsync(gate.Task, ct);
}
/// <summary>
/// Whether the initial open failed on its own, as opposed to a teardown
/// that closed the entry while the open was in flight. Set once when
/// the open fails, so a later lifecycle change does not alter it.
/// </summary>
internal bool InitialOpenFaulted
{
get
{
lock (m_lock)
{
return m_initialOpenFaulted;
}
}
}
/// <summary>
/// Wait until a transport is installed, by the initial open or by a
/// reconnect that started while the initial open was in flight. Unlike
/// <see cref="WaitForReadyAsync"/> this does not wait for participants
/// to reactivate, so a participant can call it from inside a reconnect
/// cycle.
/// </summary>
internal Task WaitForTransportAsync(CancellationToken ct)
{
Task installed;
lock (m_lock)
{
if (m_underlying != null)
{
return Task.CompletedTask;
}
if (IsClosingLocked)
{
return Task.FromException(
ServiceResultException.Create(
StatusCodes.BadSecureChannelClosed,
"Channel is {0}.", m_state));
}
installed = m_transportInstalled.Task;
}
return installed.WaitAsync(ct);
}
public ValueTask DisposeAsync()
{
return DisposeAsync(ChannelCloseReason.Faulted);
@@ -905,6 +960,9 @@ namespace Opc.Ua
FailReady(new ServiceResultException(
StatusCodes.BadSecureChannelClosed,
"Channel closed."));
FailTransportInstalled(new ServiceResultException(
StatusCodes.BadSecureChannelClosed,
"Channel closed."));
if (underlying != null)
{
@@ -1357,6 +1415,7 @@ namespace Opc.Ua
old = m_underlying;
m_underlying = fresh;
m_clientCertificateVersion = certificates.Version;
m_transportInstalled.TrySetResult(true);
}
}
if (entryClosed)
@@ -1928,6 +1987,17 @@ namespace Opc.Ua
}
}
private void FailTransportInstalled(Exception ex)
{
lock (m_lock)
{
if (m_transportInstalled.TrySetException(ex))
{
ObserveRecoveryTask(m_transportInstalled.Task);
}
}
}
private void ResetReadyGate()
{
lock (m_lock)
@@ -1991,6 +2061,8 @@ namespace Opc.Ua
private long m_reconnectGeneration;
private long m_clientCertificateVersion;
private TaskCompletionSource<bool> m_readyGate;
private readonly TaskCompletionSource<bool> m_transportInstalled;
private bool m_initialOpenFaulted;
private TaskCompletionSource<bool>? m_reconnectCoalescer;
private int m_reconnectStoppedIntentionally;
private IRetryBudget? m_effectiveBudget;
@@ -0,0 +1,246 @@
/* ========================================================================
* 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.Linq;
using System.Threading;
using System.Threading.Tasks;
using NUnit.Framework;
using Opc.Ua.Bindings;
using Opc.Ua.Client.TestFramework;
namespace Opc.Ua.Client.Tests
{
/// <summary>
/// Sessions created concurrently through one <see cref="ClientChannelManager"/>
/// share a single channel, exactly as sessions created one after another do.
/// </summary>
[TestFixture]
[Category("Client")]
[Category("Session")]
public sealed class SharedChannelConcurrentSessionTests : ClientTestFramework
{
public SharedChannelConcurrentSessionTests()
: base(Utils.UriSchemeOpcTcp)
{
SingleSession = false;
}
[OneTimeSetUp]
public override Task OneTimeSetUpAsync()
{
return base.OneTimeSetUpAsync();
}
[OneTimeTearDown]
public override Task OneTimeTearDownAsync()
{
return base.OneTimeTearDownAsync();
}
/// <summary>
/// A session that attached to the shared entry while its creator was
/// still opening the transport read the entry's certificate before a
/// transport was installed, failed, and released its lease. The release
/// dropped the reference count to zero and tore the entry down under the
/// creator's open, so every session waiting on it failed with
/// BadSecureChannelClosed. The creator's transport is held until the
/// second session has attached to the opening entry.
/// </summary>
[Test]
public async Task SessionAttachedWhileChannelOpensWaitsForTransportAsync()
{
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
ConfiguredEndpoint endpoint = await ClientFixture
.GetEndpointAsync(ServerUrl, SecurityPolicies.Basic256Sha256, Endpoints)
.ConfigureAwait(false);
using var bindings = new GatedChannelBindings(
(ITransportChannelBindings)ClientChannelManager.DefaultChannelBindings);
await using var manager = new ClientChannelManager(ClientFixture.Config, Telemetry, bindings);
Task<Client.Session> creating = Task.Run(() => CreateSessionAsync(manager, endpoint, "Creator", timeout.Token));
Task<Client.Session>? attaching = null;
try
{
Assert.That(bindings.CreateStarted.Wait(TimeSpan.FromSeconds(30)), Is.True);
attaching = Task.Run(() => CreateSessionAsync(manager, endpoint, "Attached", timeout.Token));
// The creator holds no lease while it opens, so a reference
// count of one is the attached session. From its attach to the
// certificate read the attached session does no I/O; the grace
// period only lets that synchronous step run before the open is
// released.
while (!attaching.IsCompleted &&
manager.GetChannelDiagnostics() is not [{ Refcount: 1 }])
{
await Task.Delay(10, timeout.Token).ConfigureAwait(false);
}
await Task.WhenAny(attaching, Task.Delay(500, timeout.Token)).ConfigureAwait(false);
Assert.That(attaching.IsCompleted, Is.False,
"The attached session must wait for the opening channel.");
bindings.Release();
Client.Session[] sessions = await Task.WhenAll(creating, attaching).ConfigureAwait(false);
Assert.That(manager.GetChannelDiagnostics(), Has.Count.EqualTo(1));
Assert.That(manager.GetChannelDiagnostics()[0].Refcount, Is.EqualTo(2));
foreach (Client.Session session in sessions)
{
DataValue value = await session
.ReadValueAsync(VariableIds.Server_ServerStatus_State, timeout.Token)
.ConfigureAwait(false);
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
}
}
finally
{
bindings.Release();
await CloseCreatedAsync(creating, timeout.Token).ConfigureAwait(false);
if (attaching != null)
{
await CloseCreatedAsync(attaching, timeout.Token).ConfigureAwait(false);
}
}
}
/// <summary>
/// Many sessions created at once through one manager share one channel.
/// </summary>
[Test]
public async Task ConcurrentlyCreatedSessionsShareOneChannelAsync()
{
const int sessionCount = 20;
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(60));
Endpoints = await ClientFixture.GetEndpointsAsync(ServerUrl).ConfigureAwait(false);
ConfiguredEndpoint endpoint = await ClientFixture
.GetEndpointAsync(ServerUrl, SecurityPolicies.Basic256Sha256, Endpoints)
.ConfigureAwait(false);
await using var manager = new ClientChannelManager(ClientFixture.Config, Telemetry);
using var start = new ManualResetEventSlim(false);
Task<Client.Session>[] creates = [.. Enumerable.Range(0, sessionCount).Select(i => Task.Run(() =>
{
start.Wait(timeout.Token);
return CreateSessionAsync(manager, endpoint, "Concurrent" + i, timeout.Token);
}))];
start.Set();
try
{
Client.Session[] sessions = await Task.WhenAll(creates).ConfigureAwait(false);
Assert.That(manager.GetChannelDiagnostics(), Has.Count.EqualTo(1));
Assert.That(manager.GetChannelDiagnostics()[0].Refcount, Is.EqualTo(sessionCount));
foreach (Client.Session session in sessions)
{
DataValue value = await session
.ReadValueAsync(VariableIds.Server_ServerStatus_State, timeout.Token)
.ConfigureAwait(false);
Assert.That(value.StatusCode, Is.EqualTo(StatusCodes.Good));
}
}
finally
{
foreach (Task<Client.Session> create in creates)
{
await CloseCreatedAsync(create, timeout.Token).ConfigureAwait(false);
}
}
}
private Task<Client.Session> CreateSessionAsync(
ClientChannelManager manager,
ConfiguredEndpoint endpoint,
string sessionName,
CancellationToken ct)
{
return Client.Session.CreateAsync(
manager,
ClientFixture.Config,
endpoint,
updateBeforeConnect: false,
sessionName: sessionName,
ct: ct);
}
private static async Task CloseCreatedAsync(Task<Client.Session> create, CancellationToken ct)
{
try
{
await create.ConfigureAwait(false);
}
catch (Exception)
{
return;
}
await create.Result.CloseAsync(ct).ConfigureAwait(false);
create.Result.Dispose();
}
/// <summary>
/// Holds the first transport the manager creates until released, so a
/// test can attach further sessions while the shared channel opens.
/// </summary>
private sealed class GatedChannelBindings : ITransportChannelBindings, IDisposable
{
public GatedChannelBindings(ITransportChannelBindings inner)
{
m_inner = inner;
}
public ManualResetEventSlim CreateStarted { get; } = new(false);
public ITransportChannel? Create(string uriScheme, ITelemetryContext telemetry)
{
if (Interlocked.Exchange(ref m_created, 1) == 0)
{
CreateStarted.Set();
m_release.Wait(TimeSpan.FromSeconds(60));
}
return m_inner.Create(uriScheme, telemetry);
}
public void Release()
{
m_release.Set();
}
public void Dispose()
{
m_release.Set();
m_release.Dispose();
CreateStarted.Dispose();
}
private readonly ITransportChannelBindings m_inner;
private readonly ManualResetEventSlim m_release = new(false);
private int m_created;
}
}
}
@@ -905,6 +905,266 @@ namespace Opc.Ua.Core.Tests.Stack.Client
}
}
/// <summary>
/// The caller that creates a shared entry holds no lease while it opens
/// the transport. A participant that attached meanwhile and released its
/// lease closed the entry under that open, which failed the creator with
/// BadSecureChannelClosed instead of retrying on a fresh entry.
/// </summary>
[Test]
public async Task CreatorRetriesWhenLeaseReleasedDuringInitialOpenAsync()
{
var initialOpenStarted = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var releaseInitialOpen = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var initialChannel = new Mock<IChannel>();
var retryChannel = new Mock<IChannel>();
initialChannel
.Setup(channel => channel.OpenAsync(
It.IsAny<Uri>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Callback(() => initialOpenStarted.TrySetResult(true))
.Returns(() => new ValueTask(releaseInitialOpen.Task));
retryChannel
.Setup(channel => channel.OpenAsync(
It.IsAny<Uri>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
initialChannel
.Setup(channel => channel.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
retryChannel
.Setup(channel => channel.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
int createCount = 0;
var bindings = new Mock<ITransportChannelBindings>();
bindings
.Setup(factory => factory.Create(
It.IsAny<string>(),
It.IsAny<ITelemetryContext>()))
.Returns(() => Interlocked.Increment(ref createCount) == 1
? initialChannel.Object
: retryChannel.Object);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var configuration = new ApplicationConfiguration(telemetry);
await using var manager = new ClientChannelManager(
configuration,
telemetry,
bindings.Object);
ConfiguredEndpoint endpoint = GetNoneSecurityEndpoint(
new EndpointConfiguration { OperationTimeout = 6000 });
using var timeout = new CancellationTokenSource(s_completionTimeout);
try
{
ValueTask<IManagedTransportChannel> creating = manager.GetAsync(
new TestParticipant("creator", endpoint), timeout.Token);
await initialOpenStarted.Task.WaitAsync(s_completionTimeout)
.ConfigureAwait(false);
IManagedTransportChannel attached = await manager.GetAsync(
new TestParticipant("attached", endpoint), timeout.Token).ConfigureAwait(false);
object openingEntry = GetLeaseEntry(attached);
await attached.CloseAsync(timeout.Token).ConfigureAwait(false);
Assert.That(GetInternalPropertyValue(openingEntry, "IsClosing"), Is.True);
releaseInitialOpen.TrySetResult(true);
using IManagedTransportChannel lease = await creating.AsTask()
.WaitAsync(s_completionTimeout).ConfigureAwait(false);
Assert.That(GetLeaseEntry(lease), Is.Not.SameAs(openingEntry));
Assert.That(lease.State, Is.EqualTo(ChannelState.Ready));
Assert.That(createCount, Is.EqualTo(2));
initialChannel.Verify(
channel => channel.CloseAsync(It.IsAny<CancellationToken>()),
Times.Once);
}
finally
{
releaseInitialOpen.TrySetResult(true);
}
}
/// <summary>
/// A reconnect that starts while the initial open is in flight moves the
/// entry out of its initial states before any transport is installed. A
/// participant waiting for the transport must keep waiting until the
/// reconnect installs one, not read the entry while it has none.
/// </summary>
[Test]
public async Task WaitForTransportWaitsForReconnectStartedDuringInitialOpenAsync()
{
var releaseInitialOpen = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var initialOpenStarted = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var reconnectOpenStarted = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var releaseReconnectOpen = new TaskCompletionSource<bool>(
TaskCreationOptions.RunContinuationsAsynchronously);
var initialChannel = new Mock<IChannel>();
var reconnectChannel = new Mock<IChannel>();
initialChannel
.Setup(channel => channel.OpenAsync(
It.IsAny<Uri>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Callback(() => initialOpenStarted.TrySetResult(true))
.Returns(() => new ValueTask(releaseInitialOpen.Task));
reconnectChannel
.Setup(channel => channel.OpenAsync(
It.IsAny<Uri>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Callback(() => reconnectOpenStarted.TrySetResult(true))
.Returns(() => new ValueTask(releaseReconnectOpen.Task));
initialChannel
.Setup(channel => channel.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
reconnectChannel
.Setup(channel => channel.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
int createCount = 0;
var bindings = new Mock<ITransportChannelBindings>();
bindings
.Setup(factory => factory.Create(
It.IsAny<string>(),
It.IsAny<ITelemetryContext>()))
.Returns(() => Interlocked.Increment(ref createCount) == 1
? initialChannel.Object
: reconnectChannel.Object);
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var configuration = new ApplicationConfiguration(telemetry);
await using var manager = new ClientChannelManager(
configuration,
telemetry,
bindings.Object);
ConfiguredEndpoint endpoint = GetNoneSecurityEndpoint(
new EndpointConfiguration { OperationTimeout = 6000 });
var entry = new ChannelEntry(
manager,
ManagedChannelKey.FromEndpoint(endpoint),
endpoint,
reverseConnection: null);
ManagedTransportChannelLease? lease = null;
try
{
Task initialOpen = entry.OpenInitialAsync(
clientCertificate: null,
clientCertificateChain: null,
clientCertificateVersion: 0,
default);
await initialOpenStarted.Task.WaitAsync(s_completionTimeout)
.ConfigureAwait(false);
lease = entry.AcquireLease(new TestParticipant("p1", endpoint));
Task<bool> reconnect = entry.RequestReconnectAsync(default);
await reconnectOpenStarted.Task.WaitAsync(s_completionTimeout)
.ConfigureAwait(false);
Assert.That(entry.State, Is.EqualTo(ChannelState.TransportReconnecting));
Assert.That(entry.Underlying, Is.Null);
Task waiting = entry.WaitForTransportAsync(default);
Assert.That(waiting.IsCompleted, Is.False);
releaseReconnectOpen.TrySetResult(true);
await waiting.WaitAsync(s_completionTimeout).ConfigureAwait(false);
Assert.That(entry.Underlying, Is.SameAs(reconnectChannel.Object));
Assert.That(
await reconnect.WaitAsync(s_completionTimeout).ConfigureAwait(false),
Is.True);
releaseInitialOpen.TrySetResult(true);
await initialOpen.WaitAsync(s_completionTimeout).ConfigureAwait(false);
}
finally
{
releaseInitialOpen.TrySetResult(true);
releaseReconnectOpen.TrySetResult(true);
if (lease != null)
{
await lease.CloseAsync(default).ConfigureAwait(false);
}
await entry.DisposeAsync().ConfigureAwait(false);
}
}
/// <summary>
/// An initial open that fails on its own fails the creator, even after
/// the entry it faulted moved on to Closed; only a teardown that closed
/// the entry under the open lets the creator retry.
/// </summary>
[Test]
public async Task CreatorDoesNotRetryInitialOpenThatFaultedAsync()
{
var channel = new Mock<IChannel>();
channel
.Setup(c => c.OpenAsync(
It.IsAny<Uri>(),
It.IsAny<TransportChannelSettings>(),
It.IsAny<CancellationToken>()))
.Returns(() => new ValueTask(Task.FromException(
new ServiceResultException(StatusCodes.BadSecureChannelClosed))));
channel
.Setup(c => c.CloseAsync(It.IsAny<CancellationToken>()))
.Returns(new ValueTask());
int createCount = 0;
var bindings = new Mock<ITransportChannelBindings>();
bindings
.Setup(factory => factory.Create(
It.IsAny<string>(),
It.IsAny<ITelemetryContext>()))
.Returns(() =>
{
Interlocked.Increment(ref createCount);
return channel.Object;
});
ITelemetryContext telemetry = NUnitTelemetryContext.Create();
var configuration = new ApplicationConfiguration(telemetry);
await using var manager = new ClientChannelManager(
configuration,
telemetry,
bindings.Object);
ConfiguredEndpoint endpoint = GetNoneSecurityEndpoint(
new EndpointConfiguration { OperationTimeout = 6000 });
var entry = new ChannelEntry(
manager,
ManagedChannelKey.FromEndpoint(endpoint),
endpoint,
reverseConnection: null);
Assert.That(
async () => await entry.OpenInitialAsync(null, null, 0, default).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>());
await entry.DisposeAsync().ConfigureAwait(false);
Assert.Multiple(() =>
{
Assert.That(entry.State, Is.EqualTo(ChannelState.Closed));
Assert.That(entry.InitialOpenFaulted, Is.True);
Assert.That(
async () => await entry.WaitForTransportAsync(default).ConfigureAwait(false),
Throws.TypeOf<ServiceResultException>()
.With.Property(nameof(ServiceResultException.StatusCode))
.EqualTo(StatusCodes.BadSecureChannelClosed));
});
createCount = 0;
ServiceResultException? sre = Assert.ThrowsAsync<ServiceResultException>(
async () => await manager.GetAsync(
new TestParticipant("creator", endpoint), default).ConfigureAwait(false));
Assert.That(sre!.StatusCode, Is.EqualTo(StatusCodes.BadSecureChannelClosed));
Assert.That(createCount, Is.EqualTo(1));
}
/// <summary>
/// Fatal participant verdicts fault the channel without starting another cycle for a bounded caller.
/// </summary>