fix(worker): unconditional fault observation for abandoned pipe I/O; exception-total transport dispose
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user