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
@@ -22,6 +22,14 @@ public sealed class WorkerFrameReader
// Reused across frames rather than allocated per read (GWC-30). Safe because ReadAsync is
// single-consumer by construction; the prefix is fully overwritten by every read.
//
// The single-consumer invariant has to survive teardown as well as steady state, because a read
// abandoned by WorkerPipeSession's message loop still owns this buffer (and its rented payload
// buffer) until it faults. Nothing may call ReadAsync again after that point: a second read
// would race the abandoned one for the prefix, and could hand a pooled payload buffer back to
// ArrayPool twice. The loop guarantees it structurally — it only ever issues a read after
// awaiting the previous one, and it never re-enters after unwinding — and teardown only awaits
// the abandoned read, never reissues it (WorkerPipeSession.ObserveAbandonedPipeReadAsync).
private readonly byte[] _lengthPrefix = new byte[sizeof(uint)];
/// <summary>Initializes the reader with a stream and protocol options.</summary>
@@ -106,6 +114,12 @@ public sealed class WorkerFrameReader
int offset = 0;
while (offset < count)
{
// The token is forwarded but is NOT a bound on a pipe read: on .NET Framework 4.8
// NamedPipeClientStream.ReadAsync accepts a CancellationToken and never wires it to the
// overlapped I/O, so a read waiting on gateway bytes ignores cancellation entirely. Only
// closing the handle ends it (WorkerPipeSession disposes the transport at teardown for
// exactly this reason). It is still passed because non-pipe streams — the
// MemoryStream-backed unit tests, and any future transport — do honor it.
int bytesRead = await _stream
.ReadAsync(buffer, offset, count - offset, cancellationToken)
.ConfigureAwait(false);
@@ -151,6 +151,13 @@ public sealed class WorkerPipeClient : IWorkerPipeClient
WorkerFrameProtocolOptions frameOptions = new(options);
// The session disposes this pipe itself as its last teardown step — that disposal is what
// unblocks a net48 pipe read the message loop abandoned, and it has to happen while the
// session still holds the read task so the resulting fault is observed rather than orphaned
// (see WorkerPipeSession.RunAsync). The `using` stays as the backstop for the paths the
// session never reaches: a session factory that throws, or a RunAsync that never gets past
// its own construction. Disposal is idempotent, so the second Dispose is a no-op — do not
// "clean up" this `using` on the assumption that it is now redundant.
using NamedPipeClientStream pipe = await ConnectWithRetryAsync(options.PipeName, cancellationToken)
.ConfigureAwait(false);
@@ -39,8 +39,21 @@ public sealed class WorkerPipeSession
private readonly WorkerFrameWriter _writer;
private readonly object _commandTaskGate = new();
private readonly HashSet<Task> _activeCommandTasks = new();
// The transport the reader and writer share, when this session was handed one to own. Null for
// the reader/writer constructors, whose callers keep ownership of whatever streams they built the
// pair over. Owning it is what lets teardown close the handle (WRK-31): on net48 a pipe read
// parked in the kernel cannot be cancelled, only unblocked by disposal.
private readonly Stream? _transportStream;
private IWorkerRuntimeSession? _runtimeSession;
// The one outstanding, not-yet-awaited frame read, or null when no read is in flight. Written
// only by the message loop (which sets it at each read issue and clears it before awaiting that
// read itself) and read only by RunAsync's finally — which runs after the loop's task has
// completed, so the await supplies the happens-before edge and no interlock is needed.
private Task<WorkerEnvelope>? _pendingReadTask;
// Mutated from the message loop, command tasks, the heartbeat loop and the
// shutdown path; volatile so cross-thread reads observe the latest state
// without tearing (WorkerState is an int-backed protobuf enum).
@@ -63,7 +76,8 @@ public sealed class WorkerPipeSession
() => Process.GetCurrentProcess().Id,
new WorkerPipeSessionOptions(),
() => new MxAccessStaSession((eq, affinity, comFactory) => new AlarmCommandHandler(eq, () => new WnWrapAlarmConsumer(), affinity, comFactory, standbyFactory: null)),
logger)
logger,
stream)
{
}
@@ -96,6 +110,12 @@ public sealed class WorkerPipeSession
/// <param name="sessionOptions">Session-specific options.</param>
/// <param name="runtimeSessionFactory">Factory creating the MXAccess runtime session.</param>
/// <param name="logger">Optional logger for diagnostic output.</param>
/// <param name="transportStream">
/// Stream the reader and writer share, when this session is to own it. Supplying it makes
/// <see cref="RunAsync"/> dispose the transport as its last teardown step, which is the only
/// way to unblock a pending net48 pipe read (see <see cref="RunMessageLoopAsync"/>). Null
/// leaves ownership — and the disposal — with the caller.
/// </param>
public WorkerPipeSession(
WorkerFrameReader reader,
WorkerFrameWriter writer,
@@ -103,7 +123,8 @@ public sealed class WorkerPipeSession
Func<int> processIdProvider,
WorkerPipeSessionOptions sessionOptions,
Func<IWorkerRuntimeSession> runtimeSessionFactory,
IWorkerLogger? logger = null)
IWorkerLogger? logger = null,
Stream? transportStream = null)
{
_reader = reader ?? throw new ArgumentNullException(nameof(reader));
_writer = writer ?? throw new ArgumentNullException(nameof(writer));
@@ -112,6 +133,7 @@ public sealed class WorkerPipeSession
_sessionOptions = sessionOptions ?? throw new ArgumentNullException(nameof(sessionOptions));
_runtimeSessionFactory = runtimeSessionFactory ?? throw new ArgumentNullException(nameof(runtimeSessionFactory));
_logger = logger;
_transportStream = transportStream;
_sessionOptions.Validate();
}
@@ -142,9 +164,92 @@ public sealed class WorkerPipeSession
_runtimeSession?.Dispose();
_runtimeSession = null;
_state = WorkerState.Stopped;
// Closing the transport is what actually ends a pipe read parked in the kernel: on net48
// NamedPipeClientStream.ReadAsync ignores its CancellationToken, so the message loop's
// cancellation can never reach one (WRK-31). This is deliberately the LAST teardown step,
// because every frame this session will ever write has completed by the time control
// reaches here: WorkerFrameWriter.WriteAsync signals only after the frame is written AND
// flushed, and every exit path awaits its final write before unwinding — the shutdown ack
// and shutdown-timeout fault inside the loop's dispatch, the event-drain and
// oversized-event faults inside the drain task the loop awaits, the watchdog fault inside
// the heartbeat task the loop awaits, and the handshake fault inside
// CompleteStartupHandshakeAsync's catch. In-flight command replies are the one class of
// write that can still be racing here, and they raced the identical disposal before this
// change (WorkerPipeClient's `using` fired on the very next statement after RunAsync);
// both ProcessCommandAsync's Ready-state gate and TryWriteFaultAsync's
// ObjectDisposedException/IOException swallow already cover that race.
//
// Owning the disposal here — rather than leaving it to WorkerPipeClient's `using` — is
// what makes the abandoned read observable: the fault it takes on disposal lands on a
// task this session still holds, so ObserveAbandonedPipeReadAsync can await it instead of
// leaving it for a TaskScheduler.UnobservedTaskException handler the worker does not have.
DisposeTransportStream();
await ObserveAbandonedPipeReadAsync().ConfigureAwait(false);
}
}
/// <summary>
/// Disposes the transport this session owns, if it was handed one. Dispose-time failures are
/// logged and swallowed: this runs inside <see cref="RunAsync"/>'s finally, where letting an
/// <see cref="IOException"/> escape would replace the exception that actually ended the
/// session (a shutdown timeout, a protocol violation, an event too large to frame) with a
/// far less actionable one. Disposal is idempotent, so <c>WorkerPipeClient</c>'s outer
/// <c>using</c> re-disposing the same stream immediately afterwards is a no-op.
/// </summary>
private void DisposeTransportStream()
{
if (_transportStream is null)
{
return;
}
try
{
_transportStream.Dispose();
}
catch (Exception exception) when (exception is IOException || exception is ObjectDisposedException)
{
_logger?.Error(
"WorkerPipeSessionTransportDisposeFailed",
new Dictionary<string, object?>
{
["session_id"] = _options.SessionId,
["exception"] = exception.ToString(),
});
}
}
/// <summary>
/// Awaits the frame read the message loop walked away from, if there is one.
/// <see cref="RunMessageLoopAsync"/> races an uncancellable read against the heartbeat and
/// event-drain loops, so every fault exit (event-drain fault, oversized event, heartbeat
/// write failure) leaves a read outstanding on a task the loop never awaits again. Once
/// <see cref="DisposeTransportStream"/> has closed the handle that read faults with
/// <see cref="ObjectDisposedException"/> or <see cref="IOException"/>; awaiting it here is
/// what keeps the fault observed, because the worker installs no
/// <c>TaskScheduler.UnobservedTaskException</c> handler.
/// </summary>
/// <remarks>
/// No second read can follow this one. The message loop only ever issues a read after
/// awaiting the previous one, and it never re-enters after unwinding, so
/// <see cref="WorkerFrameReader"/>'s single-consumer invariant — and with it the safety of
/// its reused length-prefix buffer and its pooled payload buffer, which the abandoned read
/// still owns until it faults — holds through teardown.
/// </remarks>
/// <returns>A task that represents the asynchronous operation.</returns>
private async Task ObserveAbandonedPipeReadAsync()
{
Task<WorkerEnvelope>? readTask = _pendingReadTask;
_pendingReadTask = null;
if (readTask is null)
{
return;
}
await ObserveBackgroundTaskStopAsync(readTask, "PipeRead").ConfigureAwait(false);
}
/// <summary>Completes the gateway startup handshake using default MXAccess initialization.</summary>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
@@ -264,6 +369,34 @@ public sealed class WorkerPipeSession
return _writer.WriteAsync(CreateEnvelope(ready), cancellationToken);
}
/// <summary>
/// Runs the post-handshake message loop, racing one outstanding frame read against the
/// heartbeat and event-drain loops.
/// </summary>
/// <remarks>
/// <para>
/// <c>loopCancellation</c> does NOT bound the read. On .NET Framework 4.8
/// <c>NamedPipeClientStream.ReadAsync</c> accepts a <see cref="CancellationToken"/> and
/// then ignores it — the token never reaches the overlapped I/O, so a read parked
/// waiting for gateway bytes stays parked no matter what is cancelled. The token is
/// still passed because the reader's contract takes one and a non-pipe stream (the
/// MemoryStream-backed unit tests) does honor it. What actually ends a parked read is
/// closing the handle, which <see cref="RunAsync"/> does at the end of teardown.
/// </para>
/// <para>
/// That asymmetry is why the loop records its outstanding read in
/// <c>_pendingReadTask</c>. Every fault exit — an event-drain fault, an event too large
/// to frame, a failed heartbeat write — unwinds through <c>Task.WhenAny</c> while the
/// read is still pending, and the fault that read eventually takes has to be observed by
/// somebody (see <see cref="ObserveAbandonedPipeReadAsync"/>). The field is cleared
/// before the loop awaits a read itself, so it is non-null exactly when a read is
/// outstanding and unobserved. Graceful exits — the <c>return</c> below, after a
/// <c>WorkerShutdown</c> envelope or a <c>ShutdownWorker</c> command — leave no pending
/// read at all, so teardown's observation is a no-op there.
/// </para>
/// </remarks>
/// <param name="cancellationToken">Token to cancel the asynchronous operation.</param>
/// <returns>A task that represents the asynchronous operation.</returns>
private async Task RunMessageLoopAsync(CancellationToken cancellationToken)
{
using CancellationTokenSource loopCancellation = CancellationTokenSource
@@ -273,6 +406,7 @@ public sealed class WorkerPipeSession
Task heartbeatTask = RunHeartbeatLoopAsync(heartbeatCancellation.Token);
Task eventDrainTask = RunEventDrainLoopAsync(heartbeatCancellation.Token);
Task<WorkerEnvelope> readTask = _reader.ReadAsync(loopCancellation.Token);
_pendingReadTask = readTask;
try
{
@@ -281,6 +415,9 @@ public sealed class WorkerPipeSession
Task completedTask = await Task.WhenAny(readTask, heartbeatTask, eventDrainTask).ConfigureAwait(false);
if (completedTask == readTask)
{
// The loop observes this read itself, whether it yields an envelope or throws,
// so it is no longer the abandoned one teardown has to account for.
_pendingReadTask = null;
WorkerEnvelope envelope = await readTask.ConfigureAwait(false);
bool keepReading = await DispatchGatewayEnvelopeAsync(envelope, cancellationToken).ConfigureAwait(false);
if (!keepReading)
@@ -289,6 +426,7 @@ public sealed class WorkerPipeSession
}
readTask = _reader.ReadAsync(loopCancellation.Token);
_pendingReadTask = readTask;
}
else if (completedTask == heartbeatTask)
{