Files
mxaccessgw/src/ZB.MOM.WW.MxGateway.Worker/Ipc/WorkerPipeClient.cs
T

264 lines
11 KiB
C#

using System;
using System.Collections.Generic;
using System.IO;
using System.IO.Pipes;
using System.Threading;
using System.Threading.Tasks;
using ZB.MOM.WW.MxGateway.Worker.Bootstrap;
using Polly;
using Polly.Retry;
namespace ZB.MOM.WW.MxGateway.Worker.Ipc;
/// <summary>
/// Connects to the gateway via a named pipe and runs the worker frame protocol session.
/// </summary>
public sealed class WorkerPipeClient : IWorkerPipeClient
{
/// <summary>Default overall connection timeout in milliseconds.</summary>
public const int DefaultConnectTimeoutMilliseconds = 30000;
/// <summary>Default per-attempt connection timeout in milliseconds.</summary>
public const int DefaultConnectAttemptTimeoutMilliseconds = 2000;
/// <summary>Environment variable for overriding the per-attempt connection timeout.</summary>
public const string ConnectAttemptTimeoutEnvironmentVariableName =
"MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS";
private readonly int _connectTimeoutMilliseconds;
private readonly int _connectAttemptTimeoutMilliseconds;
private readonly Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> _sessionFactory;
private readonly IWorkerLogger? _logger;
/// <summary>
/// Initializes a worker pipe client with default timeouts.
/// </summary>
public WorkerPipeClient()
: this(null, DefaultConnectTimeoutMilliseconds)
{
}
/// <summary>
/// Initializes a worker pipe client with a logger and default timeouts.
/// </summary>
/// <param name="logger">Optional logger for diagnostic output.</param>
public WorkerPipeClient(IWorkerLogger? logger)
: this(logger, DefaultConnectTimeoutMilliseconds)
{
}
/// <summary>
/// Initializes a worker pipe client with a custom overall connect timeout.
/// </summary>
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
public WorkerPipeClient(int connectTimeoutMilliseconds)
: this(null, connectTimeoutMilliseconds)
{
}
/// <summary>
/// Initializes a worker pipe client with custom timeouts and a session factory.
/// </summary>
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
public WorkerPipeClient(
int connectTimeoutMilliseconds,
Func<Stream, WorkerFrameProtocolOptions, WorkerPipeSession> sessionFactory)
: this(
null,
connectTimeoutMilliseconds,
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
(stream, frameOptions, _) => sessionFactory(stream, frameOptions))
{
}
/// <summary>
/// Initializes a worker pipe client with a logger and custom overall timeout.
/// </summary>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
public WorkerPipeClient(
IWorkerLogger? logger,
int connectTimeoutMilliseconds)
: this(
logger,
connectTimeoutMilliseconds,
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
(stream, frameOptions, workerLogger) => new WorkerPipeSession(stream, frameOptions, workerLogger))
{
}
/// <summary>
/// Initializes a worker pipe client with logger, timeouts, and a session factory.
/// </summary>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
public WorkerPipeClient(
IWorkerLogger? logger,
int connectTimeoutMilliseconds,
Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> sessionFactory)
: this(
logger,
connectTimeoutMilliseconds,
ResolveDefaultConnectAttemptTimeoutMilliseconds(),
sessionFactory)
{
}
/// <summary>
/// Initializes a worker pipe client with full configuration.
/// </summary>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <param name="connectTimeoutMilliseconds">Overall connection timeout in milliseconds.</param>
/// <param name="connectAttemptTimeoutMilliseconds">Per-attempt connection timeout in milliseconds.</param>
/// <param name="sessionFactory">Factory creating the worker pipe session.</param>
public WorkerPipeClient(
IWorkerLogger? logger,
int connectTimeoutMilliseconds,
int connectAttemptTimeoutMilliseconds,
Func<Stream, WorkerFrameProtocolOptions, IWorkerLogger?, WorkerPipeSession> sessionFactory)
{
if (connectTimeoutMilliseconds <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(connectTimeoutMilliseconds),
"Worker pipe connect timeout must be greater than zero.");
}
if (connectAttemptTimeoutMilliseconds <= 0)
{
throw new ArgumentOutOfRangeException(
nameof(connectAttemptTimeoutMilliseconds),
"Worker pipe connect attempt timeout must be greater than zero.");
}
_logger = logger;
_sessionFactory = sessionFactory ?? throw new ArgumentNullException(nameof(sessionFactory));
_connectTimeoutMilliseconds = connectTimeoutMilliseconds;
_connectAttemptTimeoutMilliseconds = connectAttemptTimeoutMilliseconds;
}
/// <inheritdoc />
public async Task RunAsync(
WorkerOptions options,
CancellationToken cancellationToken = default)
{
if (options is null)
{
throw new ArgumentNullException(nameof(options));
}
WorkerFrameProtocolOptions frameOptions = new(options);
// The session disposes this pipe itself as its last teardown step — that disposal is what
// unblocks a net48 pipe read the message loop abandoned, and it has to happen while the
// session still holds the read task so the resulting fault is observed rather than orphaned
// (see WorkerPipeSession.RunAsync). The `using` stays as the backstop for the paths the
// session never reaches: a session factory that throws, or a RunAsync that never gets past
// its own construction. Disposal is idempotent, so the second Dispose is a no-op — do not
// "clean up" this `using` on the assumption that it is now redundant.
using NamedPipeClientStream pipe = await ConnectWithRetryAsync(options.PipeName, cancellationToken)
.ConfigureAwait(false);
WorkerPipeSession session = _sessionFactory(pipe, frameOptions, _logger);
await session.RunAsync(cancellationToken).ConfigureAwait(false);
}
private async Task<NamedPipeClientStream> ConnectWithRetryAsync(
string pipeName,
CancellationToken cancellationToken)
{
// The real bound on connection attempts is the connectDeadline token
// below (CancelAfter(connectTimeout)): Polly stops retrying as soon as
// that token is cancelled. Driving retries purely off the deadline —
// rather than a fragile attempt-count formula that ignored the
// exponential backoff between attempts — keeps the time budget the
// single source of truth. MaxRetryAttempts is set to its maximum so it
// never ends the retry loop before the deadline does.
ResiliencePipeline<NamedPipeClientStream> pipeline = new ResiliencePipelineBuilder<NamedPipeClientStream>()
.AddRetry(new RetryStrategyOptions<NamedPipeClientStream>
{
MaxRetryAttempts = int.MaxValue,
BackoffType = DelayBackoffType.Exponential,
UseJitter = true,
Delay = TimeSpan.FromMilliseconds(250),
MaxDelay = TimeSpan.FromSeconds(2),
ShouldHandle = new PredicateBuilder<NamedPipeClientStream>()
.Handle<Exception>(exception => exception is TimeoutException or IOException),
OnRetry = args =>
{
args.Outcome.Result?.Dispose();
_logger?.Information(
"WorkerPipeConnectRetry",
new Dictionary<string, object?>
{
["attempt"] = args.AttemptNumber + 1,
["pipe_name"] = pipeName,
});
return default;
},
})
.Build();
using CancellationTokenSource connectDeadline =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
connectDeadline.CancelAfter(_connectTimeoutMilliseconds);
try
{
return await pipeline.ExecuteAsync(
async token => await ConnectSingleAttemptAsync(pipeName, token).ConfigureAwait(false),
connectDeadline.Token)
.ConfigureAwait(false);
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
throw new TimeoutException(
$"Worker pipe {pipeName} did not connect within {_connectTimeoutMilliseconds}ms.");
}
}
private async Task<NamedPipeClientStream> ConnectSingleAttemptAsync(
string pipeName,
CancellationToken cancellationToken)
{
NamedPipeClientStream pipe = new(
".",
pipeName,
PipeDirection.InOut,
PipeOptions.Asynchronous);
try
{
using CancellationTokenSource attemptTimeout =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
attemptTimeout.CancelAfter(_connectAttemptTimeoutMilliseconds);
await Task.Run(
() =>
{
attemptTimeout.Token.ThrowIfCancellationRequested();
pipe.Connect(_connectAttemptTimeoutMilliseconds);
},
attemptTimeout.Token)
.ConfigureAwait(false);
return pipe;
}
catch
{
pipe.Dispose();
throw;
}
}
private static int ResolveDefaultConnectAttemptTimeoutMilliseconds()
{
string? configuredValue = Environment.GetEnvironmentVariable(ConnectAttemptTimeoutEnvironmentVariableName);
return int.TryParse(configuredValue, out int milliseconds) && milliseconds > 0
? milliseconds
: DefaultConnectAttemptTimeoutMilliseconds;
}
}