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; /// /// Connects to the gateway via a named pipe and runs the worker frame protocol session. /// public sealed class WorkerPipeClient : IWorkerPipeClient { /// Default overall connection timeout in milliseconds. public const int DefaultConnectTimeoutMilliseconds = 30000; /// Default per-attempt connection timeout in milliseconds. public const int DefaultConnectAttemptTimeoutMilliseconds = 2000; /// Environment variable for overriding the per-attempt connection timeout. public const string ConnectAttemptTimeoutEnvironmentVariableName = "MXGATEWAY_WORKER_PIPE_CONNECT_ATTEMPT_TIMEOUT_MS"; private readonly int _connectTimeoutMilliseconds; private readonly int _connectAttemptTimeoutMilliseconds; private readonly Func _sessionFactory; private readonly IWorkerLogger? _logger; /// /// Initializes a worker pipe client with default timeouts. /// public WorkerPipeClient() : this(null, DefaultConnectTimeoutMilliseconds) { } /// /// Initializes a worker pipe client with a logger and default timeouts. /// /// Optional logger for diagnostic output. public WorkerPipeClient(IWorkerLogger? logger) : this(logger, DefaultConnectTimeoutMilliseconds) { } /// /// Initializes a worker pipe client with a custom overall connect timeout. /// /// Overall connection timeout in milliseconds. public WorkerPipeClient(int connectTimeoutMilliseconds) : this(null, connectTimeoutMilliseconds) { } /// /// Initializes a worker pipe client with custom timeouts and a session factory. /// /// Overall connection timeout in milliseconds. /// Factory creating the worker pipe session. public WorkerPipeClient( int connectTimeoutMilliseconds, Func sessionFactory) : this( null, connectTimeoutMilliseconds, ResolveDefaultConnectAttemptTimeoutMilliseconds(), (stream, frameOptions, _) => sessionFactory(stream, frameOptions)) { } /// /// Initializes a worker pipe client with a logger and custom overall timeout. /// /// Optional logger for diagnostic output. /// Overall connection timeout in milliseconds. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds) : this( logger, connectTimeoutMilliseconds, ResolveDefaultConnectAttemptTimeoutMilliseconds(), (stream, frameOptions, workerLogger) => new WorkerPipeSession(stream, frameOptions, workerLogger)) { } /// /// Initializes a worker pipe client with logger, timeouts, and a session factory. /// /// Optional logger for diagnostic output. /// Overall connection timeout in milliseconds. /// Factory creating the worker pipe session. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds, Func sessionFactory) : this( logger, connectTimeoutMilliseconds, ResolveDefaultConnectAttemptTimeoutMilliseconds(), sessionFactory) { } /// /// Initializes a worker pipe client with full configuration. /// /// Optional logger for diagnostic output. /// Overall connection timeout in milliseconds. /// Per-attempt connection timeout in milliseconds. /// Factory creating the worker pipe session. public WorkerPipeClient( IWorkerLogger? logger, int connectTimeoutMilliseconds, int connectAttemptTimeoutMilliseconds, Func 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; } /// 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 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 pipeline = new ResiliencePipelineBuilder() .AddRetry(new RetryStrategyOptions { MaxRetryAttempts = int.MaxValue, BackoffType = DelayBackoffType.Exponential, UseJitter = true, Delay = TimeSpan.FromMilliseconds(250), MaxDelay = TimeSpan.FromSeconds(2), ShouldHandle = new PredicateBuilder() .Handle(exception => exception is TimeoutException or IOException), OnRetry = args => { args.Outcome.Result?.Dispose(); _logger?.Information( "WorkerPipeConnectRetry", new Dictionary { ["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 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; } }