Skip to content

fix(async): tear down connection on writer write failure - #324

Open
soulfy wants to merge 1 commit into
containerd:masterfrom
soulfy:master
Open

fix(async): tear down connection on writer write failure#324
soulfy wants to merge 1 commit into
containerd:masterfrom
soulfy:master

Conversation

@soulfy

@soulfy soulfy commented Sep 2, 2026

Copy link
Copy Markdown

The writer task logged write errors but kept looping to send the next message. Because write_to relies on write_all, a failed write may leave a partially written frame on the wire, desynchronizing frame boundaries for every stream multiplexed on the connection; continuing to write then silently corrupts the peer's parsing.

Transient errors are especially problematic since the read half observes nothing and the reader-driven teardown never fires. A concrete example is ENOMEM from the write syscall: under high memory fragmentation the kernel can fail a high-order allocation needed to service the write even though the socket itself is perfectly healthy, so relying on the peer or the read side to notice is not enough.

On a write error, shut down the write half so the peer sees EOF and break out of the writer loop. The read half then reads EOF and drives the existing connection teardown path. Also restrict send_result(Ok) to the success path so a failed send no longer reports Ok afterward.

Comment thread src/asynchronous/connection.rs Outdated
// peer sees EOF, then exit the writer task: the read half will
// subsequently observe EOF and drive the connection teardown.
let _ = writer.shutdown().await;
break;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Propagate writer failure to every client stream

When multiple client RPCs are in flight, ClientWriter::disconnect removes only the stream associated with the failed message. After this break, ClientWriter::exit signals shutdown, causing Connection::run to take the wait_shutdown branch and skip ClientReader::disconnect, which is the only path that drains and fails all registered streams.

Closing only the socket's write direction does not make the local read half return EOF. The Client also continues to hold the stream map, so its remaining ResultSenders stay alive. Consequently, a request with timeout_nano == 0, or a streaming receive, can wait indefinitely.

Please treat writer failure as a connection-level error: close the outbound queue, reject subsequent sends, and fail every registered client stream before the connection exits.

Comment thread src/asynchronous/connection.rs Outdated
// corrupt the peer's parsing. Shut down the write half so the
// peer sees EOF, then exit the writer task: the read half will
// subsequently observe EOF and drive the connection teardown.
let _ = writer.shutdown().await;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Drop the entire connection when the transport cannot half-close

On Windows, Tokio's NamedPipeServer::poll_shutdown only calls poll_flush; it does not disconnect the named pipe. Because tokio::io::split keeps the underlying Socket alive until both halves are dropped, shutting down and dropping the writer half is insufficient while the reader half remains active.

ServerWriter::exit sends no per-connection shutdown notification, and ServerReader::wait_shutdown only observes the server-wide shutdown signal. Therefore, after a recoverable write error, the pipe may remain connected even though there is no writer left to send responses.

Please propagate the writer failure back to Connection::run and perform a connection-wide teardown, including dropping the read half and stopping the connection's handlers. This also handles custom transports whose poll_shutdown does not provide true half-close semantics.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Changes recommended

Teardown can block before shutdown and leave unrelated in-flight requests unresolved.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

Stops asynchronous writers after frame-write failures to prevent protocol desynchronization.

Changes:

  • Shuts down the write half after errors.
  • Exits the writer loop and avoids subsequent success handling.
File summaries
File Description
src/asynchronous/connection.rs Adds write-failure connection teardown.
Review details

Suppressed comments (1)

src/asynchronous/connection.rs:73

  • Breaking here makes ClientWriter::exit wake ClientReader::wait_shutdown, which exits the reader without calling ReaderDelegate::disconnect. The delegate only removes the stream for the failed frame, so any other in-flight request remains in Client::streams; a request with timeout_nano == 0 can then wait forever on a sender that is never dropped. Route writer failures through the full disconnect path (or explicitly drain all pending streams) before stopping the reader.
                    break;
  • Files reviewed: 1/1 changed files
  • Comments generated: 1
  • Review effort level: Balanced

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/asynchronous/connection.rs Outdated
Comment on lines +62 to +73
error!("write_message got error: {:?}", e);
sending_msg.send_result(Err(e.clone()));
writer_delegate.disconnect(&sending_msg.msg, e).await;
// write_to uses write_all internally, so a failure may have
// written only part of a frame. From here the frame
// boundaries for every stream on this connection can no
// longer be trusted, and writing further messages would only
// corrupt the peer's parsing. Shut down the write half so the
// peer sees EOF, then exit the writer task: the read half will
// subsequently observe EOF and drive the connection teardown.
let _ = writer.shutdown().await;
break;
@soulfy

soulfy commented Sep 3, 2026

Copy link
Copy Markdown
Author

@Tim-Zhang thanks, you're right that the half-close approach was insufficient.
I've reworked it into a connection-level teardown driven from Connection::run, rather than relying on writer.shutdown() + EOF.

@Tim-Zhang Tim-Zhang left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let Connection::run handle all write-failure cleanup

The oneshot channel is the right idea, but the current implementation is more complex than needed. It adds several shutdown paths and many lines of lifecycle logic, while a focused change of a few dozen lines should be enough.

The current flow can also still block:

  • writer.shutdown().await runs before the error is sent to Connection::run. If shutdown never completes, connection cleanup never starts.
  • ClientReader::disconnect waits while sending errors to bounded stream channels. One full channel can block cleanup of all other streams.

Please keep the flow simple:

  1. Report the write error to Connection::run immediately.
  2. Exit the writer without waiting for shutdown().
  3. Let Connection::run close the whole connection and stop its handlers.
  4. Fail all client streams without waiting on a full stream channel.

Please also add tests for a pending shutdown and a full stream channel. This should provide reliable teardown for TCP, Windows named pipes, and custom transports with much less code.

The writer task logged write errors but kept looping to send the next
message. Because write_to relies on write_all, a failed write may leave a
partially written frame on the wire, desynchronizing frame boundaries for
every stream multiplexed on the connection; continuing to write then
silently corrupts the peer's parsing.

Transient errors are especially problematic since the read half observes
nothing and the reader-driven teardown never fires. A concrete example is
ENOMEM from the write syscall: under high memory fragmentation the kernel
can fail a high-order allocation needed to service the write even though
the socket itself is healthy, so relying on the peer or the read side to
notice is not enough.

Treat a write failure as a connection-level fatal error. On failure the
writer task reports the error to Connection::run over a oneshot channel and
returns immediately; it does NOT wait on writer.shutdown() first, which can
block (some transports never complete poll_shutdown) and would delay or
prevent cleanup. run() prioritizes that signal with a biased select and
calls reader_delegate.disconnect, which fails every registered client
stream (a request with timeout_nano == 0 or a streaming receive would
otherwise wait forever) and stops the server's connection handlers, then
drops the read half on exit.

Cleanup no longer relies on half-closing the write direction to surface EOF
to the peer or the local read half, which is not portable: on Windows
NamedPipeServer::poll_shutdown only flushes, tokio::io::split keeps the
socket alive until both halves are dropped, and custom transports may lack
half-close entirely. Dropping the writer task drops the write half; the
connection is closed by run().

ClientReader::disconnect now fails pending streams with try_send instead of
awaiting a bounded channel, so one stream with a full channel (a slow or
stalled receiver) can no longer block teardown of the others.

The previous per-message WriterDelegate::disconnect only removed the single
failed stream and is now redundant with the connection-wide teardown, so it
is removed along with its client and server implementations.

Add tests for both hazards: a write failure tears the connection down and
fails a pending request even when poll_shutdown never completes and the read
half never returns, and disconnect does not block on a full stream channel.

Signed-off-by: liukai254 <liukai254@jd.com>
@soulfy

soulfy commented Sep 4, 2026

Copy link
Copy Markdown
Author

Let Connection::run handle all write-failure cleanup

The oneshot channel is the right idea, but the current implementation is more complex than needed. It adds several shutdown paths and many lines of lifecycle logic, while a focused change of a few dozen lines should be enough.

The current flow can also still block:

  • writer.shutdown().await runs before the error is sent to Connection::run. If shutdown never completes, connection cleanup never starts.
  • ClientReader::disconnect waits while sending errors to bounded stream channels. One full channel can block cleanup of all other streams.

Please keep the flow simple:

  1. Report the write error to Connection::run immediately.
  2. Exit the writer without waiting for shutdown().
  3. Let Connection::run close the whole connection and stop its handlers.
  4. Fail all client streams without waiting on a full stream channel.

Please also add tests for a pending shutdown and a full stream channel. This should provide reliable teardown for TCP, Windows named pipes, and custom transports with much less code.

I made the following changes:

  1. Report the error before anything that can block. The writer no longer calls writer.shutdown().await before reporting the failure. On a write error it now immediately sends the error to Connection::run over the oneshot and returns — no shutdown() at all. The write half is closed by dropping the writer task, and run() closes the connection. This removes the "shutdown never completes → cleanup
    never starts" hazard and also cuts the writer task roughly in half (the fatal bookkeeping and the match are gone).

  2. Fail streams without waiting on a full channel. ClientReader::disconnect now uses try_send instead of send().await. A stream whose channel is full (slow/stalled receiver) no longer blocks teardown of the others; that stream still observes RemoteClosed when its sender is dropped during the drain.

  3. Let run() own the teardown. The write failure is propagated in-process and run() runs the same connection-wide teardown as a read failure (reader_delegate.disconnect): it fails every registered client stream, stops the server's handlers, and drops the read half on exit. Correctness no longer depends on half-close, so it holds for TCP, Windows named pipes, and custom transports alike. The
    now-redundant per-message WriterDelegate::disconnect was removed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants