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:
Joseph Doherty
2026-08-10 05:57:53 -04:00
parent 8769ee9765
commit 84dbf20a43
3 changed files with 171 additions and 1 deletions
@@ -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.