Skip to content

h1 servers can shutdown connections with pending buffered data on filled sockets#4018

Merged
seanmonstar merged 1 commit into
hyperium:masterfrom
deven96:diretnan/fix-shutdown-with-unflushed-body
Jun 11, 2026
Merged

h1 servers can shutdown connections with pending buffered data on filled sockets#4018
seanmonstar merged 1 commit into
hyperium:masterfrom
deven96:diretnan/fix-shutdown-with-unflushed-body

Conversation

@deven96

@deven96 deven96 commented Feb 13, 2026

Copy link
Copy Markdown
Contributor

Target fix for #4022

The fix I am proposing is to simply propagate pending flushes within the poll loop to give the buffer a chance to get fully flushed and the included test just simply asserts that shutdown was not called with pending flushes

I did notice a previously yanked change on here that modified the poll loop to achieve something similar but this should lead to exiting the poll loop and waiting to get polled again rather than exhausting the CPU

@deven96 deven96 changed the title h1 servers can be shutdown connections with pending buffered data h1 servers can shutdown connections with pending buffered data Feb 13, 2026
@deven96 deven96 changed the title h1 servers can shutdown connections with pending buffered data h1 servers can shutdown connections with pending buffered data on blocked sockets Feb 13, 2026
@deven96 deven96 changed the title h1 servers can shutdown connections with pending buffered data on blocked sockets h1 servers can shutdown connections with pending buffered data on filled sockets Feb 13, 2026
@deven96
deven96 force-pushed the diretnan/fix-shutdown-with-unflushed-body branch from 3de4ad6 to e786002 Compare February 16, 2026 21:27
@deven96

deven96 commented Feb 16, 2026

Copy link
Copy Markdown
Contributor Author

After some discussion, decided to not follow the approach of returning pending from within the poll loop as it could cause us to read less frequently ... given that the bug is explicitly that we shouldn't call shutdown whenever buffered hasn't completed then it probably makes sense to ensure that poll_shutdown for Buffered type completes the flush?

@deven96
deven96 force-pushed the diretnan/fix-shutdown-with-unflushed-body branch from e786002 to 0757da0 Compare February 16, 2026 21:37
@kornelski

Copy link
Copy Markdown

poll_shutdown in Buffered performing flush seems like the right place to address this.

@seanmonstar

Copy link
Copy Markdown
Member

Sorry for the delay, just took a look.

So, I think ultimately, the poll_loop method is fragile, AND the Write enum should likely get a new Flushing state, so that is_done isn't true until all expected bytes for a message are flushed. Then, poll_shutdown really would be a passdown to the IO transport shutdown. But, because that fragility, that could probably be a much larger and harder change.

In the meantime, this patch does fix it, so I'm inclined to go with it. It looks like CI is angry about an unused method, now.

@deven96
deven96 force-pushed the diretnan/fix-shutdown-with-unflushed-body branch from cc08d5a to 3c039ea Compare June 11, 2026 19:55
@deven96

deven96 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Sorry for the delay, just took a look.

So, I think ultimately, the poll_loop method is fragile, AND the Write enum should likely get a new Flushing state, so that is_done isn't true until all expected bytes for a message are flushed. Then, poll_shutdown really would be a passdown to the IO transport shutdown. But, because that fragility, that could probably be a much larger and harder change.

In the meantime, this patch does fix it, so I'm inclined to go with it. It looks like CI is angry about an unused method, now.

Thanks for taking a look @seanmonstar . Yes it was the fragility of alternative changes that caused the shutdown instead ... otherwise the best option would be to the poll_loop itself. Willing to look into it in a separate PR if need be

@seanmonstar

Copy link
Copy Markdown
Member

Hm, is the CI failure relevant? Kinda looks that way, but I'm distracted at the moment:

Test output ``` Running tests/ready_on_poll_stream.rs (target/debug/deps/ready_on_poll_stream-d4f86d9d0defa2fd)

running 1 test
2026-06-11T19:58:42.507946Z INFO body_test ThreadId(02) ready_on_poll_stream::h1_server::fixture: Client is reading response...
2026-06-11T19:58:42.508111Z INFO tokio-rt-worker ThreadId(04) ready_on_poll_stream::h1_server::fixture: Creating payload of 16 chunks of 64 KiB each (1 MiB total)...
2026-06-11T19:58:42.508184Z INFO tokio-rt-worker ThreadId(04) ready_on_poll_stream::h1_server::fixture: Server: Sending data response...
test body_test ... FAILED

failures:

---- body_test stdout ----

thread 'body_test' (3627) panicked at tests/h1_server/fixture.rs:105:17:
Chunk timeout: chunk took longer than 200ms
stack backtrace:
0: __rustc::rust_begin_unwind
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/std/src/panicking.rs:689:5
1: core::panicking::panic_fmt
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/panicking.rs:80:14
2: ready_on_poll_stream::h1_server::fixture::run::{{closure}}
at ./tests/h1_server/fixture.rs:105:17
3: ready_on_poll_stream::body_test::{{closure}}
at ./tests/ready_on_poll_stream.rs:138:42
4: <core::pin::Pin

as core::future::future::Future>::poll
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/future/future.rs:133:9
5: tokio::runtime::park::CachedParkThread::block_on::{{closure}}
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/park.rs:284:71
6: tokio::task::coop::with_budget
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/task/coop/mod.rs:167:5
7: tokio::task::coop::budget
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/task/coop/mod.rs:133:5
8: tokio::runtime::park::CachedParkThread::block_on
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/park.rs:284:31
9: tokio::runtime::context::blocking::BlockingRegionGuard::block_on
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/context/blocking.rs:66:14
10: tokio::runtime::scheduler::multi_thread::MultiThread::block_on::{{closure}}
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/scheduler/multi_thread/mod.rs:92:22
11: tokio::runtime::context::runtime::enter_runtime
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/context/runtime.rs:65:16
12: tokio::runtime::scheduler::multi_thread::MultiThread::block_on
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/scheduler/multi_thread/mod.rs:91:9
13: tokio::runtime::runtime::Runtime::block_on_inner
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/runtime.rs:373:50
14: tokio::runtime::runtime::Runtime::block_on
at /home/runner/.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/tokio-1.52.3/src/runtime/runtime.rs:345:18
15: ready_on_poll_stream::body_test
at ./tests/ready_on_poll_stream.rs:138:47
16: ready_on_poll_stream::body_test::{{closure}}
at ./tests/ready_on_poll_stream.rs:134:21
17: core::ops::function::FnOnce::call_once
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/function.rs:250:5
18: <fn() -> core::result::Result<(), alloc::string::String> as core::ops::function::FnOnce<()>>::call_once
at /rustc/ac68faa20c58cbccd01ee7208bf3b6e93a7d7f96/library/core/src/ops/function.rs:250:5
note: Some details are omitted, run with RUST_BACKTRACE=full for a verbose backtrace.

failures:
body_test

test result: FAILED. 0 passed; 1 failed; 0 ignored; 0 measured; 0 filtered out; finished in 0.73s

</details>

@deven96
deven96 force-pushed the diretnan/fix-shutdown-with-unflushed-body branch 3 times, most recently from 764686d to faa15de Compare June 11, 2026 20:25
@deven96
deven96 force-pushed the diretnan/fix-shutdown-with-unflushed-body branch from faa15de to f41c288 Compare June 11, 2026 20:33
@deven96

deven96 commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Hm, is the CI failure relevant? Kinda looks that way, but I'm distracted at the moment:

Test output

Yes poll_shutdown previously never called poll_flush but now that it does for the fixture, we just need to early return if there's still pending writes

@seanmonstar seanmonstar 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.

Right on, thanks for the fix!

@seanmonstar
seanmonstar merged commit 72046cc into hyperium:master Jun 11, 2026
23 checks passed
fotinakis added a commit to fotinakis/hyper that referenced this pull request Jul 13, 2026
`Buffered::poll_shutdown` reuses `poll_flush` to drain any committed
response bytes before closing the connection (added in hyperium#4018). But
`poll_flush` short-circuits to `Ready(Ok(()))` whenever the pipeline-flush
optimization is enabled and there are still unparsed request bytes in the
read buffer:

    if self.flush_pipeline && !self.read_buf.is_empty() {
        Poll::Ready(Ok(()))
    }

So when a server is built with `pipeline_flush(true)` and a client pipelines
another request into the read buffer, the flush inside `poll_shutdown`
becomes a no-op and the connection is torn down with response bytes still
sitting in the write buffer. The client gets a truncated (or entirely
missing) response even though the status line and Content-Length claimed a
complete one. This is the same failure mode as hyperium#4022, reached through the
pipeline-flush path that hyperium#4018 didn't cover.

A client pipelining after a `Connection: close` request is enough to hit
this ("SHOULD NOT", not "MUST NOT" -- RFC 9112 section 9.6).

Split the actual buffer-draining out of `poll_flush` into
`poll_flush_buffered`, which ignores the `flush_pipeline` short-circuit, and
have `poll_shutdown` call it. `poll_flush`'s behavior is unchanged.

Adds a unit test that puts a `Buffered` in the exact short-circuit state
(pipeline flushing on, a pipelined request in the read buffer, a committed
response in the write buffer) and asserts shutdown flushes it first.
overtrue added a commit to rustfs/rustfs that referenced this pull request Jul 13, 2026
…ected EOF (#4797)

* fix(deps): pin hyper to flush-before-shutdown fix for large-GET unexpected EOF

hyper <= 1.10.1 can call poll_shutdown() on an HTTP/1 socket while response
bytes are still buffered (a prior poll_flush() returned Poll::Pending and the
result was discarded), so a backpressured/slow-reading peer receives a graceful
FIN before the full Content-Length body is flushed. Standard S3 clients
(minio-go/warp) report this as sporadic `unexpected EOF` on large-object GET
under load (rustfs/backlog#1232). This is the transport-layer bug from
Cloudflare's "hyper-bug" writeup — distinct from the EC-reconstruct-desync EOF
already fixed via lockstep decode; here the app layer delivers the full body and
truncation is purely hyper->socket.

Fixed upstream in hyperium/hyper#4018 (commit 72046cc7, "fix(http1): flush
buffered data before shutdown"), which is not in any crates.io release yet as of
hyper 1.10.1. Pin hyper via [patch.crates-io] to git rev ccc1e850 (a descendant
of the fix that still declares version 1.10.1).

Use [patch.crates-io], not a git+rev on the workspace `hyper` dependency: the
server connection is driven by the transitive hyper-util (conn::auto /
GracefulShutdown), and a git source would not unify with the crates.io hyper
hyper-util resolves, leaving two hyper copies with the server path still on the
buggy one. The patch rewrites the crates.io source globally, so the lockfile
holds a single git-sourced hyper.

Add rustfs/tests/hyper_h1_shutdown_flush_regression.rs, a deterministic guard
(mirrors hyper's own h1_shutdown_while_buffered) that fails if the pin is dropped
or hyper is downgraded below the fix. Its load-bearing assertion is a
synchronously-set flag, so a slow runner can only under-detect, never false-red.

Closes rustfs/backlog#1232.

* build(deny): allow the hyperium/hyper git source for the flush-before-shutdown pin

cargo-deny's [sources] check denies unknown git sources. The hyper
[patch.crates-io] pin added for rustfs/backlog#1232 uses a github.com/hyperium
git source, so add it to allow-git with an owner/review note and a removal
condition (drop once a released hyper > 1.10.1 carries commit 72046cc7).
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