@@ -131,6 +131,34 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
private bool _disposed;
|
||||
/// <summary>URL of the endpoint the driver actually connected to. Exposed via <see cref="HostName"/>.</summary>
|
||||
private string? _connectedEndpointUrl;
|
||||
|
||||
/// <summary>
|
||||
/// Reverse-connect listener acquired during <see cref="InitializeAsync"/> when
|
||||
/// <see cref="ReverseConnectOptions.Enabled"/> is set. Null when reverse-connect is
|
||||
/// disabled. Released back to the singleton pool on shutdown so multiple driver
|
||||
/// instances on the same listener URL can come and go independently.
|
||||
/// </summary>
|
||||
private ReverseConnectListener? _reverseListener;
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — pluggable reverse-connect "wait" hook. When non-null,
|
||||
/// <see cref="OpenReverseConnectSessionAsync"/> uses this delegate instead of
|
||||
/// calling into a real <see cref="ReverseConnectListener"/>. Lets unit tests
|
||||
/// inject a synthetic <c>ITransportWaitingConnection</c> without binding a port
|
||||
/// or running the SDK's listener threads.
|
||||
/// </summary>
|
||||
internal Func<Uri, string?, CancellationToken, Task<Opc.Ua.ITransportWaitingConnection>>? ReverseConnectWaitHookForTest { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Test seam — pluggable session factory invoked in the reverse-connect path.
|
||||
/// Tests can use this to verify that the session-create call receives the
|
||||
/// expected <c>ITransportWaitingConnection</c> without instantiating the SDK
|
||||
/// <see cref="DefaultSessionFactory"/> (which hits real cert + transport code).
|
||||
/// </summary>
|
||||
internal Func<ApplicationConfiguration, Opc.Ua.ITransportWaitingConnection, ConfiguredEndpoint, UserIdentity, CancellationToken, Task<ISession>>? ReverseConnectSessionFactoryForTest { get; set; }
|
||||
|
||||
/// <summary>Test seam — last reverse-connect listener acquired (null when reverse-connect is disabled or shut down).</summary>
|
||||
internal ReverseConnectListener? ReverseListenerForTest => _reverseListener;
|
||||
/// <summary>
|
||||
/// SDK-provided reconnect handler that owns the retry loop + session-transfer machinery
|
||||
/// when the session's keep-alive channel reports a bad status. Null outside the
|
||||
@@ -202,34 +230,60 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
|
||||
var identity = BuildUserIdentity(_options);
|
||||
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
// aggregate exception names every URL that was tried and why — critical diag for
|
||||
// operators debugging 'why did the failover pick #3?'.
|
||||
var attemptErrors = new List<string>(candidates.Count);
|
||||
ISession? session = null;
|
||||
string? connectedUrl = null;
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
session = await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (session is null)
|
||||
throw new AggregateException(
|
||||
"OPC UA Client failed to connect to any of the configured endpoints. " +
|
||||
"Tried:\n " + string.Join("\n ", attemptErrors),
|
||||
attemptErrors.Select(e => new InvalidOperationException(e)));
|
||||
if (_options.ReverseConnect.Enabled)
|
||||
{
|
||||
// Reverse-connect path: instead of dialling each candidate URL, we register
|
||||
// our listener URL with the process-wide ReverseConnectManager and wait for
|
||||
// the upstream server to dial in. The first candidate URL still drives
|
||||
// EndpointDescription selection so SecurityPolicy/Mode + user-identity flow
|
||||
// through the same code path as the conventional dial — only the transport
|
||||
// direction flips. ExpectedServerUri filters incoming connections so the
|
||||
// listener can be shared across drivers targeting different upstreams.
|
||||
if (string.IsNullOrWhiteSpace(_options.ReverseConnect.ListenerUrl))
|
||||
throw new InvalidOperationException(
|
||||
"ReverseConnect.Enabled=true but ReverseConnect.ListenerUrl is not set. " +
|
||||
"Configure a listener URL like 'opc.tcp://0.0.0.0:4844' so the upstream server can dial in.");
|
||||
|
||||
var endpointForReverse = candidates.FirstOrDefault()
|
||||
?? throw new InvalidOperationException(
|
||||
"ReverseConnect requires at least one EndpointUrl in the candidate list to derive the EndpointDescription from.");
|
||||
|
||||
session = await OpenReverseConnectSessionAsync(
|
||||
appConfig, endpointForReverse, identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = endpointForReverse;
|
||||
}
|
||||
else
|
||||
{
|
||||
// Failover sweep: try each endpoint in order, return the session from the first
|
||||
// one that successfully connects. Per-endpoint failures are captured so the final
|
||||
// aggregate exception names every URL that was tried and why — critical diag for
|
||||
// operators debugging 'why did the failover pick #3?'.
|
||||
var attemptErrors = new List<string>(candidates.Count);
|
||||
foreach (var url in candidates)
|
||||
{
|
||||
try
|
||||
{
|
||||
session = await OpenSessionOnEndpointAsync(
|
||||
appConfig, url, _options.SecurityPolicy, _options.SecurityMode,
|
||||
identity, cancellationToken).ConfigureAwait(false);
|
||||
connectedUrl = url;
|
||||
break;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
attemptErrors.Add($"{url} -> {ex.GetType().Name}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (session is null)
|
||||
throw new AggregateException(
|
||||
"OPC UA Client failed to connect to any of the configured endpoints. " +
|
||||
"Tried:\n " + string.Join("\n ", attemptErrors),
|
||||
attemptErrors.Select(e => new InvalidOperationException(e)));
|
||||
}
|
||||
|
||||
// Wire the session's keep-alive channel into HostState + the reconnect trigger.
|
||||
// OPC UA keep-alives are authoritative for session liveness: the SDK pings on
|
||||
@@ -268,6 +322,13 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
{
|
||||
try { if (Session is Session s) await s.CloseAsync().ConfigureAwait(false); } catch { }
|
||||
Session = null;
|
||||
// Release the reverse-connect listener if we acquired it but session-create failed
|
||||
// — leaks a port-bind otherwise, blocking the next init attempt.
|
||||
if (_reverseListener is not null)
|
||||
{
|
||||
try { _reverseListener.Release(); } catch { /* best-effort */ }
|
||||
_reverseListener = null;
|
||||
}
|
||||
_health = new DriverHealth(DriverState.Faulted, null, ex.Message);
|
||||
throw;
|
||||
}
|
||||
@@ -644,6 +705,96 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Open a session over a server-initiated (reverse) connect. Acquires a process-wide
|
||||
/// <see cref="ReverseConnectListener"/> for the configured listener URL, waits for the
|
||||
/// upstream server to dial in (filtered by <see cref="ReverseConnectOptions.ExpectedServerUri"/>),
|
||||
/// then hands the resulting <see cref="Opc.Ua.ITransportWaitingConnection"/> into the
|
||||
/// session-create path. The endpoint description still comes from the candidate URL so
|
||||
/// SecurityPolicy / Mode / cert handling are identical to the dial path — only the
|
||||
/// transport direction flips.
|
||||
/// </summary>
|
||||
private async Task<ISession> OpenReverseConnectSessionAsync(
|
||||
ApplicationConfiguration appConfig,
|
||||
string endpointUrl,
|
||||
UserIdentity identity,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var listenerUrl = _options.ReverseConnect.ListenerUrl!;
|
||||
var expectedServerUri = _options.ReverseConnect.ExpectedServerUri;
|
||||
|
||||
// Acquire a ref to the singleton listener for this URL. Multiple driver instances
|
||||
// sharing a URL share one underlying SDK manager — see ReverseConnectListener for
|
||||
// the ref-count model.
|
||||
if (ReverseConnectWaitHookForTest is null)
|
||||
{
|
||||
_reverseListener = ReverseConnectListener.Acquire(listenerUrl, appConfig);
|
||||
}
|
||||
|
||||
// Build the ConfiguredEndpoint from the configured endpointUrl. We DON'T call
|
||||
// GetEndpointsAsync over reverse connect here — the SDK's reverse-connect overload
|
||||
// accepts a synthetic EndpointDescription and the upstream resends its real one
|
||||
// during ReverseHello, so a static description is fine for the create call.
|
||||
var endpointDescription = new EndpointDescription(endpointUrl)
|
||||
{
|
||||
SecurityPolicyUri = MapSecurityPolicy(_options.SecurityPolicy),
|
||||
SecurityMode = _options.SecurityMode switch
|
||||
{
|
||||
OpcUaSecurityMode.None => MessageSecurityMode.None,
|
||||
OpcUaSecurityMode.Sign => MessageSecurityMode.Sign,
|
||||
OpcUaSecurityMode.SignAndEncrypt => MessageSecurityMode.SignAndEncrypt,
|
||||
_ => MessageSecurityMode.None,
|
||||
},
|
||||
};
|
||||
var endpointConfig = EndpointConfiguration.Create(appConfig);
|
||||
endpointConfig.OperationTimeout = (int)_options.Timeout.TotalMilliseconds;
|
||||
var endpoint = new ConfiguredEndpoint(null, endpointDescription, endpointConfig);
|
||||
|
||||
// Wait for the upstream to dial in. Bounded by Timeout so a stuck listener doesn't
|
||||
// hang init forever — operators see a clear timeout error rather than a silent stall.
|
||||
using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct);
|
||||
cts.CancelAfter(_options.Timeout);
|
||||
|
||||
Opc.Ua.ITransportWaitingConnection connection;
|
||||
if (ReverseConnectWaitHookForTest is not null)
|
||||
{
|
||||
connection = await ReverseConnectWaitHookForTest(
|
||||
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
connection = await _reverseListener!.WaitForServerAsync(
|
||||
new Uri(listenerUrl), expectedServerUri, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
// Hand the inbound connection into the session-create path. The factory hook lets
|
||||
// unit tests assert that the right connection + endpoint flow through without
|
||||
// standing up a real DefaultSessionFactory (which expects a fully-wired transport).
|
||||
ISession session;
|
||||
if (ReverseConnectSessionFactoryForTest is not null)
|
||||
{
|
||||
session = await ReverseConnectSessionFactoryForTest(
|
||||
appConfig, connection, endpoint, identity, cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
session = await new DefaultSessionFactory(telemetry: null!).CreateAsync(
|
||||
appConfig,
|
||||
connection,
|
||||
endpoint,
|
||||
updateBeforeConnect: false,
|
||||
checkDomain: false,
|
||||
_options.SessionName,
|
||||
(uint)_options.SessionTimeout.TotalMilliseconds,
|
||||
identity,
|
||||
preferredLocales: null,
|
||||
cts.Token).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
session.KeepAliveInterval = (int)_options.KeepAliveInterval.TotalMilliseconds;
|
||||
return session;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Select the remote endpoint matching both the requested <paramref name="policy"/>
|
||||
/// and <paramref name="mode"/>. The SDK's <c>CoreClientUtils.SelectEndpointAsync</c>
|
||||
@@ -799,6 +950,15 @@ public sealed class OpcUaClientDriver(OpcUaClientDriverOptions options, string d
|
||||
_connectedEndpointUrl = null;
|
||||
_operationLimits = null;
|
||||
|
||||
// Release our hold on the reverse-connect listener. Last release tears the manager
|
||||
// down; siblings that share the URL keep it alive. Idempotent — releasing a null
|
||||
// listener (e.g. shutdown after a failed init) is a no-op.
|
||||
if (_reverseListener is not null)
|
||||
{
|
||||
try { _reverseListener.Release(); } catch { /* best-effort */ }
|
||||
_reverseListener = null;
|
||||
}
|
||||
|
||||
TransitionTo(HostState.Unknown);
|
||||
_health = new DriverHealth(DriverState.Unknown, _health.LastSuccessfulRead, null);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user