mirror of
https://github.com/OPCFoundation/UA-.NETStandard.git
synced 2026-10-16 21:02:51 +08:00
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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user