/* ========================================================================
* Copyright (c) 2005-2025 The OPC Foundation, Inc. All rights reserved.
*
* OPC Foundation MIT License 1.00
*
* Permission is hereby granted, free of charge, to any person
* obtaining a copy of this software and associated documentation
* files (the "Software"), to deal in the Software without
* restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the
* Software is furnished to do so, subject to the following
* conditions:
*
* The above copyright notice and this permission notice shall be
* included in all copies or substantial portions of the Software.
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
* EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
* OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
* NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
* HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
* WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
* FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
* OTHER DEALINGS IN THE SOFTWARE.
*
* The complete license agreement can be found here:
* http://opcfoundation.org/License/MIT/1.00/
* ======================================================================*/
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Logging;
using Opc.Ua.Security.Certificates;
namespace Opc.Ua.Client
{
public partial class Session : IReconnectParticipant, IRecreateAwareReconnectParticipant
{
///
/// Stable participant identifier used by
/// for diagnostics. Set
/// during construction.
///
///
/// The "Session-" prefix is significant: the channel
/// manager strips the suffix when emitting the bounded
/// participant metric tag, so the metric stays at
/// per-kind cardinality (one series per "Session", "Client",
/// …) instead of accumulating one permanent series per session
/// instance. The full per-instance identifier is preserved on
/// distributed-trace Activity tags and EventSource events so
/// individual sessions remain correlatable in traces.
///
public string ParticipantId { get; } = "Session-" + Guid.NewGuid().ToString("N");
///
string IReconnectParticipant.Id => ParticipantId;
///
ConfiguredEndpoint IReconnectParticipant.Endpoint => ConfiguredEndpoint;
///
/// The managed channel currently bound to this session, or
/// null if the session was constructed against a raw
/// without a channel manager.
///
public IManagedTransportChannel? ManagedChannel => m_managedChannel;
///
/// The channel manager that owns this session's managed
/// channel, or null if the session was constructed
/// against a raw channel.
///
public IClientChannelManager? ChannelManager => m_channelManager;
///
/// Internal hook used by
/// to bind a freshly constructed Session to its managed channel
/// while the channel-manager participant factory is running.
///
internal void BindManagedChannel(
IClientChannelManager manager,
IManagedTransportChannel channel)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
if (channel == null)
{
throw new ArgumentNullException(nameof(channel));
}
m_channelManager = manager;
m_managedChannel = channel;
}
///
async ValueTask IReconnectParticipant.OnReconnectAsync(
IManagedTransportChannel channel,
int reconnectAttempt,
CancellationToken ct)
{
if (reconnectAttempt < 0)
{
// Final shutdown notification from the manager — the
// channel is going away. Stop the keep-alive timer so
// the session doesn't keep probing a dead transport.
try
{
await StopKeepAliveTimerAsync().ConfigureAwait(false);
}
catch
{
// best effort
}
return ParticipantReconnectResult.Reactivated;
}
try
{
// Pass the wrapper back in as the "channel" so the
// existing legacy path hits the "set channel" no-op
// branch (the wrapper IS the current channel) and goes
// straight to ActivateSession. The wrapper's
// SendRequestAsync bypasses the ready-state gate while
// the manager is in the participant-reactivation
// scope, so ActivateSession can complete.
await ReconnectAsync(connection: null, channel: channel, ct: ct)
.ConfigureAwait(false);
return ParticipantReconnectResult.Reactivated;
}
catch (ServiceResultException sre) when (
sre.StatusCode == StatusCodes.BadSessionIdInvalid ||
sre.StatusCode == StatusCodes.BadSessionClosed ||
sre.StatusCode == StatusCodes.BadSessionNotActivated)
{
m_logger.LogInformation(
sre,
"Session {SessionId}: server-side session lost during reactivation; scheduling recreate.",
SessionId);
return ParticipantReconnectResult.RequiresSessionRecreate;
}
catch (ServiceResultException sre) when (
sre.StatusCode == StatusCodes.BadIdentityTokenRejected ||
sre.StatusCode == StatusCodes.BadIdentityTokenInvalid ||
sre.StatusCode == StatusCodes.BadUserAccessDenied ||
sre.StatusCode == StatusCodes.BadCertificateUntrusted ||
sre.StatusCode == StatusCodes.BadCertificateInvalid ||
sre.StatusCode == StatusCodes.BadCertificateUriInvalid ||
sre.StatusCode == StatusCodes.BadSecurityChecksFailed)
{
m_logger.LogWarning(
sre,
"Session {SessionId}: fatal participant error during reactivation.",
SessionId);
return ParticipantReconnectResult.FatalForParticipant;
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
m_logger.LogWarning(
ex,
"Session {SessionId}: transient reactivation failure.",
SessionId);
return ParticipantReconnectResult.TransientFailure;
}
}
async ValueTask IRecreateAwareReconnectParticipant.RecreateAsync(CancellationToken ct)
{
IManagedTransportChannel? channel = m_managedChannel;
if (channel != null)
{
await RecreateInPlaceAsync(channel: channel, ct: ct).ConfigureAwait(false);
return;
}
await RecreateInPlaceAsync(ct: ct).ConfigureAwait(false);
}
private static ValueTask ReconnectManagedChannelAsync(
IClientChannelManager manager,
IManagedTransportChannel channel,
IRetryBudget? budget,
CancellationToken ct)
{
if (budget == null)
{
return manager.ReconnectAsync(channel, ct);
}
#if NETSTANDARD2_1 || NET8_0_OR_GREATER
return manager.ReconnectAsync(channel, budget, ct);
#else
return manager is ClientChannelManager clientChannelManager
? clientChannelManager.ReconnectAsync(channel, budget, ct)
: manager.ReconnectAsync(channel, ct);
#endif
}
private IClientChannelManager? m_channelManager;
private IManagedTransportChannel? m_managedChannel;
///
/// Creates a new bound to a centrally
/// managed . Multiple
/// sessions targeting the same endpoint will share the
/// underlying transport channel; channel reconnect is
/// coordinated centrally by the manager and is transparent to
/// the caller.
///
/// The channel manager.
/// The client application configuration.
/// The configured endpoint to connect to.
/// When true, refresh the
/// endpoint description from the server before creating the
/// channel.
/// When true, validate the server
/// certificate domain matches the endpoint URL.
/// The session name.
/// Session timeout in milliseconds.
/// Optional user identity (defaults to
/// anonymous).
/// Preferred locales.
/// Optional subscription engine
/// factory (defaults to the classic engine).
/// Optional .
/// Cancellation token.
/// The opened session.
/// A required argument is
/// null.
public static async Task CreateAsync(
IClientChannelManager manager,
ApplicationConfiguration configuration,
ConfiguredEndpoint endpoint,
bool updateBeforeConnect = true,
bool checkDomain = false,
string sessionName = "Session",
uint sessionTimeout = 60000,
IUserIdentity? identity = null,
ArrayOf preferredLocales = default,
ISubscriptionEngineFactory? engineFactory = null,
TimeProvider? timeProvider = null,
CancellationToken ct = default)
{
if (manager == null)
{
throw new ArgumentNullException(nameof(manager));
}
if (configuration == null)
{
throw new ArgumentNullException(nameof(configuration));
}
if (endpoint == null)
{
throw new ArgumentNullException(nameof(endpoint));
}
// Refresh endpoint description from server if requested.
// The channel manager does not perform discovery on its
// own, so we do it here before key computation/channel
// acquisition.
ServiceMessageContext probeContext = configuration.CreateMessageContext();
if (updateBeforeConnect)
{
await endpoint
.UpdateFromServerAsync(probeContext.Telemetry, ct)
.ConfigureAwait(false);
}
// Load the client instance certificate up-front so the
// manager can incorporate its thumbprint into the
// ChannelKey and so the channel can be opened with a
// matching cert if security is enabled.
string secPolicy = endpoint.Description.SecurityPolicyUri ?? SecurityPolicies.None;
if (secPolicy != SecurityPolicies.None)
{
Certificate clientCert = await LoadInstanceCertificateAsync(
configuration, secPolicy, probeContext.Telemetry, ct)
.ConfigureAwait(false);
CertificateCollection? chain = await LoadCertificateChainAsync(
configuration, clientCert, ct).ConfigureAwait(false);
manager.UpdateClientCertificate(clientCert, chain);
}
Session? session = null;
IManagedTransportChannel? managedChannel = null;
try
{
managedChannel = await manager.GetAsync(
endpoint,
channel =>
{
session = new Session(
channel,
configuration,
endpoint,
probeContext,
engineFactory,
timeProvider);
session.BindManagedChannel(manager, channel);
return session;
},
reverseConnection: null,
ct)
.ConfigureAwait(false);
Session activeSession = session
?? throw new InvalidOperationException("Participant factory did not create a session.");
UserIdentity? tempIdentity = identity == null ? new UserIdentity() : null;
try
{
await activeSession
.OpenAsync(
sessionName,
sessionTimeout,
identity ?? tempIdentity!,
preferredLocales,
checkDomain,
ct)
.ConfigureAwait(false);
}
finally
{
tempIdentity = null;
}
return activeSession;
}
catch
{
if (session != null)
{
session.Dispose();
}
else
{
managedChannel?.Dispose();
}
throw;
}
}
}
}