fix(ipc): cancellation-priority timeout classification; structurally-enforced no-throw cancel send
This commit is contained in:
@@ -604,8 +604,16 @@ running on it, and the rule above still holds. A command whose envelope is still
|
|||||||
in the gateway's outbound queue needs no special handling: the queue is FIFO, so
|
in the gateway's outbound queue needs no special handling: the queue is FIFO, so
|
||||||
the worker reads the command and then its cancel and drops it before execution.
|
the worker reads the command and then its cancel and drops it before execution.
|
||||||
|
|
||||||
Failing to send the cancel is logged at debug and never replaces the
|
Cancels ride the same outbound channel as commands, whose capacity is
|
||||||
`CommandTimeout` the caller is owed.
|
`MaxPendingCommands + 4`: the reserve above the pending-command limit is what
|
||||||
|
absorbs them, so a burst of timeouts stays bounded and cannot deadlock the
|
||||||
|
enqueue path. Failing to send the cancel is logged at debug and never replaces
|
||||||
|
the `CommandTimeout` the caller is owed.
|
||||||
|
|
||||||
|
Cancellation outranks the deadline. When a caller's token is canceled around the
|
||||||
|
same time the timeout fires, the command is reported as canceled
|
||||||
|
(`GatewayShutdown`, `OperationCanceledException`), not as `CommandTimeout`, and
|
||||||
|
no cancel is forwarded.
|
||||||
|
|
||||||
## Fault Model
|
## Fault Model
|
||||||
|
|
||||||
|
|||||||
@@ -256,26 +256,20 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
// continuation — the old shape allocated a linked CancellationTokenSource, its
|
// continuation — the old shape allocated a linked CancellationTokenSource, its
|
||||||
// registration, a delay Task, and the WhenAny Task on every invoke, and left the delay
|
// 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
|
// Task rooted until the cancel completed. WaitAsync raises TimeoutException for the
|
||||||
// deadline and OperationCanceledException for the caller's token, which is exactly the
|
// deadline and OperationCanceledException for the caller's token — but unlike the old
|
||||||
// two-way split the old completedTask/IsCancellationRequested test made by hand; the
|
// wait it races the two and reports whichever fired first, whereas the old code inspected
|
||||||
// error codes and messages below are unchanged.
|
// 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
|
try
|
||||||
{
|
{
|
||||||
return await pendingCommand.Task.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
return await pendingCommand.Task.WaitAsync(timeout, cancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (OperationCanceledException)
|
catch (TimeoutException) when (!cancellationToken.IsCancellationRequested)
|
||||||
{
|
|
||||||
RemovePendingCommandAsFailed(
|
|
||||||
correlationId,
|
|
||||||
pendingCommand,
|
|
||||||
WorkerClientErrorCode.GatewayShutdown,
|
|
||||||
"Command wait was canceled.");
|
|
||||||
throw;
|
|
||||||
}
|
|
||||||
catch (TimeoutException)
|
|
||||||
{
|
{
|
||||||
string timeoutMessage = $"Worker command {method} timed out after {timeout}.";
|
string timeoutMessage = $"Worker command {method} timed out after {timeout}.";
|
||||||
RemovePendingCommandAsFailed(
|
bool removed = RemovePendingCommandAsFailed(
|
||||||
correlationId,
|
correlationId,
|
||||||
pendingCommand,
|
pendingCommand,
|
||||||
WorkerClientErrorCode.CommandTimeout,
|
WorkerClientErrorCode.CommandTimeout,
|
||||||
@@ -284,14 +278,45 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
// The gateway has stopped waiting, but the worker has not stopped working: the
|
// 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)
|
// 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
|
// regardless. Tell it, so a queued-but-not-started command is dropped instead of
|
||||||
// occupying the STA behind a caller that is already gone. Best-effort by design — a
|
// occupying the STA behind a caller that is already gone. Gated on the removal so a
|
||||||
// failure here must never replace the timeout the caller is owed.
|
// 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);
|
TrySendCancelForTimedOutCommand(correlationId, method, timeout);
|
||||||
|
}
|
||||||
|
|
||||||
throw new WorkerClientException(
|
throw new WorkerClientException(
|
||||||
WorkerClientErrorCode.CommandTimeout,
|
WorkerClientErrorCode.CommandTimeout,
|
||||||
timeoutMessage);
|
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
|
catch
|
||||||
{
|
{
|
||||||
@@ -755,7 +780,12 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
/// <param name="pendingCommand">The pending command.</param>
|
/// <param name="pendingCommand">The pending command.</param>
|
||||||
/// <param name="errorCode">Error code.</param>
|
/// <param name="errorCode">Error code.</param>
|
||||||
/// <param name="message">Error message.</param>
|
/// <param name="message">Error message.</param>
|
||||||
private void RemovePendingCommandAsFailed(
|
/// <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,
|
string correlationId,
|
||||||
PendingCommand pendingCommand,
|
PendingCommand pendingCommand,
|
||||||
WorkerClientErrorCode errorCode,
|
WorkerClientErrorCode errorCode,
|
||||||
@@ -763,13 +793,14 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
{
|
{
|
||||||
if (!_pendingCommands.TryRemove(correlationId, out _))
|
if (!_pendingCommands.TryRemove(correlationId, out _))
|
||||||
{
|
{
|
||||||
return;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
ReleasePendingCommandSlot();
|
ReleasePendingCommandSlot();
|
||||||
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
TimeSpan duration = _timeProvider.GetElapsedTime(pendingCommand.StartTimestamp);
|
||||||
_metrics?.CommandFailed(pendingCommand.Method, errorCode.ToString(), duration);
|
_metrics?.CommandFailed(pendingCommand.Method, errorCode.ToString(), duration);
|
||||||
pendingCommand.SetException(new WorkerClientException(errorCode, message));
|
pendingCommand.SetException(new WorkerClientException(errorCode, message));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -786,14 +817,17 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
/// </para>
|
/// </para>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <remarks>
|
/// <remarks>
|
||||||
/// Every failure is swallowed and logged at debug: the caller is on the throw path for the
|
/// The whole body sits under one catch-all that debug-logs, because the guarantee this method
|
||||||
/// timeout, and losing the cancel costs the worker one wasted command, whereas letting an
|
/// owes its caller is structural, not incidental: the caller is on the throw path for the
|
||||||
/// exception escape would replace the <see cref="WorkerClientErrorCode.CommandTimeout"/> the
|
/// timeout, so anything escaping here — an envelope that failed to build, a <c>TryWrite</c>
|
||||||
/// caller is owed. <c>TryWrite</c> is the fast path and cannot throw for a closed or completed
|
/// against a disposed channel, a scheduler refusing the detached task — would replace the
|
||||||
/// channel; only when the outbound channel is momentarily at its bound is a detached task
|
/// <see cref="WorkerClientErrorCode.CommandTimeout"/> the caller is owed with an unrelated
|
||||||
/// started, and that task catches everything it can observe — including the
|
/// exception. Losing the cancel costs the worker one wasted command; losing the timeout
|
||||||
/// <see cref="ObjectDisposedException"/> from <c>_stopCts</c> if the client is disposed
|
/// misreports why the call failed. The detached task carries its own handler for the same
|
||||||
/// underneath it — so nothing is left unobserved.
|
/// 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>
|
/// </remarks>
|
||||||
/// <param name="correlationId">Correlation id of the command that timed out.</param>
|
/// <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="method">Command method name, for the cancel reason and diagnostics.</param>
|
||||||
@@ -802,6 +836,8 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
string correlationId,
|
string correlationId,
|
||||||
string method,
|
string method,
|
||||||
TimeSpan timeout)
|
TimeSpan timeout)
|
||||||
|
{
|
||||||
|
try
|
||||||
{
|
{
|
||||||
WorkerEnvelope cancelEnvelope = CreateEnvelope(
|
WorkerEnvelope cancelEnvelope = CreateEnvelope(
|
||||||
correlationId,
|
correlationId,
|
||||||
@@ -822,6 +858,25 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
await EnqueueAsync(cancelEnvelope, _stopCts.Token).ConfigureAwait(false);
|
await EnqueueAsync(cancelEnvelope, _stopCts.Token).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
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(
|
_logger.LogDebug(
|
||||||
exception,
|
exception,
|
||||||
@@ -831,8 +886,6 @@ public sealed class WorkerClient : IWorkerClient
|
|||||||
SessionId,
|
SessionId,
|
||||||
correlationId);
|
correlationId);
|
||||||
}
|
}
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>Reads and validates a handshake envelope.</summary>
|
/// <summary>Reads and validates a handshake envelope.</summary>
|
||||||
/// <param name="expectedBody">Expected envelope body type.</param>
|
/// <param name="expectedBody">Expected envelope body type.</param>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ public sealed class WorkerClientTests
|
|||||||
// The timeout also emits a WorkerCancel for the abandoned correlation (GWC-31), which sits
|
// The timeout also emits a WorkerCancel for the abandoned correlation (GWC-31), which sits
|
||||||
// ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the
|
// ahead of the second command on the FIFO pipe; skip it rather than mistaking it for the
|
||||||
// command this assertion is about.
|
// command this assertion is about.
|
||||||
WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair);
|
WorkerEnvelope secondCommand = await ReadNextCommandAsync(pipePair, timedOutCommand.CorrelationId);
|
||||||
await pipePair.WriteAsync(
|
await pipePair.WriteAsync(
|
||||||
CreateCommandReplyEnvelope(secondCommand.CorrelationId, MxCommandKind.GetWorkerInfo));
|
CreateCommandReplyEnvelope(secondCommand.CorrelationId, MxCommandKind.GetWorkerInfo));
|
||||||
|
|
||||||
@@ -188,8 +188,10 @@ public sealed class WorkerClientTests
|
|||||||
await using WorkerClient client = CreateClient(pipePair);
|
await using WorkerClient client = CreateClient(pipePair);
|
||||||
await CompleteHandshakeAsync(client, pipePair);
|
await CompleteHandshakeAsync(client, pipePair);
|
||||||
|
|
||||||
|
// Advise rather than a control command: control commands bypass the worker's STA queue, so a
|
||||||
|
// data command is the case the cancel actually exists for.
|
||||||
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
|
Task<WorkerCommandReply> invokeTask = client.InvokeAsync(
|
||||||
CreateCommand(MxCommandKind.Ping),
|
CreateCommand(MxCommandKind.Advise),
|
||||||
TimeSpan.FromMilliseconds(50),
|
TimeSpan.FromMilliseconds(50),
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -1076,12 +1078,17 @@ public sealed class WorkerClientTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Reads gateway envelopes until a <c>WorkerCommand</c> arrives, skipping the control envelopes
|
/// Reads gateway envelopes until a <c>WorkerCommand</c> arrives, skipping the <c>WorkerCancel</c>
|
||||||
/// (such as the <c>WorkerCancel</c> a command timeout emits) that may precede it on the FIFO pipe.
|
/// a command timeout emits for <paramref name="canceledCorrelationId"/>. Anything else on the pipe
|
||||||
|
/// fails the test rather than being skipped: the point of the skip is to tolerate exactly the one
|
||||||
|
/// known interleaving, not to make the assertion blind to unexpected gateway traffic.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="pipePair">The connected pipe pair whose worker side is read.</param>
|
/// <param name="pipePair">The connected pipe pair whose worker side is read.</param>
|
||||||
|
/// <param name="canceledCorrelationId">Correlation id of the timed-out command whose cancel is expected.</param>
|
||||||
/// <returns>The next command envelope written by the gateway.</returns>
|
/// <returns>The next command envelope written by the gateway.</returns>
|
||||||
private static async Task<WorkerEnvelope> ReadNextCommandAsync(PipePair pipePair)
|
private static async Task<WorkerEnvelope> ReadNextCommandAsync(
|
||||||
|
PipePair pipePair,
|
||||||
|
string canceledCorrelationId)
|
||||||
{
|
{
|
||||||
while (true)
|
while (true)
|
||||||
{
|
{
|
||||||
@@ -1090,6 +1097,9 @@ public sealed class WorkerClientTests
|
|||||||
{
|
{
|
||||||
return envelope;
|
return envelope;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Assert.Equal(WorkerEnvelope.BodyOneofCase.WorkerCancel, envelope.BodyCase);
|
||||||
|
Assert.Equal(canceledCorrelationId, envelope.CorrelationId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user