Files
UA-.NETStandard/tests/Opc.Ua.Client.Tests/Session/SharedChannelConcurrentSessionTests.cs
romanett 0716603543 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>
2026-10-12 07:04:41 +02:00

247 lines
10 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.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;
}
}
}