fix(worker): session-owned stream disposal unblocks and observes the net48 pipe read at teardown

This commit is contained in:
Joseph Doherty
2026-08-15 21:06:03 -04:00
parent aac79579ab
commit e913dab5db
5 changed files with 408 additions and 3 deletions
+30
View File
@@ -887,6 +887,36 @@ Graceful shutdown sequence:
If shutdown wedges, the gateway kills the process. The worker should be written
so process kill does not corrupt other sessions.
### Ending the pipe read (net48)
Step 8 above cannot be done by cancellation. On .NET Framework 4.8
`NamedPipeClientStream.ReadAsync` accepts a `CancellationToken` and then never
wires it to the overlapped I/O, so a read parked waiting for gateway bytes stays
parked no matter what the worker cancels. Closing the handle is the only thing
that ends it.
`WorkerPipeSession.RunMessageLoopAsync` races one outstanding read against the
heartbeat and event-drain loops, so every fault exit — an event-drain fault, an
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.
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.
`MxAccessStaSession.ShutdownGracefullyAsync` implements the current cleanup
path. It first calls `StaCommandDispatcher.RequestShutdown()` so new commands
are rejected and queued commands that have not started receive