feat(historian-client): TCP connect factory + FrameChannel rename

This commit is contained in:
Joseph Doherty
2026-06-12 11:21:28 -04:00
parent 7ce7505a36
commit 6e152047eb
3 changed files with 196 additions and 7 deletions
@@ -1,4 +1,7 @@
using System.IO.Pipes;
using System.Net.Security;
using System.Net.Sockets;
using System.Security.Authentication;
using MessagePack;
using Microsoft.Extensions.Logging;
using ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Ipc;
@@ -16,7 +19,7 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client.Internal;
/// calls would interleave replies. A <see cref="SemaphoreSlim"/> serializes them. PR 6.x
/// can layer batching on top.
/// </remarks>
internal sealed class PipeChannel : IAsyncDisposable
internal sealed class FrameChannel : IAsyncDisposable
{
private readonly WonderwareHistorianClientOptions _options;
private readonly Func<CancellationToken, Task<Stream>> _connect;
@@ -44,11 +47,43 @@ internal sealed class PipeChannel : IAsyncDisposable
return pipe;
};
/// <summary>Initializes a new instance of the <see cref="PipeChannel"/> class.</summary>
/// <summary>
/// Default TCP factory: connects to the sidecar over TCP, optionally wrapping the stream
/// in TLS (server-auth; pinned-thumbprint or CA-chain validation). The Hello handshake +
/// shared secret still authenticate the caller on top of this.
/// </summary>
public static Func<WonderwareHistorianClientOptions, CancellationToken, Task<Stream>> DefaultTcpConnectFactory =
async (opts, ct) =>
{
if (string.IsNullOrWhiteSpace(opts.Host))
throw new InvalidOperationException("WonderwareHistorianClientOptions.Host is required for the TCP transport.");
var tcp = new TcpClient();
using (var connectCts = CancellationTokenSource.CreateLinkedTokenSource(ct))
{
connectCts.CancelAfter(opts.EffectiveConnectTimeout);
await tcp.ConnectAsync(opts.Host!, opts.Port, connectCts.Token).ConfigureAwait(false);
}
tcp.NoDelay = true;
Stream stream = tcp.GetStream();
if (!opts.UseTls) return stream;
var ssl = new SslStream(stream, leaveInnerStreamOpen: false, (_, cert, _, errors) =>
{
if (!string.IsNullOrEmpty(opts.ServerCertThumbprint))
return string.Equals(cert?.GetCertHashString(), opts.ServerCertThumbprint, StringComparison.OrdinalIgnoreCase);
return errors == SslPolicyErrors.None;
});
await ssl.AuthenticateAsClientAsync(opts.Host!).ConfigureAwait(false);
return ssl;
};
/// <summary>Initializes a new instance of the <see cref="FrameChannel"/> class.</summary>
/// <param name="options">Configuration options for the historian client.</param>
/// <param name="connect">Function to establish a connection stream.</param>
/// <param name="logger">Logger instance for diagnostics.</param>
public PipeChannel(
public FrameChannel(
WonderwareHistorianClientOptions options,
Func<CancellationToken, Task<Stream>> connect,
ILogger logger)
@@ -16,13 +16,13 @@ namespace ZB.MOM.WW.OtOpcUa.Driver.Historian.Wonderware.Client;
/// (alarm-event drain consumed by <c>Core.AlarmHistorian.SqliteStoreAndForwardSink</c>).
/// </summary>
/// <remarks>
/// The client owns a single <see cref="PipeChannel"/> with one in-flight call at a time;
/// The client owns a single <see cref="FrameChannel"/> with one in-flight call at a time;
/// concurrent calls serialize on the channel's gate. Reconnect is handled inside the
/// channel — transient transport failures retry once before propagating.
/// </remarks>
public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHistorianWriter, IAsyncDisposable
{
private readonly PipeChannel _channel;
private readonly FrameChannel _channel;
private readonly object _healthLock = new();
private DateTime? _lastSuccessUtc;
private DateTime? _lastFailureUtc;
@@ -39,7 +39,7 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
/// <param name="options">The client connection options.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
public WonderwareHistorianClient(WonderwareHistorianClientOptions options, ILogger<WonderwareHistorianClient>? logger = null)
: this(options, ct => PipeChannel.DefaultNamedPipeConnectFactory(options, ct), logger)
: this(options, ct => FrameChannel.DefaultNamedPipeConnectFactory(options, ct), logger)
{
}
@@ -61,7 +61,7 @@ public sealed class WonderwareHistorianClient : IHistorianDataSource, IAlarmHist
{
ArgumentNullException.ThrowIfNull(options);
var log = (ILogger?)logger ?? NullLogger.Instance;
_channel = new PipeChannel(options, connect, log);
_channel = new FrameChannel(options, connect, log);
}
// ===== IHistorianDataSource =====