Files
UA-.NETStandard/Applications/McpServer/Tools/PacketReplayTools.cs
Marc Schier f358078b1c [Client] Central channel manager: ref-counted shared channels, coalesced reconnect, IRetryBudget (#3288) (#3852)
# Description

Introduces a central `IClientChannelManager` that owns client-side
transport channels with reference counting, sharing, coalesced
reconnect, and asynchronous participant notification. Sessions,
discovery clients, and registration clients targeting the same
`ConfiguredEndpoint` (with the same reverse-connect identity) now share
a single underlying `ITransportChannel`; reconnect is transparent to
callers; and the previous `AttachChannel`/`DetachChannel` +
`SessionReconnectHandler` patterns are obsoleted.

## What lands

### Core types (`Stack/Opc.Ua.Core/Stack/Client/`)

- `ManagedChannelKey`, `ChannelState`, `ChannelStateChange`,
`ParticipantReconnectResult`
- `IReconnectParticipant`, `IManagedTransportChannel`,
`IClientChannelManager`
- `IChannelReconnectPolicy` + `ExponentialBackoffChannelReconnectPolicy`
(default mirrors historical `SessionReconnectHandler`: 500 ms → 30 s
exponential)
- `IRetryBudget` + `RetryBudget` — shared retry deadline so the
two-level retry no longer compounds multiplicatively
- `ClientChannelManager` — single non-partial sealed class with
SOLID-extracted internal types under
`Stack/Opc.Ua.Core/Stack/Client/Channels/Internal/`: `ChannelEntry`
(behind `IChannelEntryHost`), `ManagedTransportChannelLease`,
`ClientChannel`, `ClientChannelManagerMetrics` (IMeter),
`ClientChannelManagerCertRotation` (auto `ReconnectAllAsync` on
`CertificateManager` rotation), `ClientChannelManagerDiagnostics`
(ActivitySource + EventSource). Refcount, coalesced reconnect, and
transparent faulted-entry recovery via `SwapFaultedEntryAsync` (swaps a
lease's underlying entry on subsequent `ReconnectAsync` when the prior
cycle Faulted/Closed, preserves participant, back-off via policy
`GetDelay(SwapCount)`)

### Three-state lifecycle gate

`Disconnected` → `TransportConnecting` / `TransportReconnecting` →
`TransportConnectedSessionReactivating` → `Ready` (or `Faulted` /
`Closed`). Only `Ready` releases the service-call gate; the manager
bypasses the gate internally for participant `ActivateSession` traffic
during reactivation via an `AsyncLocal` scope.

### Session integration (`Libraries/Opc.Ua.Client/Session/`)

- New `Session.CreateAsync(IClientChannelManager, …)` overload
- `Session` implements `IReconnectParticipant`; reactivation handler
distinguishes `Reactivated` / `RequiresSessionRecreate` /
`TransientFailure` / `FatalForParticipant` / `FatalForChannel`
- `Session.ReconnectAsync(ct)` automatically delegates to the channel
manager when wired
- `Session.RecreateInPlaceAsync` swaps managed-channel leases for
failover-to-different-endpoint via a new atomic
`IClientChannelManager.GetAsync(endpoint, participantFactory, …)`
overload; same-key recreates delegate to manager reconnect;
explicit-channel/connection callers retain legacy behavior
- KA failure routes through
`IClientChannelManager.ReconnectAsync(channel)`

### ManagedSession integration

- `WithChannelManager(...)` builder + ctor param; DI-resolved
automatically
- Two-level retry: channel-mgr handles transparent reconnect; outer
`ConnectionStateMachine` + `IReconnectPolicy` only triggers on terminal
channel `Faulted`. Outer-state churn suppressed during channel-mgr
cycles via `_channelReconnectInProgress` flag wired through
`IManagedTransportChannel.StateChanged`
- New `ManagedSession.ChannelStateChanged` event surfaces transparent
reconnects
- `ConnectionStateChangedEventArgs.UnderlyingChannelState` populated
when outer reconnect was triggered by a channel fault
- Shared `IRetryBudget` propagated so the layers enforce a single
max-total-time

### Discovery / Registration / GDS

Channel-manager-aware `CreateAsync` overloads on `DiscoveryClient`,
`RegistrationClient`, `ServerPushConfigurationClient`,
`LocalDiscoveryServerClient`, `GlobalDiscoveryServerClient`. Each
registers a minimal `IReconnectParticipant`; co-located sessions and
discovery probes targeting the same endpoint share a channel.

### HTTPS transport (`Stack/Opc.Ua.Bindings.Https/`)

- New `IOpcUaHttpClientFactory` abstraction
- HTTPS / OPC-HTTPS channels migrated to `IHttpClientFactory` +
`Microsoft.Extensions.Http.Resilience` (10.6.0; AOT-compatible on net10
via source generators; legacy direct-HTTP path retained for older TFMs /
no-DI consumers)

### DI

`AddOpcUa().AddClient(...)` registers `IClientChannelManager` and the
named HTTPS `HttpClient` + standard resilience handler automatically.
`ManagedSessionBuilder` resolves the manager from DI when present.

### Obsoletions (functional, `[Obsolete]` with guidance)

- `IClientBase.AttachChannel` / `DetachChannel`
- `SessionReconnectHandler`
- `SessionExtensions.ReconnectAsync(connection, ct)` and `(channel, ct)`
legacy overloads

### Tests

- Core: `ClientChannelManagerManagedTests`,
`ClientChannelManagerCertRotationTests`, `RetryBudgetTests`,
`OpcUaHttpClientFactoryTests` — **56 / 56 pass**
- Client: `ManagedSession*Tests` updated; legacy
`SessionReconnectHandlerTests` + `SessionExtensionsTests` preserved with
`#pragma warning disable CS0618` — **129 / 129 pass**
- Sessions integration: `ChannelManagerSharing` / `TransparentReconnect`
/ `SessionLifecycle` / `CertRotation` — **7 pass, 1 skipped**
(`ChannelManagerExhaustionEscalatesAndRecoversWhenServerReturns`
un-`[Ignore]`d and implemented in this PR via `SwapFaultedEntryAsync`;
remaining skip is end-to-end `RequiresSessionRecreate` plumbing —
carry-forward)
- Stress (consolidated `Tests/Opc.Ua.Stress.Tests/Subscriptions/` +
`Channels/`): chaos / contract / soak / lifecycle / leak-accuracy / gap
/ participant-result-aggregation tests merged from the
previously-separate `Opc.Ua.Channels.Stress.Tests` project; nightly run
via `.github/workflows/stress-test.yml`

Builds clean (0 warnings / 0 errors) across all six TFMs: `net472`,
`net48`, `netstandard2.1`, `net8.0`, `net9.0`, `net10.0`.

### Docs

- `Docs/Sessions.md` new § 4 documents the channel manager, three-state
gating, two-level retry with shared `IRetryBudget`, HTTPS resilience
layering
- `Docs/MigrationGuide.md` migration recipes for the four obsoleted API
groups
- `Docs/DependencyInjection.md` channel-manager and HTTPS factory
registration

## Carry-forward (separate PRs)

- End-to-end `ParticipantReconnectResult.RequiresSessionRecreate` →
`Session.RecreateAsync` lifecycle wiring (currently incomplete — flagged
in the 1 remaining skipped integration test)

## Related Issues

- Fixes #3288
- Builds on #3282 (PR that introduced the initial
`ITransportChannelManager` skeleton)

## 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.
*(Channel-manager-specific tests + ManagedSession/client tests verified
across all 6 TFMs; full UA.slnx run is pending CI.)*
- [ ] I fixed **all** failing and flaky tests in the CI pipelines and
**all** CodeQL warnings.
- [ ] I have addressed **all** PR feedback received.
2026-06-13 16:34:48 +02:00

257 lines
10 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/* ========================================================================
* Copyright (c) 2005-2026 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.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Server;
using Opc.Ua.Bindings.Pcap.Audit;
using Opc.Ua.Bindings.Pcap.Capture;
using Opc.Ua.Bindings.Pcap.DependencyInjection;
using Opc.Ua.Bindings.Pcap.Models;
using Opc.Ua.Bindings.Pcap.Replay;
namespace Opc.Ua.Mcp.Tools
{
/// <summary>
/// MCP tools for replaying OPC UA packet captures.
/// </summary>
[McpServerToolType]
[SuppressMessage(
"Performance",
"CA1812:Avoid uninstantiated internal classes",
Justification = "MCP discovers tool types through reflection; TODO: remove if the analyzer recognizes MCP tools.")]
internal sealed class PacketReplayTools
{
/// <summary>
/// Starts replaying a pcap capture.
/// </summary>
[McpServerTool(Name = "replay_pcap")]
[Description("Replays a captured pcap. mode='mock-server' opens an OPC UA listener that replays the " +
"captured server bytes to a connecting client; mode='mock-client' walks the captured request stream " +
"and re-issues each request against a live target endpoint. Returns the replay session id which must " +
"be passed to stop_replay.")]
public static async Task<ReplaySessionInfo> ReplayPcapAsync(
IServiceProvider services,
[Description("Path to the pcap file.")] string pcapPath,
[Description("Path to the uakeys file (required for mock-client; optional for mock-server).")]
string? keyLogPath,
[Description("Replay mode: mock-server | mock-client.")] string mode = "mock-server",
[Description("For mock-client: target endpoint URL (e.g. opc.tcp://target:62541).")]
string? targetEndpointUrl = null,
[Description("Speed multiplier (0.5 = half speed; default 1.0).")]
double speed = 1.0,
[Description("For mock-server: optional explicit port.")] int? listenPort = null,
CancellationToken ct = default)
{
ArgumentNullException.ThrowIfNull(services);
if (double.IsNaN(speed) || double.IsInfinity(speed) || speed <= 0d)
{
throw new ArgumentException(
$"replay_pcap speed {speed} is not a finite positive number. " +
"Provide a value > 0 (e.g., 1.0 for real-time, 2.0 for 2× faster).",
nameof(speed));
}
ReplayMode replayMode = ParseReplayMode(mode);
PcapOptions pcapOptions = services.GetService<PcapOptions>() ?? new PcapOptions();
if (replayMode == ReplayMode.MockClient && !pcapOptions.AllowMockClientReplay)
{
throw new PcapDiagnosticsException(
"Mock-client replay is disabled. " +
"Configure PcapOptions.AllowMockClientReplay and " +
"AllowedReplayEndpoints in the host's DI setup to enable.");
}
ReplaySessionManager manager = GetReplayManager(services);
await AuditAsync(
services,
new PcapAuditEvent(
PcapAuditEventKind.StartReplay,
DateTimeOffset.UtcNow,
sessionId: null,
resourcePath: pcapPath,
remoteEndpoint: targetEndpointUrl,
properties: new Dictionary<string, string>
{
["Mode"] = replayMode.ToString(),
["Speed"] = speed.ToString(System.Globalization.CultureInfo.InvariantCulture)
}),
ct).ConfigureAwait(false);
ReplaySession session = await manager.StartAsync(
new StartReplayRequest
{
PcapFilePath = pcapPath,
KeyLogFilePath = keyLogPath,
Mode = replayMode,
TargetEndpointUrl = targetEndpointUrl,
Speed = speed,
ListenPort = listenPort
},
ct).ConfigureAwait(false);
return CreateInfo(session);
}
/// <summary>
/// Stops a replay session.
/// </summary>
[McpServerTool(Name = "stop_replay")]
[Description("Stops a running replay session.")]
public static async Task<ReplaySessionInfo> StopReplayAsync(
IServiceProvider services,
[Description("Replay session id.")] string sessionId,
CancellationToken ct)
{
ReplaySessionManager manager = GetReplayManager(services);
await AuditAsync(
services,
new PcapAuditEvent(
PcapAuditEventKind.StopReplay,
DateTimeOffset.UtcNow,
sessionId,
resourcePath: null,
remoteEndpoint: null,
properties: null),
ct).ConfigureAwait(false);
await manager.StopAsync(sessionId, ct).ConfigureAwait(false);
return CreateInfo(manager.Get(sessionId));
}
/// <summary>
/// Lists known replay sessions.
/// </summary>
[McpServerTool(Name = "list_replays")]
[Description("Lists every replay session known to the server.")]
public static IReadOnlyList<ReplaySessionInfo> ListReplays(IServiceProvider services)
{
ReplaySessionManager manager = GetReplayManager(services);
return [.. manager.List().Select(CreateInfo)];
}
private static ReplaySessionManager GetReplayManager(IServiceProvider services)
{
ArgumentNullException.ThrowIfNull(services);
ReplaySessionManager? manager = services.GetService<ReplaySessionManager>();
if (manager is null)
{
// TODO: Remove this guard once replay registration is mandatory for every host using these tools.
throw new NotSupportedException("Replay support is not yet wired - replay agent has not completed.");
}
return manager;
}
private static ValueTask AuditAsync(
IServiceProvider services,
PcapAuditEvent auditEvent,
CancellationToken ct)
{
IPcapAuditSink? auditSink = services.GetService<IPcapAuditSink>();
if (auditSink is null)
{
return ValueTask.CompletedTask;
}
return auditSink.OnEventAsync(auditEvent, ct);
}
private static ReplayMode ParseReplayMode(string mode)
{
return mode.Trim().ToLowerInvariant() switch
{
"mock-server" or "mockserver" => ReplayMode.MockServer,
"mock-client" or "mockclient" => ReplayMode.MockClient,
_ => throw new PcapDiagnosticsException(
$"Unsupported replay mode '{mode}'. Use mock-server or mock-client.")
};
}
private static ReplaySessionInfo CreateInfo(ReplaySession session)
{
return new ReplaySessionInfo
{
SessionId = session.Id,
Mode = session.Mode.ToString(),
ListenUri = session.ListenUri?.ToString(),
TargetEndpointUrl = session.TargetEndpointUrl,
StartedAt = session.StartedAt,
IsRunning = session.IsRunning,
ResultSummary = session.Result?.ToString()
};
}
}
/// <summary>
/// Status and metadata for a replay session.
/// </summary>
public sealed class ReplaySessionInfo
{
/// <summary>
/// Gets the replay session id.
/// </summary>
public string SessionId { get; init; } = string.Empty;
/// <summary>
/// Gets the replay mode.
/// </summary>
public string Mode { get; init; } = string.Empty;
/// <summary>
/// Gets the mock-server listen URI, when available.
/// </summary>
public string? ListenUri { get; init; }
/// <summary>
/// Gets the mock-client target endpoint URL, when available.
/// </summary>
public string? TargetEndpointUrl { get; init; }
/// <summary>
/// Gets the replay start time.
/// </summary>
public DateTimeOffset StartedAt { get; init; }
/// <summary>
/// Gets whether the replay session is currently running.
/// </summary>
public bool IsRunning { get; init; }
/// <summary>
/// Gets a text summary of replay results, when available.
/// </summary>
public string? ResultSummary { get; init; }
}
}