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
+13
View File
@@ -147,6 +147,19 @@ so the caller observes `OperationCanceledException` while that one frame still
reaches the wire. That residual window is by design: blocking the canceller
behind the very write it is abandoning would defeat the point of cancellation.
Two hygiene notes on that residual (NEXT-04/NEXT-05). First, a frame the
cancelled caller abandons — claimed mid-write, or already faulted by a
concurrent queue-wide failure — completes on a task nobody awaits; the
tombstone path attaches a fault-observing continuation to it so a later write
failure never surfaces as a `TaskScheduler.UnobservedTaskException`. Second,
tombstoned entries stay in the class queues until a future `DequeueNext` pops
and skips them; that lazy purge is deliberate. Eagerly rebuilding a `Queue<T>`
under `_gate` on every cancellation would add ordering-invariant surface next
to the claim/cancel interlock for no real gain: any subsequent write of either
class drains both queues to empty, and the heartbeat loop guarantees one
arrives within a heartbeat interval, so worst-case residency is a few envelope
references for seconds — not a leak.
## Verification
The frame protocol lives in `ZB.MOM.WW.MxGateway.Worker.Ipc` (`WorkerFrameReader`,