1342 lines
56 KiB
C#
1342 lines
56 KiB
C#
using System.Collections.Concurrent;
|
||
using System.Runtime.CompilerServices;
|
||
using System.Threading.Channels;
|
||
using Google.Protobuf.WellKnownTypes;
|
||
using Microsoft.Extensions.Logging;
|
||
using Microsoft.Extensions.Logging.Abstractions;
|
||
using ZB.MOM.WW.MxGateway.Contracts;
|
||
using ZB.MOM.WW.MxGateway.Contracts.Proto;
|
||
using ZB.MOM.WW.MxGateway.Server.Metrics;
|
||
|
||
namespace ZB.MOM.WW.MxGateway.Server.Workers;
|
||
|
||
public sealed class WorkerClient : IWorkerClient
|
||
{
|
||
private const string GatewayVersionFallback = "unknown";
|
||
private static readonly TimeSpan DisposeTaskTimeout = TimeSpan.FromSeconds(5);
|
||
private readonly object _syncRoot = new();
|
||
private readonly WorkerClientConnection _connection;
|
||
private readonly WorkerClientOptions _options;
|
||
private readonly GatewayMetrics? _metrics;
|
||
private readonly TimeProvider _timeProvider;
|
||
private readonly ILogger<WorkerClient> _logger;
|
||
private readonly WorkerFrameReader _reader;
|
||
private readonly WorkerFrameWriter _writer;
|
||
private readonly Channel<WorkerEnvelope> _outboundEnvelopes;
|
||
private readonly Channel<WorkerEvent> _events;
|
||
|
||
// Staging hand-off between the read loop and the dedicated event writer. The read loop writes
|
||
// here with a non-blocking TryWrite so a full consumer channel (_events) can never stall the read
|
||
// loop behind an event — replies and heartbeats keep flowing. Bounded at 2 × EventChannelCapacity
|
||
// (GWC-24): an unbounded staging channel let a consumer that drains slower than the worker
|
||
// produces grow gateway memory without limit and without a fault, because each individual timed
|
||
// write into _events still completed inside EventChannelFullModeTimeout. A rejected TryWrite here
|
||
// is the sustained-slow-drain signal and faults the client immediately (ProtocolViolation), which
|
||
// is the fail-fast backpressure policy in docs/DesignDecisions.md. Total gateway-side buffering
|
||
// per session is therefore 3 × MxGateway:Events:QueueCapacity, and the single _eventQueueDepth
|
||
// gauge covers staged + queued events so the whole backlog is observable.
|
||
private readonly Channel<WorkerEvent> _eventStaging;
|
||
private readonly int _eventStagingCapacity;
|
||
private readonly ConcurrentDictionary<string, PendingCommand> _pendingCommands = new(StringComparer.Ordinal);
|
||
private readonly SemaphoreSlim _pendingCommandSlots;
|
||
private readonly CancellationTokenSource _stopCts = new();
|
||
|
||
// GWC-30: this client's contribution to the gateway-wide worker queue-depth gauge. Registered
|
||
// once here and read only when the gauge is scraped, so staging and consuming an event cost an
|
||
// Interlocked on _eventQueueDepth and nothing else — previously each of those two hot-path steps
|
||
// called into GatewayMetrics and took its process-wide lock. Null when metrics are disabled.
|
||
private readonly IDisposable? _eventQueueDepthRegistration;
|
||
// Touched only by WriteLoopAsync — the single consumer of _outboundEnvelopes — so it needs no
|
||
// interlocking. See WriteLoopAsync for why the stamp happens there rather than at construction.
|
||
private ulong _nextSequence;
|
||
private WorkerClientState _state;
|
||
private DateTimeOffset _lastHeartbeatAt;
|
||
private int? _processId;
|
||
private int _eventQueueDepth;
|
||
private int _eventsReaderClaimed;
|
||
private Task? _readLoopTask;
|
||
private Task? _writeLoopTask;
|
||
private Task? _eventWriteLoopTask;
|
||
private Task? _heartbeatLoopTask;
|
||
private bool _workerStartRecorded;
|
||
private bool _disposed;
|
||
|
||
/// <summary>Initializes a client for communicating with a worker process over a named pipe.</summary>
|
||
/// <param name="connection">Named pipe connection to the worker.</param>
|
||
/// <param name="options">Worker client configuration; defaults to WorkerClientOptions if null.</param>
|
||
/// <param name="metrics">Gateway metrics sink; null disables metrics recording.</param>
|
||
/// <param name="timeProvider">Time provider for timestamps; defaults to system time if null.</param>
|
||
/// <param name="logger">Logger instance; defaults to NullLogger if null.</param>
|
||
public WorkerClient(
|
||
WorkerClientConnection connection,
|
||
WorkerClientOptions? options = null,
|
||
GatewayMetrics? metrics = null,
|
||
TimeProvider? timeProvider = null,
|
||
ILogger<WorkerClient>? logger = null)
|
||
{
|
||
_connection = connection ?? throw new ArgumentNullException(nameof(connection));
|
||
_options = options ?? new WorkerClientOptions();
|
||
_metrics = metrics;
|
||
_timeProvider = timeProvider ?? TimeProvider.System;
|
||
_logger = logger ?? NullLogger<WorkerClient>.Instance;
|
||
_reader = new WorkerFrameReader(connection.Stream, connection.FrameOptions);
|
||
_writer = new WorkerFrameWriter(connection.Stream, connection.FrameOptions);
|
||
_pendingCommandSlots = new SemaphoreSlim(_options.MaxPendingCommands, _options.MaxPendingCommands);
|
||
_outboundEnvelopes = Channel.CreateBounded<WorkerEnvelope>(
|
||
new BoundedChannelOptions(_options.MaxPendingCommands + 4)
|
||
{
|
||
SingleReader = true,
|
||
SingleWriter = false,
|
||
FullMode = BoundedChannelFullMode.Wait,
|
||
AllowSynchronousContinuations = false,
|
||
});
|
||
_events = Channel.CreateBounded<WorkerEvent>(
|
||
new BoundedChannelOptions(_options.EventChannelCapacity)
|
||
{
|
||
// The worker event channel has exactly ONE consumer: the per-session
|
||
// SessionEventDistributor pump. The alarm monitor and dashboard mirror both
|
||
// attach to the distributor rather than draining this channel directly, so a
|
||
// second concurrent reader would silently split events between the two
|
||
// enumerators. SingleReader=true asserts that invariant; ReadEventsAsync adds a
|
||
// claimed-once guard so a regression fails loudly instead of losing events.
|
||
SingleReader = true,
|
||
SingleWriter = true,
|
||
FullMode = BoundedChannelFullMode.Wait,
|
||
AllowSynchronousContinuations = false,
|
||
});
|
||
_eventStagingCapacity = checked(2 * _options.EventChannelCapacity);
|
||
_eventStaging = Channel.CreateBounded<WorkerEvent>(
|
||
new BoundedChannelOptions(_eventStagingCapacity)
|
||
{
|
||
// The read loop is the only writer; EventWriteLoopAsync is the only reader.
|
||
SingleReader = true,
|
||
SingleWriter = true,
|
||
// Wait (not Drop*) so the read loop's non-blocking TryWrite returns false exactly
|
||
// when the bound is reached — the same Wait+TryWrite overflow-detection idiom the
|
||
// session event distributor uses. The read loop never awaits this channel.
|
||
FullMode = BoundedChannelFullMode.Wait,
|
||
AllowSynchronousContinuations = false,
|
||
});
|
||
_eventQueueDepthRegistration = _metrics?.RegisterWorkerEventQueueDepthSource(
|
||
() => Volatile.Read(ref _eventQueueDepth));
|
||
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public string SessionId => _connection.SessionId;
|
||
|
||
/// <inheritdoc />
|
||
public int? ProcessId
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _processId;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public WorkerClientState State
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _state;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public DateTimeOffset LastHeartbeatAt
|
||
{
|
||
get
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
return _lastHeartbeatAt;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task StartAsync(CancellationToken cancellationToken)
|
||
{
|
||
ThrowIfDisposed();
|
||
TransitionFromCreatedToHandshaking();
|
||
|
||
_writeLoopTask = Task.Run(WriteLoopAsync);
|
||
await EnqueueAsync(CreateGatewayHelloEnvelope(), cancellationToken).ConfigureAwait(false);
|
||
|
||
WorkerEnvelope helloEnvelope = await ReadHandshakeEnvelopeAsync(
|
||
WorkerEnvelope.BodyOneofCase.WorkerHello,
|
||
cancellationToken).ConfigureAwait(false);
|
||
ValidateWorkerHello(helloEnvelope.WorkerHello);
|
||
|
||
WorkerEnvelope readyEnvelope = await ReadHandshakeEnvelopeAsync(
|
||
WorkerEnvelope.BodyOneofCase.WorkerReady,
|
||
cancellationToken).ConfigureAwait(false);
|
||
MarkReady(readyEnvelope.WorkerReady);
|
||
|
||
_readLoopTask = Task.Run(ReadLoopAsync);
|
||
_eventWriteLoopTask = Task.Run(EventWriteLoopAsync);
|
||
_heartbeatLoopTask = Task.Run(HeartbeatLoopAsync);
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task<WorkerCommandReply> InvokeAsync(
|
||
WorkerCommand command,
|
||
TimeSpan timeout,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
ArgumentNullException.ThrowIfNull(command);
|
||
ThrowIfDisposed();
|
||
EnsureReady();
|
||
|
||
if (timeout <= TimeSpan.Zero)
|
||
{
|
||
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Command timeout must be greater than zero.");
|
||
}
|
||
|
||
string correlationId = Guid.NewGuid().ToString("N");
|
||
string method = GetCommandMethod(command);
|
||
if (!_pendingCommandSlots.Wait(0))
|
||
{
|
||
_metrics?.QueueOverflow("worker-pending-commands");
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.PendingCommandLimitExceeded,
|
||
$"Worker session {SessionId} already has {_options.MaxPendingCommands} pending command(s).");
|
||
}
|
||
|
||
PendingCommand pendingCommand = new(
|
||
correlationId,
|
||
method,
|
||
_timeProvider.GetTimestamp());
|
||
|
||
if (!_pendingCommands.TryAdd(correlationId, pendingCommand))
|
||
{
|
||
ReleasePendingCommandSlot();
|
||
throw new InvalidOperationException("Generated a duplicate command correlation id.");
|
||
}
|
||
|
||
_metrics?.CommandStarted(method);
|
||
|
||
try
|
||
{
|
||
WorkerEnvelope commandEnvelope = CreateCommandEnvelope(correlationId, command);
|
||
|
||
// Reject an oversized command at the enqueue boundary so only this correlation fails
|
||
// (ResourceExhausted) rather than the frame reaching the write loop and faulting the whole
|
||
// session. Command envelopes are the only gateway-authored outbound payload whose
|
||
// size the caller controls; checking here keeps a MessageTooLarge in the write loop a
|
||
// genuine desync signal.
|
||
//
|
||
// PERF(GWC-31): this size cannot be handed to WorkerFrameWriter to spare its own
|
||
// CalculateSize. WriteLoopAsync stamps envelope.Sequence immediately before the write
|
||
// (GWC-28), and a non-zero varint field grows the encoding — so the number computed here
|
||
// is a lower bound on the frame the writer actually emits, never the frame length. Passing
|
||
// it as a knownSize would under-length the prefix and desync the worker's framing. The
|
||
// pre-check stays a pre-check: it is conservative in the right direction.
|
||
int envelopeSize = commandEnvelope.CalculateSize();
|
||
if (envelopeSize > _connection.FrameOptions.MaxMessageBytes)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.CommandTooLarge,
|
||
$"Worker command {method} serializes to {envelopeSize} bytes, exceeding the negotiated "
|
||
+ $"worker-frame maximum of {_connection.FrameOptions.MaxMessageBytes} bytes.");
|
||
}
|
||
|
||
await EnqueueAsync(commandEnvelope, cancellationToken).ConfigureAwait(false);
|
||
|
||
// GWC-31: one pooled timer instead of a linked CTS + Task.Delay + WhenAny per command.
|
||
// Task.WaitAsync arms a TimerQueueTimer on the shared timer queue and cancels it when the
|
||
// reply lands, so the steady-state cost of a command that replies in time is a single
|
||
// continuation — the old shape allocated a linked CancellationTokenSource, its
|
||
// registration, a delay Task, and the WhenAny Task on every invoke, and left the delay
|
||
// Task rooted until the cancel completed. WaitAsync raises TimeoutException for the
|
||
// deadline and OperationCanceledException for the caller's token — but unlike the old
|
||
// wait it races the two and reports whichever fired first, whereas the old code inspected
|
||
// cancellationToken.IsCancellationRequested BEFORE classifying a won delay as a timeout.
|
||
// The filter on the CommandTimeout clause restores that priority: a token canceled around
|
||
// the deadline is still classified as cancellation, never as CommandTimeout. Error codes
|
||
// and messages are unchanged.
|
||
try
|
||
{
|
||
return await pendingCommand.Task.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
catch (TimeoutException) when (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
string timeoutMessage = $"Worker command {method} timed out after {timeout}.";
|
||
bool removed = RemovePendingCommandAsFailed(
|
||
correlationId,
|
||
pendingCommand,
|
||
WorkerClientErrorCode.CommandTimeout,
|
||
timeoutMessage);
|
||
|
||
// The gateway has stopped waiting, but the worker has not stopped working: the
|
||
// correlation is still on its single STA queue and would execute (or keep executing)
|
||
// regardless. Tell it, so a queued-but-not-started command is dropped instead of
|
||
// occupying the STA behind a caller that is already gone. Gated on the removal so a
|
||
// reply that won the race — the pending entry is already gone and the caller is about
|
||
// to see it — never has a cancel chase it. Best-effort by design; the send cannot
|
||
// throw, so it can never replace the timeout the caller is owed.
|
||
if (removed)
|
||
{
|
||
TrySendCancelForTimedOutCommand(correlationId, method, timeout);
|
||
}
|
||
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.CommandTimeout,
|
||
timeoutMessage);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
RemovePendingCommandAsFailed(
|
||
correlationId,
|
||
pendingCommand,
|
||
WorkerClientErrorCode.GatewayShutdown,
|
||
"Command wait was canceled.");
|
||
|
||
// WaitAsync surfaces TaskCanceledException; throwing through the token keeps the
|
||
// exception the caller observes exactly what the hand-rolled wait produced.
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
throw;
|
||
}
|
||
catch (TimeoutException)
|
||
{
|
||
// The deadline and the caller's cancellation raced and WaitAsync picked the timer.
|
||
// The old wait classified this as cancellation, so this clause — reached only when
|
||
// the filter above saw a canceled token — reproduces that treatment exactly.
|
||
RemovePendingCommandAsFailed(
|
||
correlationId,
|
||
pendingCommand,
|
||
WorkerClientErrorCode.GatewayShutdown,
|
||
"Command wait was canceled.");
|
||
cancellationToken.ThrowIfCancellationRequested();
|
||
throw;
|
||
}
|
||
}
|
||
catch
|
||
{
|
||
if (_pendingCommands.TryRemove(correlationId, out _))
|
||
{
|
||
ReleasePendingCommandSlot();
|
||
}
|
||
|
||
throw;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public IAsyncEnumerable<WorkerEvent> ReadEventsAsync(CancellationToken cancellationToken)
|
||
{
|
||
// The event channel is SingleReader: only one enumerator may ever drain it, otherwise
|
||
// the two readers would each receive a random subset of events. Claim the reader at CALL
|
||
// time (not lazily on first MoveNext) and fail loudly on a second consumer rather than
|
||
// silently splitting the stream. The distributor pump is the only intended
|
||
// caller; the alarm monitor and dashboard mirror attach to the distributor instead.
|
||
if (Interlocked.CompareExchange(ref _eventsReaderClaimed, 1, 0) != 0)
|
||
{
|
||
throw new InvalidOperationException(
|
||
"WorkerClient.ReadEventsAsync was already claimed by another consumer. The worker event "
|
||
+ "channel is single-reader; attach to the SessionEventDistributor instead of draining it twice.");
|
||
}
|
||
|
||
return ReadEventsCoreAsync(cancellationToken);
|
||
}
|
||
|
||
private async IAsyncEnumerable<WorkerEvent> ReadEventsCoreAsync(
|
||
[EnumeratorCancellation] CancellationToken cancellationToken)
|
||
{
|
||
await foreach (WorkerEvent workerEvent in _events.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||
{
|
||
// No metrics call on the hot path: the gauge pulls _eventQueueDepth when scraped (GWC-30).
|
||
Interlocked.Decrement(ref _eventQueueDepth);
|
||
yield return workerEvent;
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public async Task ShutdownAsync(TimeSpan timeout, CancellationToken cancellationToken)
|
||
{
|
||
ThrowIfDisposed();
|
||
if (timeout <= TimeSpan.Zero)
|
||
{
|
||
throw new ArgumentOutOfRangeException(nameof(timeout), timeout, "Shutdown timeout must be greater than zero.");
|
||
}
|
||
|
||
WorkerClientState state = State;
|
||
if (state == WorkerClientState.Closed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
if (state == WorkerClientState.Faulted)
|
||
{
|
||
KillOwnedProcess("ShutdownFaulted");
|
||
return;
|
||
}
|
||
|
||
MarkClosing();
|
||
await EnqueueAsync(CreateShutdownEnvelope(timeout, "gateway-shutdown"), cancellationToken).ConfigureAwait(false);
|
||
_outboundEnvelopes.Writer.TryComplete();
|
||
|
||
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||
timeoutCts.CancelAfter(timeout);
|
||
try
|
||
{
|
||
await WaitForBackgroundTasksAsync(timeoutCts.Token).ConfigureAwait(false);
|
||
await WaitForProcessExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||
MarkClosed("shutdown");
|
||
}
|
||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
SetFaulted(
|
||
WorkerClientErrorCode.ShutdownTimeout,
|
||
"Worker shutdown timed out.",
|
||
null);
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.ShutdownTimeout,
|
||
$"Worker shutdown timed out after {timeout}.");
|
||
}
|
||
}
|
||
|
||
/// <inheritdoc />
|
||
public void Kill(string reason)
|
||
{
|
||
ThrowIfDisposed();
|
||
KillOwnedProcess(reason);
|
||
SetFaulted(
|
||
WorkerClientErrorCode.WorkerFaulted,
|
||
$"Worker was killed by the gateway: {reason}.",
|
||
null);
|
||
}
|
||
|
||
/// <summary>Disposes the worker client and releases resources.</summary>
|
||
/// <returns>A task that represents the asynchronous operation.</returns>
|
||
public async ValueTask DisposeAsync()
|
||
{
|
||
if (_disposed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_disposed = true;
|
||
// Drop out of the worker queue-depth gauge before teardown: whatever this client still holds
|
||
// is about to be discarded, and the sum must not keep counting a client that is going away.
|
||
_eventQueueDepthRegistration?.Dispose();
|
||
KillOwnedProcess("Dispose");
|
||
_stopCts.Cancel();
|
||
_outboundEnvelopes.Writer.TryComplete();
|
||
_eventStaging.Writer.TryComplete();
|
||
_events.Writer.TryComplete();
|
||
CompletePendingCommands(
|
||
new WorkerClientException(
|
||
WorkerClientErrorCode.GatewayShutdown,
|
||
"Worker client was disposed."));
|
||
|
||
await _connection.Stream.DisposeAsync().ConfigureAwait(false);
|
||
using CancellationTokenSource disposeTimeout = new(DisposeTaskTimeout);
|
||
try
|
||
{
|
||
await WaitForBackgroundTasksAsync(disposeTimeout.Token).ConfigureAwait(false);
|
||
}
|
||
catch (OperationCanceledException)
|
||
{
|
||
_logger.LogWarning(
|
||
"Timed out waiting for worker client background tasks to stop for session {SessionId}.",
|
||
SessionId);
|
||
}
|
||
|
||
_connection.ProcessHandle?.Dispose();
|
||
_pendingCommandSlots.Dispose();
|
||
_stopCts.Dispose();
|
||
}
|
||
|
||
/// <summary>Manages writing envelopes to the worker pipe.</summary>
|
||
private async Task WriteLoopAsync()
|
||
{
|
||
try
|
||
{
|
||
await foreach (WorkerEnvelope envelope in _outboundEnvelopes.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
|
||
{
|
||
// GWC-28: stamp the sequence at the point of writing, not when the envelope is built.
|
||
// Stamping at construction let two concurrent InvokeAsync callers take 1 and 2 and then
|
||
// enqueue in the order 2, 1 — non-monotonic on the wire, breaking gateway.md's
|
||
// "monotonic per sender" contract. This loop is the channel's single consumer
|
||
// (SingleReader = true), so wire order and stamp order are the same thing here and
|
||
// _nextSequence needs no interlocking. Mirrors the worker's WRK-04 fix.
|
||
envelope.Sequence = unchecked(++_nextSequence);
|
||
await _writer.WriteAsync(envelope, _stopCts.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||
{
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
SetFaulted(
|
||
WorkerClientErrorCode.WriteFailed,
|
||
"Worker pipe write failed.",
|
||
exception);
|
||
}
|
||
}
|
||
|
||
/// <summary>Manages reading envelopes from the worker pipe.</summary>
|
||
private async Task ReadLoopAsync()
|
||
{
|
||
try
|
||
{
|
||
while (!_stopCts.IsCancellationRequested)
|
||
{
|
||
WorkerEnvelope envelope = await _reader.ReadAsync(_stopCts.Token).ConfigureAwait(false);
|
||
DispatchEnvelope(envelope);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||
{
|
||
}
|
||
catch (WorkerFrameProtocolException exception) when (exception.ErrorCode == WorkerFrameProtocolErrorCode.EndOfStream)
|
||
{
|
||
SetFaulted(
|
||
WorkerClientErrorCode.PipeDisconnected,
|
||
"Worker pipe disconnected.",
|
||
exception);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
SetFaulted(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
"Worker read loop failed.",
|
||
exception);
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Monitors worker heartbeat and detects stale sessions. Mirrors the
|
||
/// worker-side watchdog: while a command is in flight on the
|
||
/// gateway↔worker pipe, the heartbeat watchdog is suppressed up to
|
||
/// <see cref="WorkerClientOptions.HeartbeatStuckCeiling"/> — the worker
|
||
/// may be busy executing a slow STA command and the heartbeat write may
|
||
/// be queued behind a long event-drain burst, neither of which
|
||
/// indicates the worker is actually hung. Once the oldest pending
|
||
/// command exceeds the ceiling, the fault fires anyway so a truly stuck
|
||
/// COM call doesn't hide the worker forever.
|
||
/// </summary>
|
||
private async Task HeartbeatLoopAsync()
|
||
{
|
||
try
|
||
{
|
||
while (!_stopCts.IsCancellationRequested)
|
||
{
|
||
await Task.Delay(_options.HeartbeatCheckInterval, _stopCts.Token).ConfigureAwait(false);
|
||
if (State != WorkerClientState.Ready)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
DateTimeOffset lastHeartbeatAt = LastHeartbeatAt;
|
||
DateTimeOffset now = _timeProvider.GetUtcNow();
|
||
if (now - lastHeartbeatAt <= _options.HeartbeatGrace)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
if (TryGetOldestPendingCommandAge(out TimeSpan oldestCommandAge)
|
||
&& oldestCommandAge <= _options.HeartbeatStuckCeiling)
|
||
{
|
||
continue;
|
||
}
|
||
|
||
_metrics?.HeartbeatFailed(SessionId);
|
||
SetFaulted(
|
||
WorkerClientErrorCode.HeartbeatExpired,
|
||
$"Worker heartbeat expired. Last heartbeat was at {lastHeartbeatAt:O}.",
|
||
null);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||
{
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns the age of the oldest pending command on the worker pipe,
|
||
/// measured via <see cref="TimeProvider.GetElapsedTime(long)"/> against
|
||
/// <see cref="PendingCommand.StartTimestamp"/>, or <c>false</c> when no
|
||
/// commands are pending. Used by the heartbeat watchdog
|
||
/// to decide whether a heartbeat gap is plausibly the result of
|
||
/// pipe-write contention rather than a hung worker.
|
||
/// </summary>
|
||
private bool TryGetOldestPendingCommandAge(out TimeSpan oldestAge)
|
||
{
|
||
long oldestStart = long.MaxValue;
|
||
foreach (PendingCommand pending in _pendingCommands.Values)
|
||
{
|
||
if (pending.StartTimestamp < oldestStart)
|
||
{
|
||
oldestStart = pending.StartTimestamp;
|
||
}
|
||
}
|
||
|
||
if (oldestStart == long.MaxValue)
|
||
{
|
||
oldestAge = TimeSpan.Zero;
|
||
return false;
|
||
}
|
||
|
||
oldestAge = _timeProvider.GetElapsedTime(oldestStart);
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Routes a received envelope to its handler. Every branch dispatches synchronously and
|
||
/// immediately — the event branch only stages the event for the dedicated writer — so a full
|
||
/// event channel can never delay a command reply, heartbeat, fault, or shutdown ack behind an
|
||
/// event backlog.
|
||
/// </summary>
|
||
/// <param name="envelope">The envelope to dispatch.</param>
|
||
private void DispatchEnvelope(WorkerEnvelope envelope)
|
||
{
|
||
switch (envelope.BodyCase)
|
||
{
|
||
case WorkerEnvelope.BodyOneofCase.WorkerCommandReply:
|
||
CompleteCommand(envelope);
|
||
break;
|
||
case WorkerEnvelope.BodyOneofCase.WorkerEvent:
|
||
StageWorkerEvent(envelope.WorkerEvent);
|
||
break;
|
||
case WorkerEnvelope.BodyOneofCase.WorkerHeartbeat:
|
||
MarkHeartbeat(envelope.WorkerHeartbeat);
|
||
break;
|
||
case WorkerEnvelope.BodyOneofCase.WorkerFault:
|
||
SetFaulted(
|
||
WorkerClientErrorCode.WorkerFaulted,
|
||
CreateWorkerFaultMessage(envelope.WorkerFault),
|
||
null);
|
||
break;
|
||
case WorkerEnvelope.BodyOneofCase.WorkerShutdownAck:
|
||
MarkClosed("worker-shutdown-ack");
|
||
break;
|
||
default:
|
||
SetFaulted(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
$"Worker sent unexpected envelope body {envelope.BodyCase}.",
|
||
null);
|
||
break;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Hands a received worker event to the dedicated event writer without blocking the read loop.
|
||
/// <c>TryWrite</c> is non-blocking, so the read loop keeps dispatching replies, heartbeats and
|
||
/// faults regardless of how backed up the event path is. It returns <c>false</c> in two cases:
|
||
/// the staging channel has been completed during shutdown (the event is safely dropped because
|
||
/// the client is already terminal), or the channel is full at its
|
||
/// 2 × <see cref="WorkerClientOptions.EventChannelCapacity"/> bound. The latter means the
|
||
/// consumer has been draining slower than the worker produces for the whole time it took the
|
||
/// worker to emit that many further events, so the client is faulted immediately (GWC-24) —
|
||
/// <see cref="SetFaulted"/> is non-blocking, so the read loop still never awaits here. The
|
||
/// complementary full-stall case (a consumer that stops entirely) is caught earlier by the
|
||
/// timed write in <see cref="EnqueueWorkerEventAsync"/>.
|
||
/// </summary>
|
||
/// <param name="workerEvent">The event received from the worker.</param>
|
||
private void StageWorkerEvent(WorkerEvent workerEvent)
|
||
{
|
||
if (workerEvent.Event is not null)
|
||
{
|
||
_metrics?.EventReceived(SessionId, workerEvent.Event.Family.ToString());
|
||
}
|
||
|
||
if (_eventStaging.Writer.TryWrite(workerEvent))
|
||
{
|
||
// Counted here rather than at the _events write so the single gauge reports total
|
||
// undelivered events (staged + queued). ReadEventsCoreAsync decrements on consumer read.
|
||
Interlocked.Increment(ref _eventQueueDepth);
|
||
return;
|
||
}
|
||
|
||
if (IsTerminalState())
|
||
{
|
||
// Shutdown completed the staging channel; dropping the event is the documented behavior.
|
||
return;
|
||
}
|
||
|
||
_metrics?.QueueOverflow("worker-event-staging");
|
||
int depthAtOverflow = Volatile.Read(ref _eventQueueDepth);
|
||
SetFaulted(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
$"Worker event staging channel is full at its {_eventStagingCapacity}-event bound "
|
||
+ $"(2 x EventChannelCapacity {_options.EventChannelCapacity}); undelivered depth is "
|
||
+ $"{depthAtOverflow}. The event consumer is draining slower than the worker produces. "
|
||
+ $"Attach or unblock the StreamEvents consumer or raise MxGateway:Events:QueueCapacity.",
|
||
null);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Drains staged worker events and applies the bounded-channel backpressure (and
|
||
/// sustained-overflow fault) on a dedicated task, so the timed <see cref="Channel"/> write
|
||
/// never runs on the read loop. Mirrors <see cref="WriteLoopAsync"/> for events.
|
||
/// </summary>
|
||
private async Task EventWriteLoopAsync()
|
||
{
|
||
try
|
||
{
|
||
await foreach (WorkerEvent workerEvent in
|
||
_eventStaging.Reader.ReadAllAsync(_stopCts.Token).ConfigureAwait(false))
|
||
{
|
||
await EnqueueWorkerEventAsync(workerEvent, _stopCts.Token).ConfigureAwait(false);
|
||
}
|
||
}
|
||
catch (OperationCanceledException) when (_stopCts.IsCancellationRequested || IsTerminalState())
|
||
{
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// Enqueues a worker event for client consumption. The channel is
|
||
/// configured with <see cref="BoundedChannelFullMode.Wait"/>
|
||
/// and a brief consumer hiccup is tolerated for up to
|
||
/// <see cref="WorkerClientOptions.EventChannelFullModeTimeout"/>
|
||
/// (default 5s) before the worker is faulted. The channel previously
|
||
/// used <c>TryWrite</c> (non-blocking), which never honored the
|
||
/// configured <c>FullModeTimeout</c> — the worker faulted on the first
|
||
/// missed slot even though the wait-mode channel would have absorbed
|
||
/// the burst. The diagnostic now names the capacity, current depth, and
|
||
/// the actionable fix (attach <c>StreamEvents</c> or raise
|
||
/// <c>MxGateway:Events:QueueCapacity</c>). This is the full-stall half of
|
||
/// the backpressure policy; a consumer that merely drains too slowly is
|
||
/// caught by the staging bound in <see cref="StageWorkerEvent"/> (GWC-24).
|
||
/// Queue depth is not counted here — the event was already counted when it
|
||
/// was staged, so moving it between the two channels changes nothing.
|
||
/// </summary>
|
||
/// <param name="workerEvent">The event to enqueue.</param>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
private async Task EnqueueWorkerEventAsync(
|
||
WorkerEvent workerEvent,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
if (_events.Writer.TryWrite(workerEvent))
|
||
{
|
||
return;
|
||
}
|
||
|
||
using CancellationTokenSource fullModeCts = CancellationTokenSource
|
||
.CreateLinkedTokenSource(cancellationToken);
|
||
fullModeCts.CancelAfter(_options.EventChannelFullModeTimeout);
|
||
try
|
||
{
|
||
await _events.Writer.WriteAsync(workerEvent, fullModeCts.Token).ConfigureAwait(false);
|
||
return;
|
||
}
|
||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||
{
|
||
// Only the full-mode timeout fired — the outer cancellation is
|
||
// a different concern and is rethrown by the await above when it
|
||
// triggers.
|
||
}
|
||
|
||
_metrics?.QueueOverflow("worker-events");
|
||
int depthAtOverflow = Volatile.Read(ref _eventQueueDepth);
|
||
SetFaulted(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
$"Worker event channel rejected an event after waiting "
|
||
+ $"{_options.EventChannelFullModeTimeout.TotalMilliseconds:F0} ms; "
|
||
+ $"undelivered depth is {depthAtOverflow} against a consumer channel "
|
||
+ $"of {_options.EventChannelCapacity} capacity. "
|
||
+ $"Attach a StreamEvents consumer or raise MxGateway:Events:QueueCapacity.",
|
||
null);
|
||
}
|
||
|
||
/// <summary>Completes pending command with worker reply.</summary>
|
||
/// <param name="envelope">Envelope containing command reply.</param>
|
||
private void CompleteCommand(WorkerEnvelope envelope)
|
||
{
|
||
string correlationId = envelope.CorrelationId;
|
||
if (string.IsNullOrWhiteSpace(correlationId))
|
||
{
|
||
correlationId = envelope.WorkerCommandReply.Reply?.CorrelationId ?? string.Empty;
|
||
}
|
||
|
||
if (!_pendingCommands.TryRemove(correlationId, out PendingCommand? pendingCommand))
|
||
{
|
||
_logger.LogDebug(
|
||
"Ignoring late or unknown worker command reply for session {SessionId} and correlation {CorrelationId}.",
|
||
SessionId,
|
||
correlationId);
|
||
return;
|
||
}
|
||
|
||
ReleasePendingCommandSlot();
|
||
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||
_metrics?.CommandSucceeded(pendingCommand.Method, duration);
|
||
pendingCommand.SetResult(envelope.WorkerCommandReply);
|
||
}
|
||
|
||
/// <summary>Fails a pending command with an error.</summary>
|
||
/// <param name="correlationId">Command correlation ID.</param>
|
||
/// <param name="pendingCommand">The pending command.</param>
|
||
/// <param name="errorCode">Error code.</param>
|
||
/// <param name="message">Error message.</param>
|
||
/// <returns>
|
||
/// <c>true</c> when this call removed the pending entry and owns the failure; <c>false</c> when
|
||
/// the entry was already gone — a reply, fault, or shutdown got there first, so the caller must
|
||
/// not take any further action on behalf of that correlation.
|
||
/// </returns>
|
||
private bool RemovePendingCommandAsFailed(
|
||
string correlationId,
|
||
PendingCommand pendingCommand,
|
||
WorkerClientErrorCode errorCode,
|
||
string message)
|
||
{
|
||
if (!_pendingCommands.TryRemove(correlationId, out _))
|
||
{
|
||
return false;
|
||
}
|
||
|
||
ReleasePendingCommandSlot();
|
||
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||
_metrics?.CommandFailed(pendingCommand.Method, errorCode.ToString(), duration);
|
||
pendingCommand.SetException(new WorkerClientException(errorCode, message));
|
||
return true;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Forwards a <c>WorkerCancel</c> for a correlation the gateway has given up waiting for, so the
|
||
/// worker can drop it from its STA queue (<c>WorkerPipeSession</c> routes the envelope to
|
||
/// <c>CancelCommand</c>). A cancel that arrives after the command already reached the COM call
|
||
/// is a no-op — MXAccess offers no way to abort an in-flight call — so this shortens the STA
|
||
/// backlog rather than freeing a call already running on it.
|
||
/// <para>
|
||
/// A command whose envelope has not yet left <c>_outboundEnvelopes</c> is handled by the same
|
||
/// path rather than by pulling it back out: <see cref="Channel{T}"/> exposes no removal, and
|
||
/// the queue is FIFO, so the worker simply reads the command and then its cancel and drops the
|
||
/// correlation before it ever reaches the STA. Nothing is gained by dequeuing it here.
|
||
/// </para>
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// The whole body sits under one catch-all that debug-logs, because the guarantee this method
|
||
/// owes its caller is structural, not incidental: the caller is on the throw path for the
|
||
/// timeout, so anything escaping here — an envelope that failed to build, a <c>TryWrite</c>
|
||
/// against a disposed channel, a scheduler refusing the detached task — would replace the
|
||
/// <see cref="WorkerClientErrorCode.CommandTimeout"/> the caller is owed with an unrelated
|
||
/// exception. Losing the cancel costs the worker one wasted command; losing the timeout
|
||
/// misreports why the call failed. The detached task carries its own handler for the same
|
||
/// reason: its failures (including the <see cref="ObjectDisposedException"/> from
|
||
/// <c>_stopCts</c> if the client is disposed underneath it) happen after this method returns
|
||
/// and would otherwise be unobserved. It is deliberately not tracked or awaited — it holds no
|
||
/// resource the shutdown path needs back, and the outbound channel is completed on close.
|
||
/// </remarks>
|
||
/// <param name="correlationId">Correlation id of the command that timed out.</param>
|
||
/// <param name="method">Command method name, for the cancel reason and diagnostics.</param>
|
||
/// <param name="timeout">The elapsed command timeout, for the cancel reason.</param>
|
||
private void TrySendCancelForTimedOutCommand(
|
||
string correlationId,
|
||
string method,
|
||
TimeSpan timeout)
|
||
{
|
||
try
|
||
{
|
||
WorkerEnvelope cancelEnvelope = CreateEnvelope(
|
||
correlationId,
|
||
envelope => envelope.WorkerCancel = new WorkerCancel
|
||
{
|
||
Reason = $"gateway command timeout after {timeout}",
|
||
});
|
||
|
||
if (_outboundEnvelopes.Writer.TryWrite(cancelEnvelope))
|
||
{
|
||
return;
|
||
}
|
||
|
||
_ = Task.Run(async () =>
|
||
{
|
||
try
|
||
{
|
||
await EnqueueAsync(cancelEnvelope, _stopCts.Token).ConfigureAwait(false);
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
LogCancelNotForwarded(exception, method, correlationId);
|
||
}
|
||
});
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
LogCancelNotForwarded(exception, method, correlationId);
|
||
}
|
||
}
|
||
|
||
/// <summary>Records a cancel that could not be forwarded for a timed-out command.</summary>
|
||
/// <param name="exception">The failure that stopped the cancel from being sent.</param>
|
||
/// <param name="method">Command method name of the timed-out command.</param>
|
||
/// <param name="correlationId">Correlation id of the timed-out command.</param>
|
||
private void LogCancelNotForwarded(
|
||
Exception exception,
|
||
string method,
|
||
string correlationId)
|
||
{
|
||
_logger.LogDebug(
|
||
exception,
|
||
"Could not forward a cancel for timed-out worker command {Method} on session {SessionId} "
|
||
+ "and correlation {CorrelationId}.",
|
||
method,
|
||
SessionId,
|
||
correlationId);
|
||
}
|
||
|
||
/// <summary>Reads and validates a handshake envelope.</summary>
|
||
/// <param name="expectedBody">Expected envelope body type.</param>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
/// <returns>The read envelope.</returns>
|
||
private async Task<WorkerEnvelope> ReadHandshakeEnvelopeAsync(
|
||
WorkerEnvelope.BodyOneofCase expectedBody,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
WorkerEnvelope envelope = await _reader.ReadAsync(cancellationToken).ConfigureAwait(false);
|
||
if (envelope.BodyCase != expectedBody)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
$"Worker handshake expected {expectedBody} but received {envelope.BodyCase}.");
|
||
}
|
||
|
||
return envelope;
|
||
}
|
||
|
||
/// <summary>Validates worker hello message protocol and nonce.</summary>
|
||
/// <param name="workerHello">The hello message to validate.</param>
|
||
private void ValidateWorkerHello(WorkerHello workerHello)
|
||
{
|
||
if (workerHello.ProtocolVersion != _connection.FrameOptions.ProtocolVersion)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
"Worker hello protocol version does not match the gateway protocol version.");
|
||
}
|
||
|
||
if (!string.Equals(workerHello.Nonce, _connection.Nonce, StringComparison.Ordinal))
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.ProtocolViolation,
|
||
"Worker hello nonce does not match the gateway nonce.");
|
||
}
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
_processId = workerHello.WorkerProcessId == 0
|
||
? _connection.ProcessHandle?.ProcessId
|
||
: workerHello.WorkerProcessId;
|
||
}
|
||
}
|
||
|
||
/// <summary>Marks the worker as ready and records startup metrics.</summary>
|
||
/// <param name="ready">The ready message.</param>
|
||
private void MarkReady(WorkerReady ready)
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
_processId = ready.WorkerProcessId == 0
|
||
? _processId ?? _connection.ProcessHandle?.ProcessId
|
||
: ready.WorkerProcessId;
|
||
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||
_state = WorkerClientState.Ready;
|
||
_workerStartRecorded = true;
|
||
}
|
||
|
||
DateTimeOffset readyAt = _timeProvider.GetUtcNow();
|
||
DateTimeOffset launchedAt = _connection.ProcessHandle?.LaunchedAt ?? readyAt;
|
||
_metrics?.WorkerStarted(readyAt - launchedAt);
|
||
}
|
||
|
||
/// <summary>Updates the heartbeat timestamp and process ID.</summary>
|
||
/// <param name="heartbeat">The heartbeat message.</param>
|
||
private void MarkHeartbeat(WorkerHeartbeat heartbeat)
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
_lastHeartbeatAt = _timeProvider.GetUtcNow();
|
||
if (heartbeat.WorkerProcessId != 0)
|
||
{
|
||
_processId = heartbeat.WorkerProcessId;
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Transitions to closing state.</summary>
|
||
private void MarkClosing()
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
if (_state is WorkerClientState.Closed or WorkerClientState.Faulted)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_state = WorkerClientState.Closing;
|
||
}
|
||
}
|
||
|
||
/// <summary>Marks client as closed and cleans up resources.</summary>
|
||
/// <param name="reason">Reason for closure.</param>
|
||
private void MarkClosed(string reason)
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
if (_state == WorkerClientState.Closed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_state = WorkerClientState.Closed;
|
||
}
|
||
|
||
_stopCts.Cancel();
|
||
_outboundEnvelopes.Writer.TryComplete();
|
||
_eventStaging.Writer.TryComplete();
|
||
_events.Writer.TryComplete();
|
||
CompletePendingCommands(
|
||
new WorkerClientException(
|
||
WorkerClientErrorCode.GatewayShutdown,
|
||
$"Worker client closed because {reason}."));
|
||
RecordWorkerStoppedOnce(reason);
|
||
}
|
||
|
||
/// <summary>Marks client as faulted and propagates the error.</summary>
|
||
/// <param name="errorCode">Error code.</param>
|
||
/// <param name="message">Error message.</param>
|
||
/// <param name="exception">Optional inner exception.</param>
|
||
private void SetFaulted(
|
||
WorkerClientErrorCode errorCode,
|
||
string message,
|
||
Exception? exception)
|
||
{
|
||
WorkerClientException fault = exception is null
|
||
? new WorkerClientException(errorCode, message)
|
||
: new WorkerClientException(errorCode, message, exception);
|
||
|
||
lock (_syncRoot)
|
||
{
|
||
if (_state is WorkerClientState.Faulted or WorkerClientState.Closed)
|
||
{
|
||
return;
|
||
}
|
||
|
||
_state = WorkerClientState.Faulted;
|
||
}
|
||
|
||
_stopCts.Cancel();
|
||
_outboundEnvelopes.Writer.TryComplete(fault);
|
||
_eventStaging.Writer.TryComplete(fault);
|
||
_events.Writer.TryComplete(fault);
|
||
KillOwnedProcess(errorCode.ToString());
|
||
CompletePendingCommands(fault);
|
||
RecordWorkerStoppedOnce(errorCode.ToString());
|
||
_metrics?.Fault(errorCode.ToString());
|
||
_logger.LogWarning(exception, "Worker client faulted for session {SessionId}: {Message}", SessionId, message);
|
||
}
|
||
|
||
private void KillOwnedProcess(string reason)
|
||
{
|
||
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
|
||
if (processHandle is null)
|
||
{
|
||
return;
|
||
}
|
||
|
||
try
|
||
{
|
||
if (!processHandle.Process.HasExited)
|
||
{
|
||
processHandle.Process.Kill(entireProcessTree: true);
|
||
_metrics?.WorkerKilled(reason);
|
||
}
|
||
}
|
||
catch (Exception exception)
|
||
{
|
||
_logger.LogWarning(
|
||
exception,
|
||
"Failed to kill worker process {ProcessId} for session {SessionId}.",
|
||
processHandle.ProcessId,
|
||
SessionId);
|
||
}
|
||
}
|
||
|
||
/// <summary>Records worker stopped metric only once.</summary>
|
||
/// <param name="reason">Reason for stopping.</param>
|
||
private void RecordWorkerStoppedOnce(string reason)
|
||
{
|
||
bool shouldRecord;
|
||
lock (_syncRoot)
|
||
{
|
||
shouldRecord = _workerStartRecorded;
|
||
_workerStartRecorded = false;
|
||
}
|
||
|
||
if (shouldRecord)
|
||
{
|
||
_metrics?.WorkerStopped(reason);
|
||
}
|
||
}
|
||
|
||
/// <summary>Fails all pending commands with the given exception.</summary>
|
||
/// <param name="exception">Exception to apply to all commands.</param>
|
||
private void CompletePendingCommands(Exception exception)
|
||
{
|
||
foreach (KeyValuePair<string, PendingCommand> item in _pendingCommands.ToArray())
|
||
{
|
||
if (_pendingCommands.TryRemove(item.Key, out PendingCommand? pendingCommand))
|
||
{
|
||
ReleasePendingCommandSlot();
|
||
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||
_metrics?.CommandFailed(pendingCommand.Method, exception.GetType().Name, duration);
|
||
pendingCommand.SetException(exception);
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>Releases a pending command slot.</summary>
|
||
private void ReleasePendingCommandSlot()
|
||
{
|
||
try
|
||
{
|
||
_pendingCommandSlots.Release();
|
||
}
|
||
catch (SemaphoreFullException)
|
||
{
|
||
}
|
||
}
|
||
|
||
/// <summary>Transitions from created to handshaking state.</summary>
|
||
private void TransitionFromCreatedToHandshaking()
|
||
{
|
||
lock (_syncRoot)
|
||
{
|
||
if (_state != WorkerClientState.Created)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.InvalidState,
|
||
$"Worker client cannot start from state {_state}.");
|
||
}
|
||
|
||
_state = WorkerClientState.Handshaking;
|
||
}
|
||
}
|
||
|
||
/// <summary>Throws if client is not in ready state.</summary>
|
||
private void EnsureReady()
|
||
{
|
||
WorkerClientState state = State;
|
||
if (state != WorkerClientState.Ready)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.InvalidState,
|
||
$"Worker client is not ready. Current state is {state}.");
|
||
}
|
||
}
|
||
|
||
/// <summary>Checks if current state is terminal.</summary>
|
||
/// <returns>True if closed, closing, or faulted.</returns>
|
||
private bool IsTerminalState()
|
||
{
|
||
WorkerClientState state = State;
|
||
return state is WorkerClientState.Closing or WorkerClientState.Closed or WorkerClientState.Faulted;
|
||
}
|
||
|
||
/// <summary>Enqueues an envelope for writing to the worker.</summary>
|
||
/// <param name="envelope">Envelope to enqueue.</param>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
private async Task EnqueueAsync(
|
||
WorkerEnvelope envelope,
|
||
CancellationToken cancellationToken)
|
||
{
|
||
try
|
||
{
|
||
await _outboundEnvelopes.Writer.WriteAsync(envelope, cancellationToken).ConfigureAwait(false);
|
||
}
|
||
catch (ChannelClosedException exception)
|
||
{
|
||
throw new WorkerClientException(
|
||
WorkerClientErrorCode.WriteFailed,
|
||
"Worker outbound channel is closed.",
|
||
exception);
|
||
}
|
||
}
|
||
|
||
/// <summary>Creates gateway hello envelope.</summary>
|
||
/// <returns>The hello envelope.</returns>
|
||
private WorkerEnvelope CreateGatewayHelloEnvelope()
|
||
{
|
||
return CreateEnvelope(
|
||
correlationId: string.Empty,
|
||
envelope => envelope.GatewayHello = new GatewayHello
|
||
{
|
||
SupportedProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||
Nonce = _connection.Nonce,
|
||
GatewayVersion = typeof(GatewayContractInfo).Assembly.GetName().Version?.ToString() ?? GatewayVersionFallback,
|
||
|
||
// Convey the negotiated worker-frame maximum so the worker adopts it instead of a
|
||
// hard-coded default. Sits above the public gRPC cap by the envelope reserve.
|
||
MaxFrameBytes = (uint)_connection.FrameOptions.MaxMessageBytes,
|
||
});
|
||
}
|
||
|
||
/// <summary>Creates command envelope.</summary>
|
||
/// <param name="correlationId">Command correlation ID.</param>
|
||
/// <param name="command">The command to wrap.</param>
|
||
/// <returns>The command envelope.</returns>
|
||
private WorkerEnvelope CreateCommandEnvelope(
|
||
string correlationId,
|
||
WorkerCommand command)
|
||
{
|
||
// IPC-05: no second clone. MxAccessGrpcMapper.MapCommand already deep-cloned the command
|
||
// out of the caller-owned gRPC MxCommandRequest, so this WorkerCommand is a fresh graph
|
||
// owned by the invoke pipeline and referenced by no other consumer. The envelope is built
|
||
// and owned entirely inside WorkerClient and the command is not touched again after this
|
||
// point, so transferring it into the envelope introduces no aliasing hazard.
|
||
return CreateEnvelope(
|
||
correlationId,
|
||
envelope => envelope.WorkerCommand = command);
|
||
}
|
||
|
||
/// <summary>Creates shutdown envelope.</summary>
|
||
/// <param name="timeout">Shutdown timeout.</param>
|
||
/// <param name="reason">Shutdown reason.</param>
|
||
/// <returns>The shutdown envelope.</returns>
|
||
private WorkerEnvelope CreateShutdownEnvelope(
|
||
TimeSpan timeout,
|
||
string reason)
|
||
{
|
||
return CreateEnvelope(
|
||
correlationId: string.Empty,
|
||
envelope => envelope.WorkerShutdown = new WorkerShutdown
|
||
{
|
||
GracePeriod = Duration.FromTimeSpan(timeout),
|
||
Reason = reason,
|
||
});
|
||
}
|
||
|
||
/// <summary>Creates a new worker envelope with common fields.</summary>
|
||
/// <param name="correlationId">Correlation ID for the envelope.</param>
|
||
/// <param name="setBody">Action to set envelope body.</param>
|
||
/// <returns>The created envelope.</returns>
|
||
private WorkerEnvelope CreateEnvelope(
|
||
string correlationId,
|
||
Action<WorkerEnvelope> setBody)
|
||
{
|
||
// Sequence is deliberately left unset here: WriteLoopAsync stamps it immediately before the
|
||
// frame goes out, so the numbers are monotonic in wire order however the callers interleave
|
||
// between construction and enqueue (GWC-28, mirroring the worker's WRK-04 fix).
|
||
WorkerEnvelope envelope = new()
|
||
{
|
||
ProtocolVersion = _connection.FrameOptions.ProtocolVersion,
|
||
SessionId = SessionId,
|
||
CorrelationId = correlationId,
|
||
};
|
||
setBody(envelope);
|
||
|
||
return envelope;
|
||
}
|
||
|
||
/// <summary>Gets the human-readable command method name.</summary>
|
||
/// <param name="command">The command to get method name from.</param>
|
||
/// <returns>Command method name.</returns>
|
||
private static string GetCommandMethod(WorkerCommand command)
|
||
{
|
||
return command.Command?.Kind.ToString() ?? MxCommandKind.Unspecified.ToString();
|
||
}
|
||
|
||
/// <summary>Creates a fault message from worker fault data.</summary>
|
||
/// <param name="fault">The worker fault.</param>
|
||
/// <returns>Formatted fault message.</returns>
|
||
private static string CreateWorkerFaultMessage(WorkerFault fault)
|
||
{
|
||
return string.IsNullOrWhiteSpace(fault.DiagnosticMessage)
|
||
? $"Worker faulted with category {fault.Category}."
|
||
: $"Worker faulted with category {fault.Category}: {fault.DiagnosticMessage}";
|
||
}
|
||
|
||
/// <summary>Waits for all background tasks to complete.</summary>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
private async Task WaitForBackgroundTasksAsync(CancellationToken cancellationToken)
|
||
{
|
||
Task[] tasks = new[] { _readLoopTask, _writeLoopTask, _eventWriteLoopTask, _heartbeatLoopTask }
|
||
.Where(task => task is not null)
|
||
.Cast<Task>()
|
||
.ToArray();
|
||
|
||
if (tasks.Length == 0)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await Task.WhenAll(tasks).WaitAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>Waits for the worker process to exit.</summary>
|
||
/// <param name="cancellationToken">Cancellation token.</param>
|
||
private async Task WaitForProcessExitAsync(CancellationToken cancellationToken)
|
||
{
|
||
WorkerProcessHandle? processHandle = _connection.ProcessHandle;
|
||
if (processHandle is null || processHandle.Process.HasExited)
|
||
{
|
||
return;
|
||
}
|
||
|
||
await processHandle.Process.WaitForExitAsync(cancellationToken).ConfigureAwait(false);
|
||
}
|
||
|
||
/// <summary>Throws if the client has been disposed.</summary>
|
||
private void ThrowIfDisposed()
|
||
{
|
||
ObjectDisposedException.ThrowIf(_disposed, this);
|
||
}
|
||
|
||
private sealed class PendingCommand
|
||
{
|
||
private readonly TaskCompletionSource<WorkerCommandReply> _completion = new(TaskCreationOptions.RunContinuationsAsynchronously);
|
||
|
||
/// <summary>Initializes a pending command awaiting a worker reply.</summary>
|
||
/// <param name="correlationId">Command correlation ID for reply matching.</param>
|
||
/// <param name="method">Command method name.</param>
|
||
/// <param name="startTimestamp">Start time in milliseconds for duration tracking.</param>
|
||
public PendingCommand(
|
||
string correlationId,
|
||
string method,
|
||
long startTimestamp)
|
||
{
|
||
CorrelationId = correlationId;
|
||
Method = method;
|
||
StartTimestamp = startTimestamp;
|
||
}
|
||
|
||
/// <summary>Gets the command correlation ID.</summary>
|
||
public string CorrelationId { get; }
|
||
|
||
/// <summary>Gets the command method name.</summary>
|
||
public string Method { get; }
|
||
|
||
/// <summary>Gets the command start timestamp.</summary>
|
||
public long StartTimestamp { get; }
|
||
|
||
/// <summary>Gets the task that completes when reply arrives.</summary>
|
||
public Task<WorkerCommandReply> Task => _completion.Task;
|
||
|
||
/// <summary>Completes the command with a reply.</summary>
|
||
/// <param name="reply">The command reply.</param>
|
||
public void SetResult(WorkerCommandReply reply)
|
||
{
|
||
_completion.TrySetResult(reply);
|
||
}
|
||
|
||
/// <summary>Completes the command with an exception.</summary>
|
||
/// <param name="exception">The exception.</param>
|
||
public void SetException(Exception exception)
|
||
{
|
||
_completion.TrySetException(exception);
|
||
}
|
||
}
|
||
}
|