Files
romanett 1f0125c385 Fix reverse connect hold time when several Servers share one listener (#4342)
# Description

Fixes the reverse connect problem reported in #3985 on `master`. #4009
and #4059 addressed the listener startup diagnostics and the async
lifecycle, but the actual hold time defect described in [mrsuciu's
comment on the
issue](https://github.com/OPCFoundation/UA-.NETStandard/issues/3985)
survived both refactors unchanged.

Symptom: a Client which uses one reverse connect listener port for
several Servers connects to the first Server, and every following
`WaitForConnectionAsync` times out. A separate listener port per Server
was the only workaround.

## Root cause

- Servers which send `ReverseHello` before the application registered a
waiting connection for them are held in `OnConnectionWaitingAsync` for
`HoldTime`.
- All held connections share a single `CancellationTokenSource`, so
`RegisterWaitingConnection`/`WaitForConnectionAsync` wakes up **every**
held connection, not only the one it was registered for.
- The hold loop was `while (!matched) { ... break; }` with an
unconditional `break`, so each woken connection got exactly one re-match
attempt. The connections which did not match the new registration fell
out of the loop unaccepted, although most of their own hold time was
left.
- An unaccepted connection is force faulted by the listener
(`TcpReverseConnectChannel`, "The reverse connection was rejected by the
client"), so that Server is dropped and only returns on its next
`ReverseHello` interval, long after the Client timed out waiting for it.

Separate ports avoid the shared wakeup path, which is exactly why that
workaround succeeds.

## Changes

- `OnConnectionWaitingAsync` re-arms the hold after a wakeup: it
re-matches, and if the registration which caused the wakeup was for
another Server the connection keeps waiting for the remainder of its own
hold time. The loop is still bounded by `HoldTime`, so a Server which is
never registered for is rejected exactly as before.
- New `IsCallbackDrainPending()` check before the hold is re-armed.
`DrainConnectionCallbacksAsync` cancels the hold token without renewing
it, so a re-armed hold would spin on the already cancelled token until
the hold time expires and would block the drain until then. With the
check a held callback releases its transport immediately, which keeps
the existing `StopDrainsHeldConnectionCallbackBeforeListenerClose` and
`DisposeDrainsHeldConnectionCallbackBeforeListenerDisposal` guarantees
intact.
- `docs/ReverseConnect.md`: document the `HoldTime` semantics in the
"Sharing a listener across multiple Servers" section.

No public API change.

## Validation

- New `HeldConnectionSurvivesRegistrationForAnotherServer` test in
`ReverseConnectManagerLifecycleTests` drives two held callbacks through
`InvokeConnectionWaitingForTest` and proves the second Server keeps its
connection when the first Server is registered. Verified that it fails
against the previous behaviour with "the reverse connection of the
second Server was released before its hold time expired".
- `FullyQualifiedName~ReverseConnect` (183 tests, including
`ReverseConnectManagerTests`, `ReverseConnectManagerLifecycleTests` and
`ReverseConnectHostTests`): all pass on net10.0 and net48.

## Related Issues

- Relates to #3985.
- The same fix for the LTS branch is #4341 (base `master378`).

## 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 <noreply@anthropic.com>
Co-authored-by: Marc Schier <marcschier@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-08-30 16:38:07 +00:00

16 KiB

Reverse Connect

Overview

The Reverse Connect option consists of the following elements:

  • Updated C# Stack that supports the ReverseHello message for Client and Server;
  • Updated server library which support to
    • Create a ReverseConnectServer derived from a StandardServer class.
    • Extended configuration parameters to setup the client location and timeouts.
    • an API extension in the ReverseConnectServer to programmatically control client connections.
  • Updated client library which support to
    • Configure a client endpoint to accept ReverseHello messages using a ReverseConnectManager.
    • A client API extension to allow applications to register for reverse connections either by callback or by waiting for the ReverseHello message for a specific server endpoint and application Uri combination. An optional filter for server Uris or endpoint Urls can be applied to allow multiple clients to use the same endpoint.
  • The C# Console Reference Server with reverse connect support in the configuration xml.
  • The C# Core Console Reference Client that can initiate a Reverse connection with command line options.
  • A modified C# Aggregation Server that supports incoming and outgoing reverse connections.

Reverse Connect Handshake

More details on the reverse connect handshake can be found in the OPC UA spec Part 6, Establishing a connection.

The ReverseHello message allows Servers behind firewalls to initiate communication with Clients. This requires that the Server be pre-configured with the location of the Client. The Server adds a configuration option that can be used to initiate a ReverseHello with the Client running behind a firewall.

Once the reverse connection is established, the Server will automatically re-establish the connection if it is closed. Most Servers keep sending ReverseHello messages, even if the Client is already connected. In this Server implementation the behavior is configurable to keep sending ReverseHello messages, to allow only a single connection or to stop sending messages once the maximum number of Server sessions is exceeded. Only for the single connection configuration the sending of messages is suspended for a configurable timeout if the connection is rejected (i.e. the Client returns BadTcpMessageTypeInvalid meaning the Client does not support reverse connections or it does not want a connection from this Server at this time).

In order to validate and accept a reverse connection in a Client application a ReverseConnectManager is configured to call back to the registered applications or to hold incoming connections open for a programmable timeout. An application can register for incoming requests to accept or reject a reverse connection directly or an application can start a connection and wait for an incoming ReverseHello message. If the ReverseConnectManager holds already an open connection to the Server the connection can be established without waiting.

The host port is implemented by a transport which implements the ITransportListener interface. The transport calls the ReverseConnectionManager class in the client library to provide the application interface. This implementation uses only a single port on a client to support multiple incoming ReverseHello server connections. The clients register at the ReverseConnectionManager for specific serversUris, endpointUrls or any incoming message for callbacks. When the Client receives a ReverseHello the application receives an ITransportWaitingConnection connection object which can be used to create a client session in a similar way as by connecting using the endpointUrl, just by using a different Connect API which supports the connection object as a parameter. The client then uses the open socket to send the Hello message back to the Server for the well known establishment of a secure communication session.

The second option for a client application is to call the Connect API with a configured ReverseConnectionManager to wait for an incoming connection and to establish the connection before the timeout expires. This connection model is similar to the standard connect flow with a Server and might be a good model to add reverse connect support for existing applications, without changing the application logic.

If no client responds to the ReverseHello message or if it is even rejected, the channel is closed with a BadTcpMessageTypeInvalid error which the server should interpret as an indication that the Client is not configured to respond to ReverseHello messages of that Server.

If the Client accepts the connection, a secure connection requires that the Client calls GetEndpoints to fetch the Server Certificate. At this point the Client closes the channel, which means it needs to wait for the Server to automatically re-connect. When it does, it can use the security information previously cached to connect securely back to the Server. An optimized Server implementation could respond with an immediate ReverseHello message to avoid connection delays after a call to GetEndpoints .

The auto-reconnect behavior on the Server is essential to any real application, because Clients close the Socket when the SecureChannel is closed. According to the specification a Server needs to abort the auto-reconnect if it receives a BadTcpMessageTypeInvalid code, because that is the error it will receive from peers that have not been upgraded to support the ReverseHello. Because of this a Client can use the same error code to tell the Server to stop reconnecting, if a user has rejected the connection. However, in this implementation, only if the Server is configured for a single connection it applies an extended timeout before reconnecting to the Client to reduce the overall traffic. In other configurations the Server keeps sending the ReverseHello messages at the configured time interval.

Sharing a listener across multiple Servers

A reverse-connect listener remains bound for the lifetime of its ReverseConnectManager. Seeing the listener port remain in the LISTENING state after a Session is established is expected. The listening socket accepts additional transport connections while each accepted socket is handed to the Session that claimed its ReverseHello message. Dispose the manager when the listener should be released.

Use one shared ReverseConnectManager for all Servers that connect to the same Client URL. Register or wait for each Server separately by using its Server EndpointUrl and, preferably, its ServerUri. The fluent dependency-injection integration registers the manager as a singleton.

await using var manager = new ReverseConnectManager(telemetry);
manager.AddEndpoint(new Uri("opc.tcp://client-host:65300"));
await manager.StartServiceAsync(
    new ReverseConnectClientConfiguration
    {
        HoldTime = 15000,
        WaitTimeout = 20000
    },
    cancellationToken).ConfigureAwait(false);

Task<ITransportWaitingConnection> serverA = manager.WaitForConnectionAsync(
    new Uri("opc.tcp://server-a:4840"),
    "urn:example:server-a",
    cancellationToken);
Task<ITransportWaitingConnection> serverB = manager.WaitForConnectionAsync(
    new Uri("opc.tcp://server-b:4840"),
    "urn:example:server-b",
    cancellationToken);

await Task.WhenAll(serverA, serverB).ConfigureAwait(false);

Pass each returned ITransportWaitingConnection to the session factory for the corresponding Server.

HoldTime controls how long an incoming ReverseHello that does not match any registration yet is held open before it is rejected. This matters when several Servers connect before the application has registered a waiting connection for each of them: every held connection waits for the remainder of its own hold time, also when a registration for a different Server arrives in the meantime. A Server whose hold time expires without a matching registration is rejected and reconnects on its next ReverseHello interval. A committed stop or dispose releases the held connections immediately.

StartServiceAsync validates and prepares the complete listener set before it changes a running service. If activation fails after existing listeners have stopped, the manager recreates and reopens the previous configuration. Cancellation cleans partially initialized candidates and either preserves or restores the prior service. Use await manager.StopServiceAsync(...) for an explicit stop and await manager.DisposeAsync() (or await using) for teardown.

The synchronous StartService, RegisterWaitingConnection, and Dispose APIs remain as obsolete compatibility wrappers. New code should use StartServiceAsync, RegisterWaitingConnectionAsync, and DisposeAsync; the compatibility wrappers may block a caller thread.

Dependency-injection lifecycle

AddClient(...) registers one singleton ReverseConnectManager, an IReverseConnectConfigurationProvider, and an internal hosted service. In a .NET Generic Host, the hosted service eagerly opens configured listeners during host startup and closes them during host shutdown. In a plain ServiceCollection without a running host, WaitForConnectionAsync and RegisterWaitingConnectionAsync call EnsureStartedAsync lazily. Resolving the manager itself never blocks on listener startup.

services
    .AddOpcUa()
    .AddClient(options =>
    {
        options.Configuration = applicationConfiguration;
        options.ReverseConnect = new ClientReverseConnectOptions
        {
            HoldTimeMs = 15000,
            WaitTimeoutMs = 20000
        };
        options.ReverseConnect.ClientEndpointUrls.Add(
            "opc.tcp://client-host:65300");
    });

Applications can replace the default pass-through provider to asynchronously validate or transform the effective listener configuration before any active listener is stopped:

services.AddSingleton<IReverseConnectConfigurationProvider, MyProvider>();

Providers run outside the manager's lifecycle gate. Provider exceptions reject the candidate while the current service remains active. The former protected OnUpdateConfiguration hooks were removed; custom configuration logic belongs in IReverseConnectConfigurationProvider.

Do not create a separate manager for each Server when those managers use the same local listener URL. Only one listener can bind a given host and port. An invalid URL reports BadTcpEndpointUrlInvalid, an unsupported transport retains its transport-specific status, and a bind or listener-open failure reports BadNoCommunication. Startup diagnostics identify the affected endpoint URLs, and listeners opened by a failed attempt are closed instead of allowing a later connection wait to time out.

This behavior follows OPC UA Part 6, 7.1.3, which defines a separate transport connection for each reverse connection and requires Servers to maintain an available socket to each configured Client. OPC UA Part 12, 4.4.2 defines one or more Client URLs that allow Servers to connect. Clients shall validate the ServerUri and EndpointUrl as described in OPC UA Part 2, 6.14.

Configuration Extensions

This configuration sample shows the configuration setting for a reverse connection on port 65300.

The Server configuration extension to connect to one or more reverse connect clients:

</ServerConfiguration>
  ...
  <ReverseConnect>
    <Clients>
      <ReverseConnectClient>
        <EndpointUrl>opc.tcp://localhost:65300</EndpointUrl>
        <Timeout>30000</Timeout>
      </ReverseConnectClient>
    </Clients>
    <ConnectInterval>15000</ConnectInterval>
    <ConnectTimeout>30000</ConnectTimeout>
    <RejectTimeout>60000</RejectTimeout>
  </ReverseConnect>
</ServerConfiguration>

The Client configuration extension to allow incoming connections for one or more reverse connect servers:

<ClientConfiguration>
  ...
  <ReverseConnect>
    <ClientEndpoints>
      <ClientEndpoint>
        <EndpointUrl>opc.tcp://localhost:65300</EndpointUrl>
      </ClientEndpoint>
    </ClientEndpoints>
    <HoldTime>15000</HoldTime>
    <WaitTimeout>20000</WaitTimeout>
  </ReverseConnect>
</ClientConfiguration>

WSS reverse-connect (opc.wss://)

The WSS reverse-connect path covers the same two halves as the TCP path but layered over TLS + WebSocket:

  • Server-side outbound — HttpsTransportListener.CreateReverseConnection opens an outbound ClientWebSocket to the configured client URI, wraps the resulting WebSocket in a WebSocketClientByteTransport, and drives the reverse-hello handshake via the same TcpServerChannel.BeginReverseConnect(... IUaSCByteTransport ...) overload the TCP path uses. The server's ReverseConnect configuration block accepts opc.wss://... URIs identical to the TCP form above.

  • Client-side listener — ReverseConnectHost.CreateListener accepts an optional TLS certificate (and validator) pair for opc.wss:// reverse-connect endpoints. The host wires the cert into the Kestrel-backed HttpsTransportListener via settings.ReverseConnectListener = true, dispatches each accepted WSS upgrade to a TcpReverseConnectChannel, and fires ConnectionWaiting via TransferListenerChannelAsync when the ReverseHello arrives. The application takes ownership of the ITransportWaitingConnection exactly as it would for the TCP path.

ReverseConnectManager.AddEndpoint gained an additive overload to support TLS-terminating listeners:

// Old: works for opc.tcp:// (no TLS state needed at bind time).
manager.AddEndpoint(new Uri("opc.tcp://localhost:65300"));

// New: also works for opc.wss:// because the CertificateManager is
// supplied at AddEndpoint time (m_appConfig is otherwise only set
// when StartService runs - too late for WSS listeners that need the
// server certificate at bind time).
manager.AddEndpoint(new Uri("opc.wss://localhost:65300"), config);
await manager.StartServiceAsync(config, cancellationToken)
    .ConfigureAwait(false);

The original single-parameter AddEndpoint(Uri) is unchanged for back-compat (opc.tcp consumers do not need the new overload).

Kestrel-hosted opc.tcp reverse-connect (opt-in)

The Opc.Ua.Bindings.Https package serves the opc.tcp listener from a Kestrel IHost instead of raw Socket + SAEA (an opt-in alternative on net8.0+ to the default raw-socket listener that ships in Opc.Ua.Core). It supports the full forward AND reverse-connect listener modes the raw-socket TcpTransportListener does. To use it, install the binding via DI before opening the listener:

// Microsoft.Extensions.DependencyInjection consumers:
services
    .AddOpcUa()
    .AddOpcTcpTransport()           // raw-socket opc.tcp default
    .AddKestrelOpcTcpTransport();   // overrides with Kestrel (last-writer-wins)

// Non-DI consumers (e.g. test fixtures):
DefaultTransportBindingRegistry registry =
    DefaultTransportBindingRegistry.WithDefaultTcp();
registry.RegisterListenerFactory(new KestrelTcpTransportListenerFactory());
// Forward the registry to:
//   - ServerBase via the new ctor overload or .TransportBindings setter
//     (server-side reverse-connect outbound and forward listeners),
//   - ReverseConnectManager.TransportBindings (client-side reverse-connect
//     listener: every AddEndpoint(Uri,...) call constructs a
//     ReverseConnectHost that resolves the right factory from this registry).

The default raw-socket implementation stays available for deployments that want to avoid the ASP.NET Core dependency in Opc.Ua.Core.

Known limitations and issues

  • Only a limited number of samples is available yet, the Reference Server, the Aggregation Server and the Console server and client.