fix(worker): observe faults on frames abandoned by cancellation (NEXT-04, NEXT-05 decision)
A WriteAsync/WriteBatchAsync caller cancelled after the draining lock-holder claimed its frame unwinds without awaiting that frame's completion; the same holds for a frame already faulted by a concurrent FailAllQueued, where TrySetCanceled loses. A later wire-write failure then lands TrySetException on a task with no awaiter and surfaces as TaskScheduler.UnobservedTaskException. The tombstone helpers now attach a fault-observing continuation to every frame in the cancelled call (a cancelled task never fires OnlyOnFaulted, so unconditional attach is safe), outside _gate because an already-faulted task runs the continuation inline. NEXT-05 is resolved as a documented decision, not a code change: tombstoned entries keep their lazy DequeueNext purge — any subsequent write drains both queues to empty and the heartbeat loop bounds residency to one interval, while eager Queue<T> rebuilds under _gate would add ordering-invariant surface for no gain. Rationale recorded in docs/WorkerFrameProtocol.md alongside the WRK-22 residual-window contract. New regression test drives the exact abandonment: gated stream holds writer A mid-write, the queued event frame is claimed and blocked mid-write, its caller is cancelled, the write then faults with a marker exception, and the test asserts the marker never reaches UnobservedTaskException after a forced GC. net48 x86 build/test runs on windev with the rest of this batch.
This commit is contained in:
@@ -92,6 +92,8 @@ public sealed class WorkerFrameWriter
|
||||
/// already claimed it, in which case the frame may still reach the wire even though this call
|
||||
/// observes <see cref="OperationCanceledException"/>. That residual window is by design: blocking
|
||||
/// the canceller behind the very write it is abandoning would defeat the point of cancellation.
|
||||
/// The abandoned frame's completion gets a fault-observing continuation so a write failure after
|
||||
/// the caller unwinds never raises an unobserved-task exception (NEXT-04).
|
||||
/// </remarks>
|
||||
public async Task WriteAsync(
|
||||
WorkerEnvelope envelope,
|
||||
@@ -162,7 +164,9 @@ public sealed class WorkerFrameWriter
|
||||
/// awaited completions as its <see cref="WorkerFrameProtocolException"/>; the remaining frames are
|
||||
/// still observed so none faults unobserved. Cancellation while waiting for the lock tombstones
|
||||
/// every still-unclaimed frame in the batch, per the WRK-22 contract on
|
||||
/// <see cref="WriteAsync(WorkerEnvelope, WorkerFrameWritePriority, CancellationToken)"/>.
|
||||
/// <see cref="WriteAsync(WorkerEnvelope, WorkerFrameWritePriority, CancellationToken)"/>; frames
|
||||
/// the cancelled caller abandons (claimed mid-write, or already faulted) get a fault-observing
|
||||
/// continuation so a later write failure never raises an unobserved-task exception (NEXT-04).
|
||||
/// </remarks>
|
||||
public async Task WriteBatchAsync(
|
||||
IReadOnlyList<WorkerEnvelope> envelopes,
|
||||
@@ -245,6 +249,8 @@ public sealed class WorkerFrameWriter
|
||||
frame.Completion.TrySetCanceled(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
ObserveAbandonedFault(frame);
|
||||
}
|
||||
|
||||
private void TombstoneUnclaimed(PendingFrame[] frames, CancellationToken cancellationToken)
|
||||
@@ -259,6 +265,30 @@ public sealed class WorkerFrameWriter
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach (PendingFrame frame in frames)
|
||||
{
|
||||
ObserveAbandonedFault(frame);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Observes any fault on a frame the cancelled caller stops awaiting (NEXT-04). A frame
|
||||
/// claimed by a draining lock-holder — or already faulted by a concurrent
|
||||
/// <c>FailAllQueued</c> — completes on a task nobody awaits after cancellation unwinds the
|
||||
/// caller; a later write failure would then surface as an unobserved-task exception. A
|
||||
/// cancelled task never triggers the faulted continuation, so attaching unconditionally is
|
||||
/// safe. Attached outside <c>_gate</c> because an already-faulted task runs the
|
||||
/// continuation inline.
|
||||
/// </summary>
|
||||
/// <param name="frame">Frame whose completion may fault without an awaiter.</param>
|
||||
private static void ObserveAbandonedFault(PendingFrame frame)
|
||||
{
|
||||
_ = frame.Completion.Task.ContinueWith(
|
||||
task => _ = task.Exception,
|
||||
CancellationToken.None,
|
||||
TaskContinuationOptions.OnlyOnFaulted | TaskContinuationOptions.ExecuteSynchronously,
|
||||
TaskScheduler.Default);
|
||||
}
|
||||
|
||||
// Runs only under _writeLock. Drains control frames before event frames, stamping and writing each.
|
||||
|
||||
Reference in New Issue
Block a user