perf(worker): unpark awaited control-frame writers from the winning drain pass

WriteAsync enqueued its frame and then contended unconditionally for the write
lock, so a caller that lost the race stayed in WaitAsync until the winning
drainer released — even though that winner writes, flushes, and completes the
loser's control frame at the control-to-event class boundary, part-way through
its pass. The boundary flush made the delivery point honest; the awaited task
was still charged for the whole event backlog it had just been flushed ahead of.

WriteAsync now awaits its own frame's completion racing the lock acquisition.
Completion first: the caller returns at its frame's delivery point and the
outstanding acquisition is detached, not dropped — a continuation drains
whatever is queued and releases, so the lock is never acquired and silently
held and a frame enqueued between the previous drainer's last dequeue and its
release is still written. Lock first: drain as before. Cancellation keeps the
WRK-22 tombstone semantics exactly, and a wait cancelled after the caller has
already detached releases nothing (SemaphoreSlim hands no count to a wait it
cancels), so no count leaks and no queued frame is stranded. A token that fires
after the frame's completion won the race changes nothing — the frame was
delivered. WriteBatchAsync deliberately keeps the plain wait-then-drain shape:
its last completion resolves at the end-of-pass flush anyway.

Three tests: the latency win (a control caller returning while the winning
WriteBatchAsync event burst is demonstrably still blocked mid-pass), a
mixed-priority concurrency soak pinning exactly-once writes and a single
drainer, and the cancel-after-detach corner (a wrongly released count would
surface as the drainer's own Release throwing SemaphoreFullException).

edited on macOS, windev verification pending (plan Task 11). Verified here by
compiling and running WorkerFrameWriter plus the writer suite against net10.0
in a scratch harness: 31/31 pass, and the two behaviour-pinning tests fail
against the pre-change parked implementation.
This commit is contained in:
Joseph Doherty
2026-08-17 03:55:05 -04:00
parent c79aaaf9eb
commit 9130994736
4 changed files with 354 additions and 32 deletions
+27 -7
View File
@@ -171,13 +171,33 @@ oversized event) surfaces from the batch's awaited completions as that frame's
`WorkerFrameProtocolException`; the remaining completions are still observed
so none faults unobserved.
The completion is the frame's delivery point, not necessarily the instant its
caller returns. A caller that loses the race for the write lock only observes
its own completion after the winning drainer releases the lock, so its return
remains bounded by that drain pass even though its control frame was flushed
and completed at the class boundary inside it. The boundary flush is what
makes the delivery point honest; unparking a lock-race loser from the winner's
pass would be a separate change to the enqueue-then-contend shape.
The completion is the frame's delivery point, and a `WriteAsync` caller now
returns at it. The boundary flush alone only made the delivery point honest:
a caller that lost the race for the write lock still sat in the lock wait
until the winning drainer released it, so its awaited task was charged for the
whole event backlog its control frame had just been flushed ahead of. To close
that, `WriteAsync` awaits its own frame's completion *racing* the lock
acquisition instead of the acquisition alone. Whichever settles first decides:
- **Completion first** — the winning drainer wrote and flushed this frame at
the class boundary, so the caller returns immediately. The lock acquisition
it leaves outstanding is *detached*, not dropped: a continuation drains
whatever is queued and then releases, so the lock is never acquired and
silently held, and a frame enqueued between the previous drainer's last
dequeue and its release is still written by someone. Draining an empty queue
is a no-op, so the common case is acquire-nothing-release.
- **Lock first** — the caller drains the pass itself, exactly as before.
- **Cancellation** — the wait ends without the lock (`SemaphoreSlim` hands no
count to a wait it cancels, so the detached continuation releases nothing on
that path) and the tombstone rules below apply unchanged. A token that fires
*after* the frame's completion won the race changes nothing: the frame was
delivered, and the caller returns normally.
`WriteBatchAsync` deliberately keeps the plain wait-then-drain shape. A batch
caller's result is its whole set of completions and the last of those resolves
at the end-of-pass flush — the instant before the drainer releases the lock —
so racing the acquisition would buy it nothing while adding one detached
acquisition per call.
Cancellation of a `WriteAsync`/`WriteBatchAsync` call that is still waiting
for the write lock when its token fires tombstones the queued frame: the