197 lines
8.2 KiB
C#
197 lines
8.2 KiB
C#
using System.IO.Pipes;
|
|
using Microsoft.Extensions.Logging;
|
|
using Microsoft.Extensions.Options;
|
|
using ZB.MOM.WW.MxGateway.Contracts;
|
|
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
|
using ZB.MOM.WW.MxGateway.Server.Configuration;
|
|
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
|
using ZB.MOM.WW.MxGateway.Server.Workers;
|
|
|
|
namespace ZB.MOM.WW.MxGateway.Server.Sessions;
|
|
|
|
/// <summary>Factory for creating worker clients and launching worker processes.</summary>
|
|
public sealed class SessionWorkerClientFactory : ISessionWorkerClientFactory
|
|
{
|
|
/// <summary>
|
|
/// Kernel buffer quota requested for each direction of a worker pipe. A zero quota — what the
|
|
/// short <see cref="NamedPipeServerStream"/> overloads request — makes every byte-mode write
|
|
/// rendezvous with a pending read, so writer latency is coupled to reader scheduling and a
|
|
/// writer with no reader parked blocks indefinitely. That is the failure class behind the
|
|
/// historical windev full-suite wedge (all tests reported, testhost never exiting). A real
|
|
/// quota lets a whole frame land in the kernel and the writer return. 128 KiB comfortably
|
|
/// holds the control traffic and typical event batches without reserving nonpaged pool per
|
|
/// session for the rare maximum-sized frame, which still streams through in chunks.
|
|
/// </summary>
|
|
private const int PipeBufferSizeBytes = 128 * 1024;
|
|
|
|
private readonly IWorkerProcessLauncher _workerProcessLauncher;
|
|
private readonly GatewayMetrics _metrics;
|
|
private readonly TimeProvider _timeProvider;
|
|
private readonly ILoggerFactory _loggerFactory;
|
|
private readonly GatewayOptions _options;
|
|
|
|
/// <summary>Initializes a new instance of the SessionWorkerClientFactory class.</summary>
|
|
/// <param name="workerProcessLauncher">Service for launching worker processes.</param>
|
|
/// <param name="options">Configuration options.</param>
|
|
/// <param name="metrics">Metrics collector for gateway events.</param>
|
|
/// <param name="loggerFactory">Logger factory for creating loggers.</param>
|
|
/// <param name="timeProvider">Optional time provider for testing; defaults to system time.</param>
|
|
public SessionWorkerClientFactory(
|
|
IWorkerProcessLauncher workerProcessLauncher,
|
|
IOptions<GatewayOptions> options,
|
|
GatewayMetrics metrics,
|
|
ILoggerFactory loggerFactory,
|
|
TimeProvider? timeProvider = null)
|
|
{
|
|
_workerProcessLauncher = workerProcessLauncher ?? throw new ArgumentNullException(nameof(workerProcessLauncher));
|
|
ArgumentNullException.ThrowIfNull(options);
|
|
_metrics = metrics ?? throw new ArgumentNullException(nameof(metrics));
|
|
_loggerFactory = loggerFactory ?? throw new ArgumentNullException(nameof(loggerFactory));
|
|
_timeProvider = timeProvider ?? TimeProvider.System;
|
|
_options = options.Value;
|
|
}
|
|
|
|
/// <inheritdoc />
|
|
public async Task<IWorkerClient> CreateAsync(
|
|
GatewaySession session,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
ArgumentNullException.ThrowIfNull(session);
|
|
|
|
NamedPipeServerStream? pipe = CreatePipe(session.PipeName);
|
|
WorkerProcessHandle? processHandle = null;
|
|
IWorkerClient? workerClient = null;
|
|
using CancellationTokenSource startupCancellation =
|
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
|
startupCancellation.CancelAfter(session.StartupTimeout);
|
|
try
|
|
{
|
|
session.TransitionTo(SessionState.StartingWorker);
|
|
processHandle = await _workerProcessLauncher
|
|
.LaunchAsync(
|
|
new WorkerProcessLaunchRequest(
|
|
session.SessionId,
|
|
session.PipeName,
|
|
GatewayContractInfo.WorkerProtocolVersion,
|
|
session.Nonce,
|
|
pipe),
|
|
startupCancellation.Token)
|
|
.ConfigureAwait(false);
|
|
|
|
session.TransitionTo(SessionState.WaitingForPipe);
|
|
await WaitForPipeConnectionAsync(pipe, startupCancellation.Token).ConfigureAwait(false);
|
|
|
|
session.TransitionTo(SessionState.Handshaking);
|
|
WorkerFrameProtocolOptions frameOptions = new(
|
|
session.SessionId,
|
|
GatewayContractInfo.WorkerProtocolVersion,
|
|
_options.Worker.MaxMessageBytes);
|
|
WorkerClientConnection connection = new(
|
|
session.SessionId,
|
|
session.Nonce,
|
|
pipe,
|
|
frameOptions,
|
|
processHandle);
|
|
WorkerClientOptions clientOptions = new()
|
|
{
|
|
HeartbeatGrace = TimeSpan.FromSeconds(_options.Worker.HeartbeatGraceSeconds),
|
|
HeartbeatCheckInterval = TimeSpan.FromSeconds(_options.Worker.HeartbeatIntervalSeconds),
|
|
EventChannelCapacity = _options.Events.QueueCapacity,
|
|
MaxPendingCommands = _options.Sessions.MaxPendingCommandsPerSession,
|
|
};
|
|
|
|
workerClient = new WorkerClient(
|
|
connection,
|
|
clientOptions,
|
|
_metrics,
|
|
_timeProvider,
|
|
_loggerFactory.CreateLogger<WorkerClient>());
|
|
|
|
pipe = null;
|
|
processHandle = null;
|
|
|
|
session.TransitionTo(SessionState.InitializingWorker);
|
|
await workerClient.StartAsync(startupCancellation.Token).ConfigureAwait(false);
|
|
|
|
return workerClient;
|
|
}
|
|
catch (Exception exception)
|
|
{
|
|
if (workerClient is not null)
|
|
{
|
|
try
|
|
{
|
|
workerClient.Kill("OpenSessionFailed");
|
|
}
|
|
catch
|
|
{
|
|
// Preserve the startup failure while still disposing below.
|
|
}
|
|
|
|
await workerClient.DisposeAsync().ConfigureAwait(false);
|
|
}
|
|
else
|
|
{
|
|
if (processHandle is not null)
|
|
{
|
|
try
|
|
{
|
|
if (!processHandle.Process.HasExited)
|
|
{
|
|
processHandle.Process.Kill(entireProcessTree: true);
|
|
_metrics.WorkerKilled("OpenSessionFailed");
|
|
}
|
|
}
|
|
finally
|
|
{
|
|
processHandle.Dispose();
|
|
}
|
|
}
|
|
|
|
pipe?.Dispose();
|
|
}
|
|
|
|
if (exception is OperationCanceledException
|
|
&& startupCancellation.IsCancellationRequested
|
|
&& !cancellationToken.IsCancellationRequested)
|
|
{
|
|
throw new TimeoutException(
|
|
$"Worker session {session.SessionId} did not complete startup within {session.StartupTimeout}.",
|
|
exception);
|
|
}
|
|
|
|
throw;
|
|
}
|
|
}
|
|
|
|
/// <summary>Creates a named pipe for worker communication.</summary>
|
|
/// <param name="pipeName">The pipe name.</param>
|
|
/// <returns>Named pipe server stream.</returns>
|
|
/// <remarks>
|
|
/// The buffer sizes are explicit so the pipe is not created with a zero quota; see
|
|
/// <see cref="PipeBufferSizeBytes"/>. On Unix hosts (the macOS test matrix, where named pipes
|
|
/// are Unix domain sockets) the sizes are advisory — the fix targets Windows production.
|
|
/// </remarks>
|
|
private static NamedPipeServerStream CreatePipe(string pipeName)
|
|
{
|
|
return new NamedPipeServerStream(
|
|
pipeName,
|
|
PipeDirection.InOut,
|
|
maxNumberOfServerInstances: 1,
|
|
PipeTransmissionMode.Byte,
|
|
PipeOptions.Asynchronous,
|
|
inBufferSize: PipeBufferSizeBytes,
|
|
outBufferSize: PipeBufferSizeBytes);
|
|
}
|
|
|
|
/// <summary>Waits for a client to connect to the pipe.</summary>
|
|
/// <param name="pipe">The named pipe.</param>
|
|
/// <param name="cancellationToken">Cancellation token.</param>
|
|
private static async Task WaitForPipeConnectionAsync(
|
|
NamedPipeServerStream pipe,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
await pipe.WaitForConnectionAsync(cancellationToken).ConfigureAwait(false);
|
|
}
|
|
}
|