/* ======================================================================== * 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.Collections.Generic; using System.IO; using System.Threading; using System.Threading.Tasks; using Microsoft.Extensions.Logging; using Opc.Ua.Identity; namespace Opc.Ua.Client { /// /// A managed session that wraps a unmanaged /// and automatically handles connection lifecycle, reconnection /// with configurable policy, and server redundancy failover. /// /// /// /// Service calls are gated during reconnect — callers transparently /// wait until the session is reconnected. The gating uses an /// : connected /// service calls take a reader lock (cheap, concurrent), while /// reconnect / failover holds the writer lock exclusively. /// /// /// This class uses composition, not inheritance, to wrap the unmanaged /// . All members are /// delegated to the inner session. /// /// public partial class ManagedSession : ISession { /// /// Initializes a new instance of the /// class. Use to create a connected /// instance. /// private ManagedSession( ApplicationConfiguration configuration, ConfiguredEndpoint endpoint, ISessionFactory sessionFactory, IReconnectPolicy reconnectPolicy, IServerRedundancyHandler? redundancyHandler, ILogger logger, IUserIdentity? identity, IClientIdentityProvider? identityProvider, TimeProvider? timeProvider, ArrayOf preferredLocales, string sessionName, uint sessionTimeout, bool checkDomain, bool transferSubscriptionsOnRecreate, bool poolNotifications, IClientChannelManager? channelManager) { m_configuration = configuration ?? throw new ArgumentNullException(nameof(configuration)); ConfiguredEndpoint = endpoint ?? throw new ArgumentNullException(nameof(endpoint)); SessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory)); m_reconnectPolicy = reconnectPolicy ?? throw new ArgumentNullException(nameof(reconnectPolicy)); m_redundancyHandler = redundancyHandler; m_logger = logger ?? throw new ArgumentNullException(nameof(logger)); m_identity = identity; m_identityProvider = identityProvider; m_timeProvider = timeProvider ?? TimeProvider.System; m_maxTotalReconnectTime = reconnectPolicy is ReconnectPolicy policy ? policy.MaxTotalReconnectTime : ReconnectPolicy.DefaultMaxTotalReconnectTime; m_preferredLocales = preferredLocales; m_sessionName = sessionName; m_sessionTimeout = sessionTimeout; m_checkDomain = checkDomain; m_transferSubscriptionsOnRecreate = transferSubscriptionsOnRecreate; m_poolNotifications = poolNotifications; m_channelManager = channelManager; StateMachine = new ConnectionStateMachine( reconnectPolicy, logger, m_maxTotalReconnectTime, m_timeProvider); WireStateMachineCallbacks(); SubscribeCertificateChanges(); } /// /// Creates a new that is connected /// to the specified endpoint. /// /// The application configuration. /// /// The configured endpoint to connect to. /// /// The session factory to use for /// creating sessions. /// Optional user identity. /// Optional reconnect policy. /// Defaults to . /// Optional redundancy handler. /// /// Optional telemetry context. /// The session name. /// Session timeout in ms. /// Preferred locales. /// Whether to check the domain in /// the server certificate. /// Optional subscription engine /// factory. Defaults to /// (V2 engine) so that is /// available. Pass /// for legacy classic-engine behavior. /// When /// true, opt the V2 subscription engine into /// transfer-on-recreate. After a session re-create (e.g. a /// failover via Session.RecreateInPlaceAsync) /// the V2 manager attempts to transfer existing server-side /// subscriptions before falling back to per-subscription /// recreate. Default false — recreate is the universal /// fallback; transfer requires server support. /// When true, the V2 /// subscription manager calls /// on notification payload instances after handler dispatch to /// release them back to their activator pools. Default /// false. See ManagedSessionOptions.PoolNotifications /// for the retain-by-copy contract. /// Optional lazy identity provider. /// Optional time provider for proactive refresh. /// Optional central /// . When supplied, the /// inner Session shares its transport channel with any other /// session/discovery client targeting the same endpoint, and /// channel reconnect is coordinated centrally. /// Cancellation token. /// A connected . public static async Task CreateAsync( ApplicationConfiguration configuration, ConfiguredEndpoint endpoint, ISessionFactory sessionFactory, IUserIdentity? identity = null, IReconnectPolicy? reconnectPolicy = null, IServerRedundancyHandler? redundancyHandler = null, ITelemetryContext? telemetry = null, string sessionName = "ManagedSession", uint sessionTimeout = 60000, ArrayOf preferredLocales = default, bool checkDomain = false, ISubscriptionEngineFactory? engineFactory = null, bool transferSubscriptionsOnRecreate = false, bool poolNotifications = false, IClientIdentityProvider? identityProvider = null, TimeProvider? timeProvider = null, IClientChannelManager? channelManager = null, CancellationToken ct = default) { telemetry ??= sessionFactory.Telemetry; ILogger logger = telemetry.CreateLogger(); // Default the engine factory to V2 so callers get the new // ISubscriptionManager API by default. If the inner session // factory is a DefaultSessionFactory and no engine factory is // already configured on it, propagate this choice so the inner // Session is constructed with the V2 engine. engineFactory ??= timeProvider == null ? DefaultSubscriptionEngineFactory.Instance : new DefaultSubscriptionEngineFactory(timeProvider); if (sessionFactory is DefaultSessionFactory dsf && dsf.SubscriptionEngineFactory is null) { sessionFactory = new DefaultSessionFactory(dsf.Telemetry) { ReturnDiagnostics = dsf.ReturnDiagnostics, SubscriptionEngineFactory = engineFactory, TimeProvider = timeProvider ?? dsf.TimeProvider }; } var managed = new ManagedSession( configuration, endpoint, sessionFactory, reconnectPolicy ?? new ReconnectPolicy(), redundancyHandler, logger, identity, identityProvider, timeProvider, preferredLocales, sessionName, sessionTimeout, checkDomain, transferSubscriptionsOnRecreate, poolNotifications, channelManager); managed.m_engineFactory = engineFactory; managed.StateMachine.Start(); managed.StateMachine.RequestConnect(); await managed.StateMachine .WaitForConnectedAsync(ct) .ConfigureAwait(false); return managed; } /// /// Gets the inner unmanaged session. /// Throws if no session is available. /// /// internal Session InnerSession => m_session ?? throw new ServiceResultException( StatusCodes.BadNotConnected, "The managed session is not connected."); /// /// Gets the connection state machine. /// internal ConnectionStateMachine StateMachine { get; } /// /// Waits until a proactive identity refresh attempt has completed. /// internal async Task EnsureRefreshAsync(CancellationToken ct) { ct.ThrowIfCancellationRequested(); Task? task; long version; lock (m_identityRefreshLock) { if (m_identityRefreshCompletedVersion > m_identityRefreshObservedVersion) { m_identityRefreshObservedVersion = m_identityRefreshCompletedVersion; return; } task = m_identityRefreshAttemptCompletion?.Task; version = m_identityRefreshAttemptVersion; } if (task == null) { return; } await AwaitWithCancellationAsync(task, ct).ConfigureAwait(false); MarkIdentityRefreshObserved(version); } /// public ISessionFactory SessionFactory { get; } /// public ConfiguredEndpoint ConfiguredEndpoint { get; } /// public string SessionName => m_session?.SessionName ?? m_sessionName; /// public double SessionTimeout => m_session?.SessionTimeout ?? m_sessionTimeout; /// public object? Handle { get => m_session?.Handle; set => m_session?.Handle = value; } /// public IUserIdentity Identity => m_session?.Identity ?? m_identity!; /// public IEnumerable IdentityHistory => m_session?.IdentityHistory ?? []; /// public NamespaceTable NamespaceUris => InnerSession.NamespaceUris; /// public StringTable ServerUris => InnerSession.ServerUris; /// public ISystemContext SystemContext => InnerSession.SystemContext; /// public IEncodeableFactory Factory => InnerSession.Factory; /// public ITypeTable TypeTree => InnerSession.TypeTree; /// public INodeCache NodeCache => InnerSession.NodeCache; /// public IFilterContext FilterContext => InnerSession.FilterContext; /// public ArrayOf PreferredLocales => m_session?.PreferredLocales ?? m_preferredLocales; /// public IEnumerable Subscriptions => m_session?.Subscriptions ?? []; /// /// The new options-based . /// Available when the underlying session was created with the V2 /// subscription engine (the default for ). /// /// when the session /// is using the classic engine. public Subscriptions.ISubscriptionManager SubscriptionManager { get { if (InnerSession.SubscriptionEngine is DefaultSubscriptionEngine v2) { return v2.SubscriptionManager; } throw new InvalidOperationException( "ManagedSession.SubscriptionManager requires the V2 subscription engine. " + "The session is using the classic engine; use Subscriptions/AddSubscription " + "for the legacy API or recreate the ManagedSession with the V2 engine factory."); } } /// public int SubscriptionCount => m_session?.SubscriptionCount ?? 0; /// public bool DeleteSubscriptionsOnClose { get => m_session?.DeleteSubscriptionsOnClose ?? true; set => m_session?.DeleteSubscriptionsOnClose = value; } /// public int PublishRequestCancelDelayOnCloseSession { get => m_session?.PublishRequestCancelDelayOnCloseSession ?? 5000; set => m_session?.PublishRequestCancelDelayOnCloseSession = value; } /// public Subscription DefaultSubscription { get => InnerSession.DefaultSubscription; set => InnerSession.DefaultSubscription = value; } /// public int KeepAliveInterval { get => m_session?.KeepAliveInterval ?? 5000; set => m_session?.KeepAliveInterval = value; } /// public bool KeepAliveStopped => m_session?.KeepAliveStopped ?? true; /// public DateTime LastKeepAliveTime => m_session?.LastKeepAliveTime ?? DateTime.MinValue; /// public long LastKeepAliveTimestamp => m_session?.LastKeepAliveTimestamp ?? 0L; /// public int OutstandingRequestCount => m_session?.OutstandingRequestCount ?? 0; /// public int DefunctRequestCount => m_session?.DefunctRequestCount ?? 0; /// public int GoodPublishRequestCount => m_session?.GoodPublishRequestCount ?? 0; /// public int MinPublishRequestCount { get => m_session?.MinPublishRequestCount ?? 1; set => m_session?.MinPublishRequestCount = value; } /// public int MaxPublishRequestCount { get => m_session?.MaxPublishRequestCount ?? 20; set => m_session?.MaxPublishRequestCount = value; } /// public bool Reconnecting => StateMachine.State is ConnectionState.Reconnecting or ConnectionState.Failover; /// public OperationLimits OperationLimits => InnerSession.OperationLimits; /// public ServerCapabilities ServerCapabilities => InnerSession.ServerCapabilities; /// public bool TransferSubscriptionsOnReconnect { get => m_session?.TransferSubscriptionsOnReconnect ?? false; set => m_session?.TransferSubscriptionsOnReconnect = value; } /// public bool CheckDomain => m_session?.CheckDomain ?? m_checkDomain; /// public ContinuationPointPolicy ContinuationPointPolicy { get => m_session?.ContinuationPointPolicy ?? ContinuationPointPolicy.Default; set => m_session?.ContinuationPointPolicy = value; } /// public NodeId SessionId => m_session?.SessionId ?? NodeId.Null; /// public bool Connected => m_session?.Connected ?? false; /// public ClientTraceFlags ActivityTraceFlags { get => m_session?.ActivityTraceFlags ?? ClientTraceFlags.None; set => m_session?.ActivityTraceFlags = value; } /// public EndpointDescription Endpoint => m_session?.Endpoint ?? ConfiguredEndpoint.Description; /// public EndpointConfiguration EndpointConfiguration => m_session?.EndpointConfiguration ?? ConfiguredEndpoint.Configuration!; /// public IServiceMessageContext MessageContext => InnerSession.MessageContext!; /// public ITransportChannel NullableTransportChannel => InnerSession.NullableTransportChannel!; /// public ITransportChannel TransportChannel => InnerSession.TransportChannel; /// public DiagnosticsMasks ReturnDiagnostics { get => m_session?.ReturnDiagnostics ?? DiagnosticsMasks.None; set => m_session?.ReturnDiagnostics = value; } /// public int OperationTimeout { get => m_session?.OperationTimeout ?? 0; set => m_session?.OperationTimeout = value; } /// public int DefaultTimeoutHint { get => m_session?.DefaultTimeoutHint ?? 0; set => m_session?.DefaultTimeoutHint = value; } /// public bool Disposed => m_disposed != 0; /// public event KeepAliveEventHandler KeepAlive { add => m_keepAlive += value; remove => m_keepAlive -= value; } /// public event NotificationEventHandler Notification { add => m_notification += value; remove => m_notification -= value; } /// public event PublishErrorEventHandler PublishError { add => m_publishError += value; remove => m_publishError -= value; } /// public event PublishSequenceNumbersToAcknowledgeEventHandler PublishSequenceNumbersToAcknowledge { add => m_publishSequenceNumbersToAcknowledge += value; remove => m_publishSequenceNumbersToAcknowledge -= value; } /// public event EventHandler SubscriptionsChanged { add => m_subscriptionsChanged += value; remove => m_subscriptionsChanged -= value; } /// public event EventHandler SessionClosing { add => m_sessionClosing += value; remove => m_sessionClosing -= value; } /// public event EventHandler SessionConfigurationChanged { add => m_sessionConfigurationChanged += value; remove => m_sessionConfigurationChanged -= value; } /// public event RenewUserIdentityEventHandler RenewUserIdentity { add => m_renewUserIdentity += value; remove => m_renewUserIdentity -= value; } /// /// Raised when the outer connection state changes. Subscribe to /// for underlying channel-manager /// state transitions that do not change the outer state. /// public event EventHandler? ConnectionStateChanged; /// /// Raised when the underlying managed transport channel state changes. /// This event is only raised when the inner session uses an /// supplied by a channel manager. /// public event Action? ChannelStateChanged; /// public async Task ReconnectAsync( ITransportWaitingConnection? connection, ITransportChannel? channel, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.ReconnectAsync( connection, channel, ct).ConfigureAwait(false); } } /// public async Task ReloadInstanceCertificateAsync( CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.ReloadInstanceCertificateAsync(ct) .ConfigureAwait(false); } } /// public void Save( Stream stream, IEnumerable subscriptions, IEnumerable? knownTypes = null) { InnerSession.Save(stream, subscriptions, knownTypes); } /// public IEnumerable Load( Stream stream, bool transferSubscriptions = false, IEnumerable? knownTypes = null) { return InnerSession.Load(stream, transferSubscriptions, knownTypes); } /// public SessionConfiguration SaveSessionConfiguration( Stream? stream = null) { return InnerSession.SaveSessionConfiguration(stream); } /// public bool ApplySessionConfiguration( SessionConfiguration sessionConfiguration) { return InnerSession.ApplySessionConfiguration(sessionConfiguration); } /// public async Task FetchNamespaceTablesAsync( CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.FetchNamespaceTablesAsync(ct) .ConfigureAwait(false); } } /// public async Task FetchTypeTreeAsync( ExpandedNodeId typeId, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.FetchTypeTreeAsync(typeId, ct) .ConfigureAwait(false); } } /// public async Task FetchTypeTreeAsync( ArrayOf typeIds, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.FetchTypeTreeAsync(typeIds, ct) .ConfigureAwait(false); } } /// public async Task OpenAsync( string sessionName, uint sessionTimeout, IUserIdentity identity, ArrayOf preferredLocales, bool checkDomain, bool closeChannel, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.OpenAsync( sessionName, sessionTimeout, identity, preferredLocales, checkDomain, closeChannel, ct) .ConfigureAwait(false); } } /// public async Task UpdateSessionAsync( IUserIdentity identity, ArrayOf preferredLocales, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.UpdateSessionAsync( identity, preferredLocales, ct) .ConfigureAwait(false); } } /// public async Task ChangePreferredLocalesAsync( ArrayOf preferredLocales, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { await InnerSession.ChangePreferredLocalesAsync( preferredLocales, ct).ConfigureAwait(false); } } /// public async Task CloseAsync( int timeout, bool closeChannel, CancellationToken ct = default) { StateMachine.RequestClose(); await StateMachine.WaitForClosedAsync(ct).ConfigureAwait(false); return StatusCodes.Good; } /// public bool AddSubscription(Subscription subscription) { return InnerSession.AddSubscription(subscription); } /// public bool RemoveTransferredSubscription( Subscription subscription) { return InnerSession .RemoveTransferredSubscription(subscription); } /// public async Task RemoveSubscriptionAsync( Subscription subscription, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { return await InnerSession.RemoveSubscriptionAsync( subscription, ct).ConfigureAwait(false); } } /// public async Task RemoveSubscriptionsAsync( IEnumerable subscriptions, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { return await InnerSession.RemoveSubscriptionsAsync( subscriptions, ct).ConfigureAwait(false); } } /// public async Task ReactivateSubscriptionsAsync( SubscriptionCollection subscriptions, bool sendInitialValues, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { return await InnerSession .ReactivateSubscriptionsAsync( subscriptions, sendInitialValues, ct) .ConfigureAwait(false); } } /// public async Task TransferSubscriptionsAsync( SubscriptionCollection subscriptions, bool sendInitialValues, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { return await InnerSession .TransferSubscriptionsAsync( subscriptions, sendInitialValues, ct) .ConfigureAwait(false); } } /// public bool BeginPublish(int timeout) { return InnerSession.BeginPublish(timeout); } /// public void StartPublishing(int timeout, bool fullQueue) { InnerSession.StartPublishing(timeout, fullQueue); } /// public async Task<(bool, ServiceResult)> RepublishAsync( uint subscriptionId, uint sequenceNumber, CancellationToken ct = default) { using (await m_serviceLock.ReaderLockAsync(ct) .ConfigureAwait(false)) { return await InnerSession.RepublishAsync( subscriptionId, sequenceNumber, ct) .ConfigureAwait(false); } } /// [Obsolete("Channels are now managed centrally via IClientChannelManager. " + "Use ManagedSessionBuilder.WithChannelManager(...) or " + "Session.CreateAsync(IClientChannelManager, ...) instead of manual " + "AttachChannel/DetachChannel. This method remains functional for back-compat.")] public void AttachChannel(ITransportChannel channel) { InnerSession.AttachChannel(channel); } /// [Obsolete("Channels are now managed centrally via IClientChannelManager. " + "Use ManagedSessionBuilder.WithChannelManager(...) or " + "Session.CreateAsync(IClientChannelManager, ...) instead of manual " + "AttachChannel/DetachChannel. This method remains functional for back-compat.")] public void DetachChannel() { InnerSession.DetachChannel(); } /// public async Task CloseAsync( CancellationToken ct = default) { StateMachine.RequestClose(); Session? session = m_session; if (session != null) { return await session.CloseAsync(ct) .ConfigureAwait(false); } return StatusCodes.Good; } /// public uint NewRequestHandle() { return InnerSession.NewRequestHandle(); } /// /// Refreshes the user identity on the connected inner session. /// /// is null. public async ValueTask UpdateIdentityAsync( IClientIdentityProvider provider, CancellationToken ct = default) { if (provider == null) { throw new ArgumentNullException(nameof(provider)); } using (await m_serviceLock.WriterLockAsync(ct) .ConfigureAwait(false)) { await InnerSession .UpdateIdentityAsync(provider, ct) .ConfigureAwait(false); } } private void WireStateMachineCallbacks() { StateMachine.ConnectAsync = HandleConnectAsync; StateMachine.ReconnectWithBudgetAsync = HandleReconnectAsync; StateMachine.FailoverWithBudgetAsync = HandleFailoverAsync; StateMachine.CloseSessionAsync = HandleCloseSessionAsync; StateMachine.StateChanged += OnStateChanged; } private async Task HandleConnectAsync( CancellationToken ct) { try { m_logger.LogInformation( "ManagedSession: Connecting to {Endpoint}.", ConfiguredEndpoint.EndpointUrl); Session session; if (m_channelManager != null) { // Channel-manager-aware path: acquire a shared // managed channel and let the manager drive any // future reconnect transparently. Other sessions // sharing this endpoint join the same channel. session = await Session.CreateAsync( m_channelManager, m_configuration, ConfiguredEndpoint, updateBeforeConnect: true, m_checkDomain, m_sessionName, m_sessionTimeout, m_identityProvider == null ? m_identity : null, m_preferredLocales, m_engineFactory, m_timeProvider, ct).ConfigureAwait(false); } else { session = (Session)await SessionFactory.CreateAsync( m_configuration, ConfiguredEndpoint, updateBeforeConnect: true, m_checkDomain, m_sessionName, m_sessionTimeout, m_identityProvider == null ? m_identity : null, m_preferredLocales, ct).ConfigureAwait(false); } WireSessionEvents(session); m_session = session; if (m_identityProvider != null) { using (await m_serviceLock.WriterLockAsync(ct) .ConfigureAwait(false)) { await session .UpdateIdentityAsync(m_identityProvider, ct) .ConfigureAwait(false); } StartIdentityRefreshLoop(); } // Apply opt-in V2 transfer-on-recreate. The V2 engine // and SubscriptionManager survive in-place re-creates // (failover via Session.RecreateInPlaceAsync), so this // setting persists for the entire session lifetime // once applied here. No-op when the classic engine is // in use. if ((m_transferSubscriptionsOnRecreate || m_poolNotifications) && session.SubscriptionEngine is DefaultSubscriptionEngine v2 && v2.SubscriptionManager is Subscriptions.SubscriptionManager v2Manager) { if (m_transferSubscriptionsOnRecreate) { v2Manager.TransferSubscriptionsOnRecreate = true; } if (m_poolNotifications) { v2Manager.PoolNotifications = true; } } if (m_redundancyHandler != null) { m_redundancyInfo = await m_redundancyHandler .FetchRedundancyInfoAsync(this, ct) .ConfigureAwait(false); } m_logger.LogInformation( "ManagedSession: Connected, SessionId={SessionId}.", session.SessionId); return ServiceResult.Good; } catch (Exception ex) { m_logger.LogError( ex, "ManagedSession: Connect failed."); return new ServiceResult(ex); } } private async Task HandleReconnectAsync( IRetryBudget budget, CancellationToken ct) { try { m_logger.LogInformation( "ManagedSession: Reconnecting."); using (await m_serviceLock.WriterLockAsync(ct) .ConfigureAwait(false)) { Session? session = m_session; if (session != null) { try { await session.ReconnectAsync( budget, ct) .ConfigureAwait(false); } catch (ServiceResultException sre) when ( sre.StatusCode == StatusCodes.BadSecureChannelClosed && session.ManagedChannel?.State is ChannelState.Closed or ChannelState.Faulted) { m_logger.LogInformation( sre, "ManagedSession: managed channel is faulted; recreating session in place."); await session.RecreateInPlaceAsync( endpoint: null, budget: budget, ct: ct) .ConfigureAwait(false); } } } m_reconnectPolicy.Reset(); m_logger.LogInformation( "ManagedSession: Reconnected."); return ServiceResult.Good; } catch (Exception ex) { m_logger.LogWarning( ex, "ManagedSession: Reconnect attempt failed."); return new ServiceResult(ex); } } private async Task HandleFailoverAsync( IRetryBudget budget, CancellationToken ct) { if (m_redundancyHandler == null || m_redundancyInfo == null) { return new ServiceResult( StatusCodes.BadNotSupported); } try { ConfiguredEndpoint? failoverEndpoint = m_redundancyHandler.SelectFailoverTarget( m_redundancyInfo, ConfiguredEndpoint); if (failoverEndpoint == null) { return new ServiceResult( StatusCodes.BadNothingToDo); } m_logger.LogInformation( "ManagedSession: Failing over to {Endpoint}.", failoverEndpoint.EndpointUrl); using (await m_serviceLock.WriterLockAsync(ct) .ConfigureAwait(false)) { Session? session = m_session; if (session == null) { return new ServiceResult( StatusCodes.BadInvalidState); } // Recreate in place: the inner Session reference is // preserved across failover so that the V2 // SubscriptionEngine, SubscriptionManager, and any // external holders of InnerSession all continue to // operate on the same Session object. Session // internals re-run CreateSession+ActivateSession // against the new endpoint and drive subscription // recreate/transfer for both unamanged templates and // the new engine. await session .RecreateInPlaceAsync( failoverEndpoint, budget, ct) .ConfigureAwait(false); } m_reconnectPolicy.Reset(); m_logger.LogInformation( "ManagedSession: Failover complete."); return ServiceResult.Good; } catch (Exception ex) { m_logger.LogError( ex, "ManagedSession: Failover failed."); return new ServiceResult(ex); } } private async Task HandleCloseSessionAsync(CancellationToken ct) { await StopIdentityRefreshLoopAsync().ConfigureAwait(false); Session? session = m_session; if (session != null) { m_session = null; UnwireSessionEvents(session); try { await session .CloseAsync(ct) .ConfigureAwait(false); } catch (Exception ex) { m_logger.LogDebug( ex, "ManagedSession: Session close failed."); } session.Dispose(); } } private void WireSessionEvents(Session session) { session.KeepAlive += OnInnerKeepAlive; session.Notification += OnInnerNotification; session.PublishError += OnInnerPublishError; session.PublishSequenceNumbersToAcknowledge += OnInnerPublishSequenceNumbers; session.SubscriptionsChanged += OnInnerSubscriptionsChanged; session.SessionClosing += OnInnerSessionClosing; session.SessionConfigurationChanged += OnInnerSessionConfigurationChanged; session.RenewUserIdentity += OnInnerRenewUserIdentity; if (session.ManagedChannel != null) { session.ManagedChannel.StateChanged += OnManagedChannelStateChanged; } } private void UnwireSessionEvents(Session session) { session.KeepAlive -= OnInnerKeepAlive; session.Notification -= OnInnerNotification; session.PublishError -= OnInnerPublishError; session.PublishSequenceNumbersToAcknowledge -= OnInnerPublishSequenceNumbers; session.SubscriptionsChanged -= OnInnerSubscriptionsChanged; session.SessionClosing -= OnInnerSessionClosing; session.SessionConfigurationChanged -= OnInnerSessionConfigurationChanged; session.RenewUserIdentity -= OnInnerRenewUserIdentity; if (session.ManagedChannel != null) { session.ManagedChannel.StateChanged -= OnManagedChannelStateChanged; } } private void OnInnerKeepAlive(ISession session, KeepAliveEventArgs e) { if (e.Status != null && ServiceResult.IsBad(e.Status)) { // When the channel manager is wired AND it already // reports the channel as not Ready (TransportReconnecting // or TransportConnectedSessionReactivating), it is // already handling the reconnect. Suppress the outer // state-machine churn — the manager will drive the // inner Session reactivation transparently via // OnReconnectAsync. The outer state machine only takes // over when the channel manager terminally faults the // channel. if (Volatile.Read(ref m_channelReconnectInProgress) > 0) { m_logger.LogDebug( "ManagedSession: keep-alive failure suppressed " + "while channel manager reconnect is in progress."); } else { StateMachine.TriggerReconnect(); } } m_keepAlive?.Invoke(this, e); } private void OnManagedChannelStateChanged( IManagedTransportChannel channel, ChannelStateChange change) { RaiseChannelStateChanged(change); // Track whether the manager is in the middle of a // reconnect cycle. When entering reconnect, we want to // suppress OnInnerKeepAlive-triggered outer reconnects so // both layers don't race. When the manager terminally // faults the channel, surface that to the outer state // machine so the higher-level retry policy can take over. switch (change.NewState) { case ChannelState.TransportReconnecting: case ChannelState.TransportConnectedSessionReactivating: Interlocked.Increment(ref m_channelReconnectInProgress); break; case ChannelState.Ready: Interlocked.Exchange(ref m_channelReconnectInProgress, 0); break; case ChannelState.Faulted: Interlocked.Exchange(ref m_channelReconnectInProgress, 0); // Channel-mgr gave up. Surface to outer state // machine so the IReconnectPolicy / failover path // can run. StateMachine.TriggerReconnect(change); break; case ChannelState.Closed: Interlocked.Exchange(ref m_channelReconnectInProgress, 0); // Channel-mgr closed. Surface to outer state // machine so the IReconnectPolicy / failover path // can run. StateMachine.TriggerReconnect(); break; default: break; } } private void RaiseChannelStateChanged(ChannelStateChange change) { try { ChannelStateChanged?.Invoke(this, change); } catch (Exception ex) { m_logger.LogError( ex, "ManagedSession: ChannelStateChanged handler threw an exception."); } } private void OnInnerNotification(ISession session, NotificationEventArgs e) { m_notification?.Invoke(this, e); } private void OnInnerPublishError(ISession session, PublishErrorEventArgs e) { m_publishError?.Invoke(this, e); } private void OnInnerPublishSequenceNumbers( ISession session, PublishSequenceNumbersToAcknowledgeEventArgs e) { m_publishSequenceNumbersToAcknowledge?.Invoke(this, e); } private void OnInnerSubscriptionsChanged(object? sender, EventArgs e) { m_subscriptionsChanged?.Invoke(this, e); } private void OnInnerSessionClosing(object? sender, EventArgs e) { m_sessionClosing?.Invoke(this, e); } private void OnInnerSessionConfigurationChanged(object? sender, EventArgs e) { m_sessionConfigurationChanged?.Invoke(this, e); } private IUserIdentity OnInnerRenewUserIdentity( ISession session, IUserIdentity identity) { return m_renewUserIdentity?.Invoke(this, identity) ?? identity; } private void OnStateChanged(object? sender, ConnectionStateChangedEventArgs e) { ConnectionStateChanged?.Invoke(this, e); } private void StartIdentityRefreshLoop() { if (m_identityProvider == null) { return; } var cts = new CancellationTokenSource(); Task task = RunIdentityRefreshLoopAsync(m_identityProvider, cts.Token); CancellationTokenSource? previousCts; lock (m_identityRefreshLock) { previousCts = m_identityRefreshCancellation; m_identityRefreshCancellation = cts; m_identityRefreshTask = task; } previousCts?.Cancel(); } private async Task RunIdentityRefreshLoopAsync( IClientIdentityProvider provider, CancellationToken ct) { int retryAttempt = 0; TaskCompletionSource? attemptCompletion = null; long attemptVersion = 0; while (!ct.IsCancellationRequested) { attemptCompletion ??= BeginIdentityRefreshAttempt(out attemptVersion); TaskCompletionSource currentCompletion = attemptCompletion; long currentVersion = attemptVersion; TimeSpan delay = GetIdentityRefreshDelay(provider.ExpiresAt, retryAttempt == 0); try { await DelayAsync(delay, ct).ConfigureAwait(false); await RefreshIdentityOnceAsync(provider, ct).ConfigureAwait(false); retryAttempt = 0; CompleteIdentityRefreshAttempt(currentCompletion, currentVersion); attemptCompletion = null; } catch (OperationCanceledException) when (ct.IsCancellationRequested) { currentCompletion.TrySetCanceled(ct); break; } catch (Exception ex) { retryAttempt++; m_logger.LogWarning( ex, "ManagedSession: proactive identity refresh failed; retrying."); TimeSpan backoff = GetIdentityRefreshBackoff(retryAttempt); Task backoffTask = DelayAsync(backoff, ct); attemptCompletion = BeginIdentityRefreshAttempt(out attemptVersion); CompleteIdentityRefreshAttempt(currentCompletion, currentVersion); try { await backoffTask.ConfigureAwait(false); } catch (OperationCanceledException) when (ct.IsCancellationRequested) { attemptCompletion.TrySetCanceled(ct); break; } } } } private TaskCompletionSource BeginIdentityRefreshAttempt(out long version) { var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); lock (m_identityRefreshLock) { version = ++m_identityRefreshAttemptVersion; m_identityRefreshAttemptCompletion = completion; } return completion; } private void CompleteIdentityRefreshAttempt( TaskCompletionSource completion, long version) { lock (m_identityRefreshLock) { if (m_identityRefreshCompletedVersion < version) { m_identityRefreshCompletedVersion = version; } } completion.TrySetResult(null); } private void MarkIdentityRefreshObserved(long version) { lock (m_identityRefreshLock) { if (m_identityRefreshObservedVersion < version) { m_identityRefreshObservedVersion = version; } } } private static async Task AwaitWithCancellationAsync(Task task, CancellationToken ct) { if (task.IsCompleted || !ct.CanBeCanceled) { await task.ConfigureAwait(false); return; } var cancellation = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); using (ct.Register( static state => ((TaskCompletionSource)state!).TrySetCanceled(), cancellation)) { Task completed = await Task.WhenAny(task, cancellation.Task).ConfigureAwait(false); await completed.ConfigureAwait(false); } } private async Task DelayAsync(TimeSpan delay, CancellationToken ct) { ct.ThrowIfCancellationRequested(); if (delay == TimeSpan.Zero) { return; } var delayState = new DelayState(); delayState.Timer = m_timeProvider.CreateTimer( static state => ((DelayState)state!).Complete(), delayState, delay, Timeout.InfiniteTimeSpan); delayState.Cancellation = ct.Register( static state => ((DelayState)state!).Cancel(), delayState); try { await delayState.Task.ConfigureAwait(false); } finally { delayState.Dispose(); } } private TimeSpan GetIdentityRefreshDelay(DateTime expiresAt, bool allowInfinite) { if (expiresAt == DateTime.MaxValue) { return allowInfinite ? Timeout.InfiniteTimeSpan : TimeSpan.FromMinutes(5); } DateTimeOffset now = m_timeProvider.GetUtcNow(); DateTimeOffset expires = expiresAt.Kind == DateTimeKind.Local ? new DateTimeOffset(expiresAt).ToUniversalTime() : new DateTimeOffset(DateTime.SpecifyKind(expiresAt, DateTimeKind.Utc)); TimeSpan delay = expires - now - IdentityRefreshSafetyMargin; if (delay <= TimeSpan.Zero) { return allowInfinite ? TimeSpan.FromSeconds(1) : TimeSpan.Zero; } return delay; } private static TimeSpan GetIdentityRefreshBackoff(int retryAttempt) { int cappedAttempt = Math.Min(retryAttempt, 5); double seconds = Math.Min(5 * Math.Pow(2, cappedAttempt - 1), 60); return TimeSpan.FromSeconds(seconds); } private async Task RefreshIdentityOnceAsync( IClientIdentityProvider provider, CancellationToken ct) { using (await m_serviceLock.WriterLockAsync(ct) .ConfigureAwait(false)) { Session? session = m_session; if (session == null) { return; } await session.UpdateIdentityAsync(provider, ct).ConfigureAwait(false); } } private async Task StopIdentityRefreshLoopAsync() { CancellationTokenSource? cts; Task? task; lock (m_identityRefreshLock) { cts = m_identityRefreshCancellation; task = m_identityRefreshTask; m_identityRefreshCancellation = null; m_identityRefreshTask = null; } if (cts == null) { return; } try { cts.Cancel(); if (task != null) { await task.ConfigureAwait(false); } } catch (OperationCanceledException) { } finally { cts.Dispose(); } } private void CancelIdentityRefreshLoop() { CancellationTokenSource? cts; lock (m_identityRefreshLock) { cts = m_identityRefreshCancellation; m_identityRefreshCancellation = null; m_identityRefreshTask = null; } cts?.Cancel(); } /// public void Dispose() { Dispose(disposing: true); GC.SuppressFinalize(this); } /// /// Releases managed and unmanaged resources. /// /// True if called from /// . protected virtual void Dispose(bool disposing) { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } if (disposing) { CancelIdentityRefreshLoop(); UnsubscribeCertificateChanges(); StateMachine.RequestClose(); Session? session = m_session; m_session = null; if (session != null) { UnwireSessionEvents(session); session.Dispose(); } m_serviceLock.Dispose(); } } /// public async ValueTask DisposeAsync() { if (Interlocked.Exchange(ref m_disposed, 1) != 0) { return; } await StopIdentityRefreshLoopAsync().ConfigureAwait(false); UnsubscribeCertificateChanges(); await StopRevalidationLoopAsync().ConfigureAwait(false); // Tear down streaming subscription and model change tracker // before closing the session so any in-flight publish work // completes against a still-valid session. await DisposeStreamingAsync().ConfigureAwait(false); await StateMachine.DisposeAsync() .ConfigureAwait(false); Session? session = m_session; m_session = null; if (session != null) { UnwireSessionEvents(session); try { await session.CloseAsync(default) .ConfigureAwait(false); } catch (Exception ex) { m_logger.LogDebug( ex, "ManagedSession: Dispose close failed."); } session.Dispose(); } GC.SuppressFinalize(this); } private sealed class DelayState : IDisposable { public Task Task => m_completion.Task; public ITimer? Timer { get; set; } public CancellationTokenRegistration Cancellation { get; set; } public void Complete() { m_completion.TrySetResult(null); } public void Cancel() { m_completion.TrySetCanceled(); } public void Dispose() { Cancellation.Dispose(); Timer?.Dispose(); } private readonly TaskCompletionSource m_completion = new( TaskCreationOptions.RunContinuationsAsynchronously); } private KeepAliveEventHandler? m_keepAlive; private NotificationEventHandler? m_notification; private PublishErrorEventHandler? m_publishError; private PublishSequenceNumbersToAcknowledgeEventHandler? m_publishSequenceNumbersToAcknowledge; private EventHandler? m_subscriptionsChanged; private EventHandler? m_sessionClosing; private EventHandler? m_sessionConfigurationChanged; private RenewUserIdentityEventHandler? m_renewUserIdentity; private volatile Session? m_session; private readonly AsyncReaderWriterLock m_serviceLock = new(); private readonly ApplicationConfiguration m_configuration; private readonly IReconnectPolicy m_reconnectPolicy; private readonly IServerRedundancyHandler? m_redundancyHandler; private static readonly TimeSpan IdentityRefreshSafetyMargin = TimeSpan.FromSeconds(60); private readonly ILogger m_logger; private readonly IUserIdentity? m_identity; private readonly IClientIdentityProvider? m_identityProvider; private readonly TimeProvider m_timeProvider; private readonly TimeSpan m_maxTotalReconnectTime; private readonly ArrayOf m_preferredLocales; private readonly string m_sessionName; private readonly uint m_sessionTimeout; private readonly bool m_checkDomain; private readonly bool m_transferSubscriptionsOnRecreate; private readonly bool m_poolNotifications; private readonly IClientChannelManager? m_channelManager; private ISubscriptionEngineFactory? m_engineFactory; private int m_channelReconnectInProgress; private ServerRedundancyInfo? m_redundancyInfo; private readonly Lock m_identityRefreshLock = new(); #pragma warning disable CA2213 // Disposed by StopIdentityRefreshLoopAsync; sync Dispose cancels because it cannot await. // TODO: move ManagedSession to async-only disposal. private CancellationTokenSource? m_identityRefreshCancellation; #pragma warning restore CA2213 private Task? m_identityRefreshTask; private TaskCompletionSource? m_identityRefreshAttemptCompletion; private long m_identityRefreshAttemptVersion; private long m_identityRefreshCompletedVersion; private long m_identityRefreshObservedVersion; private int m_disposed; } }