fix(worker): unconditional fault observation for abandoned pipe I/O; exception-total transport dispose

This commit is contained in:
Joseph Doherty
2026-08-15 21:21:25 -04:00
parent e913dab5db
commit 3ef56be2dd
3 changed files with 262 additions and 145 deletions
+28 -14
View File
@@ -900,22 +900,36 @@ heartbeat and event-drain loops, so every fault exit — an event-drain fault, a
event too large to frame, a failed heartbeat write — unwinds while that read is
still pending. The session therefore owns the transport: `RunAsync`'s outermost
`finally` disposes the stream as its last teardown step and then awaits the read
that disposal unblocks. Both halves matter. Disposal has to come last because
every frame the session will ever write is complete by then (the frame writer
signals a write only after it has been written *and* flushed), and the await has
to happen while the session still holds the read task, because the worker
installs no `TaskScheduler.UnobservedTaskException` handler — otherwise the
read's `ObjectDisposedException`/`IOException` lands on a task nobody observes,
still holding the reader's reused length-prefix buffer and its pooled payload
buffer.
that disposal unblocks. Disposal comes last because in the ordinary case every
frame the session will ever write is already complete by then the frame writer
signals a write only after it has been written *and* flushed. It is not last
because that is guaranteed: the wait on the heartbeat and drain loops is
budgeted, and a stream write is genuinely uncancellable, so an overrunning write
can still be in flight against the stream being disposed. Disposal is
consequently exception-*total*, catching anything the handle close throws and
logging it, because nothing raised while releasing a handle is more actionable
than the terminal exception that ended the session, and nothing may displace it.
Observation is unconditional; only the *logging* of it is budgeted.
`ObserveBackgroundTaskStopAsync` waits `BackgroundTaskStopTimeout` for a task to
stop and logs what it saw, but when it gives up it hands the task a
fault-observing continuation before returning. Windows owes no deadline for a
completion torn off a closed handle, so a bounded await on its own would reopen
the very orphaning window it was added to close. The same helper — and so the
same guarantee — covers the abandoned read, the heartbeat loop, and the
event-drain loop. This matters because the worker installs no
`TaskScheduler.UnobservedTaskException` handler: an unheld faulted task would
otherwise surface only at finalization, still holding the reader's reused
length-prefix buffer and its pooled payload buffer.
Two invariants follow. Nothing may call `WorkerFrameReader.ReadAsync` again once
a read has been abandoned: a second read would race the first for those buffers
and could return a pooled buffer twice. And `WorkerPipeClient`'s `using` on the
pipe stays as a backstop for the paths the session never reaches (a session
factory that throws), not as the primary owner — disposal is idempotent, so its
second `Dispose` is a no-op. Graceful shutdown leaves no pending read at all, so
the observation step is a no-op on that path.
a read has been abandoned a second read would race the first for those buffers
and could return a pooled buffer twice — which `Debug.Assert`s at both read-issue
sites guard. And `WorkerPipeClient`'s `using` on the pipe stays as a backstop for
the paths the session never reaches (a session factory that throws), not as the
primary owner; disposal is idempotent, so its second `Dispose` is a no-op.
Graceful shutdown leaves no pending read at all, so the observation step is a
no-op on that path.
`MxAccessStaSession.ShutdownGracefullyAsync` implements the current cleanup
path. It first calls `StaCommandDispatcher.RequestShutdown()` so new commands