Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 43 additions & 8 deletions docs/ReverseConnect.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,13 +43,15 @@ A reverse-connect listener remains bound for the lifetime of its `ReverseConnect
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.

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

Task<ITransportWaitingConnection> serverA = manager.WaitForConnectionAsync(
new Uri("opc.tcp://server-a:4840"),
Expand All @@ -65,7 +67,39 @@ await Task.WhenAll(serverA, serverB).ConfigureAwait(false);

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

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. `StartService` validates and opens all configured listener endpoints atomically. 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.
`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.

``` csharp
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:

``` csharp
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](https://reference.opcfoundation.org/specs/OPC-10000-6/v1.05.07/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](https://reference.opcfoundation.org/specs/OPC-10000-12/v1.05.07/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](https://reference.opcfoundation.org/specs/OPC-10000-2/v1.05.06/6.14).

Expand Down Expand Up @@ -145,7 +179,8 @@ manager.AddEndpoint(new Uri("opc.tcp://localhost:65300"));
// 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);
manager.StartService(config);
await manager.StartServiceAsync(config, cancellationToken)
.ConfigureAwait(false);
```

The original single-parameter `AddEndpoint(Uri)` is unchanged for
Expand Down
2 changes: 1 addition & 1 deletion docs/migrate/2.0.x/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ table; loading a single sub-doc keeps the context window small.
| `UaPubSubApplication.Create*`, `IUaPubSubConnection`, `UaPubSubConfigurator`, `IUaPublisher`, AMQP transport, `JsonEncodingMode.Reversible/NonReversible`, PubSub JSON encoder changes, `DataSetFieldContentMask` RawData / timestamp behaviour | [`pubsub.md`](pubsub.md) |
| `AlarmConditionState` state-transition behaviour, auto-emitted `GeneralModelChangeEvent`, `ModelChangeAggregator`, `INodeCache.InvalidateNode` triggered by model change | [`alarms-model-change.md`](alarms-model-change.md) |
| `DateTime.UtcNow`, `Timer`, deterministic time in tests; `System.TimeProvider` adoption | [`timeprovider.md`](timeprovider.md) |
| `ITransportListener.Open` / `Close` removed, `using var listener = …` no longer compiles, custom `ITransportListenerFactory` / `ITransportListenerCertificateRotation` implementers need the new async method names | [`transport-listener-async.md`](transport-listener-async.md) |
| `ITransportListener.Open` / `Close` removed, `ReverseConnectManager.StartService` / `Dispose` obsolete, reverse-connect DI/provider migration, custom `ITransportListenerFactory` / `ITransportListenerCertificateRotation` implementers need the new async method names | [`transport-listener-async.md`](transport-listener-async.md) |

## All sub-documents

Expand Down
56 changes: 51 additions & 5 deletions docs/migrate/2.0.x/transport-listener-async.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,11 +85,51 @@ public ValueTask OpenAsync(CancellationToken ct = default);
public ValueTask CloseAsync(CancellationToken ct = default);
```

`ReverseConnectManager.StartService` / `StopService` / `Dispose` retain
their public synchronous shape; internally they bridge to async
`OpenHostsAsync` / `CloseHostsAsync` (snapshot-under-lock,
await-outside-lock) so the listener-layer work is fully async without
breaking existing call sites in samples, fluent builders, and tests.
`ReverseConnectManager` now exposes a fully asynchronous lifecycle:

```csharp
await manager.StartServiceAsync(configuration, ct);
await manager.StopServiceAsync(ct);
await manager.DisposeAsync();
```

The synchronous `StartService` and `Dispose` APIs remain as `[Obsolete]` compatibility wrappers. They run the async lifecycle on an off-context bridge and may block the caller thread. Replace them with `StartServiceAsync` and `DisposeAsync` (`await using`) when migrating.

The manager validates and prepares a candidate configuration before stopping a working listener. If activation fails after the old listeners stop, it recreates and reopens the previous configuration. Cancellation cleans partially initialized listeners and preserves or restores the previous service.

### `ReverseConnectManager.RegisterWaitingConnection`

```csharp
// Before (still compiles, now [Obsolete])
int id = manager.RegisterWaitingConnection(url, serverUri, handler, strategy);

// After
int id = await manager.RegisterWaitingConnectionAsync(
url, serverUri, handler, strategy, ct);
```

The synchronous `RegisterWaitingConnection` overload is now `[Obsolete]`.
It is retained for backward compatibility, but in DI-lazy scenarios (where
the manager was configured with an initial startup and started on first
use) it must block on an off-context bridge to `EnsureStartedAsync` before
registering so the configured listeners are bound. Prefer
`RegisterWaitingConnectionAsync`, which starts the manager without blocking.
Directly constructed, manually started, or unconfigured managers keep the
previous registration-only behavior for the synchronous overload.

### Reverse-connect configuration providers

Custom subclasses that previously overrode `OnUpdateConfiguration` should migrate to `IReverseConnectConfigurationProvider`. Providers run asynchronously before any active listener is stopped and may validate, replace, or augment the effective `ReverseConnectClientConfiguration`.

```csharp
services.AddSingleton<IReverseConnectConfigurationProvider, MyProvider>();
```

The protected `OnUpdateConfiguration` hooks were removed. Move validation or transformation logic from those overrides into an `IReverseConnectConfigurationProvider`.

### Dependency-injection startup

`AddClient(...)` no longer blocks inside the singleton factory. It registers a hosted service that eagerly invokes `EnsureStartedAsync` when a .NET Generic Host starts. In a plain `ServiceCollection`, `WaitForConnectionAsync` and `RegisterWaitingConnectionAsync` start the manager lazily on first use.

## Migration steps

Expand All @@ -103,6 +143,12 @@ breaking existing call sites in samples, fluent builders, and tests.
`await using var listener = ...`.
5. **Replace `rotator.CloseChannelsForCertificate(cert)`** with
`await rotator.CloseChannelsForCertificateAsync(cert)`.
6. **Replace `manager.StartService(config)`** with
`await manager.StartServiceAsync(config, ct)`.
7. **Replace `manager.RegisterWaitingConnection(...)`** with
`await manager.RegisterWaitingConnectionAsync(..., ct)`.
8. **Replace `manager.Dispose()` / `using`** with
`await manager.DisposeAsync()` / `await using`.

### Custom transport binding implementations

Expand Down
8 changes: 6 additions & 2 deletions samples/ConsoleReferenceClient/Program.cs
Original file line number Diff line number Diff line change
Expand Up @@ -335,7 +335,8 @@ await application.DeleteApplicationInstanceCertificateAsync(ct: cancellationToke
Console.WriteLine($"Create reverse connection endpoint at {reverseConnectUrlString}.");
reverseConnectManager = new ReverseConnectManager(telemetry);
reverseConnectManager.AddEndpoint(new Uri(reverseConnectUrlString));
reverseConnectManager.StartService(config);
await reverseConnectManager.StartServiceAsync(config, cancellationToken)
.ConfigureAwait(false);
}

// wait for timeout or Ctrl-C
Expand Down Expand Up @@ -766,7 +767,10 @@ await uaClient
}
finally
{
reverseConnectManager?.Dispose();
if (reverseConnectManager != null)
{
await reverseConnectManager.DisposeAsync().ConfigureAwait(false);
}
}
});

Expand Down
11 changes: 7 additions & 4 deletions src/Opc.Ua.Client/Fluent/ClientReverseConnectOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,10 +43,13 @@ namespace Opc.Ua.Client
/// <remarks>
/// When <see cref="OpcUaClientOptions.ReverseConnect"/> is set, the DI
/// container registers a singleton
/// <see cref="ReverseConnectManager"/> that opens the configured
/// listener endpoints on first resolution. Consumers awaiting an
/// inbound reverse-hello message resolve the manager and call
/// <see cref="ReverseConnectManager.WaitForConnectionAsync"/>.
/// <see cref="ReverseConnectManager"/> plus an internal hosted service
/// that opens the configured listener endpoints asynchronously on host
/// start. When no host is present the manager starts lazily on first
/// use. Consumers awaiting an inbound reverse-hello message resolve the
/// manager and call
/// <see cref="ReverseConnectManager.WaitForConnectionAsync"/>, which
/// ensures the manager is started.
/// </remarks>
public sealed class ClientReverseConnectOptions
{
Expand Down
Loading