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:
@@ -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`,
|
||||
|
||||
Reference in New Issue
Block a user