From afc9bfa1df34c69113cc9cdf04edc530c6837539 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 4 Jul 2025 13:47:13 -0400 Subject: [PATCH 001/128] feat(rt): introduce quic traits --- Cargo.toml | 3 +- src/rt/mod.rs | 4 +++ src/rt/quic.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/rt/quic.rs diff --git a/Cargo.toml b/Cargo.toml index 8334230937..5435142c7f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,8 @@ nightly = [] level = "warn" check-cfg = [ 'cfg(hyper_unstable_tracing)', - 'cfg(hyper_unstable_ffi)' + 'cfg(hyper_unstable_ffi)', + 'cfg(hyper_unstable_quic)', ] [package.metadata.docs.rs] diff --git a/src/rt/mod.rs b/src/rt/mod.rs index 0eb266974f..d1b2f81792 100644 --- a/src/rt/mod.rs +++ b/src/rt/mod.rs @@ -17,6 +17,10 @@ pub mod bounds; mod io; mod timer; +#[cfg(hyper_unstable_quic)] +#[cfg_attr(docsrs, doc(cfg(hyper_unstable_quic)))] +pub mod quic; + pub use self::io::{Read, ReadBuf, ReadBufCursor, Write}; pub use self::timer::{Sleep, Timer}; diff --git a/src/rt/quic.rs b/src/rt/quic.rs new file mode 100644 index 0000000000..18271d34a8 --- /dev/null +++ b/src/rt/quic.rs @@ -0,0 +1,91 @@ +//! Generic QUIC support + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Buf; + +// TODO: Should this be gated by an `http3` feature? + +/// A QUIC connection. +pub trait Connection { + /// Send streams that can be opened by this connection. + type SendStream: SendStream; + /// Receive streams that can be accepted by this connection. + type RecvStream: RecvStream; + /// Bidirectional streams that can be opened or accepted by this connection. + type BidiStream: SendStream + RecvStream; + /// Errors that may occur opening or accepting streams. + type Error; + + // Accepting streams + + // Q: shorten to bidi? + /// Accept a bidirection stream. + fn poll_accept_bidirectional_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>; + + /// Accept a unidirectional receive stream. + fn poll_accept_recv_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>; + + // Creating streams + + // Q: shorten to bidi? + /// Open a bidirectional stream. + fn poll_open_bidirectional_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>; + + /// Open a unidirectional send stream. + fn poll_open_send_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>; +} + +/// The send portion of a QUIC stream. +pub trait SendStream { + /// Errors that may happen trying to send data. + type Error; // bounds? + /// Polls that the stream is ready to send more data. + // Q: Should this be Pin<&mut Self>? + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll>; + /// Send data on the stream. + fn send_data(&mut self, data: B) -> Result<(), Self::Error>; + // fn poll_flush? + /// finish? + fn poll_finish(&mut self, cx: &mut Context<'_>) -> Poll>; + /// Close the stream with an error code. + fn reset(&mut self, reset_code: u64); +} + +/// The receive portion of a QUIC stream. +pub trait RecvStream { + /// Buffers of data that can be received. + type Buf: Buf; + /// Errors that may be received. + type Error; // bounds? + + // Q: should this be Pin? + /// Poll for more data received from the remote on this stream. + fn poll_data(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>>; + /// Signal to the remote peer to stop sending data. + fn stop_sending(&mut self, error_code: u64); +} + +/// An optional trait if a QUIC stream can be split into two sides. +pub trait BidiStream: SendStream + RecvStream { + /// The send side of a stream. + type SendStream: SendStream; + /// The receive side of a stream. + type RecvStream: RecvStream; + + /// Split this stream into two sides. + fn split(self) -> (Self::SendStream, Self::RecvStream); +} From 6bb179c916ecb0b46a46f44320a2bf437d8a65ca Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 4 Jul 2025 14:06:42 -0400 Subject: [PATCH 002/128] feat(server): introduce http3 connections --- Cargo.toml | 4 +- src/cfg.rs | 2 +- src/common/mod.rs | 1 + src/proto/h3/glue.rs | 165 +++++++++++++++++++++++++++++++++++++++ src/proto/h3/mod.rs | 34 ++++++++ src/proto/mod.rs | 3 + src/rt/mod.rs | 2 +- src/server/conn/http3.rs | 58 ++++++++++++++ src/server/conn/mod.rs | 2 + 9 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 src/proto/h3/glue.rs create mode 100644 src/proto/h3/mod.rs create mode 100644 src/server/conn/http3.rs diff --git a/Cargo.toml b/Cargo.toml index 5435142c7f..b6079f40e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ futures-channel = { version = "0.3", optional = true } futures-core = { version = "0.3.31", optional = true } futures-util = { version = "0.3", default-features = false, features = ["alloc"], optional = true } h2 = { version = "0.4.2", optional = true } +h3 = { version = "0.0.8", optional = true } http-body-util = { version = "0.1", optional = true } httparse = { version = "1.9", optional = true } httpdate = { version = "1.0", optional = true } @@ -69,7 +70,7 @@ tokio-util = "0.7.10" [features] # Nothing by default -default = [] +default = ["http3", "server"] # Easily turn it all on full = [ @@ -82,6 +83,7 @@ full = [ # HTTP versions http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa", "dep:pin-utils"] http2 = ["dep:futures-channel", "dep:futures-core", "dep:h2"] +http3 = ["dep:h3"] # Client/Server client = ["dep:want", "dep:pin-project-lite", "dep:smallvec"] diff --git a/src/cfg.rs b/src/cfg.rs index 71a5351d21..dbd52e1381 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -15,7 +15,7 @@ macro_rules! cfg_proto { ($($item:item)*) => { cfg_feature! { #![all( - any(feature = "http1", feature = "http2"), + any(feature = "http1", feature = "http2", feature = "http3"), any(feature = "client", feature = "server"), )] $($item)* diff --git a/src/common/mod.rs b/src/common/mod.rs index 4b73437203..4cc84cae75 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod either; #[cfg(any( all(feature = "client", any(feature = "http1", feature = "http2")), all(feature = "server", feature = "http1"), + all(feature = "server", feature = "http3"), ))] pub(crate) mod future; pub(crate) mod io; diff --git a/src/proto/h3/glue.rs b/src/proto/h3/glue.rs new file mode 100644 index 0000000000..a2335d5aed --- /dev/null +++ b/src/proto/h3/glue.rs @@ -0,0 +1,165 @@ +use std::task::{Context, Poll}; + +use bytes::Buf; + +pub(super) struct Conn(Q); + +pub(super) struct BidiStream(S); +pub(super) struct SendStream(S); +pub(super) struct RecvStream(S); + +impl h3::quic::Connection for Conn +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type RecvStream = RecvStream; + type OpenStreams = Self; + + fn poll_accept_recv(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn poll_accept_bidi(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn opener(&self) -> Self::OpenStreams { + todo!(); + } +} + +impl h3::quic::OpenStreams for Conn +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type BidiStream = BidiStream; + type SendStream = SendStream; + + fn poll_open_send(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn poll_open_bidi(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn close(&mut self, _: h3::error::Code, _: &[u8]) { + + } +} + +impl h3::quic::SendStream for BidiStream +where + S: crate::rt::quic::SendStream, + B: Buf, +{ + // Required methods + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn send_data>>( + &mut self, + data: T, + ) -> Result<(), h3::quic::StreamErrorIncoming> { + todo!(); + } + fn poll_finish( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn reset(&mut self, reset_code: u64) { + todo!(); + } + fn send_id(&self) -> h3::quic::StreamId { + todo!() + } +} + +impl h3::quic::SendStream for SendStream +where + S: crate::rt::quic::SendStream, + B: Buf, +{ + // Required methods + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn send_data>>( + &mut self, + data: T, + ) -> Result<(), h3::quic::StreamErrorIncoming> { + todo!(); + } + fn poll_finish( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn reset(&mut self, reset_code: u64) { + todo!(); + } + fn send_id(&self) -> h3::quic::StreamId { + todo!() + } +} + +impl h3::quic::RecvStream for BidiStream +where + S: crate::rt::quic::RecvStream, +{ + type Buf = S::Buf; + + // Required methods + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, h3::quic::StreamErrorIncoming>> { + todo!(); + } + fn stop_sending(&mut self, error_code: u64) { + todo!(); + } + fn recv_id(&self) -> h3::quic::StreamId { + todo!(); + } +} + +impl h3::quic::RecvStream for RecvStream +where + S: crate::rt::quic::RecvStream, +{ + type Buf = S::Buf; + + // Required methods + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, h3::quic::StreamErrorIncoming>> { + todo!(); + } + fn stop_sending(&mut self, error_code: u64) { + todo!(); + } + fn recv_id(&self) -> h3::quic::StreamId { + todo!(); + } +} diff --git a/src/proto/h3/mod.rs b/src/proto/h3/mod.rs new file mode 100644 index 0000000000..2138ed50b2 --- /dev/null +++ b/src/proto/h3/mod.rs @@ -0,0 +1,34 @@ +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Buf; +use h3::server::Connection; + +use pin_project_lite::pin_project; + +mod glue; + +pin_project! { + pub(crate) struct Server + where + Q: crate::rt::quic::Connection, + B: Buf, + { + exec: E, + q: Connection, B>, + s: S, + } +} + +impl Future for Server +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type Output = crate::Result<()>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + todo!() + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index fcdf2b97c0..40acde179b 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -16,6 +16,9 @@ cfg_feature! { #[cfg(feature = "http2")] pub(crate) mod h2; +#[cfg(feature = "http3")] +pub(crate) mod h3; + /// An Incoming Message head. Includes request/status line, and headers. #[cfg(feature = "http1")] #[derive(Debug, Default)] diff --git a/src/rt/mod.rs b/src/rt/mod.rs index d1b2f81792..08dceb5813 100644 --- a/src/rt/mod.rs +++ b/src/rt/mod.rs @@ -17,7 +17,7 @@ pub mod bounds; mod io; mod timer; -#[cfg(hyper_unstable_quic)] +//#[cfg(hyper_unstable_quic)] #[cfg_attr(docsrs, doc(cfg(hyper_unstable_quic)))] pub mod quic; diff --git a/src/server/conn/http3.rs b/src/server/conn/http3.rs new file mode 100644 index 0000000000..20b149aea3 --- /dev/null +++ b/src/server/conn/http3.rs @@ -0,0 +1,58 @@ +//! HTTP/3 Server Connections + +use std::error::Error as StdError; + +use pin_project_lite::pin_project; + +use crate::body::{Body, Incoming as IncomingBody}; +use crate::rt::quic; +use crate::service::HttpService; + +pin_project! { + /// A Future representing an HTTP/3 connection. + #[must_use = "futures do nothing unless polled"] + pub struct Connection { + _i: (Q, S, E), + } +} + +/// A configuration builder for HTTP/3 server connections. +/// +/// **Note**: The default values of options are *not considered stable*. They +/// are subject to change at any time. +#[derive(Clone, Debug)] +pub struct Builder { + exec: E, +} + +// ===== impl Connection ===== + +// ===== impl Builder ===== + +impl Builder { + /// Create a new connection builder. + pub fn new(exec: E) -> Self { + Self { + exec, + } + } + + /// Bind a connection together with a [`Service`](crate::service::Service). + /// + /// This returns a Future that must be polled in order for HTTP to be + /// driven on the connection. + pub fn serve_connection(&self, quic: Q, service: S) -> Connection + where + S: HttpService, + S::Error: Into>, + Bd: Body + 'static, + Bd::Error: Into>, + Q: quic::Connection, + //E: Http2ServerConnExec, + E: Clone, + { + Connection { + _i: (quic, service, self.exec.clone()), + } + } +} diff --git a/src/server/conn/mod.rs b/src/server/conn/mod.rs index 54b309e88e..63f3089a9f 100644 --- a/src/server/conn/mod.rs +++ b/src/server/conn/mod.rs @@ -18,3 +18,5 @@ pub mod http1; #[cfg(feature = "http2")] pub mod http2; +//#[cfg(feature = "http3")] +pub mod http3; From fc53b030915f7b98f7ec1bbe7c7817cf67787779 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 4 Jul 2025 13:47:13 -0400 Subject: [PATCH 003/128] feat(rt): introduce quic traits --- Cargo.toml | 3 +- src/rt/mod.rs | 4 +++ src/rt/quic.rs | 91 ++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 97 insertions(+), 1 deletion(-) create mode 100644 src/rt/quic.rs diff --git a/Cargo.toml b/Cargo.toml index 4441bdcdea..55205fb109 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -101,7 +101,8 @@ nightly = [] level = "warn" check-cfg = [ 'cfg(hyper_unstable_tracing)', - 'cfg(hyper_unstable_ffi)' + 'cfg(hyper_unstable_ffi)', + 'cfg(hyper_unstable_quic)', ] [package.metadata.docs.rs] diff --git a/src/rt/mod.rs b/src/rt/mod.rs index 0eb266974f..d1b2f81792 100644 --- a/src/rt/mod.rs +++ b/src/rt/mod.rs @@ -17,6 +17,10 @@ pub mod bounds; mod io; mod timer; +#[cfg(hyper_unstable_quic)] +#[cfg_attr(docsrs, doc(cfg(hyper_unstable_quic)))] +pub mod quic; + pub use self::io::{Read, ReadBuf, ReadBufCursor, Write}; pub use self::timer::{Sleep, Timer}; diff --git a/src/rt/quic.rs b/src/rt/quic.rs new file mode 100644 index 0000000000..18271d34a8 --- /dev/null +++ b/src/rt/quic.rs @@ -0,0 +1,91 @@ +//! Generic QUIC support + +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Buf; + +// TODO: Should this be gated by an `http3` feature? + +/// A QUIC connection. +pub trait Connection { + /// Send streams that can be opened by this connection. + type SendStream: SendStream; + /// Receive streams that can be accepted by this connection. + type RecvStream: RecvStream; + /// Bidirectional streams that can be opened or accepted by this connection. + type BidiStream: SendStream + RecvStream; + /// Errors that may occur opening or accepting streams. + type Error; + + // Accepting streams + + // Q: shorten to bidi? + /// Accept a bidirection stream. + fn poll_accept_bidirectional_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>; + + /// Accept a unidirectional receive stream. + fn poll_accept_recv_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll, Self::Error>>; + + // Creating streams + + // Q: shorten to bidi? + /// Open a bidirectional stream. + fn poll_open_bidirectional_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>; + + /// Open a unidirectional send stream. + fn poll_open_send_stream( + self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll>; +} + +/// The send portion of a QUIC stream. +pub trait SendStream { + /// Errors that may happen trying to send data. + type Error; // bounds? + /// Polls that the stream is ready to send more data. + // Q: Should this be Pin<&mut Self>? + fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll>; + /// Send data on the stream. + fn send_data(&mut self, data: B) -> Result<(), Self::Error>; + // fn poll_flush? + /// finish? + fn poll_finish(&mut self, cx: &mut Context<'_>) -> Poll>; + /// Close the stream with an error code. + fn reset(&mut self, reset_code: u64); +} + +/// The receive portion of a QUIC stream. +pub trait RecvStream { + /// Buffers of data that can be received. + type Buf: Buf; + /// Errors that may be received. + type Error; // bounds? + + // Q: should this be Pin? + /// Poll for more data received from the remote on this stream. + fn poll_data(&mut self, cx: &mut Context<'_>) -> Poll, Self::Error>>; + /// Signal to the remote peer to stop sending data. + fn stop_sending(&mut self, error_code: u64); +} + +/// An optional trait if a QUIC stream can be split into two sides. +pub trait BidiStream: SendStream + RecvStream { + /// The send side of a stream. + type SendStream: SendStream; + /// The receive side of a stream. + type RecvStream: RecvStream; + + /// Split this stream into two sides. + fn split(self) -> (Self::SendStream, Self::RecvStream); +} From 64d487aba8cc73653438513648bd8b667129ee44 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 4 Jul 2025 14:06:42 -0400 Subject: [PATCH 004/128] feat(server): introduce http3 connections --- Cargo.toml | 4 +- src/cfg.rs | 2 +- src/common/mod.rs | 1 + src/proto/h3/glue.rs | 165 +++++++++++++++++++++++++++++++++++++++ src/proto/h3/mod.rs | 34 ++++++++ src/proto/mod.rs | 3 + src/rt/mod.rs | 2 +- src/server/conn/http3.rs | 58 ++++++++++++++ src/server/conn/mod.rs | 2 + 9 files changed, 268 insertions(+), 3 deletions(-) create mode 100644 src/proto/h3/glue.rs create mode 100644 src/proto/h3/mod.rs create mode 100644 src/server/conn/http3.rs diff --git a/Cargo.toml b/Cargo.toml index 55205fb109..8ab0510b56 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -32,6 +32,7 @@ futures-channel = { version = "0.3", optional = true } futures-core = { version = "0.3.31", optional = true } futures-util = { version = "0.3", default-features = false, features = ["alloc"], optional = true } h2 = { version = "0.4.2", optional = true } +h3 = { version = "0.0.8", optional = true } http-body-util = { version = "0.1", optional = true } httparse = { version = "1.9", optional = true } httpdate = { version = "1.0", optional = true } @@ -69,7 +70,7 @@ tokio-util = "0.7.10" [features] # Nothing by default -default = [] +default = ["http3", "server"] # Easily turn it all on full = [ @@ -82,6 +83,7 @@ full = [ # HTTP versions http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa", "dep:pin-utils"] http2 = ["dep:futures-channel", "dep:futures-core", "dep:h2"] +http3 = ["dep:h3"] # Client/Server client = ["dep:want", "dep:pin-project-lite", "dep:smallvec"] diff --git a/src/cfg.rs b/src/cfg.rs index 71a5351d21..dbd52e1381 100644 --- a/src/cfg.rs +++ b/src/cfg.rs @@ -15,7 +15,7 @@ macro_rules! cfg_proto { ($($item:item)*) => { cfg_feature! { #![all( - any(feature = "http1", feature = "http2"), + any(feature = "http1", feature = "http2", feature = "http3"), any(feature = "client", feature = "server"), )] $($item)* diff --git a/src/common/mod.rs b/src/common/mod.rs index 4b73437203..4cc84cae75 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -7,6 +7,7 @@ pub(crate) mod either; #[cfg(any( all(feature = "client", any(feature = "http1", feature = "http2")), all(feature = "server", feature = "http1"), + all(feature = "server", feature = "http3"), ))] pub(crate) mod future; pub(crate) mod io; diff --git a/src/proto/h3/glue.rs b/src/proto/h3/glue.rs new file mode 100644 index 0000000000..a2335d5aed --- /dev/null +++ b/src/proto/h3/glue.rs @@ -0,0 +1,165 @@ +use std::task::{Context, Poll}; + +use bytes::Buf; + +pub(super) struct Conn(Q); + +pub(super) struct BidiStream(S); +pub(super) struct SendStream(S); +pub(super) struct RecvStream(S); + +impl h3::quic::Connection for Conn +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type RecvStream = RecvStream; + type OpenStreams = Self; + + fn poll_accept_recv(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn poll_accept_bidi(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn opener(&self) -> Self::OpenStreams { + todo!(); + } +} + +impl h3::quic::OpenStreams for Conn +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type BidiStream = BidiStream; + type SendStream = SendStream; + + fn poll_open_send(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn poll_open_bidi(&mut self, _cx: &mut Context<'_>) + -> Poll> + { + todo!(); + } + + fn close(&mut self, _: h3::error::Code, _: &[u8]) { + + } +} + +impl h3::quic::SendStream for BidiStream +where + S: crate::rt::quic::SendStream, + B: Buf, +{ + // Required methods + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn send_data>>( + &mut self, + data: T, + ) -> Result<(), h3::quic::StreamErrorIncoming> { + todo!(); + } + fn poll_finish( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn reset(&mut self, reset_code: u64) { + todo!(); + } + fn send_id(&self) -> h3::quic::StreamId { + todo!() + } +} + +impl h3::quic::SendStream for SendStream +where + S: crate::rt::quic::SendStream, + B: Buf, +{ + // Required methods + fn poll_ready( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn send_data>>( + &mut self, + data: T, + ) -> Result<(), h3::quic::StreamErrorIncoming> { + todo!(); + } + fn poll_finish( + &mut self, + cx: &mut Context<'_>, + ) -> Poll> { + todo!(); + } + fn reset(&mut self, reset_code: u64) { + todo!(); + } + fn send_id(&self) -> h3::quic::StreamId { + todo!() + } +} + +impl h3::quic::RecvStream for BidiStream +where + S: crate::rt::quic::RecvStream, +{ + type Buf = S::Buf; + + // Required methods + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, h3::quic::StreamErrorIncoming>> { + todo!(); + } + fn stop_sending(&mut self, error_code: u64) { + todo!(); + } + fn recv_id(&self) -> h3::quic::StreamId { + todo!(); + } +} + +impl h3::quic::RecvStream for RecvStream +where + S: crate::rt::quic::RecvStream, +{ + type Buf = S::Buf; + + // Required methods + fn poll_data( + &mut self, + cx: &mut Context<'_>, + ) -> Poll, h3::quic::StreamErrorIncoming>> { + todo!(); + } + fn stop_sending(&mut self, error_code: u64) { + todo!(); + } + fn recv_id(&self) -> h3::quic::StreamId { + todo!(); + } +} diff --git a/src/proto/h3/mod.rs b/src/proto/h3/mod.rs new file mode 100644 index 0000000000..2138ed50b2 --- /dev/null +++ b/src/proto/h3/mod.rs @@ -0,0 +1,34 @@ +use std::future::Future; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Buf; +use h3::server::Connection; + +use pin_project_lite::pin_project; + +mod glue; + +pin_project! { + pub(crate) struct Server + where + Q: crate::rt::quic::Connection, + B: Buf, + { + exec: E, + q: Connection, B>, + s: S, + } +} + +impl Future for Server +where + Q: crate::rt::quic::Connection, + B: Buf, +{ + type Output = crate::Result<()>; + + fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + todo!() + } +} diff --git a/src/proto/mod.rs b/src/proto/mod.rs index fcdf2b97c0..40acde179b 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -16,6 +16,9 @@ cfg_feature! { #[cfg(feature = "http2")] pub(crate) mod h2; +#[cfg(feature = "http3")] +pub(crate) mod h3; + /// An Incoming Message head. Includes request/status line, and headers. #[cfg(feature = "http1")] #[derive(Debug, Default)] diff --git a/src/rt/mod.rs b/src/rt/mod.rs index d1b2f81792..08dceb5813 100644 --- a/src/rt/mod.rs +++ b/src/rt/mod.rs @@ -17,7 +17,7 @@ pub mod bounds; mod io; mod timer; -#[cfg(hyper_unstable_quic)] +//#[cfg(hyper_unstable_quic)] #[cfg_attr(docsrs, doc(cfg(hyper_unstable_quic)))] pub mod quic; diff --git a/src/server/conn/http3.rs b/src/server/conn/http3.rs new file mode 100644 index 0000000000..20b149aea3 --- /dev/null +++ b/src/server/conn/http3.rs @@ -0,0 +1,58 @@ +//! HTTP/3 Server Connections + +use std::error::Error as StdError; + +use pin_project_lite::pin_project; + +use crate::body::{Body, Incoming as IncomingBody}; +use crate::rt::quic; +use crate::service::HttpService; + +pin_project! { + /// A Future representing an HTTP/3 connection. + #[must_use = "futures do nothing unless polled"] + pub struct Connection { + _i: (Q, S, E), + } +} + +/// A configuration builder for HTTP/3 server connections. +/// +/// **Note**: The default values of options are *not considered stable*. They +/// are subject to change at any time. +#[derive(Clone, Debug)] +pub struct Builder { + exec: E, +} + +// ===== impl Connection ===== + +// ===== impl Builder ===== + +impl Builder { + /// Create a new connection builder. + pub fn new(exec: E) -> Self { + Self { + exec, + } + } + + /// Bind a connection together with a [`Service`](crate::service::Service). + /// + /// This returns a Future that must be polled in order for HTTP to be + /// driven on the connection. + pub fn serve_connection(&self, quic: Q, service: S) -> Connection + where + S: HttpService, + S::Error: Into>, + Bd: Body + 'static, + Bd::Error: Into>, + Q: quic::Connection, + //E: Http2ServerConnExec, + E: Clone, + { + Connection { + _i: (quic, service, self.exec.clone()), + } + } +} diff --git a/src/server/conn/mod.rs b/src/server/conn/mod.rs index 54b309e88e..63f3089a9f 100644 --- a/src/server/conn/mod.rs +++ b/src/server/conn/mod.rs @@ -18,3 +18,5 @@ pub mod http1; #[cfg(feature = "http2")] pub mod http2; +//#[cfg(feature = "http3")] +pub mod http3; From 73a9b215006aa5d76b287285f423699496a22247 Mon Sep 17 00:00:00 2001 From: Carlos O'Ryan Date: Mon, 1 Dec 2025 14:47:45 +0000 Subject: [PATCH 005/128] docs(rt): fix comment in `put_slice()` (#3986) Nitpicky, I know, but renders poorly in the docs. --- src/rt/io.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/rt/io.rs b/src/rt/io.rs index ed4af0929f..2e486665c9 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -253,7 +253,7 @@ impl ReadBufCursor<'_> { self.buf.remaining() } - /// Transfer bytes into `self`` from `src` and advance the cursor + /// Transfer bytes into `self` from `src` and advance the cursor /// by the number of bytes written. /// /// # Panics From 15597a75137fd43184752fa45b0e306b052c5edf Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 16 Dec 2025 14:00:54 -0500 Subject: [PATCH 006/128] test(lib): fix unused warnings due to feature gating test imports (#3997) --- src/body/incoming.rs | 4 ++++ src/common/io/rewind.rs | 12 ++++++++++-- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/src/body/incoming.rs b/src/body/incoming.rs index 64ee5001a9..8c7c6ff67e 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -458,12 +458,16 @@ impl fmt::Debug for Sender { #[cfg(test)] mod tests { + #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use std::mem; + #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use std::task::Poll; + #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use super::{Body, Incoming, SizeHint}; #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use super::{DecodedLength, Sender}; + #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use http_body_util::BodyExt; #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] diff --git a/src/common/io/rewind.rs b/src/common/io/rewind.rs index c1f7a4f1b9..b692f438ee 100644 --- a/src/common/io/rewind.rs +++ b/src/common/io/rewind.rs @@ -14,7 +14,11 @@ pub(crate) struct Rewind { } impl Rewind { - #[cfg(test)] + #[cfg(all( + test, + any(feature = "client", feature = "server"), + any(feature = "http1", feature = "http2") + ))] pub(crate) fn new(io: T) -> Self { Rewind { pre: None, @@ -29,7 +33,11 @@ impl Rewind { } } - #[cfg(test)] + #[cfg(all( + test, + any(feature = "client", feature = "server"), + any(feature = "http1", feature = "http2") + ))] pub(crate) fn rewind(&mut self, bs: Bytes) { debug_assert!(self.pre.is_none()); self.pre = Some(bs); From 8dcc5b6dc28a7ec17559ddb20739f1efd2323365 Mon Sep 17 00:00:00 2001 From: lif <1835304752@qq.com> Date: Fri, 19 Dec 2025 22:51:53 +0800 Subject: [PATCH 007/128] docs(rt): improve `Read` and `ReadBufCursor` documentation (#4000) Add comprehensive documentation for implementing the Read trait: - Explain the difference from tokio's AsyncRead - Provide a complete example of implementing Read using put_slice - Document when to use unsafe methods Improve ReadBufCursor documentation: - Describe safe vs unsafe approaches - Add examples for both put_slice and as_mut/advance usage - Link to relevant methods Fixes #3649 --- src/rt/io.rs | 103 ++++++++++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 2 deletions(-) diff --git a/src/rt/io.rs b/src/rt/io.rs index 2e486665c9..fef98b1258 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -25,6 +25,52 @@ use std::task::{Context, Poll}; /// Reads bytes from a source. /// /// This trait is similar to `std::io::Read`, but supports asynchronous reads. +/// +/// # Implementing `Read` +/// +/// Implementations should read data into the provided [`ReadBufCursor`] and +/// advance the cursor to indicate how many bytes were written. The simplest +/// and safest approach is to use [`ReadBufCursor::put_slice`]: +/// +/// ``` +/// use hyper::rt::{Read, ReadBufCursor}; +/// use std::pin::Pin; +/// use std::task::{Context, Poll}; +/// use std::io; +/// +/// struct MyReader { +/// data: Vec, +/// position: usize, +/// } +/// +/// impl Read for MyReader { +/// fn poll_read( +/// mut self: Pin<&mut Self>, +/// _cx: &mut Context<'_>, +/// mut buf: ReadBufCursor<'_>, +/// ) -> Poll> { +/// let remaining_data = &self.data[self.position..]; +/// if remaining_data.is_empty() { +/// // No more data to read, signal EOF by returning Ok without +/// // advancing the buffer +/// return Poll::Ready(Ok(())); +/// } +/// +/// // Calculate how many bytes we can write +/// let to_copy = remaining_data.len().min(buf.remaining()); +/// // Use put_slice to safely copy data and advance the cursor +/// buf.put_slice(&remaining_data[..to_copy]); +/// +/// self.position += to_copy; +/// Poll::Ready(Ok(())) +/// } +/// } +/// ``` +/// +/// For more advanced use cases where you need direct access to the buffer +/// (e.g., when interfacing with APIs that write directly to a pointer), +/// you can use the unsafe [`ReadBufCursor::as_mut`] and [`ReadBufCursor::advance`] +/// methods. See their documentation for safety requirements. pub trait Read { /// Attempts to read bytes into the `buf`. /// @@ -124,9 +170,62 @@ pub struct ReadBuf<'a> { init: usize, } -/// The cursor part of a [`ReadBuf`]. +/// The cursor part of a [`ReadBuf`], representing the unfilled portion. +/// +/// This is created by calling [`ReadBuf::unfilled()`]. +/// +/// `ReadBufCursor` provides safe and unsafe methods for writing data into the +/// buffer: +/// +/// - **Safe approach**: Use [`put_slice`](Self::put_slice) to copy data from +/// a slice. This handles initialization tracking and cursor advancement +/// automatically. +/// +/// - **Unsafe approach**: For zero-copy scenarios or when interfacing with +/// low-level APIs, use [`as_mut`](Self::as_mut) to get a mutable slice +/// of `MaybeUninit`, then call [`advance`](Self::advance) after writing. +/// This is more efficient but requires careful attention to safety invariants. /// -/// This is created by calling `ReadBuf::unfilled()`. +/// # Example using safe methods +/// +/// ``` +/// use hyper::rt::ReadBuf; +/// +/// let mut backing = [0u8; 64]; +/// let mut read_buf = ReadBuf::new(&mut backing); +/// +/// { +/// let mut cursor = read_buf.unfilled(); +/// // put_slice handles everything safely +/// cursor.put_slice(b"hello"); +/// } +/// +/// assert_eq!(read_buf.filled(), b"hello"); +/// ``` +/// +/// # Example using unsafe methods +/// +/// ``` +/// use hyper::rt::ReadBuf; +/// +/// let mut backing = [0u8; 64]; +/// let mut read_buf = ReadBuf::new(&mut backing); +/// +/// { +/// let mut cursor = read_buf.unfilled(); +/// // SAFETY: we will initialize exactly 5 bytes +/// let slice = unsafe { cursor.as_mut() }; +/// slice[0].write(b'h'); +/// slice[1].write(b'e'); +/// slice[2].write(b'l'); +/// slice[3].write(b'l'); +/// slice[4].write(b'o'); +/// // SAFETY: we have initialized 5 bytes +/// unsafe { cursor.advance(5) }; +/// } +/// +/// assert_eq!(read_buf.filled(), b"hello"); +/// ``` #[derive(Debug)] pub struct ReadBufCursor<'a> { buf: &'a mut ReadBuf<'a>, From 8d13f7301b797c2e08d368aa85bc165f4e4dbcdb Mon Sep 17 00:00:00 2001 From: Dongpo Liu Date: Mon, 5 Jan 2026 15:12:59 +0100 Subject: [PATCH 008/128] fix(http1): use httparse config for Servers (#4002) Previously, you could config the httparse config on the server `Builder`, just like you can for clients, but unlike clients, hyper forgot to _use_ that config when parsing requests. This fixes the mistake. Closes #3923 --- src/proto/h1/role.rs | 61 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 60 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f124c9ff2b..1c8f1fbcf7 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -164,7 +164,11 @@ impl Http1Transaction for Server { trace!(bytes = buf.len(), "Request.parse"); let mut req = httparse::Request::new(&mut []); let bytes = buf.as_ref(); - match req.parse_with_uninit_headers(bytes, &mut headers) { + match ctx.h1_parser_config.parse_request_with_uninit_headers( + &mut req, + bytes, + &mut headers, + ) { Ok(httparse::Status::Complete(parsed_len)) => { trace!("Request.parse Complete({})", parsed_len); len = parsed_len; @@ -1811,6 +1815,61 @@ mod tests { Client::parse(&mut raw, ctx).unwrap_err(); } + const REQUEST_WITH_MULTIPLE_SPACES_IN_REQUEST_LINE: &str = + "GET /echo HTTP/1.1\r\nHost: hyper.rs\r\n\r\n"; + + #[cfg(feature = "server")] + #[test] + fn test_parse_allow_request_with_multiple_spaces_in_request_line() { + use httparse::ParserConfig; + + let _ = pretty_env_logger::try_init(); + let mut raw = BytesMut::from(REQUEST_WITH_MULTIPLE_SPACES_IN_REQUEST_LINE); + let mut h1_parser_config = ParserConfig::default(); + h1_parser_config.allow_multiple_spaces_in_request_line_delimiters(true); + let mut method = None; + let ctx = ParseContext { + cached_headers: &mut None, + req_method: &mut method, + h1_parser_config, + h1_max_headers: None, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }; + let msg = Server::parse(&mut raw, ctx).unwrap().unwrap(); + assert_eq!(raw.len(), 0); + assert_eq!(msg.head.subject.0, crate::Method::GET); + assert_eq!(msg.head.subject.1, "/echo"); + assert_eq!(msg.head.version, crate::Version::HTTP_11); + assert_eq!(msg.head.headers.len(), 1); + assert_eq!(msg.head.headers["Host"], "hyper.rs"); + assert_eq!(method, Some(crate::Method::GET)); + } + + #[cfg(feature = "server")] + #[test] + fn test_parse_reject_request_with_multiple_spaces_in_request_line() { + let _ = pretty_env_logger::try_init(); + let mut raw = BytesMut::from(REQUEST_WITH_MULTIPLE_SPACES_IN_REQUEST_LINE); + let ctx = ParseContext { + cached_headers: &mut None, + req_method: &mut None, + h1_parser_config: Default::default(), + h1_max_headers: None, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }; + Server::parse(&mut raw, ctx).unwrap_err(); + } + #[cfg(feature = "server")] #[test] fn test_parse_preserve_header_case_in_request() { From 60e69494143108428f5ee15f5fe620bee84374a9 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 12 Jan 2026 09:53:46 -0500 Subject: [PATCH 009/128] test(server): fix flaky disable_keep_alive_mid_request (#4009) This test would make use of the OS TCP stack, and sometimes on Mac of Windows, it would fail. The change switches to an in-memory transport, since the purpose is to test the timing on hyper's side, not the TCP stack. Closes #3949 --- tests/server.rs | 49 +++++++++++++++++++++++++++++++++++-------------- 1 file changed, 35 insertions(+), 14 deletions(-) diff --git a/tests/server.rs b/tests/server.rs index 2ba6f92ca3..485a10e049 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -23,7 +23,7 @@ use http_body_util::{combinators::BoxBody, BodyExt, Empty, Full, StreamBody}; use hyper::rt::Timer; use hyper::rt::{Read as AsyncRead, Write as AsyncWrite}; use support::{TokioExecutor, TokioIo, TokioTimer}; -use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::io::{AsyncReadExt, AsyncWriteExt, DuplexStream}; use tokio::net::{TcpListener as TkTcpListener, TcpListener, TcpStream as TkTcpStream}; use hyper::body::{Body, Incoming as IncomingBody}; @@ -1004,6 +1004,21 @@ fn setup_tcp_listener() -> (TcpListener, SocketAddr) { (listener, addr) } +fn setup_duplex_test_server() -> (DuplexStream, DuplexStream, SocketAddr) { + use std::net::{IpAddr, Ipv6Addr}; + let _ = pretty_env_logger::try_init(); + + const BUF_SIZE: usize = 1024; + let (ioa, iob) = tokio::io::duplex(BUF_SIZE); + + /// A test address inside the 'documentation' address range. + /// See: + const TEST_ADDR: IpAddr = IpAddr::V6(Ipv6Addr::new(0x3fff, 0, 0, 0, 0, 0, 0, 1)); + const TEST_SOCKET: SocketAddr = SocketAddr::new(TEST_ADDR, 8080); + + (ioa, iob, TEST_SOCKET) +} + #[tokio::test] async fn expect_continue_waits_for_body_poll() { let (listener, addr) = setup_tcp_listener(); @@ -1189,19 +1204,26 @@ fn http_11_uri_too_long() { #[tokio::test] async fn disable_keep_alive_mid_request() { - let (listener, addr) = setup_tcp_listener(); + let (client_io, server_io, _) = setup_duplex_test_server(); let (tx1, rx1) = oneshot::channel(); - let (tx2, rx2) = mpsc::channel(); + let (tx2, rx2) = oneshot::channel(); - let child = thread::spawn(move || { - let mut req = connect(&addr); - req.write_all(b"GET / HTTP/1.1\r\n").unwrap(); - thread::sleep(Duration::from_millis(10)); + let client_task = tokio::spawn(async move { + let mut client_io = client_io; + // Send partial request + client_io.write_all(b"GET / HTTP/1.1\r\n").await.unwrap(); + // Signal server that partial request sent tx1.send(()).unwrap(); - rx2.recv().unwrap(); - req.write_all(b"Host: localhost\r\n\r\n").unwrap(); + // Wait for server to be ready for rest of request + rx2.await.unwrap(); + // Send rest of request + client_io + .write_all(b"Host: localhost\r\n\r\n") + .await + .unwrap(); + // Read response let mut buf = vec![]; - req.read_to_end(&mut buf).unwrap(); + client_io.read_to_end(&mut buf).await.unwrap(); assert!( buf.starts_with(b"HTTP/1.1 200 OK\r\n"), "should receive OK response, but buf: {:?}", @@ -1215,9 +1237,8 @@ async fn disable_keep_alive_mid_request() { ); }); - let (socket, _) = listener.accept().await.unwrap(); - let socket = TokioIo::new(socket); - let srv = http1::Builder::new().serve_connection(socket, HelloWorld); + let server_io = TokioIo::new(server_io); + let srv = http1::Builder::new().serve_connection(server_io, HelloWorld); future::try_select(srv, rx1) .then(|r| match r { Ok(Either::Left(_)) => panic!("expected rx first"), @@ -1232,7 +1253,7 @@ async fn disable_keep_alive_mid_request() { .await .unwrap(); - child.join().unwrap(); + client_task.await.unwrap(); } #[tokio::test] From a5ed9b48a4449d24cd5361392838a3b21374d478 Mon Sep 17 00:00:00 2001 From: tottoto Date: Mon, 12 Jan 2026 23:54:32 +0900 Subject: [PATCH 010/128] chore(ci): update to actions/checkout@v6 (#4005) --- .github/workflows/CI.yml | 26 +++++++++++++------------- .github/workflows/bench.yml | 2 +- .github/workflows/cargo-audit.yml | 4 ++-- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index e7e12dc195..49c0c6f70a 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -37,7 +37,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -79,7 +79,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust (${{ matrix.rust }}) uses: dtolnay/rust-toolchain@master @@ -103,7 +103,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@stable @@ -133,7 +133,7 @@ jobs: steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -152,7 +152,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -178,7 +178,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -203,7 +203,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -234,7 +234,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@stable @@ -259,7 +259,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -275,7 +275,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@master @@ -297,7 +297,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout - uses: actions/checkout@v5 + uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@nightly @@ -317,7 +317,7 @@ jobs: runs-on: ubuntu-latest needs: [style] steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: dtolnay/rust-toolchain@nightly - uses: dtolnay/rust-toolchain@stable - uses: taiki-e/install-action@cargo-hack @@ -330,7 +330,7 @@ jobs: name: semver runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - name: Check semver uses: obi1kenobi/cargo-semver-checks-action@v2 with: diff --git a/.github/workflows/bench.yml b/.github/workflows/bench.yml index 60c1a2efb4..af535d942f 100644 --- a/.github/workflows/bench.yml +++ b/.github/workflows/bench.yml @@ -14,7 +14,7 @@ jobs: - end_to_end - pipeline steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v6 - name: Install Rust uses: dtolnay/rust-toolchain@nightly diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 256991ca76..9b4a7462a3 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -1,7 +1,7 @@ name: cargo-audit on: push: - paths: + paths: - '**/Cargo.toml' - '**/Cargo.lock' schedule: @@ -11,7 +11,7 @@ jobs: security_audit: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v5 + - uses: actions/checkout@v6 - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} From 232468f35d7b0275cd1dd40af29eb152438732c5 Mon Sep 17 00:00:00 2001 From: tottoto Date: Mon, 12 Jan 2026 23:55:30 +0900 Subject: [PATCH 011/128] chore(ci): update to cargo-check-external-types 0.4.0 (#4006) --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 49c0c6f70a..4a326d913b 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -280,12 +280,12 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2025-08-06 # Compatible version for cargo-check-external-types + toolchain: nightly-2025-10-18 # Compatible version for cargo-check-external-types - name: Install cargo-check-external-types uses: taiki-e/cache-cargo-install-action@v2 with: - tool: cargo-check-external-types@0.3.0 + tool: cargo-check-external-types@0.4.0 - uses: Swatinem/rust-cache@v2 From 2d4cebeb649f8574ffadc17c1350a8c93bb559b1 Mon Sep 17 00:00:00 2001 From: jasmy <3776356370@qq.com> Date: Mon, 12 Jan 2026 23:59:40 +0800 Subject: [PATCH 012/128] chore(license): update copyright year to 2026 (#4007) --- LICENSE | 2 +- capi/cbindgen.toml | 2 +- capi/include/hyper.h | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/LICENSE b/LICENSE index da8b6ec1b5..f7ae3a7107 100644 --- a/LICENSE +++ b/LICENSE @@ -1,4 +1,4 @@ -Copyright (c) 2014-2025 Sean McArthur +Copyright (c) 2014-2026 Sean McArthur Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal diff --git a/capi/cbindgen.toml b/capi/cbindgen.toml index a4990840e1..a0a6bba00f 100644 --- a/capi/cbindgen.toml +++ b/capi/cbindgen.toml @@ -2,7 +2,7 @@ # a list of possible configuration values. language = "C" header = """/* - * Copyright 2023 Sean McArthur. MIT License. + * Copyright 2026 Sean McArthur. MIT License. * Generated by gen_header.sh. Do not edit directly. * * Full docs at: https://docs.rs/hyper/latest/hyper/ffi/index.html diff --git a/capi/include/hyper.h b/capi/include/hyper.h index b88bd39e9d..598d240b60 100644 --- a/capi/include/hyper.h +++ b/capi/include/hyper.h @@ -1,5 +1,5 @@ /* - * Copyright 2023 Sean McArthur. MIT License. + * Copyright 2026 Sean McArthur. MIT License. * Generated by gen_header.sh. Do not edit directly. * * Full docs at: https://docs.rs/hyper/latest/hyper/ffi/index.html From fefd4f123c756eb732bb8781d48dd2b30d90845f Mon Sep 17 00:00:00 2001 From: Dongpo Liu Date: Mon, 12 Jan 2026 21:28:42 +0100 Subject: [PATCH 013/128] docs(examples): replace unwraps with expects explaining why (#4001) Just avoid unwrap as much as possible. Use expect to assert that it will always succeed. Closes #3984 --- examples/params.rs | 12 ++++++------ examples/send_file.rs | 4 ++-- examples/service_struct_impl.rs | 2 +- examples/upgrades.rs | 2 +- examples/web_api.rs | 8 ++++---- 5 files changed, 14 insertions(+), 14 deletions(-) diff --git a/examples/params.rs b/examples/params.rs index 8774046a67..0f26efb8bb 100644 --- a/examples/params.rs +++ b/examples/params.rs @@ -52,7 +52,7 @@ async fn param_example( return Ok(Response::builder() .status(StatusCode::UNPROCESSABLE_ENTITY) .body(full(MISSING)) - .unwrap()); + .expect("constant status won't error")); }; let number = if let Some(n) = params.get("number") { if let Ok(v) = n.parse::() { @@ -61,13 +61,13 @@ async fn param_example( return Ok(Response::builder() .status(StatusCode::UNPROCESSABLE_ENTITY) .body(full(NOTNUMERIC)) - .unwrap()); + .expect("constant status won't error")); } } else { return Ok(Response::builder() .status(StatusCode::UNPROCESSABLE_ENTITY) .body(full(MISSING)) - .unwrap()); + .expect("constant status won't error")); }; // Render the response. This will often involve @@ -86,7 +86,7 @@ async fn param_example( return Ok(Response::builder() .status(StatusCode::UNPROCESSABLE_ENTITY) .body(full(MISSING)) - .unwrap()); + .expect("constant status won't error")); }; let params = form_urlencoded::parse(query.as_bytes()) .into_owned() @@ -97,7 +97,7 @@ async fn param_example( return Ok(Response::builder() .status(StatusCode::UNPROCESSABLE_ENTITY) .body(full(MISSING)) - .unwrap()); + .expect("constant status won't error")); }; let body = format!("You requested {}", page); Ok(Response::new(full(body))) @@ -105,7 +105,7 @@ async fn param_example( _ => Ok(Response::builder() .status(StatusCode::NOT_FOUND) .body(empty()) - .unwrap()), + .expect("constant status won't error")), } } diff --git a/examples/send_file.rs b/examples/send_file.rs index b2ef328a1f..d3dc115a5d 100644 --- a/examples/send_file.rs +++ b/examples/send_file.rs @@ -61,7 +61,7 @@ fn not_found() -> Response> { Response::builder() .status(StatusCode::NOT_FOUND) .body(Full::new(NOTFOUND.into()).map_err(|e| match e {}).boxed()) - .unwrap() + .expect("constant status won't error") } async fn simple_file_send(filename: &str) -> Result>> { @@ -85,7 +85,7 @@ async fn simple_file_send(filename: &str) -> Result> for Svc { fn call(&self, req: Request) -> Self::Future { fn mk_response(s: String) -> Result>, hyper::Error> { - Ok(Response::builder().body(Full::new(Bytes::from(s))).unwrap()) + Ok(Response::new(Full::new(Bytes::from(s)))) } if req.uri().path() != "/favicon.ico" { diff --git a/examples/upgrades.rs b/examples/upgrades.rs index b94a8b9c42..b5460d96c9 100644 --- a/examples/upgrades.rs +++ b/examples/upgrades.rs @@ -100,7 +100,7 @@ async fn client_upgrade_request(addr: SocketAddr) -> Result<()> { .uri(format!("http://{}/", addr)) .header(UPGRADE, "foobar") .body(Empty::::new()) - .unwrap(); + .expect("uri/header parse won't error"); let stream = TcpStream::connect(addr).await?; let io = TokioIo::new(stream); diff --git a/examples/web_api.rs b/examples/web_api.rs index ffc62ba89b..ff2ea90b3d 100644 --- a/examples/web_api.rs +++ b/examples/web_api.rs @@ -29,7 +29,7 @@ async fn client_request_response() -> Result> { .uri(URL) .header(header::CONTENT_TYPE, "application/json") .body(Full::new(Bytes::from(POST_DATA))) - .unwrap(); + .expect("uri/header parse from constants won't error"); let host = req.uri().host().expect("uri has no host"); let port = req.uri().port_u16().expect("uri has no port"); @@ -73,11 +73,11 @@ async fn api_get_response() -> Result> { Ok(json) => Response::builder() .header(header::CONTENT_TYPE, "application/json") .body(full(json)) - .unwrap(), + .expect("header parse from constant won't error"), Err(_) => Response::builder() .status(StatusCode::INTERNAL_SERVER_ERROR) .body(full(INTERNAL_SERVER_ERROR)) - .unwrap(), + .expect("constant status won't error"), }; Ok(res) } @@ -93,7 +93,7 @@ async fn response_examples(req: Request) -> Result Date: Fri, 6 Feb 2026 09:42:00 -0800 Subject: [PATCH 014/128] fix(http1): use case-insensitive matching for trailer fields (#4011) Trailer header values were stored as HeaderValue and compared against HeaderName, causing case mismatch. Convert to HeaderName during parsing to normalize case per RFC 9110. Closes #4010 Co-authored-by: Hugh Co-authored-by: Pavel Borzenkov --- src/proto/h1/encode.rs | 72 +++++++++++++++++++++++----------------- src/proto/h1/role.rs | 36 +++++++++++++++----- tests/client.rs | 74 ++++++++++++++++++++++++++++++++++++++++++ tests/server.rs | 45 +++++++++++++++++++++++++ 4 files changed, 188 insertions(+), 39 deletions(-) diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index 2df0c396b7..ff8f21fdf5 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -1,4 +1,4 @@ -use std::collections::HashMap; +use std::collections::HashSet; use std::fmt; use std::io::IoSlice; @@ -9,7 +9,7 @@ use http::{ AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE, HOST, MAX_FORWARDS, SET_COOKIE, TE, TRAILER, TRANSFER_ENCODING, }, - HeaderMap, HeaderName, HeaderValue, + HeaderMap, HeaderName, }; use super::io::WriteBuf; @@ -35,7 +35,7 @@ pub(crate) struct NotEof(u64); #[derive(Debug, PartialEq, Clone)] enum Kind { /// An Encoder for when Transfer-Encoding includes `chunked`. - Chunked(Option>), + Chunked(Option>), /// An Encoder for when Content-Length is set. /// /// Enforces that the body is not longer than the Content-Length header. @@ -77,7 +77,7 @@ impl Encoder { Encoder::new(Kind::CloseDelimited) } - pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec) -> Encoder { + pub(crate) fn into_chunked_with_trailing_fields(self, trailers: Vec) -> Encoder { match self.kind { Kind::Chunked(_) => Encoder { kind: Kind::Chunked(Some(trailers)), @@ -168,7 +168,7 @@ impl Encoder { trace!("encoding trailers"); match &self.kind { Kind::Chunked(Some(allowed_trailer_fields)) => { - let allowed_trailer_field_map = allowed_trailer_field_map(allowed_trailer_fields); + let allowed_set: HashSet<&HeaderName> = allowed_trailer_fields.iter().collect(); let mut cur_name = None; let mut allowed_trailers = HeaderMap::new(); @@ -179,7 +179,7 @@ impl Encoder { } let name = cur_name.as_ref().expect("current header name"); - if allowed_trailer_field_map.contains_key(name.as_str()) { + if allowed_set.contains(name) { if is_valid_trailer_field(name) { allowed_trailers.insert(name, value); } else { @@ -279,22 +279,6 @@ fn is_valid_trailer_field(name: &HeaderName) -> bool { ) } -fn allowed_trailer_field_map(allowed_trailer_fields: &Vec) -> HashMap { - let mut trailer_map = HashMap::new(); - - for header_value in allowed_trailer_fields { - if let Ok(header_str) = header_value.to_str() { - let items: Vec<&str> = header_str.split(',').map(|item| item.trim()).collect(); - - for item in items { - trailer_map.entry(item.to_string()).or_insert(()); - } - } - } - - trailer_map -} - impl Buf for EncodedBuf where B: Buf, @@ -532,7 +516,7 @@ mod tests { #[test] fn chunked_with_valid_trailers() { let encoder = Encoder::chunked(); - let trailers = vec![HeaderValue::from_static("chunky-trailer")]; + let trailers = vec![HeaderName::from_static("chunky-trailer")]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let headers = HeaderMap::from_iter(vec![ @@ -557,8 +541,8 @@ mod tests { fn chunked_with_multiple_trailer_headers() { let encoder = Encoder::chunked(); let trailers = vec![ - HeaderValue::from_static("chunky-trailer"), - HeaderValue::from_static("chunky-trailer-2"), + HeaderName::from_static("chunky-trailer"), + HeaderName::from_static("chunky-trailer-2"), ]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); @@ -606,8 +590,7 @@ mod tests { fn chunked_with_invalid_trailers() { let encoder = Encoder::chunked(); - let trailers = format!( - "{},{},{},{},{},{},{},{},{},{},{},{}", + let trailers = vec![ AUTHORIZATION, CACHE_CONTROL, CONTENT_ENCODING, @@ -620,8 +603,7 @@ mod tests { TRAILER, TRANSFER_ENCODING, TE, - ); - let trailers = vec![HeaderValue::from_str(&trailers).unwrap()]; + ]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let mut headers = HeaderMap::new(); @@ -644,7 +626,7 @@ mod tests { #[test] fn chunked_with_title_case_headers() { let encoder = Encoder::chunked(); - let trailers = vec![HeaderValue::from_static("chunky-trailer")]; + let trailers = vec![HeaderName::from_static("chunky-trailer")]; let encoder = encoder.into_chunked_with_trailing_fields(trailers); let headers = HeaderMap::from_iter(vec![( @@ -657,4 +639,34 @@ mod tests { dst.put(buf1); assert_eq!(dst, b"0\r\nChunky-Trailer: header data\r\n\r\n"); } + + #[test] + fn chunked_trailers_case_insensitive_matching() { + // Regression test for issue #4010: HTTP/1.1 trailers are case-sensitive + // + // Previously, the Trailer header values were stored as HeaderValue (preserving case) + // and compared against HeaderName (which is always lowercase). This caused trailers + // declared as "Chunky-Trailer" to not match actual trailers sent as "chunky-trailer". + // + // The fix converts Trailer header values to HeaderName during parsing, which + // normalizes the case and enables proper case-insensitive matching. + // + // Note: HeaderName::from_static() requires lowercase input. In real usage, + // HeaderName::from_bytes() is used to parse the Trailer header value, which + // normalizes mixed-case input like "Chunky-Trailer" to "chunky-trailer". + let encoder = Encoder::chunked(); + let trailers = vec![HeaderName::from_static("chunky-trailer")]; + let encoder = encoder.into_chunked_with_trailing_fields(trailers); + + // The actual trailer being sent + let headers = HeaderMap::from_iter(vec![( + HeaderName::from_static("chunky-trailer"), + HeaderValue::from_static("trailer value"), + )]); + + let buf = encoder.encode_trailers::<&[u8]>(headers, false).unwrap(); + let mut dst = Vec::new(); + dst.put(buf); + assert_eq!(dst, b"0\r\nchunky-trailer: trailer value\r\n\r\n"); + } } diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 1c8f1fbcf7..f92092e5a9 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -650,7 +650,7 @@ impl Server { }; let mut encoder = Encoder::length(0); - let mut allowed_trailer_fields: Option> = None; + let mut allowed_trailer_fields: Option> = None; let mut wrote_date = false; let mut cur_name = None; let mut is_name_written = false; @@ -860,12 +860,22 @@ impl Server { extend(dst, value.as_bytes()); } - match allowed_trailer_fields { - Some(ref mut allowed_trailer_fields) => { - allowed_trailer_fields.push(value); - } - None => { - allowed_trailer_fields = Some(vec![value]); + // Parse the Trailer header value into HeaderNames. + // The value may contain comma-separated names. + // HeaderName normalizes to lowercase for case-insensitive matching. + if let Ok(value_str) = value.to_str() { + let names: Vec = value_str + .split(',') + .filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok()) + .collect(); + + match allowed_trailer_fields { + Some(ref mut fields) => { + fields.extend(names); + } + None => { + allowed_trailer_fields = Some(names); + } } } @@ -1389,8 +1399,16 @@ impl Client { let encoder = encoder.map(|enc| { if enc.is_chunked() { - let allowed_trailer_fields: Vec = - headers.get_all(header::TRAILER).iter().cloned().collect(); + // Parse Trailer header values into HeaderNames. + // Each Trailer header value may contain comma-separated names. + // HeaderName normalizes to lowercase, enabling case-insensitive matching. + let allowed_trailer_fields: Vec = headers + .get_all(header::TRAILER) + .iter() + .filter_map(|hv| hv.to_str().ok()) + .flat_map(|s| s.split(',')) + .filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok()) + .collect(); if !allowed_trailer_fields.is_empty() { return enc.into_chunked_with_trailing_fields(allowed_trailer_fields); diff --git a/tests/client.rs b/tests/client.rs index d5a0e4e005..5f976b1792 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -704,6 +704,44 @@ test! { body: None, } +test! { + name: client_post_req_body_chunked_with_trailer_titlecase, + + server: + expected: "\ + POST / HTTP/1.1\r\n\ + trailer: Chunky-Trailer\r\n\ + host: {addr}\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + 5\r\n\ + hello\r\n\ + 0\r\n\ + chunky-trailer: header data\r\n\ + \r\n\ + ", + reply: REPLY_OK, + + client: + request: { + method: POST, + url: "http://{addr}/", + headers: { + "trailer" => "Chunky-Trailer", + }, + body_stream_with_trailers: ( + (futures_util::stream::once(async { Ok::<_, Infallible>(Bytes::from("hello"))})), + HeaderMap::from_iter(vec![( + HeaderName::from_static("chunky-trailer"), + HeaderValue::from_static("header data") + )].into_iter())), + }, + response: + status: OK, + headers: {}, + body: None, +} + test! { name: client_res_body_chunked_with_trailer, @@ -740,6 +778,42 @@ test! { }, } +test! { + name: client_res_body_chunked_with_trailer_titlecase, + + server: + expected: "GET / HTTP/1.1\r\nte: trailers\r\nhost: {addr}\r\n\r\n", + reply: "\ + HTTP/1.1 200 OK\r\n\ + transfer-encoding: chunked\r\n\ + trailer: Chunky-Trailer\r\n\ + \r\n\ + 5\r\n\ + hello\r\n\ + 0\r\n\ + chunky-trailer: header data\r\n\ + \r\n\ + ", + + client: + request: { + method: GET, + url: "http://{addr}/", + headers: { + "te" => "trailers", + }, + }, + response: + status: OK, + headers: { + "Transfer-Encoding" => "chunked", + }, + body: &b"hello"[..], + trailers: { + "chunky-trailer" => "header data", + }, +} + test! { name: client_res_body_chunked_with_pathological_trailers, diff --git a/tests/server.rs b/tests/server.rs index 485a10e049..1cd0eba570 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2850,6 +2850,51 @@ fn http1_trailer_send_fields() { assert_eq!(body, expected_body); } +#[test] +fn http1_trailer_send_fields_titlecase() { + let body = futures_util::stream::once(async move { Ok("hello".into()) }); + let mut headers = HeaderMap::new(); + headers.insert("chunky-trailer", "header data".parse().unwrap()); + // Invalid trailer field that should not be sent + headers.insert("Host", "www.example.com".parse().unwrap()); + // Not specified in Trailer header, so should not be sent + headers.insert("foo", "bar".parse().unwrap()); + + let server = serve(); + server + .reply() + .header("transfer-encoding", "chunked") + .header("trailer", "Chunky-Trailer") + .body_stream_with_trailers(body, headers); + let mut req = connect(server.addr()); + req.write_all( + b"\ + GET / HTTP/1.1\r\n\ + Host: example.domain\r\n\ + Connection: keep-alive\r\n\ + TE: trailers\r\n\ + \r\n\ + ", + ) + .expect("writing"); + + let chunky_trailer_chunk = b"\r\nchunky-trailer: header data\r\n\r\n"; + let res = read_until(&mut req, |buf| buf.ends_with(chunky_trailer_chunk)).expect("reading"); + let sres = s(&res); + + let expected_head = + "HTTP/1.1 200 OK\r\ntransfer-encoding: chunked\r\ntrailer: Chunky-Trailer\r\n"; + assert_eq!(&sres[..expected_head.len()], expected_head); + + // skip the date header + let date_fragment = "GMT\r\n\r\n"; + let pos = sres.find(date_fragment).expect("find GMT"); + let body = &sres[pos + date_fragment.len()..]; + + let expected_body = "5\r\nhello\r\n0\r\nchunky-trailer: header data\r\n\r\n"; + assert_eq!(body, expected_body); +} + #[test] fn http1_trailer_fields_not_allowed() { let body = futures_util::stream::once(async move { Ok("hello".into()) }); From cec2bd27852a8576f9a6138352a339ff32b2bba7 Mon Sep 17 00:00:00 2001 From: No jae gun <32046070+njg7194@users.noreply.github.com> Date: Sat, 7 Feb 2026 04:39:23 +0900 Subject: [PATCH 015/128] chore: convert bug report template to GitHub form (#4015) Converts the bug report issue template from markdown to GitHub's YAML form format. This provides a more structured experience for users filing bug reports with: - Required version and platform fields - Separate sections for code, expected behavior, and actual behavior - Better input validation - Improved readability for maintainers Closes #3213 --- .github/ISSUE_TEMPLATE/bug_report.md | 28 ------------ .github/ISSUE_TEMPLATE/bug_report.yml | 65 +++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 28 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/bug_report.md create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md deleted file mode 100644 index 04f5d9f01a..0000000000 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: "Bug report \U0001F41B" -about: Create a report to help us improve -title: '' -labels: C-bug -assignees: '' - ---- - -**Version** -List the version(s) of `hyper`, and any relevant hyper dependency (such as `h2` if this is related to HTTP/2). - -**Platform** -The output of `uname -a` (UNIX), or version and 32 or 64-bit (Windows) - -**Description** -Enter your issue details here. -One way to structure the description: - -[short summary of the bug] - -I tried this code: - -[code sample that causes the bug] - -I expected to see this happen: [explanation] - -Instead, this happened: [explanation] diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000000..23abc6275d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,65 @@ +name: "Bug report 🐛" +description: Create a report to help us improve +labels: ["C-bug"] +body: + - type: input + id: version + attributes: + label: Version + description: List the version(s) of `hyper`, and any relevant hyper dependency (such as `h2` if this is related to HTTP/2). + placeholder: "hyper 1.0.0, h2 0.4.0" + validations: + required: true + + - type: input + id: platform + attributes: + label: Platform + description: The output of `uname -a` (UNIX), or version and 32 or 64-bit (Windows) + placeholder: "Linux 5.15.0 x86_64 / Windows 11 64-bit" + validations: + required: true + + - type: textarea + id: summary + attributes: + label: Summary + description: A short summary of the bug + placeholder: "Brief description of what went wrong" + validations: + required: true + + - type: textarea + id: code + attributes: + label: Code Sample + description: Code sample that causes the bug (if applicable) + render: rust + placeholder: | + use hyper::...; + + fn main() { + // code that reproduces the bug + } + + - type: textarea + id: expected + attributes: + label: Expected Behavior + description: What you expected to happen + validations: + required: true + + - type: textarea + id: actual + attributes: + label: Actual Behavior + description: What actually happened instead + validations: + required: true + + - type: textarea + id: additional + attributes: + label: Additional Context + description: Any other context about the problem (stack traces, related issues, etc.) From 89ba9997262ebb776f789aa343e769c45a71aa91 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 16 Feb 2026 08:45:27 -0500 Subject: [PATCH 016/128] chore(ci): remove comments to allow CI MSRV to parse toml --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8ab0510b56..57dedb063c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -60,7 +60,7 @@ tokio = { version = "1", features = [ "io-std", "io-util", "rt", - "rt-multi-thread", # so examples can use #[tokio::main] + "rt-multi-thread", "sync", "time", "test-util", From 0288ba3670c2ffb537eecb7f9d53c5fec87b68b8 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 16 Feb 2026 08:45:27 -0500 Subject: [PATCH 017/128] chore(ci): generate lockfile for audit workflow --- .github/workflows/cargo-audit.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 9b4a7462a3..3c436edbe4 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -12,6 +12,8 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v6 + - name: Generate lockfile + run: cargo generate-lockfile --features full - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} From b5149e6bf91ba257744c1e98cde9fa5c0b45218c Mon Sep 17 00:00:00 2001 From: cui Date: Mon, 16 Feb 2026 22:32:09 +0800 Subject: [PATCH 018/128] fix(http2): non-utf8 char in Connection header may cause panic when calling to_str (#4019) --- src/proto/h2/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index e6f40f3467..1a70b789c7 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -64,17 +64,17 @@ fn strip_connection_headers(headers: &mut HeaderMap, is_request: bool) { "Connection header illegal in HTTP/2: {}", CONNECTION.as_str() ); - let header_contents = header.to_str().unwrap(); - // A `Connection` header may have a comma-separated list of names of other headers that // are meant for only this specific connection. // // Iterate these names and remove them as headers. Connection-specific headers are // forbidden in HTTP2, as that information has been moved into frame types of the h2 // protocol. - for name in header_contents.split(',') { - let name = name.trim(); - headers.remove(name); + if let Ok(header_contents) = header.to_str() { + for name in header_contents.split(',') { + let name = name.trim(); + headers.remove(name); + } } } } From 0ff75ba08ae2966d89223ef0cdb7aea87ab2c647 Mon Sep 17 00:00:00 2001 From: Roman Titov Date: Mon, 16 Feb 2026 20:59:34 +0100 Subject: [PATCH 019/128] feat(client): add HTTP/2 `max_local_error_reset_streams` option (#4021) --- src/client/conn/http2.rs | 11 +++++++++++ src/proto/h2/client.rs | 3 +++ 2 files changed, 14 insertions(+) diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index 0efaabe41e..fa6151f160 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -465,6 +465,17 @@ where self } + /// Configures the maximum number of local resets due to protocol errors made by the remote end. + /// + /// See the documentation of [`h2::client::Builder::max_local_error_reset_streams`] for more + /// details. + /// + /// The default value is 1024. + pub fn max_local_error_reset_streams(&mut self, max: impl Into>) -> &mut Self { + self.h2_builder.max_local_error_reset_streams = max.into(); + self + } + /// Constructs a connection with the configured options and IO. /// See [`client::conn`](crate::client::conn) for more. /// diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index 455c70980c..1fcc288c88 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -74,6 +74,7 @@ pub(crate) struct Config { pub(crate) max_concurrent_reset_streams: Option, pub(crate) max_send_buffer_size: usize, pub(crate) max_pending_accept_reset_streams: Option, + pub(crate) max_local_error_reset_streams: Option, pub(crate) header_table_size: Option, pub(crate) max_concurrent_streams: Option, } @@ -93,6 +94,7 @@ impl Default for Config { max_concurrent_reset_streams: None, max_send_buffer_size: DEFAULT_MAX_SEND_BUF_SIZE, max_pending_accept_reset_streams: None, + max_local_error_reset_streams: Some(1024), header_table_size: None, max_concurrent_streams: None, } @@ -107,6 +109,7 @@ fn new_builder(config: &Config) -> Builder { .initial_connection_window_size(config.initial_conn_window_size) .max_header_list_size(config.max_header_list_size) .max_send_buffer_size(config.max_send_buffer_size) + .max_local_error_reset_streams(config.max_local_error_reset_streams) .enable_push(false); if let Some(max) = config.max_frame_size { builder.max_frame_size(max); From 1e20d438a835d25260913fec7ade9368af63a876 Mon Sep 17 00:00:00 2001 From: tottoto Date: Tue, 17 Feb 2026 21:23:16 +0900 Subject: [PATCH 020/128] chore(dependencies): drop pin-utils dependency (#4023) --- Cargo.toml | 3 +-- src/common/task.rs | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 57dedb063c..8f69fea66b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -38,7 +38,6 @@ httparse = { version = "1.9", optional = true } httpdate = { version = "1.0", optional = true } itoa = { version = "1", optional = true } pin-project-lite = { version = "0.2.4", optional = true } -pin-utils = { version = "0.1", optional = true } # TODO: replace with std::pin::pin! once MSRV >= 1.68 smallvec = { version = "1.12", features = ["const_generics", "const_new"], optional = true } tracing = { version = "0.1", default-features = false, features = ["std"], optional = true } want = { version = "0.3", optional = true } @@ -81,7 +80,7 @@ full = [ ] # HTTP versions -http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa", "dep:pin-utils"] +http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa"] http2 = ["dep:futures-channel", "dep:futures-core", "dep:h2"] http3 = ["dep:h3"] diff --git a/src/common/task.rs b/src/common/task.rs index 90a0883465..7e6024557a 100644 --- a/src/common/task.rs +++ b/src/common/task.rs @@ -36,8 +36,8 @@ fn noop_waker() -> Waker { pub(crate) fn now_or_never(fut: F) -> Option { let waker = noop_waker(); let mut cx = Context::from_waker(&waker); - // TODO: replace with std::pin::pin! and drop pin-utils once MSRV >= 1.68 - pin_utils::pin_mut!(fut); + // TODO: replace with std::pin::pin! once MSRV >= 1.68 + tokio::pin!(fut); match fut.poll(&mut cx) { Poll::Ready(res) => Some(res), Poll::Pending => None, From c28122f5b57313bed28577fb9c583cd83c9905e8 Mon Sep 17 00:00:00 2001 From: dentiny Date: Fri, 13 Mar 2026 09:52:07 -0700 Subject: [PATCH 021/128] docs(client): fix HTTP/2 max concurrent stream link to spec (#4037) --- src/client/conn/http2.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index fa6151f160..ef14dee778 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -387,7 +387,7 @@ where /// /// See [Section 5.1.2] in the HTTP/2 spec for more details. /// - /// [Section 5.1.2]: https://http2.github.io/http2-spec/#rfc.section.5.1.2 + /// [Section 5.1.2]: https://httpwg.org/specs/rfc7540.html#rfc.section.5.1.2 pub fn max_concurrent_streams(&mut self, max: impl Into>) -> &mut Self { self.h2_builder.max_concurrent_streams = max.into(); self From 5c55ae9dacfd25edf6d8dd345a3f2a9c71f86f93 Mon Sep 17 00:00:00 2001 From: Dhruva D <70537056+DhruvaD1@users.noreply.github.com> Date: Mon, 16 Mar 2026 07:03:36 -0700 Subject: [PATCH 022/128] fix(ffi): validate null pointers before dereferencing in request/response functions (#4038) --- src/ffi/http_types.rs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/ffi/http_types.rs b/src/ffi/http_types.rs index 3dc4a2549d..14c3d243c8 100644 --- a/src/ffi/http_types.rs +++ b/src/ffi/http_types.rs @@ -189,9 +189,10 @@ ffi_fn! { }; builder = builder.path_and_query(path_and_query_bytes); } + let req = non_null!(&mut *req ?= hyper_code::HYPERE_INVALID_ARG); match builder.build() { Ok(u) => { - *unsafe { &mut *req }.0.uri_mut() = u; + *req.0.uri_mut() = u; hyper_code::HYPERE_OK }, Err(_) => { @@ -232,7 +233,8 @@ ffi_fn! { /// This is not an owned reference, so it should not be accessed after the /// `hyper_request` has been consumed. fn hyper_request_headers(req: *mut hyper_request) -> *mut hyper_headers { - hyper_headers::get_or_default(unsafe { &mut *req }.0.extensions_mut()) + let req = non_null!(&mut *req ?= std::ptr::null_mut()); + hyper_headers::get_or_default(req.0.extensions_mut()) } ?= std::ptr::null_mut() } @@ -367,7 +369,8 @@ ffi_fn! { /// This is not an owned reference, so it should not be accessed after the /// `hyper_response` has been freed. fn hyper_response_headers(resp: *mut hyper_response) -> *mut hyper_headers { - hyper_headers::get_or_default(unsafe { &mut *resp }.0.extensions_mut()) + let resp = non_null!(&mut *resp ?= std::ptr::null_mut()); + hyper_headers::get_or_default(resp.0.extensions_mut()) } ?= std::ptr::null_mut() } From e183f5ecd992615b9ae0b1cc9f21bd530f556933 Mon Sep 17 00:00:00 2001 From: Rafael de Conde Reis <82062045+Rafael-Conde@users.noreply.github.com> Date: Wed, 21 Oct 2026 14:42:31 -0300 Subject: [PATCH 023/128] bump on h3 version Signed-off-by: Rafael de Conde Reis <82062045+Rafael-Conde@users.noreply.github.com> --- Cargo.toml | 2 +- src/client/conn/http2.rs | 20 ++++++++++++++++++++ src/proto/h2/client.rs | 6 ++++++ 3 files changed, 27 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 8f69fea66b..48bf0be7b5 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ atomic-waker = { version = "1.1.2", optional = true } futures-channel = { version = "0.3", optional = true } futures-core = { version = "0.3.31", optional = true } futures-util = { version = "0.3", default-features = false, features = ["alloc"], optional = true } -h2 = { version = "0.4.2", optional = true } +h2 = { version = "0.4.6", optional = true } h3 = { version = "0.0.8", optional = true } http-body-util = { version = "0.1", optional = true } httparse = { version = "1.9", optional = true } diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index ef14dee778..e29e678b57 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -216,6 +216,26 @@ where pub fn is_extended_connect_protocol_enabled(&self) -> bool { self.inner.1.is_extended_connect_protocol_enabled() } + + /// Returns the current maximum send stream count. + /// + /// This setting is configured in a [`SETTINGS_MAX_CONCURRENT_STREAMS` parameter][1] in a `SETTINGS` frame, + /// and may change throughout the connection lifetime. + /// + /// [1]: https://datatracker.ietf.org/doc/html/rfc7540#section-5.1.2 + pub fn current_max_send_streams(&self) -> usize { + self.inner.1.current_max_send_streams() + } + + /// Returns the current maximum receive stream count. + /// + /// This setting is configured in a [`SETTINGS_MAX_CONCURRENT_STREAMS` parameter][1] in a `SETTINGS` frame, + /// and may change throughout the connection lifetime. + /// + /// [1]: https://datatracker.ietf.org/doc/html/rfc7540#section-5.1.2 + pub fn current_max_recv_streams(&self) -> usize { + self.inner.1.current_max_recv_streams() + } } impl fmt::Debug for Connection diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index 1fcc288c88..f96d67d73a 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -442,6 +442,12 @@ where pub(crate) fn is_extended_connect_protocol_enabled(&self) -> bool { self.h2_tx.is_extended_connect_protocol_enabled() } + pub(crate) fn current_max_send_streams(&self) -> usize { + self.h2_tx.current_max_send_streams() + } + pub(crate) fn current_max_recv_streams(&self) -> usize { + self.h2_tx.current_max_recv_streams() + } } pin_project! { From f3b9f5034125992111af5897278bdafc7071a55e Mon Sep 17 00:00:00 2001 From: wi-adam Date: Fri, 27 Mar 2026 09:10:45 -0700 Subject: [PATCH 024/128] fix(http1): allow keep-alive for chunked requests with trailers (#4043) When a chunked request body included trailers, poll_read_body incorrectly transitioned to Reading::Closed instead of Reading::KeepAlive. This prevented connection reuse for any request that sent trailers, even though trailers signal body completion just like a final data frame at EOF. Closes #4044 --- src/proto/h1/conn.rs | 3 ++- tests/server.rs | 52 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 3d71ed5bc5..3373021bf7 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -393,7 +393,8 @@ where }; (reading, Poll::Ready(maybe_frame)) } else if frame.is_trailers() { - (Reading::Closed, Poll::Ready(Some(Ok(frame)))) + debug!("incoming body completed with trailers"); + (Reading::KeepAlive, Poll::Ready(Some(Ok(frame)))) } else { trace!("discarding unknown frame"); (Reading::Closed, Poll::Ready(None)) diff --git a/tests/server.rs b/tests/server.rs index 1cd0eba570..651fbdf40d 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2967,6 +2967,58 @@ fn http1_trailer_recv_fields() { ); } +#[test] +fn http1_trailer_recv_keep_alive() { + let server = serve(); + server.reply().header("content-length", "2").body(b"ok"); + let mut req = connect(server.addr()); + + // First request: chunked POST with trailers + req.write_all( + b"\ + POST / HTTP/1.1\r\n\ + trailer: chunky-trailer\r\n\ + host: example.domain\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + 5\r\n\ + hello\r\n\ + 0\r\n\ + chunky-trailer: header data\r\n\ + \r\n\ + ", + ) + .expect("writing 1"); + + assert_eq!(server.body(), b"hello"); + + let trailers = server.trailers(); + assert_eq!( + trailers.get("chunky-trailer"), + Some(&"header data".parse().unwrap()) + ); + + read_until(&mut req, |buf| buf.ends_with(b"ok")).expect("reading 1"); + + // Second request: reuse the same connection to verify keep-alive + let quux = b"zar quux"; + server + .reply() + .header("content-length", quux.len().to_string()) + .body(quux); + req.write_all( + b"\ + GET /quux HTTP/1.1\r\n\ + Host: example.domain\r\n\ + Connection: close\r\n\ + \r\n\ + ", + ) + .expect("writing 2"); + + read_until(&mut req, |buf| buf.ends_with(quux)).expect("reading 2"); +} + // ------------------------------------------------- // the Server that is used to run all the tests with // ------------------------------------------------- From 4e0b70cbdfc051dd4b0d66b5874b9b1f69b152cf Mon Sep 17 00:00:00 2001 From: Manish Mishra Date: Tue, 31 Mar 2026 14:40:11 +0100 Subject: [PATCH 025/128] fix(http2): cancel sending client request body on response future drop (#4042) When the client drops the response future (e.g. due to a timeout), send_task detects the cancellation but does not notify pipe_task. The pipe_task continues to hold the h2 SendStream, preventing a RST_STREAM from being sent and keeping flow-control window capacity locked. Add a oneshot channel between send_task and pipe_task. When send_task detects cancellation via poll_canceled, it signals pipe_task through the channel. pipe_task then calls send_reset(CANCEL) on the h2 SendStream, which sends RST_STREAM to the server and frees flow-control capacity. Closes #4040 --- src/client/dispatch.rs | 3 +++ src/proto/h2/client.rs | 37 +++++++++++++++++++++++++++++++++++++ src/proto/h2/mod.rs | 5 +++++ tests/client.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 86 insertions(+) diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 2cbc1b9225..6dbc8672e0 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -366,6 +366,9 @@ where } }; trace!("send_when canceled"); + // Tell pipe_task to reset the h2 stream so that + // RST_STREAM is sent and flow-control capacity freed. + this.when.as_mut().cancel(); Poll::Ready(()) } Poll::Ready(Err((error, message))) => { diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index f96d67d73a..bf154e4580 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -461,6 +461,7 @@ pin_project! { conn_drop_ref: Option>, #[pin] ping: Option, + cancel_rx: Option>, } } @@ -474,6 +475,26 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> std::task::Poll { let mut this = self.project(); + // Check if the client cancelled the request (e.g. dropped the + // response future due to a timeout). If so, reset the h2 stream + // so that a RST_STREAM is sent and flow-control capacity is freed. + let cancel_result = this.cancel_rx.as_mut().map(|rx| Pin::new(rx).poll(cx)); + match cancel_result { + Some(Poll::Ready(Ok(()))) => { + debug!("client request body send cancelled, resetting stream"); + this.pipe.as_mut().send_reset(h2::Reason::CANCEL); + drop(this.conn_drop_ref.take().expect("Future polled twice")); + drop(this.ping.take().expect("Future polled twice")); + return Poll::Ready(()); + } + Some(Poll::Ready(Err(_))) => { + // Sender dropped without cancelling (normal response or error). + // Stop polling the receiver. + *this.cancel_rx = None; + } + Some(Poll::Pending) | None => {} + } + match Pin::new(&mut this.pipe).poll(cx) { Poll::Ready(result) => { if let Err(_e) = result { @@ -500,6 +521,10 @@ where fn poll_pipe(&mut self, f: FutCtx, cx: &mut Context<'_>) { let ping = self.ping.clone(); + // A one-shot channel so that send_task can tell pipe_task to + // reset the stream when the client cancels the request. + let (cancel_tx, cancel_rx) = oneshot::channel::<()>(); + let send_stream = if !f.is_connect { if !f.eos { let mut pipe = PipeToSendStream::new(f.body, f.body_tx); @@ -519,6 +544,7 @@ where pipe, conn_drop_ref: Some(conn_drop_ref), ping: Some(ping), + cancel_rx: Some(cancel_rx), }; // Clear send task self.executor @@ -539,6 +565,7 @@ where ping: Some(ping), send_stream: Some(send_stream), exec: self.executor.clone(), + cancel_tx: Some(cancel_tx), }, call_back: Some(f.cb), }, @@ -558,6 +585,16 @@ pin_project! { #[pin] send_stream: Option::Data>>>>, exec: E, + cancel_tx: Option>, + } +} + +impl ResponseFutMap { + /// Signal the pipe_task to reset the stream (e.g. on client cancellation). + pub(crate) fn cancel(self: Pin<&mut Self>) { + if let Some(cancel_tx) = self.project().cancel_tx.take() { + let _ = cancel_tx.send(()); + } } } diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index 1a70b789c7..9d4826152e 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -104,6 +104,11 @@ where stream, } } + + #[cfg(feature = "client")] + fn send_reset(self: Pin<&mut Self>, reason: h2::Reason) { + self.project().body_tx.send_reset(reason); + } } impl Future for PipeToSendStream diff --git a/tests/client.rs b/tests/client.rs index 5f976b1792..e80459327a 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -2868,6 +2868,47 @@ mod conn { Pin::new(&mut self.tcp).poll_read(cx, buf) } } + + // https://github.com/hyperium/hyper/issues/4040 + #[tokio::test] + async fn h2_pipe_task_cancelled_on_response_future_drop() { + let (client_io, server_io, _) = setup_duplex_test_server(); + let (rst_tx, rst_rx) = oneshot::channel::(); + + tokio::spawn(async move { + let mut builder = h2::server::Builder::new(); + builder.initial_window_size(0); + let mut h2 = builder.handshake::<_, Bytes>(server_io).await.unwrap(); + let (req, _respond) = h2.accept().await.unwrap().unwrap(); + tokio::spawn(async move { + let _ = poll_fn(|cx| h2.poll_closed(cx)).await; + }); + + let mut body = req.into_body(); + let got_rst = tokio::time::timeout(Duration::from_secs(2), body.data()) + .await + .map_or(false, |frame| matches!(frame, Some(Err(_)) | None)); + let _ = rst_tx.send(got_rst); + }); + + let io = TokioIo::new(client_io); + let (mut client, conn) = conn::http2::Builder::new(TokioExecutor) + .handshake(io) + .await + .expect("http handshake"); + tokio::spawn(async move { + let _ = conn.await; + }); + + let req = Request::post("http://localhost/") + .body(Full::new(Bytes::from(vec![b'x'; 50]))) + .unwrap(); + let res = tokio::time::timeout(Duration::from_millis(5), client.send_request(req)).await; + assert!(res.is_err(), "should timeout waiting for response"); + + let got_rst = rst_rx.await.expect("server task should complete"); + assert!(got_rst, "server should receive RST_STREAM"); + } } trait FutureHyperExt: TryFuture { From 7a61bc100baa86135021d4d16bbca0dfab18ecb3 Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Tue, 16 Dec 2025 13:41:00 +0100 Subject: [PATCH 026/128] feat(error): add 'Error::is_parse_version_h2' method The method returns true if the underlying error is a parse error caused by HTTP2 preface sent over an HTTP1 connection. --- src/error.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/src/error.rs b/src/error.rs index 8b41f9c93d..90c64008ab 100644 --- a/src/error.rs +++ b/src/error.rs @@ -192,6 +192,13 @@ impl Error { matches!(self.inner.kind, Kind::Parse(Parse::Status)) } + /// Returns true if this was an HTTP parse error caused by HTTP2 preface sent over an HTTP1 + /// connection. + #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] + pub fn is_parse_version_h2(&self) -> bool { + matches!(self.inner.kind, Kind::Parse(Parse::VersionH2)) + } + /// Returns true if this error was caused by user code. pub fn is_user(&self) -> bool { matches!(self.inner.kind, Kind::User(_)) From c30edc675a7ffff754016e4822f63172fde6fd7c Mon Sep 17 00:00:00 2001 From: Pavel Borzenkov Date: Tue, 16 Dec 2025 13:47:01 +0100 Subject: [PATCH 027/128] feat(http1): add UpgradeableConnection::into_parts This allows the connection to be deconstructed into I/O object and some additional information, provided it hasn't yet been upgraded. --- src/server/conn/http1.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index 36dda98069..a2fa5e1d6d 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -522,6 +522,12 @@ where Pin::new(conn).graceful_shutdown() } } + + /// Return the inner IO object, and additional information provided the connection + /// has not yet been upgraded. + pub fn into_parts(self) -> Option> { + self.inner.map(|conn| conn.into_parts()) + } } impl Future for UpgradeableConnection From 3cc79f1ef44f7ed9369d5c40031e2baa701e08dd Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 31 Mar 2026 11:36:10 -0400 Subject: [PATCH 028/128] v1.9.0 --- CHANGELOG.md | 24 ++++++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 883a0620c5..b1adf4accc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,27 @@ +## v1.9.0 (2026-03-31) + + +#### Bug Fixes + +* **ffi:** validate null pointers before dereferencing in request/response functions (#4038 ([28e73ccd](https://github.com/hyperium/hyper/commit/28e73ccd230cff8d7e5b5880ce2ab5fb8ced36be)) +* **http1:** + * allow keep-alive for chunked requests with trailers (#4043) ([7211ec25](https://github.com/hyperium/hyper/commit/7211ec25eff2ea6ee783817fee2a221d4eb2ed03), closes [#4044](https://github.com/hyperium/hyper/issues/4044)) + * use case-insensitive matching for trailer fields (#4011) ([3b344cac](https://github.com/hyperium/hyper/commit/3b344cac9f96a9365409086dde51d06aa797ffc3), closes [#4010](https://github.com/hyperium/hyper/issues/4010)) + * use httparse config for Servers (#4002) ([bcb8ec57](https://github.com/hyperium/hyper/commit/bcb8ec576619650d3388604e6c246829e7be133a), closes [#3923](https://github.com/hyperium/hyper/issues/3923)) +* **http2:** + * cancel sending client request body on response future drop (#4042) ([5b17a69e](https://github.com/hyperium/hyper/commit/5b17a69ebcf969471c1a19b25ed2cb81299d1be6), closes [#4040](https://github.com/hyperium/hyper/issues/4040)) + * non-utf8 char in Connection header may cause panic when calling to_str (#4019) ([c36ca8a5](https://github.com/hyperium/hyper/commit/c36ca8a5c50e4a05a78aa3e158f13456ee674fb1)) + + +#### Features + +* **client:** + * expose HTTP/2 current max stream count (#4026) ([d51cb715](https://github.com/hyperium/hyper/commit/d51cb71569bbca7927b3828ef11e3bec4fa97eb4)) + * add HTTP/2 `max_local_error_reset_streams` option (#4021) ([57787459](https://github.com/hyperium/hyper/commit/577874591cae246dfb2d72d7140d794ac2fa605a)) +* **error:** add 'Error::is_parse_version_h2' method ([393c77c7](https://github.com/hyperium/hyper/commit/393c77c71138ff6e33d7089deac770a3f3f1436b)) +* **http1:** add UpgradeableConnection::into_parts ([e21205cf](https://github.com/hyperium/hyper/commit/e21205cfe4066edbcddc56150d963dabbc7d3ec4)) + + ### v1.8.1 (2025-11-13) diff --git a/Cargo.toml b/Cargo.toml index 48bf0be7b5..77ceca8c6a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.8.1" +version = "1.9.0" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From 4f0bf0c76af4a1f1564ac5b3d41dd8f1e812bf81 Mon Sep 17 00:00:00 2001 From: Louis Thiery Date: Tue, 2 Dec 2025 13:02:52 -0800 Subject: [PATCH 029/128] test(http1): setup test fixture for dispatch loop --- Cargo.toml | 12 +++ tests/h1_server/fixture.rs | 136 +++++++++++++++++++++++++++++++++ tests/h1_server/mod.rs | 97 ++++++++++++++++++++++++ tests/ready_on_poll_stream.rs | 139 ++++++++++++++++++++++++++++++++++ tests/unbuffered_stream.rs | 126 ++++++++++++++++++++++++++++++ 5 files changed, 510 insertions(+) create mode 100644 tests/h1_server/fixture.rs create mode 100644 tests/h1_server/mod.rs create mode 100644 tests/ready_on_poll_stream.rs create mode 100644 tests/unbuffered_stream.rs diff --git a/Cargo.toml b/Cargo.toml index 77ceca8c6a..56b28ff3fe 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -52,6 +52,8 @@ pin-project-lite = "0.2.4" spmc = "0.3" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +tracing = "0.1" +tracing-subscriber = "0.3" tokio = { version = "1", features = [ "fs", "macros", @@ -241,6 +243,16 @@ name = "integration" path = "tests/integration.rs" required-features = ["full"] +[[test]] +name = "ready_on_poll_stream" +path = "tests/ready_on_poll_stream.rs" +required-features = ["full"] + +[[test]] +name = "unbuffered_stream" +path = "tests/unbuffered_stream.rs" +required-features = ["full"] + [[test]] name = "server" path = "tests/server.rs" diff --git a/tests/h1_server/fixture.rs b/tests/h1_server/fixture.rs new file mode 100644 index 0000000000..6b31efecbc --- /dev/null +++ b/tests/h1_server/fixture.rs @@ -0,0 +1,136 @@ +use http_body_util::StreamBody; +use hyper::body::Bytes; +use hyper::body::Frame; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::{Response, StatusCode}; +use std::convert::Infallible; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tracing::{error, info}; + +pub struct TestConfig { + pub total_chunks: usize, + pub chunk_size: usize, + pub chunk_timeout: Duration, +} + +impl TestConfig { + pub fn with_timeout(chunk_timeout: Duration) -> Self { + Self { + total_chunks: 16, + chunk_size: 64 * 1024, + chunk_timeout, + } + } +} + +pub struct Client { + pub rx: mpsc::UnboundedReceiver>, + pub tx: mpsc::UnboundedSender>, +} + +pub async fn run(server: S, mut client: Client, config: TestConfig) +where + S: hyper::rt::Read + hyper::rt::Write + Send + Unpin + 'static, +{ + let mut http_builder = http1::Builder::new(); + http_builder.max_buf_size(config.chunk_size); + + let total_chunks = config.total_chunks; + let chunk_size = config.chunk_size; + + let service = service_fn(move |_| { + let total_chunks = total_chunks; + let chunk_size = chunk_size; + async move { + info!( + "Creating payload of {} chunks of {} KiB each ({} MiB total)...", + total_chunks, + chunk_size / 1024, + total_chunks * chunk_size / (1024 * 1024) + ); + let bytes = Bytes::from(vec![0; chunk_size]); + let data = vec![bytes.clone(); total_chunks]; + let stream = futures_util::stream::iter( + data.into_iter() + .map(|b| Ok::<_, Infallible>(Frame::data(b))), + ); + let body = StreamBody::new(stream); + info!("Server: Sending data response..."); + Ok::<_, hyper::Error>( + Response::builder() + .status(StatusCode::OK) + .header("content-type", "application/octet-stream") + .header("content-length", (total_chunks * chunk_size).to_string()) + .body(body) + .unwrap(), + ) + } + }); + + let server_task = tokio::spawn(async move { + let conn = http_builder.serve_connection(Box::pin(server), service); + let conn_result = conn.await; + if let Err(e) = &conn_result { + error!("Server connection error: {}", e); + } + conn_result + }); + + let get_request = "GET / HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"; + client + .tx + .send(get_request.as_bytes().to_vec()) + .map_err(|e| { + Box::new(std::io::Error::new( + std::io::ErrorKind::Other, + format!("Failed to send request: {}", e), + )) + }) + .unwrap(); + + info!("Client is reading response..."); + let mut bytes_received = 0; + let mut all_data = Vec::new(); + loop { + match timeout(config.chunk_timeout, client.rx.recv()).await { + Ok(Some(chunk)) => { + bytes_received += chunk.len(); + all_data.extend_from_slice(&chunk); + } + Ok(None) => break, + Err(_) => { + panic!( + "Chunk timeout: chunk took longer than {:?}", + config.chunk_timeout + ); + } + } + } + + // Clean up + let result = server_task.await.unwrap(); + result.unwrap(); + + // Parse HTTP response to find body start + // HTTP response format: "HTTP/1.1 200 OK\r\n...headers...\r\n\r\n" + let body_start = all_data + .windows(4) + .position(|w| w == b"\r\n\r\n") + .map(|pos| pos + 4) + .unwrap_or(0); + + let body_bytes = bytes_received - body_start; + assert_eq!( + body_bytes, + config.total_chunks * config.chunk_size, + "Expected {} body bytes, got {} (total received: {}, headers: {})", + config.total_chunks * config.chunk_size, + body_bytes, + bytes_received, + body_start + ); + info!(bytes_received, body_bytes, "Client done receiving bytes"); +} diff --git a/tests/h1_server/mod.rs b/tests/h1_server/mod.rs new file mode 100644 index 0000000000..7b2ee0a350 --- /dev/null +++ b/tests/h1_server/mod.rs @@ -0,0 +1,97 @@ +pub mod fixture; + +use hyper::rt::{Read, ReadBufCursor}; +use pin_project_lite::pin_project; +use std::io; +use std::pin::Pin; +use std::task::{ready, Context, Poll}; +use tokio::sync::mpsc; + +// Common read half shared by both stream types +pin_project! { + #[derive(Debug)] + pub struct StreamReadHalf { + #[pin] + read_rx: mpsc::UnboundedReceiver>, + read_buffer: Vec, + } +} + +impl StreamReadHalf { + pub fn new(read_rx: mpsc::UnboundedReceiver>) -> Self { + Self { + read_rx, + read_buffer: Vec::new(), + } + } +} + +impl Read for StreamReadHalf { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + mut buf: ReadBufCursor<'_>, + ) -> Poll> { + let mut this = self.as_mut().project(); + + // First, try to satisfy the read request from the internal buffer + if !this.read_buffer.is_empty() { + let to_read = std::cmp::min(this.read_buffer.len(), buf.remaining()); + // Copy data from internal buffer to the read buffer + buf.put_slice(&this.read_buffer[..to_read]); + // Remove the consumed data from the internal buffer + this.read_buffer.drain(..to_read); + return Poll::Ready(Ok(())); + } + + // If internal buffer is empty, try to get data from the channel + match this.read_rx.as_mut().get_mut().try_recv() { + Ok(data) => { + // Copy as much data as we can fit in the buffer + let to_read = std::cmp::min(data.len(), buf.remaining()); + buf.put_slice(&data[..to_read]); + + // Store any remaining data in the internal buffer for next time + if to_read < data.len() { + let remaining = &data[to_read..]; + this.read_buffer.extend_from_slice(remaining); + } + Poll::Ready(Ok(())) + } + Err(mpsc::error::TryRecvError::Empty) => { + match ready!(this.read_rx.poll_recv(cx)) { + Some(data) => { + // Copy as much data as we can fit in the buffer + let to_read = std::cmp::min(data.len(), buf.remaining()); + buf.put_slice(&data[..to_read]); + + // Store any remaining data in the internal buffer for next time + if to_read < data.len() { + let remaining = &data[to_read..]; + this.read_buffer.extend_from_slice(remaining); + } + Poll::Ready(Ok(())) + } + None => Poll::Ready(Ok(())), + } + } + Err(mpsc::error::TryRecvError::Disconnected) => { + // Channel closed, return EOF + Poll::Ready(Ok(())) + } + } + } +} + +pub fn init_tracing() { + use std::sync::Once; + static INIT: Once = Once::new(); + INIT.call_once(|| { + tracing_subscriber::fmt() + .with_max_level(tracing::Level::INFO) + .with_target(true) + .with_thread_ids(true) + .with_thread_names(true) + .init(); + }); +} diff --git a/tests/ready_on_poll_stream.rs b/tests/ready_on_poll_stream.rs new file mode 100644 index 0000000000..f393d31c0e --- /dev/null +++ b/tests/ready_on_poll_stream.rs @@ -0,0 +1,139 @@ +#[path = "h1_server/mod.rs"] +mod h1_server; + +use h1_server::{fixture, init_tracing, StreamReadHalf}; +use hyper::rt::{Read, ReadBufCursor, Write}; +use pin_project_lite::pin_project; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{ready, Context, Poll}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Sleep; +use tracing::error; + +pin_project! { + #[derive(Debug)] + pub struct ReadyOnPollStream { + #[pin] + read_half: StreamReadHalf, + write_tx: mpsc::UnboundedSender>, + #[pin] + pending_write: Option>>, + poll_since_write: bool, + flush_count: usize, + } +} + +impl ReadyOnPollStream { + fn new( + read_rx: mpsc::UnboundedReceiver>, + write_tx: mpsc::UnboundedSender>, + ) -> Self { + Self { + read_half: StreamReadHalf::new(read_rx), + write_tx, + poll_since_write: true, + flush_count: 0, + pending_write: None, + } + } + + /// Create a new server stream and client pair. + /// Returns a server stream (Read+Write) and a client (rx/tx channels). + pub fn new_pair() -> (Self, fixture::Client) { + let (client_tx, server_rx) = mpsc::unbounded_channel(); + let (server_tx, client_rx) = mpsc::unbounded_channel(); + let server = Self::new(server_rx, server_tx); + let client = fixture::Client { + rx: client_rx, + tx: client_tx, + }; + (server, client) + } +} + +impl Read for ReadyOnPollStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + self.as_mut().project().read_half.poll_read(cx, buf) + } +} + +const WRITE_DELAY: Duration = Duration::from_millis(100); + +impl Write for ReadyOnPollStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if let Some(sleep) = self.pending_write.as_mut() { + let sleep = sleep.as_mut(); + ready!(Future::poll(sleep, cx)); + } + { + let mut this = self.as_mut().project(); + this.pending_write + .set(Some(Box::pin(tokio::time::sleep(WRITE_DELAY)))); + } + let Some(sleep) = self.pending_write.as_mut() else { + panic!("Sleep should have just been set"); + }; + // poll the future so that we can woken + let sleep = sleep.as_mut(); + let Poll::Pending = Future::poll(sleep, cx) else { + panic!("Sleep always be pending on first poll") + }; + + let this = self.project(); + let buf = Vec::from(&buf[..buf.len()]); + let len = buf.len(); + + // Send data through the channel - this should always be ready for unbounded channels + match this.write_tx.send(buf) { + Ok(_) => Poll::Ready(Ok(len)), + Err(_) => { + error!("ReadyStream::poll_write failed - channel closed"); + Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Write channel closed", + ))) + } + } + } + + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.flush_count += 1; + // We require two flushes to complete each chunk, simulating a success at the end of the old + // poll loop. After all chunks are written, we always succeed on flush to allow for finish. + const TOTAL_CHUNKS: usize = 16; + if self.flush_count % 2 != 0 && self.flush_count < TOTAL_CHUNKS * 2 { + if let Some(sleep) = self.pending_write.as_mut() { + let sleep = sleep.as_mut(); + ready!(Future::poll(sleep, cx)); + } else { + return Poll::Pending; + } + } + let mut this = self.as_mut().project(); + this.pending_write.set(None); + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn body_test() { + init_tracing(); + let (server, client) = ReadyOnPollStream::new_pair(); + let config = fixture::TestConfig::with_timeout(WRITE_DELAY * 2); + fixture::run(server, client, config).await; +} diff --git a/tests/unbuffered_stream.rs b/tests/unbuffered_stream.rs new file mode 100644 index 0000000000..9cea28d5f8 --- /dev/null +++ b/tests/unbuffered_stream.rs @@ -0,0 +1,126 @@ +#[path = "h1_server/mod.rs"] +mod h1_server; + +use h1_server::{fixture, init_tracing, StreamReadHalf}; +use hyper::rt::{Read, ReadBufCursor, Write}; +use pin_project_lite::pin_project; +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{ready, Context, Poll}; +use std::time::Duration; +use tokio::sync::mpsc; +use tokio::time::Sleep; +use tracing::error; + +pin_project! { + #[derive(Debug)] + pub struct UnbufferedStream { + #[pin] + read_half: StreamReadHalf, + #[pin] + pending_write: Option>>, + write_tx: mpsc::UnboundedSender>, + poll_cnt: usize, + } +} + +impl UnbufferedStream { + fn new( + read_rx: mpsc::UnboundedReceiver>, + write_tx: mpsc::UnboundedSender>, + ) -> Self { + Self { + read_half: StreamReadHalf::new(read_rx), + write_tx, + pending_write: None, + poll_cnt: 0, + } + } + + /// Create a new server stream and client pair. + /// Returns a server stream (Read+Write) and a client (rx/tx channels). + pub fn new_pair() -> (Self, fixture::Client) { + let (client_tx, server_rx) = mpsc::unbounded_channel(); + let (server_tx, client_rx) = mpsc::unbounded_channel(); + let server = Self::new(server_rx, server_tx); + let client = fixture::Client { + rx: client_rx, + tx: client_tx, + }; + (server, client) + } +} + +const WRITE_DELAY: Duration = Duration::from_millis(100); + +impl Read for UnbufferedStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: ReadBufCursor<'_>, + ) -> Poll> { + let response = self.as_mut().project().read_half.poll_read(cx, buf); + response + } +} + +impl Write for UnbufferedStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + self.poll_cnt += 1; + let poll_cnt = self.poll_cnt; + if let Some(sleep) = self.pending_write.as_mut() { + let sleep = sleep.as_mut(); + if poll_cnt > 4 { + return Poll::Ready(Err(io::Error::other("We are being hot polled!"))); + } + ready!(Future::poll(sleep, cx)); + let mut this = self.as_mut().project(); + this.pending_write.set(None); + *this.poll_cnt = 0; + } + let len = buf.len(); + { + let mut this = self.as_mut().project(); + let buf = Vec::from(&buf[..buf.len()]); + // Send data through the channel - this should always be ready for unbounded channels + let Ok(_) = this.write_tx.send(buf) else { + error!("UnbufferedStream::poll_write failed - channel closed"); + return Poll::Ready(Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Write channel closed", + ))); + }; + this.pending_write + .set(Some(Box::pin(tokio::time::sleep(WRITE_DELAY)))) + } + let Some(sleep) = self.pending_write.as_mut() else { + panic!("Sleep should have just been set"); + }; + let sleep = sleep.as_mut(); + let Poll::Pending = Future::poll(sleep, cx) else { + panic!("Sleep always be pending on first poll") + }; + Poll::Ready(Ok(len)) + } + + fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn body_test() { + init_tracing(); + let (server, client) = UnbufferedStream::new_pair(); + let config = fixture::TestConfig::with_timeout(WRITE_DELAY * 2); + fixture::run(server, client, config).await; +} From ec0106a51cb06d2c35bad28e067446f428cce482 Mon Sep 17 00:00:00 2001 From: Louis Thiery Date: Tue, 2 Dec 2025 13:03:12 -0800 Subject: [PATCH 030/128] fix(http1): fix rare missed write wakeup on connections v2 --- src/proto/h1/dispatch.rs | 40 ++++++++++++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 6 deletions(-) diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 5daeb5ebf6..91defd3234 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -170,8 +170,14 @@ where // benchmarks often use. Perhaps it should be a config option instead. for _ in 0..16 { let _ = self.poll_read(cx)?; - let _ = self.poll_write(cx)?; - let _ = self.poll_flush(cx)?; + let write_ready = self.poll_write(cx)?.is_ready(); + let flush_ready = self.poll_flush(cx)?.is_ready(); + + // If we can write more body and the connection is ready, we should + // write again. If we return `Ready(Ok(())` here, we will yield + // without a guaranteed wake-up from the write side of the connection. + // This would lead to a deadlock if we also don't expect reads. + let wants_write_again = self.can_write_again() && (write_ready || flush_ready); // This could happen if reading paused before blocking on IO, // such as getting to the end of a framed message, but then @@ -181,14 +187,31 @@ where // // Using this instead of task::current() and notify() inside // the Conn is noticeably faster in pipelined benchmarks. - if !self.conn.wants_read_again() { - //break; + let wants_read_again = self.conn.wants_read_again(); + + // If we cannot write or read again, we yield and rely on the + // wake-up from the connection futures. + if !(wants_write_again || wants_read_again) { return Poll::Ready(Ok(())); } - } + // If we are continuing only because "wants_write_again", check if write is ready. + if !wants_read_again && wants_write_again { + // If write was ready, just proceed with the loop + if write_ready { + continue; + } + // Write was previously pending, but may have become ready since polling flush, so + // we need to check it again. If we simply proceeded, the case of an unbuffered + // writer where flush is always ready would cause us to hot loop. + if self.poll_write(cx)?.is_pending() { + // write is pending, so it is safe to yield and rely on wake-up from connection + // futures. + return Poll::Ready(Ok(())); + } + } + } trace!("poll_loop yielding (self = {:p})", self); - task::yield_now(cx).map(|never| match never {}) } @@ -433,6 +456,11 @@ where self.conn.close_write(); } + /// If there is pending data in body_rx, we can make progress writing if the connection is ready. + fn can_write_again(&mut self) -> bool { + self.body_rx.is_some() + } + fn is_done(&self) -> bool { if self.is_closing { return true; From c87ec95ec9eb88e4260d0086821b22c9e37aefff Mon Sep 17 00:00:00 2001 From: Ulyssa Date: Wed, 22 Apr 2026 14:40:55 -0700 Subject: [PATCH 031/128] fix(http2): reading trailers shouldn't propagate `NO_ERROR` from early response (#3998) This is the trailers variant of the fix for reading the body in #3275, so that it is possible to both attempt to read the body and the trailers when the server has sent a `RST_STREAM` with `NO_ERROR` after its response to indicate that the client should stop attempting to send it the body. I have added a trailers-only variant of `http2_responds_before_consuming_request_body` that fails without the fix, and also updated `http2_responds_before_consuming_request_body` to verify that it can check whether there are any trailers. --- src/body/incoming.rs | 27 ++++++++---- tests/client.rs | 97 ++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 107 insertions(+), 17 deletions(-) diff --git a/src/body/incoming.rs b/src/body/incoming.rs index 8c7c6ff67e..4b0ca60e04 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -248,14 +248,14 @@ impl Body for Incoming { return Poll::Ready(Some(Ok(Frame::data(bytes)))); } Some(Err(e)) => { - return match e.reason() { - // These reasons should cause the body reading to stop, but not fail it. - // The same logic as for `Read for H2Upgraded` is applied here. - Some(h2::Reason::NO_ERROR) | Some(h2::Reason::CANCEL) => { - Poll::Ready(None) - } - _ => Poll::Ready(Some(Err(crate::Error::new_body(e)))), - }; + if let Some(h2::Reason::NO_ERROR) = e.reason() { + // As mentioned in RFC 7540 Section 8.1, a RST_STREAM with NO_ERROR + // indicates an early response, and should cause the body reading + // to stop, but not fail it: + return Poll::Ready(None); + } else { + return Poll::Ready(Some(Err(crate::Error::new_body(e)))); + } } None => { *data_done = true; @@ -270,7 +270,16 @@ impl Body for Incoming { ping.record_non_data(); Poll::Ready(Ok(t.map(Frame::trailers)).transpose()) } - Err(e) => Poll::Ready(Some(Err(crate::Error::new_h2(e)))), + Err(e) => { + if let Some(h2::Reason::NO_ERROR) = e.reason() { + // Same as above, a RST_STREAM with NO_ERROR indicates an early + // response, and should cause reading the trailers to stop, but + // not fail it: + Poll::Ready(None) + } else { + Poll::Ready(Some(Err(crate::Error::new_h2(e)))) + } + } } } diff --git a/tests/client.rs b/tests/client.rs index e80459327a..f74cec740f 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -2626,7 +2626,7 @@ mod conn { } #[tokio::test] - async fn http2_responds_before_consuming_request_body() { + async fn http2_responds_before_consuming_request_body_no_trailers() { // Test that a early-response from server works correctly (request body wasn't fully consumed). // https://github.com/hyperium/hyper/issues/2872 use hyper::service::service_fn; @@ -2670,15 +2670,96 @@ mod conn { let resp = client.send_request(req).await.expect("send_request"); assert!(resp.status().is_success()); - let mut body = String::new(); - concat(resp.into_body()) + let (body, trailers) = crate::concat_with_trailers(resp.into_body()).await.unwrap(); + assert_eq!(body.as_ref(), b"No bread for you!"); + assert!(trailers.is_none()); + } + + #[tokio::test] + async fn http2_responds_before_consuming_request_body_with_trailers() { + // Test that a early-response from server works correctly (request body wasn't fully consumed). + // https://github.com/hyperium/hyper/issues/2872 + use hyper::body::{Body, Frame, SizeHint}; + use hyper::header::{HeaderMap, HeaderValue}; + use hyper::service::service_fn; + + let _ = pretty_env_logger::try_init(); + + let (listener, addr) = setup_tk_test_server().await; + + /// An `HttpBody` implementation whose `is_end_stream()` will + /// return `true` after sending trailers. + pub struct TrailersBody(Option); + + impl Body for TrailersBody { + type Data = bytes::Bytes; + type Error = hyper::Error; + + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + if let Some(trailers) = self.0.take() { + Poll::Ready(Some(Ok(Frame::trailers(trailers)))) + } else { + Poll::Ready(None) + } + } + + fn is_end_stream(&self) -> bool { + self.0.is_none() + } + + fn size_hint(&self) -> SizeHint { + SizeHint::with_exact(0) + } + } + + // Spawn an HTTP2 server that responds before reading the whole request body. + // It's normal case to decline the request due to headers or size of the body. + tokio::spawn(async move { + let sock = TokioIo::new(listener.accept().await.unwrap().0); + hyper::server::conn::http2::Builder::new(TokioExecutor) + .timer(TokioTimer) + .serve_connection( + sock, + service_fn(|_req| async move { + let mut trailers = HeaderMap::new(); + trailers.insert("grpc", HeaderValue::from_static("0")); + let body = TrailersBody(Some(trailers)); + Ok::<_, hyper::Error>(http::Response::new(body)) + }), + ) + .await + .expect("serve_connection"); + }); + + let io = tcp_connect(&addr).await.expect("tcp connect"); + let (mut client, conn) = conn::http2::Builder::new(TokioExecutor) + .timer(TokioTimer) + .handshake(io) .await - .unwrap() - .reader() - .read_to_string(&mut body) - .unwrap(); + .expect("http handshake"); + + tokio::spawn(async move { + conn.await.expect("client conn shouldn't error"); + }); + + // Use a channel to keep request stream open + let (_tx, recv) = mpsc::channel::, Box>>(0); + let req = Request::post("/a").body(StreamBody::new(recv)).unwrap(); + let resp = client.send_request(req).await.expect("send_request"); + assert!(resp.status().is_success()); + + let (body, trailers) = crate::concat_with_trailers(resp.into_body()).await.unwrap(); + + // No body: + assert!(body.is_empty()); - assert_eq!(&body, "No bread for you!"); + // Have our `grpc` trailer: + let trailers = trailers.expect("response has trailers"); + assert_eq!(trailers.len(), 1); + assert_eq!(trailers.get("grpc").unwrap(), "0"); } #[tokio::test] From fbc0a99e681f6c90e30680519aa42041076dfd15 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 27 Apr 2026 08:31:58 -0400 Subject: [PATCH 032/128] fix(http1): fix reading large bodies on 32-bit systems (#4056) On 32-bit systems, the cast of the length to read from a u64 to a usize could truncate, and cause less to be read. If the length was larger than usize::MAX, the end could not be read. To fix this, we use a saturating cast pattern instead. Closes #4055 --- src/proto/h1/decode.rs | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index e19dcb4400..6047d45ab2 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -152,7 +152,7 @@ impl Decoder { if *remaining == 0 { Poll::Ready(Ok(Frame::data(Bytes::new()))) } else { - let to_read = *remaining as usize; + let to_read = usize::try_from(*remaining).unwrap_or(usize::MAX); let buf = ready!(body.read_mem(cx, to_read))?; let num = buf.as_ref().len() as u64; if num > *remaining { @@ -489,12 +489,7 @@ impl ChunkedState { trace!("Chunked read, remaining={:?}", rem); // cap remaining bytes at the max capacity of usize - let rem_cap = match *rem { - r if r > usize::MAX as u64 => usize::MAX, - r => r as usize, - }; - - let to_read = rem_cap; + let to_read = usize::try_from(*rem).unwrap_or(usize::MAX); let slice = ready!(rdr.read_mem(cx, to_read))?; let count = slice.len(); From 49557e37a371613d37922e4f88ce2352e63a1536 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 4 May 2026 16:49:23 -0400 Subject: [PATCH 033/128] fix(http2): do not reserve capacity before body data is available (#4061) PipeToSendStream used to call `reserve_capacity(1)` at the top of every poll iteration as a probe, before asking the body for the next chunk. The reservation is immediately assigned from the connection-level flow-control window, and while the stream is parked waiting for more body data the reservation keeps the last byte of the connection window pinned to the stream. Against peers that only emit a WINDOW_UPDATE once their receive window is fully exhausted (for example Bun's built-in HTTP/2 server, as well as other implementations with a similar strategy), this one-byte reservation is enough to deadlock a second concurrent stream: the connection window never drops to zero on the peer, so no WINDOW_UPDATE is ever sent, and the second stream can never get any capacity. Restructure the loop so it polls the body for the next frame first and only reserves capacity equal to the chunk's exact size. The polled chunk is stashed in a new `buffered_data` field so it survives the `poll_capacity` wait across `Poll::Pending` returns without being dropped. Zero-length data frames are forwarded immediately without touching the reservation. `poll_reset` is now registered at the top of every iteration so RST_STREAM still wakes the task while it waits for either more body data or more capacity. Add a regression test that pairs a streaming request filling the connection window with a second one-byte request, talking to a raw h2::server that never releases recv capacity, and asserts the second request reaches the server. Closes #4003 Co-authored-by: barry3406 --- src/proto/h2/mod.rs | 94 ++++++++++++++++++------- tests/client.rs | 167 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 237 insertions(+), 24 deletions(-) diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index 9d4826152e..f3159cf96c 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -88,11 +88,21 @@ pin_project! { { body_tx: SendStream>, data_done: bool, + // A data chunk that has been polled from the body but is still waiting + // for stream-level capacity before it can be shipped. Stored here so + // it survives across `Poll::Pending` returns from `poll_capacity`; if + // we left the chunk in a local, it would be dropped on every repoll. + buffered_data: Option>, #[pin] stream: S, } } +struct Peeked { + data: D, + is_eos: bool, +} + impl PipeToSendStream where S: Body, @@ -101,6 +111,7 @@ where PipeToSendStream { body_tx: tx, data_done: false, + buffered_data: None, stream, } } @@ -121,13 +132,23 @@ where fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let mut me = self.project(); loop { - // we don't have the next chunk of data yet, so just reserve 1 byte to make - // sure there's some capacity available. h2 will handle the capacity management - // for the actual body chunk. - me.body_tx.reserve_capacity(1); + // Register for RST_STREAM notification while we wait for the next + // body chunk or for send capacity, so the task wakes up if the + // peer resets the stream. + if let Poll::Ready(reason) = me + .body_tx + .poll_reset(cx) + .map_err(crate::Error::new_body_write)? + { + debug!("stream received RST_STREAM: {:?}", reason); + return Poll::Ready(Err(crate::Error::new_body_write(::h2::Error::from(reason)))); + } - if me.body_tx.capacity() == 0 { - loop { + // If a previously-polled chunk is still waiting for stream-level + // send capacity, drive that to completion before touching the + // body again. + if me.buffered_data.is_some() { + while me.body_tx.capacity() == 0 { match ready!(me.body_tx.poll_capacity(cx)) { Some(Ok(0)) => {} Some(Ok(_)) => break, @@ -142,34 +163,59 @@ where } } } - } else if let Poll::Ready(reason) = me - .body_tx - .poll_reset(cx) - .map_err(crate::Error::new_body_write)? - { - debug!("stream received RST_STREAM: {:?}", reason); - return Poll::Ready(Err(crate::Error::new_body_write(::h2::Error::from(reason)))); + + let peeked = me.buffered_data.take().expect("checked is_some above"); + let buf = SendBuf::Buf(peeked.data); + me.body_tx + .send_data(buf, peeked.is_eos) + .map_err(crate::Error::new_body_write)?; + + if peeked.is_eos { + return Poll::Ready(Ok(())); + } + continue; } + // Poll for the next body frame *before* reserving any connection + // flow-control capacity. Reserving capacity speculatively (even a + // single byte) pins that capacity on the connection-level window, + // which can deadlock a second stream when talking to peers that + // only emit WINDOW_UPDATE once their receive window is fully + // exhausted. See #4003. match ready!(me.stream.as_mut().poll_frame(cx)) { Some(Ok(frame)) => { if frame.is_data() { let chunk = frame.into_data().unwrap_or_else(|_| unreachable!()); let is_eos = me.stream.is_end_stream(); - trace!( - "send body chunk: {} bytes, eos={}", - chunk.remaining(), - is_eos, - ); + let len = chunk.remaining(); + trace!("send body chunk: {} bytes, eos={}", len, is_eos); - let buf = SendBuf::Buf(chunk); - me.body_tx - .send_data(buf, is_eos) - .map_err(crate::Error::new_body_write)?; + if len == 0 { + // Zero-length data frames need no capacity; send + // them straight through so trailing empty frames + // (e.g. an explicit end-of-stream marker) are + // delivered. + let buf = SendBuf::Buf(chunk); + me.body_tx + .send_data(buf, is_eos) + .map_err(crate::Error::new_body_write)?; - if is_eos { - return Poll::Ready(Ok(())); + if is_eos { + return Poll::Ready(Ok(())); + } + continue; } + + // Reserve exactly the chunk size so we never pin more + // connection-level flow-control window than we are + // about to consume. Stash the chunk in `self` so it + // survives the upcoming `poll_capacity` wait even if + // it returns `Poll::Pending`. + me.body_tx.reserve_capacity(len); + *me.buffered_data = Some(Peeked { + data: chunk, + is_eos, + }); } else if frame.is_trailers() { // no more DATA, so give any capacity back me.body_tx.reserve_capacity(0); diff --git a/tests/client.rs b/tests/client.rs index f74cec740f..823cf4011d 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1570,6 +1570,7 @@ mod conn { use bytes::{Buf, Bytes}; use futures_channel::{mpsc, oneshot}; use futures_util::future::{self, poll_fn, FutureExt, TryFutureExt}; + use http_body_util::combinators::BoxBody; use http_body_util::{BodyExt, Empty, Full, StreamBody}; use hyper::rt::Timer; use tokio::io::{AsyncReadExt as _, AsyncWriteExt as _, DuplexStream}; @@ -2990,6 +2991,172 @@ mod conn { let got_rst = rst_rx.await.expect("server task should complete"); assert!(got_rst, "server should receive RST_STREAM"); } + + // https://github.com/hyperium/hyper/issues/4003 + // + // An idle `PipeToSendStream` must not reserve any connection-level flow + // control capacity speculatively. If it does, a first stream that has + // filled the connection window will pin the remaining byte(s), and no + // second stream can make progress when talking to a peer that only emits + // `WINDOW_UPDATE` after its receive window is fully exhausted. + #[tokio::test] + async fn h2_idle_stream_does_not_pin_connection_window() { + use std::sync::{Arc, Mutex}; + + // The HTTP/2 spec fixes the initial connection-level window at 65535 + // (RFC 9113 section 6.9.2), and it can only be increased via + // WINDOW_UPDATE. Stream A therefore sends 65534 bytes to leave exactly + // one byte of connection window for stream B. + const STREAM_A_LEN: usize = 65534; + + let (client_io, server_io, _) = setup_duplex_test_server(); + let (stream_a_full_tx, stream_a_full_rx) = oneshot::channel::<()>(); + let (stream_b_got_tx, stream_b_got_rx) = oneshot::channel::(); + + // Raw h2 server that never calls `release_capacity`, so no + // connection-level WINDOW_UPDATE is ever sent — mimicking peers that + // only emit WINDOW_UPDATE after their receive window is fully + // exhausted. The main server task accepts streams in a loop so the + // h2 codec is driven continuously; each stream is dispatched to a + // spawned handler that reads the body without ever releasing + // capacity. + // + // The `stream_a_done` channel keeps stream A's server-side request + // alive until the test is done. Dropping the recv side of stream A + // would let h2 auto-release its in-flight recv capacity and emit a + // WINDOW_UPDATE, which would hide the bug. + let (stream_a_done_tx, stream_a_done_rx) = oneshot::channel::<()>(); + let stream_a_full_tx = Arc::new(Mutex::new(Some(stream_a_full_tx))); + let stream_b_got_tx = Arc::new(Mutex::new(Some(stream_b_got_tx))); + let stream_a_done_rx = Arc::new(Mutex::new(Some(stream_a_done_rx))); + tokio::spawn(async move { + let mut h2 = h2::server::handshake(server_io).await.unwrap(); + let mut seen = 0u32; + while let Some(result) = h2.accept().await { + let (req, mut respond) = result.unwrap(); + seen += 1; + let which = seen; + let stream_a_full_tx = stream_a_full_tx.clone(); + let stream_b_got_tx = stream_b_got_tx.clone(); + let stream_a_done_rx = stream_a_done_rx.clone(); + tokio::spawn(async move { + let mut body = req.into_body(); + if which == 1 { + // Stream A: drain the burst of body data without ever + // releasing recv capacity, then park on the done + // channel to hold on to the recv stream. + let mut received = 0usize; + while received < STREAM_A_LEN { + let frame = match body.data().await { + Some(Ok(f)) => f, + _ => return, + }; + received += frame.len(); + // Intentionally do NOT call release_capacity. + } + if let Some(tx) = stream_a_full_tx.lock().unwrap().take() { + let _ = tx.send(()); + } + // Keep the recv stream alive so that dropping it + // cannot auto-release connection-level recv capacity + // and emit a WINDOW_UPDATE mid-test. + let done = stream_a_done_rx.lock().unwrap().take(); + if let Some(done) = done { + let _ = done.await; + } + // Keep `body` in scope until here. + drop(body); + } else { + // Stream B: record the first data frame and respond. + let mut received = 0usize; + if let Some(Ok(frame)) = body.data().await { + received += frame.len(); + } + if let Some(tx) = stream_b_got_tx.lock().unwrap().take() { + let _ = tx.send(received); + } + let mut send = respond.send_response(Response::new(()), false).unwrap(); + let _ = send.send_data(Bytes::from_static(b"ok"), true); + } + }); + } + }); + + let io = TokioIo::new(client_io); + let (mut client, conn) = conn::http2::Builder::new(TokioExecutor) + .handshake::<_, BoxBody>>(io) + .await + .expect("http handshake"); + tokio::spawn(async move { + let _ = conn.await; + }); + + // Request A: streaming body that sends STREAM_A_LEN bytes and then + // stays open, waiting for more data. This fills the advertised + // connection-level window down to one byte remaining. + let (mut tx_a, rx_a) = + mpsc::channel::, Box>>(4); + let body_a: BoxBody> = + BodyExt::boxed(StreamBody::new(rx_a)); + let req_a = Request::post("http://localhost/a").body(body_a).unwrap(); + let mut client_a = client.clone(); + let a_handle = tokio::spawn(async move { client_a.send_request(req_a).await }); + + // Push stream A's body in 16 KiB chunks to match the default h2 + // `SETTINGS_MAX_FRAME_SIZE`. + use futures_util::SinkExt; + let mut remaining = STREAM_A_LEN; + while remaining > 0 { + let take = remaining.min(16_384); + let bytes = Bytes::from(vec![b'A'; take]); + tx_a.send(Ok(Frame::data(bytes))) + .await + .expect("stream A channel send"); + remaining -= take; + } + + // Wait for the server to confirm it received the full body on stream + // A, which means the connection window is now down to its last byte. + tokio::time::timeout(Duration::from_secs(5), stream_a_full_rx) + .await + .expect("server should receive full stream A body in time") + .expect("stream_a_full_rx"); + + // Give the client's `PipeToSendStream` for stream A a moment to park + // itself waiting for more body frames, which (with the bug) would + // speculatively reserve the last byte of connection-level capacity. + for _ in 0..16 { + tokio::task::yield_now().await; + } + + // Request B: one byte of body. With the bug in `PipeToSendStream`, + // stream A pins the last byte of connection window via a speculative + // reserve, so stream B can never ship its data frame. + let body_b: BoxBody> = BodyExt::boxed( + http_body_util::Full::new(Bytes::from_static(b"b")) + .map_err(|never: std::convert::Infallible| match never {}), + ); + let req_b = Request::post("http://localhost/b").body(body_b).unwrap(); + let b_fut = client.send_request(req_b); + + let received_b = tokio::time::timeout(Duration::from_secs(5), stream_b_got_rx) + .await + .expect("stream B must reach the server even while stream A is idle") + .expect("stream_b_got_rx"); + assert_eq!( + received_b, 1, + "stream B should deliver its single body byte" + ); + + // Drive request B to completion so we don't leak the future. + let _ = tokio::time::timeout(Duration::from_secs(5), b_fut).await; + + // Close stream A cleanly: first release the server-side handler so + // it drops the recv stream, then drop the body sender. + let _ = stream_a_done_tx.send(()); + drop(tx_a); + let _ = tokio::time::timeout(Duration::from_secs(5), a_handle).await; + } } trait FutureHyperExt: TryFuture { From 04d1640238138099436a6a31569f76d0a6bdb379 Mon Sep 17 00:00:00 2001 From: Jonas Platte Date: Fri, 8 May 2026 14:33:56 -0400 Subject: [PATCH 034/128] style(lib): fix some clippy lints * Stop using soft-deprecated integer constants * Remove pointless `let _`s --- benches/end_to_end.rs | 2 +- src/client/dispatch.rs | 14 +++++++------- src/proto/h1/io.rs | 2 +- 3 files changed, 9 insertions(+), 9 deletions(-) diff --git a/benches/end_to_end.rs b/benches/end_to_end.rs index 07f08c186b..d47029c44e 100644 --- a/benches/end_to_end.rs +++ b/benches/end_to_end.rs @@ -95,7 +95,7 @@ fn http1_parallel_x10_res_10mb(b: &mut test::Bencher) { // HTTP2 -const HTTP2_MAX_WINDOW: u32 = std::u32::MAX >> 1; +const HTTP2_MAX_WINDOW: u32 = u32::MAX >> 1; #[bench] fn http2_consecutive_x1_empty(b: &mut test::Bencher) { diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 6dbc8672e0..718ed15647 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -447,7 +447,7 @@ mod tests { let (mut tx, mut rx) = channel::(); // one is allowed to buffer, second is rejected - let _ = tx.try_send(Custom(1)).expect("1 buffered"); + tx.try_send(Custom(1)).expect("1 buffered"); tx.try_send(Custom(2)).expect_err("2 not ready"); assert!(PollOnce(&mut rx).await.is_some(), "rx once"); @@ -458,7 +458,7 @@ mod tests { assert!(PollOnce(&mut rx).await.is_none(), "rx empty"); - let _ = tx.try_send(Custom(2)).expect("2 ready"); + tx.try_send(Custom(2)).expect("2 ready"); } #[cfg(feature = "http2")] @@ -467,13 +467,13 @@ mod tests { let (tx, rx) = channel::(); let mut tx = tx.unbound(); - let _ = tx.try_send(Custom(1)).unwrap(); - let _ = tx.try_send(Custom(2)).unwrap(); - let _ = tx.try_send(Custom(3)).unwrap(); + tx.try_send(Custom(1)).unwrap(); + tx.try_send(Custom(2)).unwrap(); + tx.try_send(Custom(3)).unwrap(); drop(rx); - let _ = tx.try_send(Custom(4)).unwrap_err(); + tx.try_send(Custom(4)).unwrap_err(); } #[cfg(feature = "nightly")] @@ -487,7 +487,7 @@ mod tests { let (mut tx, mut rx) = channel::, Response>(); b.iter(move || { - let _ = tx.send(Request::new(Incoming::empty())).unwrap(); + tx.send(Request::new(Incoming::empty())).unwrap(); rt.block_on(async { loop { let poll_once = PollOnce(&mut rx); diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 6a30d73c84..2ec67d4f0a 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -826,7 +826,7 @@ mod tests { } let mut max = 8192; - while max < std::usize::MAX { + while max < usize::MAX { fuzz(max); max = (max / 2).saturating_mul(3); } From 41d9b66dba355aeffe4c99ea0051b50401d19760 Mon Sep 17 00:00:00 2001 From: Li Duoyang Date: Sat, 9 May 2026 02:36:35 +0800 Subject: [PATCH 035/128] docs(client): document Drop behavior for Connection types (#4064) Explain what happens when Connection is dropped: the underlying IO resource is closed and in-flight requests are interrupted. Suggest polling to completion for graceful shutdown. Closes #3192 Co-authored-by: lihan3238 --- src/client/conn/http1.rs | 7 +++++++ src/client/conn/http2.rs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 85c1fdc665..696aa005b3 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -50,6 +50,13 @@ pub struct Parts { /// can process incoming and outgoing messages, notice hangups, and the like. /// /// Instances of this type are typically created via the [`handshake`] function +/// +/// # Drop behavior +/// +/// Dropping the `Connection` will close the underlying IO resource. +/// Any in-flight requests that have not received a response will be +/// interrupted. If graceful shutdown is desired, poll the connection +/// until it completes instead of dropping. #[must_use = "futures do nothing unless polled"] pub struct Connection where diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index e29e678b57..d62b4a4e99 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -39,6 +39,13 @@ impl Clone for SendRequest { /// can process incoming and outgoing messages, notice hangups, and the like. /// /// Instances of this type are typically created via the [`handshake`] function +/// +/// # Drop behavior +/// +/// Dropping the `Connection` will close the underlying IO resource. +/// Any in-flight requests that have not received a response will be +/// interrupted. If graceful shutdown is desired, poll the connection +/// until it completes instead of dropping. #[must_use = "futures do nothing unless polled"] pub struct Connection where From a7d5cbc6b6f0f7f043df1f8256d76979933e2cab Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81rni=20Dagur?= Date: Mon, 11 May 2026 15:31:08 +0100 Subject: [PATCH 036/128] feat(http2): Add 'header_table_size()' method to server builder (#4062) --- Cargo.toml | 2 +- src/proto/h2/server.rs | 5 +++++ src/server/conn/http2.rs | 12 ++++++++++++ 3 files changed, 18 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 56b28ff3fe..419644d8eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,7 +31,7 @@ atomic-waker = { version = "1.1.2", optional = true } futures-channel = { version = "0.3", optional = true } futures-core = { version = "0.3.31", optional = true } futures-util = { version = "0.3", default-features = false, features = ["alloc"], optional = true } -h2 = { version = "0.4.6", optional = true } +h2 = { version = "0.4.14", optional = true } h3 = { version = "0.0.8", optional = true } http-body-util = { version = "0.1", optional = true } httparse = { version = "1.9", optional = true } diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 483ed96dd9..6180ce8f80 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -53,6 +53,7 @@ pub(crate) struct Config { pub(crate) keep_alive_interval: Option, pub(crate) keep_alive_timeout: Duration, pub(crate) max_send_buffer_size: usize, + pub(crate) header_table_size: Option, pub(crate) max_header_list_size: u32, pub(crate) date_header: bool, } @@ -68,6 +69,7 @@ impl Default for Config { max_concurrent_streams: Some(200), max_pending_accept_reset_streams: None, max_local_error_reset_streams: Some(DEFAULT_MAX_LOCAL_ERROR_RESET_STREAMS), + header_table_size: None, keep_alive_interval: None, keep_alive_timeout: Duration::from_secs(20), max_send_buffer_size: DEFAULT_MAX_SEND_BUF_SIZE, @@ -142,6 +144,9 @@ where if let Some(max) = config.max_pending_accept_reset_streams { builder.max_pending_accept_reset_streams(max); } + if let Some(size) = config.header_table_size { + builder.header_table_size(size); + } if config.enable_connect_protocol { builder.enable_connect_protocol(); } diff --git a/src/server/conn/http2.rs b/src/server/conn/http2.rs index 0452109c3c..77b605a086 100644 --- a/src/server/conn/http2.rs +++ b/src/server/conn/http2.rs @@ -260,6 +260,18 @@ impl Builder { self } + /// Sets the header table size. + /// + /// This setting informs the peer of the maximum size of the header compression + /// table used to encode header blocks, in octets. The encoder may select any value + /// equal to or less than the header table size specified by the sender. + /// + /// The default value of crate `h2` is 4,096. + pub fn header_table_size(&mut self, size: impl Into>) -> &mut Self { + self.h2_builder.header_table_size = size.into(); + self + } + /// Sets the max size of received header frames. /// /// Default is currently 16KB, but can change. From 1d03192aa6fe41910e0bb6c00d18adf37f3a6095 Mon Sep 17 00:00:00 2001 From: June Date: Tue, 12 May 2026 10:24:00 -0700 Subject: [PATCH 037/128] feat(http2): add `reset_stream_duration()` client option (#4068) Expose the h2 client builder's reset_stream_duration setting, which controls how long reset stream state is kept in memory. This is useful for tuning memory usage in connections that reset many streams. Closes #2599 --- src/client/conn/http2.rs | 30 ++++++++++++++++++++++++++++++ src/proto/h2/client.rs | 5 +++++ 2 files changed, 35 insertions(+) diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index d62b4a4e99..f1bc4b1ecc 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -503,6 +503,36 @@ where self } + /// Sets the duration to remember locally reset streams. + /// + /// When a stream is explicitly reset by either the client or the server, + /// the HTTP/2 specification requires that any further frames received for + /// that stream must be ignored for "some time". + /// + /// In order to satisfy the specification, internal state must be maintained + /// to implement the behavior. This state grows linearly with the number of + /// streams that are locally reset. + /// + /// The `reset_stream_duration` setting configures the max amount of time + /// this state will be maintained in memory. Once the duration elapses, the + /// stream state is purged from memory. + /// + /// Once the stream has been fully purged from memory, any additional frames + /// received for that stream will result in a connection level protocol + /// error, forcing the connection to terminate. + /// + /// The default value is determined by the `h2` crate, and is currently + /// 1 second. + /// + /// See the documentation of [`h2::client::Builder::reset_stream_duration`] for more + /// details. + /// + /// [`h2::client::Builder::reset_stream_duration`]: https://docs.rs/h2/client/struct.Builder.html#method.reset_stream_duration + pub fn reset_stream_duration(&mut self, dur: Duration) -> &mut Self { + self.h2_builder.reset_stream_duration = Some(dur); + self + } + /// Constructs a connection with the configured options and IO. /// See [`client::conn`](crate::client::conn) for more. /// diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index bf154e4580..51f548444e 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -77,6 +77,7 @@ pub(crate) struct Config { pub(crate) max_local_error_reset_streams: Option, pub(crate) header_table_size: Option, pub(crate) max_concurrent_streams: Option, + pub(crate) reset_stream_duration: Option, } impl Default for Config { @@ -97,6 +98,7 @@ impl Default for Config { max_local_error_reset_streams: Some(1024), header_table_size: None, max_concurrent_streams: None, + reset_stream_duration: None, } } } @@ -126,6 +128,9 @@ fn new_builder(config: &Config) -> Builder { if let Some(max) = config.max_concurrent_streams { builder.max_concurrent_streams(max); } + if let Some(dur) = config.reset_stream_duration { + builder.reset_stream_duration(dur); + } builder } From 5860611dac888e6bad6c8e57ecbfd27c2a904a71 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 12 May 2026 14:56:30 -0400 Subject: [PATCH 038/128] fix(http1): send error when dispatcher is dropped mid-body (#4069) Wrap body_tx in a SenderDropGuard that sends an IncompleteMessage error if the Dispatcher future is dropped while still owning a body Sender, preventing silent data truncation. Closes #3995 Closes #4016 Co-authored-by: Pierre Barre --- src/proto/h1/dispatch.rs | 49 +++++++++++++++++++++++++++++------ tests/client.rs | 55 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 96 insertions(+), 8 deletions(-) diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 91defd3234..b7e3ec3bc9 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -22,7 +22,7 @@ use crate::upgrade::OnUpgrade; pub(crate) struct Dispatcher { conn: Conn, dispatch: D, - body_tx: Option, + body_tx: SenderDropGuard, body_rx: Pin>>, is_closing: bool, } @@ -81,7 +81,7 @@ where Dispatcher { conn, dispatch, - body_tx: None, + body_tx: SenderDropGuard::none(), body_rx: Box::pin(None), is_closing: false, } @@ -126,7 +126,8 @@ where should_shutdown: bool, ) -> Poll> { Poll::Ready(ready!(self.poll_inner(cx, should_shutdown)).or_else(|e| { - // Be sure to alert a streaming body of the failure. + // Be sure to alert a streaming body of the failure with a + // more specific error than the drop guard would provide. if let Some(mut body) = self.body_tx.take() { body.send_error(crate::Error::new_body("connection error")); } @@ -226,7 +227,7 @@ where match body.poll_ready(cx) { Poll::Ready(Ok(())) => (), Poll::Pending => { - self.body_tx = Some(body); + self.body_tx.set(body); return Poll::Pending; } Poll::Ready(Err(_canceled)) => { @@ -243,7 +244,7 @@ where let chunk = frame.into_data().unwrap_or_else(|_| unreachable!()); match body.try_send_data(chunk) { Ok(()) => { - self.body_tx = Some(body); + self.body_tx.set(body); } Err(_canceled) => { if self.conn.can_read_body() { @@ -257,7 +258,7 @@ where frame.into_trailers().unwrap_or_else(|_| unreachable!()); match body.try_send_trailers(trailers) { Ok(()) => { - self.body_tx = Some(body); + self.body_tx.set(body); } Err(_canceled) => { if self.conn.can_read_body() { @@ -275,7 +276,7 @@ where // just drop, the body will close automatically } Poll::Pending => { - self.body_tx = Some(body); + self.body_tx.set(body); return Poll::Pending; } Poll::Ready(Some(Err(e))) => { @@ -310,7 +311,7 @@ where other => { let (tx, rx) = IncomingBody::new_channel(other, wants.contains(Wants::EXPECT)); - self.body_tx = Some(tx); + self.body_tx.set(tx); rx } }; @@ -524,6 +525,38 @@ impl Drop for OptGuard<'_, T> { } } +// ===== impl SenderDropGuard ===== + +/// A drop guard for the body `Sender`. +/// +/// If the `Dispatcher` future is dropped (e.g. the runtime driving the +/// connection is shut down) while it still owns a body `Sender`, the guard +/// sends an incomplete-message error so the receiver sees an error instead +/// of a silent, clean end-of-stream. +struct SenderDropGuard(Option); + +impl SenderDropGuard { + fn none() -> Self { + SenderDropGuard(None) + } + + fn set(&mut self, sender: crate::body::Sender) { + self.0 = Some(sender); + } + + fn take(&mut self) -> Option { + self.0.take() + } +} + +impl Drop for SenderDropGuard { + fn drop(&mut self) { + if let Some(mut sender) = self.0.take() { + sender.send_error(crate::Error::new_incomplete()); + } + } +} + // ===== impl Server ===== cfg_server! { diff --git a/tests/client.rs b/tests/client.rs index 823cf4011d..8695f6d34a 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1758,6 +1758,61 @@ mod conn { assert_eq!(chunk.data_ref().unwrap().len(), 5); } + #[tokio::test] + async fn dropped_conn_sends_incomplete_body_error() { + let (listener, addr) = setup_tk_test_server().await; + let (release_tx, release_rx) = oneshot::channel(); + + let server = async move { + let mut sock = listener.accept().await.unwrap().0; + let mut buf = [0; 4096]; + let n = sock.read(&mut buf).await.expect("read 1"); + + let expected = "GET / HTTP/1.1\r\n\r\n"; + assert_eq!(s(&buf[..n]), expected); + + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n") + .await + .unwrap(); + + release_rx.await.expect("release server"); + }; + + let client = async move { + let tcp = tcp_connect(&addr).await.expect("connect"); + let (mut client, conn) = conn::http1::handshake(tcp).await.expect("handshake"); + + let conn = tokio::task::spawn(async move { + conn.await.expect("http conn"); + }); + + let req = Request::builder() + .uri("/") + .body(Empty::::new()) + .unwrap(); + let mut res = client.send_request(req).await.expect("send_request"); + assert_eq!(res.status(), hyper::StatusCode::OK); + assert_eq!(res.body().size_hint().exact(), Some(5)); + assert!(!res.body().is_end_stream()); + + conn.abort(); + let err = conn.await.expect_err("conn task should be aborted"); + assert!(err.is_cancelled(), "{err:?}"); + + let err = res + .body_mut() + .frame() + .await + .expect("body frame") + .unwrap_err(); + assert!(err.is_incomplete_message(), "{err:?}"); + + release_tx.send(()).expect("release server"); + }; + + future::join(server, client).await; + } + #[test] fn aborted_body_isnt_completed() { let _ = ::pretty_env_logger::try_init(); From 76c6d0beaf5c6cbb00265eee97d4f8d1824b51c5 Mon Sep 17 00:00:00 2001 From: Li Duoyang Date: Thu, 14 May 2026 03:25:35 +0800 Subject: [PATCH 039/128] docs(client): document cancel safety for client send_request futures (#4070) Per discussion in #4054: hyper futures are cancel safe via drop. Document the protocol-specific behavior on cancellation: - HTTP/1: dropping closes the underlying connection (no in-protocol per-request abort) - HTTP/2: dropping resets the stream with RST_STREAM (CANCEL); shared connection stays usable for other in-flight and future requests Also adds a brief crate-level Cancel safety section pointing readers at the per-future docs, per seanmonstar suggestion in the issue. Refs #4054 Co-authored-by: lihan3238 --- src/client/conn/http1.rs | 9 +++++++++ src/client/conn/http2.rs | 9 +++++++++ src/lib.rs | 8 ++++++++ 3 files changed, 26 insertions(+) diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 696aa005b3..72d50c7698 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -195,6 +195,15 @@ where /// /// This is however not enforced or validated and it is up to the user /// of this method to ensure the `Uri` is correct for their intended purpose. + /// + /// # Cancel safety + /// + /// Dropping the returned future is the supported way to cancel an + /// in-flight HTTP/1 request. Because HTTP/1 has no in-protocol way to + /// abort a single request without affecting the shared connection, + /// hyper closes the underlying connection when a request future is + /// dropped before completion. Any subsequent calls on the same + /// [`SendRequest`] will return a `canceled` error. pub fn send_request( &mut self, req: Request, diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index f1bc4b1ecc..5426ab2c94 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -138,6 +138,15 @@ where /// /// Absolute-form `Uri`s are not required. If received, they will be serialized /// as-is. + /// + /// # Cancel safety + /// + /// Dropping the returned future is the supported way to cancel an + /// in-flight HTTP/2 request. The stream is reset with `RST_STREAM` + /// (`CANCEL` error code); the shared connection remains usable for + /// other in-flight and future requests. The peer is notified + /// immediately rather than continuing to send a response body that + /// would be discarded. pub fn send_request( &mut self, req: Request, diff --git a/src/lib.rs b/src/lib.rs index 4683fd6597..7be9926076 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -30,6 +30,14 @@ //! If looking for just a convenient HTTP client, consider the //! [reqwest](https://crates.io/crates/reqwest) crate. //! +//! # Cancel safety +//! +//! Futures returned by hyper are cancel safe: dropping a future before it +//! completes is the supported way to cancel the operation. See the +//! documentation on individual futures — for example `SendRequest::send_request` +//! in `client::conn::http1` and `client::conn::http2` — for the protocol- +//! specific behavior on cancellation. +//! //! # Optional Features //! //! hyper uses a set of [feature flags] to reduce the amount of compiled code. From 3b69c7f08e0e007b87a082cb6226b890090f9f08 Mon Sep 17 00:00:00 2001 From: June Date: Wed, 13 May 2026 12:27:02 -0700 Subject: [PATCH 040/128] docs(error): add detailed doc comments to Error query methods (#4065) Add explanatory doc comments to is_parse, is_parse_too_large, is_parse_version_h2, is_user, is_canceled, is_closed, is_body_write_aborted, is_shutdown, and is_timeout. Each comment explains what triggers the error and links to relevant builder methods where applicable. --- src/error.rs | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/error.rs b/src/error.rs index 90c64008ab..1045b3943e 100644 --- a/src/error.rs +++ b/src/error.rs @@ -173,11 +173,22 @@ pub(super) struct TimedOut; impl Error { /// Returns true if this was an HTTP parse error. + /// + /// This can be caused by a malformed HTTP message, an invalid header, + /// an invalid URI, an invalid HTTP version, or a message head that is + /// too large. Use the more specific `is_parse_*` methods to determine + /// the exact cause. pub fn is_parse(&self) -> bool { matches!(self.inner.kind, Kind::Parse(_)) } /// Returns true if this was an HTTP parse error caused by a message that was too large. + /// + /// This is triggered when the message head (request line plus headers for + /// HTTP/1, or header frame for HTTP/2) exceeds the configured + /// [`max_buf_size`](crate::server::conn::http1::Builder::max_buf_size). + /// It also covers the case where the URI alone exceeds the internal + /// maximum URI length. #[cfg(all(feature = "http1", feature = "server"))] pub fn is_parse_too_large(&self) -> bool { matches!( @@ -194,22 +205,38 @@ impl Error { /// Returns true if this was an HTTP parse error caused by HTTP2 preface sent over an HTTP1 /// connection. + /// + /// This can happen when a client sends an HTTP/2 connection preface to a + /// server that is only expecting HTTP/1.x requests. #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] pub fn is_parse_version_h2(&self) -> bool { matches!(self.inner.kind, Kind::Parse(Parse::VersionH2)) } /// Returns true if this error was caused by user code. + /// + /// For example, this can be returned when the user's `Service` returns + /// an error, the user's `Body` stream yields an error, or the user + /// sends an unexpected header combination (such as both + /// `content-length` and `transfer-encoding`). pub fn is_user(&self) -> bool { matches!(self.inner.kind, Kind::User(_)) } /// Returns true if this was about a `Request` that was canceled. + /// + /// This typically happens when a pending request is dropped before + /// it can be dispatched to the connection, for example because the + /// connection was not ready. pub fn is_canceled(&self) -> bool { matches!(self.inner.kind, Kind::Canceled) } /// Returns true if a sender's channel is closed. + /// + /// This can occur when the other side of a client or body channel + /// has been dropped, indicating that the receiver is no longer + /// interested in the data. pub fn is_closed(&self) -> bool { #[cfg(not(any( all(feature = "http1", any(feature = "client", feature = "server")), @@ -250,6 +277,10 @@ impl Error { } /// Returns true if the body write was aborted. + /// + /// This occurs when the user's code explicitly aborts writing of the + /// outgoing body before it completes, for example by dropping the + /// body sender. pub fn is_body_write_aborted(&self) -> bool { #[cfg(not(any( all(feature = "http1", any(feature = "client", feature = "server")), @@ -265,6 +296,9 @@ impl Error { } /// Returns true if the error was caused while calling `AsyncWrite::shutdown()`. + /// + /// This can happen when the connection is being gracefully shut down + /// and the underlying IO reports an error during the shutdown sequence. pub fn is_shutdown(&self) -> bool { #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] if matches!(self.inner.kind, Kind::Shutdown) { @@ -274,6 +308,10 @@ impl Error { } /// Returns true if the error was caused by a timeout. + /// + /// For HTTP/1 servers, this includes the header read timeout (see + /// [`header_read_timeout`](crate::server::conn::http1::Builder::header_read_timeout)). + /// It also covers any timeout set via a user-provided timer. pub fn is_timeout(&self) -> bool { #[cfg(all(feature = "http1", feature = "server"))] if matches!(self.inner.kind, Kind::HeaderTimeout) { From fa84f0250eb5030ade22b592b5e756a1a94c873e Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 19 May 2026 18:06:59 -0400 Subject: [PATCH 041/128] refactor(lib): use a panic_if_poisoned() helper for mutexes (#4072) --- src/common/lock.rs | 15 +++++++++++++++ src/common/mod.rs | 1 + src/ffi/task.rs | 10 ++++++---- src/mock.rs | 20 +++++++++++--------- src/proto/h2/ping.rs | 9 +++++---- src/upgrade.rs | 3 ++- 6 files changed, 40 insertions(+), 18 deletions(-) create mode 100644 src/common/lock.rs diff --git a/src/common/lock.rs b/src/common/lock.rs new file mode 100644 index 0000000000..4e2b70bff2 --- /dev/null +++ b/src/common/lock.rs @@ -0,0 +1,15 @@ +use std::sync::LockResult; + +pub(crate) trait LockResultExt { + fn panic_if_poisoned(self) -> T; +} + +impl LockResultExt for LockResult { + #[track_caller] + fn panic_if_poisoned(self) -> T { + match self { + Ok(inner) => inner, + Err(err) => panic!("lock poisoned by panic: {err}"), + } + } +} diff --git a/src/common/mod.rs b/src/common/mod.rs index 4cc84cae75..9a8bd0a1d4 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -11,6 +11,7 @@ pub(crate) mod either; ))] pub(crate) mod future; pub(crate) mod io; +pub(crate) mod lock; #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] pub(crate) mod task; #[cfg(any( diff --git a/src/ffi/task.rs b/src/ffi/task.rs index 0c49936c6e..47c74c5dbf 100644 --- a/src/ffi/task.rs +++ b/src/ffi/task.rs @@ -10,6 +10,8 @@ use std::task::{Context, Poll}; use futures_util::stream::{FuturesUnordered, Stream}; +use crate::common::lock::LockResultExt; + use super::error::hyper_code; use super::UserDataPointer; @@ -196,7 +198,7 @@ impl hyper_executor { fn spawn(&self, task: Box) { self.spawn_queue .lock() - .unwrap() + .panic_if_poisoned() .push(TaskFuture { task: Some(task) }); } @@ -211,7 +213,7 @@ impl hyper_executor { { // Scope the lock on the driver to ensure it is dropped before // calling drain_queue below. - let mut driver = self.driver.lock().unwrap(); + let mut driver = self.driver.lock().panic_if_poisoned(); match Pin::new(&mut *driver).poll_next(&mut cx) { Poll::Ready(val) => return val, Poll::Pending => {} @@ -238,12 +240,12 @@ impl hyper_executor { /// drain_queue locks both self.spawn_queue and self.driver, so it requires /// that neither of them be locked already. fn drain_queue(&self) -> bool { - let mut queue = self.spawn_queue.lock().unwrap(); + let mut queue = self.spawn_queue.lock().panic_if_poisoned(); if queue.is_empty() { return false; } - let driver = self.driver.lock().unwrap(); + let driver = self.driver.lock().panic_if_poisoned(); for task in queue.drain(..) { driver.push(task); diff --git a/src/mock.rs b/src/mock.rs index 1dd57de319..322ea94f1d 100644 --- a/src/mock.rs +++ b/src/mock.rs @@ -16,6 +16,8 @@ use tokio_io::{AsyncRead, AsyncWrite}; #[cfg(feature = "runtime")] use crate::client::connect::{Connect, Connected, Destination}; +#[cfg(feature = "runtime")] +use crate::common::lock::LockResultExt; @@ -59,14 +61,14 @@ impl Duplex { #[cfg(feature = "runtime")] impl Read for Duplex { fn read(&mut self, buf: &mut [u8]) -> io::Result { - self.inner.lock().unwrap().read.read(buf) + self.inner.lock().panic_if_poisoned().read.read(buf) } } #[cfg(feature = "runtime")] impl Write for Duplex { fn write(&mut self, buf: &[u8]) -> io::Result { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.inner.lock().panic_if_poisoned(); let ret = inner.write.write(buf); if let Some(task) = inner.handle_read_task.take() { trace!("waking DuplexHandle read"); @@ -76,7 +78,7 @@ impl Write for Duplex { } fn flush(&mut self) -> io::Result<()> { - self.inner.lock().unwrap().write.flush() + self.inner.lock().panic_if_poisoned().write.flush() } } @@ -91,7 +93,7 @@ impl AsyncWrite for Duplex { } fn write_buf(&mut self, buf: &mut B) -> Poll { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.inner.lock().panic_if_poisoned(); if let Some(task) = inner.handle_read_task.take() { task.notify(); } @@ -107,7 +109,7 @@ pub struct DuplexHandle { #[cfg(feature = "runtime")] impl DuplexHandle { pub fn read(&self, buf: &mut [u8]) -> Poll { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.inner.lock().panic_if_poisoned(); assert!(buf.len() >= inner.write.inner.len()); if inner.write.inner.is_empty() { trace!("DuplexHandle read parking"); @@ -118,7 +120,7 @@ impl DuplexHandle { } pub fn write(&self, bytes: &[u8]) -> Poll { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.inner.lock().panic_if_poisoned(); assert_eq!(inner.read.inner.pos, 0); assert_eq!(inner.read.inner.vec.len(), 0, "write but read isn't empty"); inner @@ -136,7 +138,7 @@ impl Drop for DuplexHandle { fn drop(&mut self) { trace!("mock duplex handle drop"); if !::std::thread::panicking() { - let mut inner = self.inner.lock().unwrap(); + let mut inner = self.inner.lock().panic_if_poisoned(); inner.read.close(); inner.write.close(); } @@ -187,7 +189,7 @@ impl MockConnector { trace!("MockConnector mocked fut ready"); Ok((duplex, connected)) })); - self.mocks.lock().unwrap().0.entry(key) + self.mocks.lock().panic_if_poisoned().0.entry(key) .or_insert(Vec::new()) .push(fut); @@ -208,7 +210,7 @@ impl Connect for MockConnector { } else { "".to_owned() }); - let mut mocks = self.mocks.lock().unwrap(); + let mut mocks = self.mocks.lock().panic_if_poisoned(); let mocks = mocks.0.get_mut(&key) .expect(&format!("unknown mocks uri: {}", key)); assert!(!mocks.is_empty(), "no additional mocks for {}", key); diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index 4952e38518..741c066a3f 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -28,6 +28,7 @@ use std::time::{Duration, Instant}; use h2::{Ping, PingPong}; +use crate::common::lock::LockResultExt; use crate::common::time::Time; use crate::rt::Sleep; @@ -196,7 +197,7 @@ impl Recorder { return; }; - let mut locked = shared.lock().unwrap(); + let mut locked = shared.lock().panic_if_poisoned(); locked.update_last_read_at(); @@ -230,7 +231,7 @@ impl Recorder { return; }; - let mut locked = shared.lock().unwrap(); + let mut locked = shared.lock().panic_if_poisoned(); locked.update_last_read_at(); } @@ -248,7 +249,7 @@ impl Recorder { pub(super) fn ensure_not_timed_out(&self) -> crate::Result<()> { if let Some(ref shared) = self.shared { - let locked = shared.lock().unwrap(); + let locked = shared.lock().panic_if_poisoned(); if locked.is_keep_alive_timed_out { return Err(KeepAliveTimedOut.crate_error()); } @@ -263,7 +264,7 @@ impl Recorder { impl Ponger { pub(super) fn poll(&mut self, cx: &mut task::Context<'_>) -> Poll { - let mut locked = self.shared.lock().unwrap(); + let mut locked = self.shared.lock().panic_if_poisoned(); let now = locked.timer.now(); // hoping this is fine to move within the lock let is_idle = self.is_idle(); diff --git a/src/upgrade.rs b/src/upgrade.rs index 9d23a29081..de144cf071 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -53,6 +53,7 @@ use bytes::Bytes; use tokio::sync::oneshot; use crate::common::io::Rewind; +use crate::common::lock::LockResultExt; /// An upgraded HTTP connection. /// @@ -226,7 +227,7 @@ impl Future for OnUpgrade { fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { match self.rx { - Some(ref rx) => Pin::new(&mut *rx.lock().unwrap()) + Some(ref rx) => Pin::new(&mut *rx.lock().panic_if_poisoned()) .poll(cx) .map(|res| match res { Ok(Ok(upgraded)) => Ok(upgraded), From bc9aff126ce9a32f02e9b94f224711e16faa5cb9 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 19 May 2026 18:09:02 -0400 Subject: [PATCH 042/128] refactor(lib): replace unwraps with expects (#4073) --- src/client/conn/http1.rs | 26 ++++++++++++++++++++++---- src/client/dispatch.rs | 10 ++++++++-- src/ext/mod.rs | 19 +++++++++++-------- src/ffi/http_types.rs | 3 ++- src/ffi/task.rs | 15 +++++++++++++-- src/proto/h1/role.rs | 13 +++++++------ src/proto/h2/upgrade.rs | 3 ++- src/server/conn/http1.rs | 18 +++++++++++++++--- 8 files changed, 80 insertions(+), 27 deletions(-) diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 72d50c7698..67461d225d 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -100,8 +100,14 @@ where pub async fn without_shutdown(self) -> crate::Result> { let mut conn = Some(self); crate::common::future::poll_fn(move |cx| -> Poll>> { - ready!(conn.as_mut().unwrap().poll_without_shutdown(cx))?; - Poll::Ready(Ok(conn.take().unwrap().into_parts())) + ready!(conn + .as_mut() + .expect("client connection polled after completion") + .poll_without_shutdown(cx))?; + Poll::Ready(Ok(conn + .take() + .expect("client connection missing before completion") + .into_parts())) }) .await } @@ -613,10 +619,22 @@ mod upgrades { type Output = crate::Result<()>; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match ready!(Pin::new(&mut self.inner.as_mut().unwrap().inner).poll(cx)) { + match ready!(Pin::new( + &mut self + .inner + .as_mut() + .expect("upgradeable client connection polled after upgrade") + .inner, + ) + .poll(cx)) + { Ok(proto::Dispatched::Shutdown) => Poll::Ready(Ok(())), Ok(proto::Dispatched::Upgrade(pending)) => { - let Parts { io, read_buf } = self.inner.take().unwrap().into_parts(); + let Parts { io, read_buf } = self + .inner + .take() + .expect("upgradeable client connection missing after upgrade") + .into_parts(); pending.fulfill(Upgraded::new(io, read_buf)); Poll::Ready(Ok(())) } diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 718ed15647..a99e14a5c3 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -284,10 +284,16 @@ impl Callback { pub(crate) fn send(mut self, val: Result>) { match self { Callback::Retry(ref mut tx) => { - let _ = tx.take().unwrap().send(val); + let _ = tx + .take() + .expect("callback sender not dropped before send") + .send(val); } Callback::NoRetry(ref mut tx) => { - let _ = tx.take().unwrap().send(val.map_err(|e| e.error)); + let _ = tx + .take() + .expect("callback sender not dropped before send") + .send(val.map_err(|e| e.error)); } } } diff --git a/src/ext/mod.rs b/src/ext/mod.rs index b59d809dea..ac78879fbe 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -238,14 +238,17 @@ impl OriginalHeaderOrder { N: IntoHeaderName + Into + Clone, { let name: HeaderName = name.into(); - let idx; - if self.num_entries.contains_key(&name) { - idx = self.num_entries[&name]; - *self.num_entries.get_mut(&name).unwrap() += 1; - } else { - idx = 0; - self.num_entries.insert(name.clone(), 1); - } + let idx = match self.num_entries.entry(name.clone()) { + std::collections::hash_map::Entry::Occupied(mut entry) => { + let idx = *entry.get(); + *entry.get_mut() += 1; + idx + } + std::collections::hash_map::Entry::Vacant(entry) => { + entry.insert(1); + 0 + } + }; self.entry_order.push((name, idx)); } diff --git a/src/ffi/http_types.rs b/src/ffi/http_types.rs index 14c3d243c8..a9c6cf6b0f 100644 --- a/src/ffi/http_types.rs +++ b/src/ffi/http_types.rs @@ -437,7 +437,8 @@ impl hyper_headers { ext.insert(hyper_headers::default()); } - ext.get_mut::().unwrap() + ext.get_mut::() + .expect("hyper headers inserted into extensions") } } diff --git a/src/ffi/task.rs b/src/ffi/task.rs index 47c74c5dbf..abef473d5c 100644 --- a/src/ffi/task.rs +++ b/src/ffi/task.rs @@ -366,9 +366,20 @@ impl Future for TaskFuture { type Output = Box; fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match Pin::new(&mut self.task.as_mut().unwrap().future).poll(cx) { + match Pin::new( + &mut self + .task + .as_mut() + .expect("ffi task future polled after completion") + .future, + ) + .poll(cx) + { Poll::Ready(val) => { - let mut task = self.task.take().unwrap(); + let mut task = self + .task + .take() + .expect("ffi task future missing task after completion"); task.output = Some(val); Poll::Ready(task) } diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f92092e5a9..ddcff1f434 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -172,13 +172,14 @@ impl Http1Transaction for Server { Ok(httparse::Status::Complete(parsed_len)) => { trace!("Request.parse Complete({})", parsed_len); len = parsed_len; - let uri = req.path.unwrap(); + let uri = req.path.expect("httparse completed"); if uri.len() > MAX_URI_LEN { return Err(Parse::UriTooLong); } - method = Method::from_bytes(req.method.unwrap().as_bytes())?; + method = + Method::from_bytes(req.method.expect("httparse completed").as_bytes())?; path_range = Server::record_path_range(bytes, uri); - version = if req.version.unwrap() == 1 { + version = if req.version.expect("httparse completed") == 1 { keep_alive = true; is_http_11 = true; Version::HTTP_11 @@ -1036,10 +1037,10 @@ impl Http1Transaction for Client { ) { Ok(httparse::Status::Complete(len)) => { trace!("Response.parse Complete({})", len); - let status = StatusCode::from_u16(res.code.unwrap())?; + let status = StatusCode::from_u16(res.code.expect("httparse completed"))?; let reason = { - let reason = res.reason.unwrap(); + let reason = res.reason.expect("httparse completed"); // Only save the reason phrase if it isn't the canonical reason if Some(reason) != status.canonical_reason() { Some(Bytes::copy_from_slice(reason.as_bytes())) @@ -1048,7 +1049,7 @@ impl Http1Transaction for Client { } }; - let version = if res.version.unwrap() == 1 { + let version = if res.version.expect("httparse completed") == 1 { Version::HTTP_11 } else { Version::HTTP_10 diff --git a/src/proto/h2/upgrade.rs b/src/proto/h2/upgrade.rs index 40a98de08a..80a110b5c2 100644 --- a/src/proto/h2/upgrade.rs +++ b/src/proto/h2/upgrade.rs @@ -273,7 +273,8 @@ fn io_error(e: crate::Error) -> std::io::Error { fn h2_to_io_error(e: h2::Error) -> std::io::Error { if e.is_io() { - e.into_io().unwrap() + e.into_io() + .expect("h2 error reported io cause without an underlying io error") } else { std::io::Error::new(std::io::ErrorKind::Other, e) } diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index a2fa5e1d6d..7772efb52e 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -180,8 +180,15 @@ where pub fn without_shutdown(self) -> impl Future>> { let mut zelf = Some(self); crate::common::future::poll_fn(move |cx| { - ready!(zelf.as_mut().unwrap().conn.poll_without_shutdown(cx))?; - Poll::Ready(Ok(zelf.take().unwrap().into_parts())) + ready!(zelf + .as_mut() + .expect("server connection polled after completion") + .conn + .poll_without_shutdown(cx))?; + Poll::Ready(Ok(zelf + .take() + .expect("server connection missing before completion") + .into_parts())) }) } @@ -545,7 +552,12 @@ where match ready!(Pin::new(&mut conn.conn).poll(cx)) { Ok(proto::Dispatched::Shutdown) => Poll::Ready(Ok(())), Ok(proto::Dispatched::Upgrade(pending)) => { - let (io, buf, _) = self.inner.take().unwrap().conn.into_inner(); + let (io, buf, _) = self + .inner + .take() + .expect("upgradeable server connection missing after upgrade") + .conn + .into_inner(); pending.fulfill(Upgraded::new(io, buf)); Poll::Ready(Ok(())) } From 0a3ef0d30bad18abf1f8a8cbc9189e4a592fad70 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 20 May 2026 15:11:59 -0400 Subject: [PATCH 043/128] docs(lib): fixup markdown and grammar in doc comments (#4074) --- src/body/length.rs | 2 +- src/body/mod.rs | 2 +- src/client/conn/http1.rs | 12 ++++++------ src/client/conn/http2.rs | 8 ++++---- src/client/dispatch.rs | 2 +- src/client/mod.rs | 2 +- src/error.rs | 8 ++++---- src/proto/h1/conn.rs | 10 +++++----- src/proto/h1/decode.rs | 2 +- src/proto/h1/dispatch.rs | 3 ++- src/proto/h1/io.rs | 4 ++-- src/proto/h1/mod.rs | 4 ++-- src/proto/h1/role.rs | 4 ++-- src/proto/h2/client.rs | 2 +- src/proto/h2/ping.rs | 6 +++--- src/proto/mod.rs | 4 ++-- src/rt/bounds.rs | 4 ++-- src/rt/io.rs | 6 +++--- src/rt/mod.rs | 2 +- src/rt/timer.rs | 10 +++++----- src/server/conn/http1.rs | 8 ++++---- src/server/conn/http2.rs | 2 +- src/server/mod.rs | 2 +- src/service/mod.rs | 4 ++-- src/service/service.rs | 2 +- src/service/util.rs | 2 +- src/upgrade.rs | 2 +- 27 files changed, 60 insertions(+), 59 deletions(-) diff --git a/src/body/length.rs b/src/body/length.rs index e5eab7449f..7dcde4c3ae 100644 --- a/src/body/length.rs +++ b/src/body/length.rs @@ -31,7 +31,7 @@ impl DecodedLength { /// Takes the length as a content-length without other checks. /// /// Should only be called if previously confirmed this isn't - /// CLOSE_DELIMITED or CHUNKED. + /// `CLOSE_DELIMITED` or `CHUNKED`. #[inline] #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] pub(crate) fn danger_len(self) -> u64 { diff --git a/src/body/mod.rs b/src/body/mod.rs index 7b71d98be4..2df3319dd5 100644 --- a/src/body/mod.rs +++ b/src/body/mod.rs @@ -1,4 +1,4 @@ -//! Streaming bodies for Requests and Responses +//! Streaming bodies for Requests and Responses. //! //! For both [Clients](crate::client) and [Servers](crate::server), requests and //! responses use streaming bodies, instead of complete buffering. This diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 67461d225d..433cfaf160 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -1,4 +1,4 @@ -//! HTTP/1 client connections +//! HTTP/1 client connections. use std::error::Error as StdError; use std::fmt; @@ -49,7 +49,7 @@ pub struct Parts { /// In most cases, this should just be spawned into an executor, so that it /// can process incoming and outgoing messages, notice hangups, and the like. /// -/// Instances of this type are typically created via the [`handshake`] function +/// Instances of this type are typically created via the [`handshake`] function. /// /// # Drop behavior /// @@ -157,7 +157,7 @@ impl SendRequest { self.dispatch.poll_ready(cx) } - /// Waits until the dispatcher is ready + /// Waits until the dispatcher is ready. /// /// If the associated connection is closed, this returns an Error. pub async fn ready(&mut self) -> crate::Result<()> { @@ -431,11 +431,11 @@ impl Builder { /// but may also improve performance when an IO transport doesn't /// support vectored writes well, such as most TLS implementations. /// - /// Setting this to true will force hyper to use queued strategy - /// which may eliminate unnecessary cloning on some TLS backends + /// Setting this to true will force hyper to use queued strategy, + /// which may eliminate unnecessary cloning on some TLS backends. /// /// Default is `auto`. In this mode hyper will try to guess which - /// mode to use + /// mode to use. pub fn writev(&mut self, enabled: bool) -> &mut Builder { self.h1_writev = Some(enabled); self diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index 5426ab2c94..5ffad524be 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -1,4 +1,4 @@ -//! HTTP/2 client connections +//! HTTP/2 client connections. use std::error::Error; use std::fmt; @@ -38,7 +38,7 @@ impl Clone for SendRequest { /// In most cases, this should just be spawned into an executor, so that it /// can process incoming and outgoing messages, notice hangups, and the like. /// -/// Instances of this type are typically created via the [`handshake`] function +/// Instances of this type are typically created via the [`handshake`] function. /// /// # Drop behavior /// @@ -102,7 +102,7 @@ impl SendRequest { } } - /// Waits until the dispatcher is ready + /// Waits until the dispatcher is ready. /// /// If the associated connection is closed, this returns an Error. pub async fn ready(&mut self) -> crate::Result<()> { @@ -327,7 +327,7 @@ where self } - /// Sets the max connection-level flow control for HTTP2 + /// Sets the max connection-level flow control for HTTP2. /// /// Passing `None` will do nothing. /// diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index a99e14a5c3..d02caffc2d 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -66,7 +66,7 @@ pub(crate) struct Sender { /// has been dropped. However, this version can be cloned. #[cfg(feature = "http2")] pub(crate) struct UnboundedSender { - /// Only used for `is_closed`, since mpsc::UnboundedSender cannot be checked. + /// Only used for `is_closed`, since `mpsc::UnboundedSender` cannot be checked. giver: want::SharedGiver, inner: mpsc::UnboundedSender>, } diff --git a/src/client/mod.rs b/src/client/mod.rs index 86e3897388..c4a5fddca3 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -1,4 +1,4 @@ -//! HTTP Client +//! HTTP Client. //! //! hyper provides HTTP over a single connection. See the [`conn`] module. //! diff --git a/src/error.rs b/src/error.rs index 1045b3943e..5134b581a3 100644 --- a/src/error.rs +++ b/src/error.rs @@ -61,7 +61,7 @@ pub(super) enum Kind { any(feature = "http1", feature = "http2") ))] Io, - /// User took too long to send headers + /// User took too long to send headers. #[cfg(all(feature = "http1", feature = "server"))] HeaderTimeout, /// Error while reading a body from connection. @@ -76,7 +76,7 @@ pub(super) enum Kind { any(feature = "http1", feature = "http2") ))] BodyWrite, - /// Error calling AsyncWrite::shutdown() + /// Error calling `AsyncWrite::shutdown()`. #[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] Shutdown, @@ -119,7 +119,7 @@ pub(super) enum Header { #[derive(Debug)] pub(super) enum User { - /// Error calling user's Body::poll_data(). + /// Error calling the user's `Body::poll_data()`. #[cfg(all( any(feature = "client", feature = "server"), any(feature = "http1", feature = "http2") @@ -131,7 +131,7 @@ pub(super) enum User { feature = "ffi" ))] BodyWriteAborted, - /// User tried to send a connect request with a nonzero body + /// User tried to send a connect request with a nonzero body. #[cfg(all(feature = "client", feature = "http2"))] InvalidConnectWithBody, /// Error from future of user's Service. diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 3373021bf7..f14d6f5959 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -911,7 +911,7 @@ impl Unpin for Conn {} struct State { allow_half_close: bool, - /// Re-usable HeaderMap to reduce allocating new ones. + /// Re-usable `HeaderMap` to reduce allocating new ones. cached_headers: Option, /// If an error occurs when there wasn't a direct way to return it /// back to the user, this is set. @@ -948,15 +948,15 @@ struct State { /// Set to true when the Dispatcher should poll read operations /// again. See the `maybe_notify` method for more. notify_read: bool, - /// State of allowed reads + /// State of allowed reads. reading: Reading, - /// State of allowed writes + /// State of allowed writes. writing: Writing, /// An expected pending HTTP upgrade. upgrade: Option, - /// Either HTTP/1.0 or 1.1 connection + /// Either HTTP/1.0 or 1.1 connection. version: Version, - /// Flag to track if trailer fields are allowed to be sent + /// Flag to track if trailer fields are allowed to be sent. allow_trailer_fields: bool, } diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index 6047d45ab2..a7dd78837a 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -21,7 +21,7 @@ const CHUNKED_EXTENSIONS_LIMIT: u64 = 1024 * 16; /// Maximum number of bytes allowed for all trailer fields. /// -/// TODO: remove this when we land h1_max_header_size support +/// TODO: remove this when we land `h1_max_header_size` support. const TRAILER_LIMIT: usize = 1024 * 16; /// Decoders to handle different Transfer-Encodings. diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index b7e3ec3bc9..30e513fa4a 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -457,7 +457,8 @@ where self.conn.close_write(); } - /// If there is pending data in body_rx, we can make progress writing if the connection is ready. + /// If there is pending data in `body_rx`, we can make progress writing if the connection is + /// ready. fn can_write_again(&mut self) -> bool { self.body_rx.is_some() } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 2ec67d4f0a..e1778366e7 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -508,10 +508,10 @@ impl> Buf for Cursor { // an internal buffer to collect writes before flushes pub(super) struct WriteBuf { - /// Re-usable buffer that holds message headers + /// Re-usable buffer that holds message headers. headers: Cursor>, max_buf_size: usize, - /// Deque of user buffers if strategy is Queue + /// Deque of user buffers if strategy is `Queue`. queue: BufList, strategy: WriteStrategy, } diff --git a/src/proto/h1/mod.rs b/src/proto/h1/mod.rs index a8f36f5fd9..0a442c4c7e 100644 --- a/src/proto/h1/mod.rs +++ b/src/proto/h1/mod.rs @@ -56,7 +56,7 @@ pub(crate) trait Http1Transaction { fn update_date() {} } -/// Result newtype for Http1Transaction::parse. +/// Result newtype for `Http1Transaction::parse`. pub(crate) type ParseResult = Result>, crate::error::Parse>; #[derive(Debug)] @@ -81,7 +81,7 @@ pub(crate) struct ParseContext<'a> { on_informational: &'a mut Option, } -/// Passed to Http1Transaction::encode +/// Passed to `Http1Transaction::encode`. pub(crate) struct Encode<'a, T> { head: &'a mut MessageHead, body: Option, diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index ddcff1f434..544f16ef80 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -1241,9 +1241,9 @@ impl Http1Transaction for Client { #[cfg(feature = "client")] impl Client { - /// Returns Some(length, wants_upgrade) if successful. + /// Returns `Some(length, wants_upgrade)` if successful. /// - /// Returns None if this message head should be skipped (like a 100 status). + /// Returns `None` if this message head should be skipped (like a 100 status). fn decoder( inc: &MessageHead, method: &mut Option, diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index 51f548444e..dae92ae410 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -595,7 +595,7 @@ pin_project! { } impl ResponseFutMap { - /// Signal the pipe_task to reset the stream (e.g. on client cancellation). + /// Signal the `pipe_task` to reset the stream (e.g. on client cancellation). pub(crate) fn cancel(self: Pin<&mut Self>) { if let Some(cancel_tx) = self.project().cancel_tx.take() { let _ = cancel_tx.send(()); diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index 741c066a3f..5c77778da0 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -1,4 +1,4 @@ -//! HTTP2 Ping usage +//! HTTP2 Ping usage. //! //! hyper uses HTTP2 pings for two purposes: //! @@ -138,11 +138,11 @@ struct Shared { } struct Bdp { - /// Current BDP in bytes + /// Current BDP in bytes. bdp: u32, /// Largest bandwidth we've seen so far. max_bandwidth: f64, - /// Round trip time in seconds + /// Round trip time in seconds. rtt: f64, /// Delay the next ping by this amount. /// diff --git a/src/proto/mod.rs b/src/proto/mod.rs index 40acde179b..bcc9bda48d 100644 --- a/src/proto/mod.rs +++ b/src/proto/mod.rs @@ -48,9 +48,9 @@ pub(crate) type ResponseHead = MessageHead; #[derive(Debug)] #[cfg(feature = "http1")] pub(crate) enum BodyLength { - /// Content-Length + /// `Content-Length`. Known(u64), - /// Transfer-Encoding: chunked (if h1) + /// `Transfer-Encoding: chunked` (if h1). Unknown, } diff --git a/src/rt/bounds.rs b/src/rt/bounds.rs index 0af623b1bf..176c8d7f55 100644 --- a/src/rt/bounds.rs +++ b/src/rt/bounds.rs @@ -1,6 +1,6 @@ -//! Trait aliases +//! Trait aliases. //! -//! Traits in this module ease setting bounds and usually automatically +//! Traits in this module ease setting bounds and are usually automatically //! implemented by implementing another trait. #[cfg(all(feature = "client", feature = "http2"))] diff --git a/src/rt/io.rs b/src/rt/io.rs index fef98b1258..54d00be2d3 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -346,7 +346,7 @@ impl ReadBufCursor<'_> { /// Returns the number of bytes that can be written from the current /// position until the end of the buffer is reached. /// - /// This value is equal to the length of the slice returned by `as_mut()``. + /// This value is equal to the length of the slice returned by `as_mut()`. #[inline] pub fn remaining(&self) -> usize { self.buf.remaining() @@ -495,8 +495,8 @@ where } } -/// Polyfill for Pin::as_deref_mut() -/// TODO: use Pin::as_deref_mut() instead once stabilized +/// Polyfill for `Pin::as_deref_mut()`. +/// TODO: use `Pin::as_deref_mut()` instead once stabilized. fn pin_as_deref_mut(pin: Pin<&mut Pin

>) -> Pin<&mut P::Target> { // SAFETY: we go directly from Pin<&mut Pin

> to Pin<&mut P::Target>, without moving or // giving out the &mut Pin

in the process. See Pin::as_deref_mut() for more detail. diff --git a/src/rt/mod.rs b/src/rt/mod.rs index 08dceb5813..d957ac66be 100644 --- a/src/rt/mod.rs +++ b/src/rt/mod.rs @@ -1,4 +1,4 @@ -//! Runtime components +//! Runtime components. //! //! This module provides traits and types that allow hyper to be runtime-agnostic. //! By abstracting over async runtimes, hyper can work with different executors, timers, and IO transports. diff --git a/src/rt/timer.rs b/src/rt/timer.rs index a9633ca8e8..944e5d34f7 100644 --- a/src/rt/timer.rs +++ b/src/rt/timer.rs @@ -1,4 +1,4 @@ -//! Provides a timer trait with timer-like functions +//! Provides a timer trait with timer-like functions. //! //! Example using tokio timer: //! ```rust @@ -90,7 +90,7 @@ pub trait Timer { /// A future returned by a `Timer`. pub trait Sleep: Send + Sync + Future { #[doc(hidden)] - /// This method is private and can not be implemented by downstream crate + /// This method is private and can not be implemented by downstream crates. fn __type_id(&self, _: private::Sealed) -> TypeId where Self: 'static, @@ -100,9 +100,9 @@ pub trait Sleep: Send + Sync + Future { } impl dyn Sleep { - //! This is a re-implementation of downcast methods from std::any::Any + // This is a re-implementation of downcast methods from `std::any::Any`. - /// Check whether the type is the same as `T` + /// Check whether the type is the same as `T`. pub fn is(&self) -> bool where T: Sleep + 'static, @@ -110,7 +110,7 @@ impl dyn Sleep { self.__type_id(private::Sealed {}) == TypeId::of::() } - /// Downcast a pinned &mut Sleep object to its original type + /// Downcast a pinned `&mut Sleep` object to its original type. pub fn downcast_mut_pin(self: Pin<&mut Self>) -> Option> where T: Sleep + 'static, diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index 7772efb52e..c4365c60f6 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -1,4 +1,4 @@ -//! HTTP/1 Server Connections +//! HTTP/1 Server Connections. use std::error::Error as StdError; use std::fmt; @@ -360,11 +360,11 @@ impl Builder { /// but may also improve performance when an IO transport doesn't /// support vectored writes well, such as most TLS implementations. /// - /// Setting this to true will force hyper to use queued strategy - /// which may eliminate unnecessary cloning on some TLS backends + /// Setting this to true will force hyper to use queued strategy, + /// which may eliminate unnecessary cloning on some TLS backends. /// /// Default is `auto`. In this mode hyper will try to guess which - /// mode to use + /// mode to use. pub fn writev(&mut self, val: bool) -> &mut Self { self.h1_writev = Some(val); self diff --git a/src/server/conn/http2.rs b/src/server/conn/http2.rs index 77b605a086..7d8bdb1741 100644 --- a/src/server/conn/http2.rs +++ b/src/server/conn/http2.rs @@ -1,4 +1,4 @@ -//! HTTP/2 Server Connections +//! HTTP/2 Server Connections. use std::error::Error as StdError; use std::fmt; diff --git a/src/server/mod.rs b/src/server/mod.rs index 980553e5e9..c1143a4cfc 100644 --- a/src/server/mod.rs +++ b/src/server/mod.rs @@ -1,4 +1,4 @@ -//! HTTP Server +//! HTTP Server. //! //! A "server" is usually created by listening on a port for new connections, //! parse HTTP requests, and hand them off to a `Service`. diff --git a/src/service/mod.rs b/src/service/mod.rs index 28ffaddbab..0e49c45bd7 100644 --- a/src/service/mod.rs +++ b/src/service/mod.rs @@ -1,4 +1,4 @@ -//! Asynchronous Services +//! Asynchronous Services. //! //! A [`Service`] is a trait representing an asynchronous //! function of a request to a response. It's similar to @@ -11,7 +11,7 @@ //! - `HttpService`: This is blanketly implemented for all types that //! implement `Service, Response = http::Response>`. //! -//! # HttpService +//! # `HttpService` //! //! In hyper, especially in the server setting, a `Service` is usually bound //! to a single connection. It defines how to respond to **all** requests that diff --git a/src/service/service.rs b/src/service/service.rs index caded3f752..1f13b6b944 100644 --- a/src/service/service.rs +++ b/src/service/service.rs @@ -37,7 +37,7 @@ pub trait Service { /// /// Note: Returning an `Error` to a hyper server, the behavior depends on the /// protocol. In most cases, hyper will cause the connection to be abruptly aborted. - /// It will abort the request however the protocol allows, either with some sort of RST_STREAM, + /// It will abort the request however the protocol allows, either with some sort of `RST_STREAM`, /// or killing the connection if that doesn't exist. type Error; diff --git a/src/service/util.rs b/src/service/util.rs index 3e017a782c..69602c7f29 100644 --- a/src/service/util.rs +++ b/src/service/util.rs @@ -38,7 +38,7 @@ where } } -/// Service returned by [`service_fn`] +/// Service returned by [`service_fn`]. pub struct ServiceFn { f: F, _req: PhantomData, diff --git a/src/upgrade.rs b/src/upgrade.rs index de144cf071..352ed5e1c7 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -1,4 +1,4 @@ -//! HTTP Upgrades +//! HTTP Upgrades. //! //! This module deals with managing [HTTP Upgrades][mdn] in hyper. Since //! several concepts in HTTP allow for first talking HTTP, and then converting From b4b9a27f964516c596d24b5ab56671b459925e79 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 22 May 2026 12:02:57 -0400 Subject: [PATCH 044/128] chore(ci): fix security-audit job (#4076) --- .github/workflows/cargo-audit.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/cargo-audit.yml b/.github/workflows/cargo-audit.yml index 3c436edbe4..8486c4d93a 100644 --- a/.github/workflows/cargo-audit.yml +++ b/.github/workflows/cargo-audit.yml @@ -13,7 +13,7 @@ jobs: steps: - uses: actions/checkout@v6 - name: Generate lockfile - run: cargo generate-lockfile --features full + run: cargo generate-lockfile - uses: rustsec/audit-check@v2 with: token: ${{ secrets.GITHUB_TOKEN }} From a9212ef42eaf403821e1b5a87eaaa072f7b65794 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 22 May 2026 14:06:57 -0400 Subject: [PATCH 045/128] chore(lib): start a strict clippy config (#4075) This starts a change to embrace a strict allowlist with Clippy. ## Why? Clippy can detect a lot of mistakes. The defaults are very good. But it has the power to detect many more _probable_ mistakes, and enforce coding patterns beyond formatting. With the increase in LLM generated code, a stricter Clippy can protect us from the LLM generating poorer code. The pedantic and restriction groups are not enabled by default. There is even a warning to not enable the restriction group blindly. But, even they have good lints. The usual recommendation is to just turn on the lints you care about. Instead, this embraces restricting everything by default, and keep an explicit allowlist. The benefits for this are that we explicitly consider every possible "bad code" lint. We decide if it's something to ignore. And we also don't accidentally not notice a new lint. Every time Clippy upgrades, we may see some new lints that could improve our code. That is excellent! When that happens, we can decide whether to adjust the code, or allow the lint. [More reading](https://billylevin.dev/posts/clippy-config/) ## How? Restricting all these lints in one go would be a large amount of changes. Some of them can be done automatically (`cargo clippy --fix`), some of them an LLM can very easily do, and some require manual inspection in each place. This starts by enabling all the groups, listing out every lint that was triggered, and then allows them explicitly for now. I've split that list into two separate smaller lists (described here in reverse order): - Lints that are expliticly allowed. - Lints that should be decided on, either by fixing the code, or removing the TODO and putting them in the explicitly allowed list (ideally explaining why). Follow up commits can address those lints, and when doing so, update the list. This will prevent rot from occurring by keeping the PR open for a long time, or conflicts. --- .github/workflows/CI.yml | 18 ++++++ Cargo.toml | 118 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 136 insertions(+) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index 4a326d913b..a5d35e10ff 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -17,6 +17,7 @@ jobs: runs-on: ubuntu-latest needs: - style + - lint - test - msrv - miri @@ -51,6 +52,23 @@ jobs: exit 1 fi + lint: + name: Linter + #needs: [style] + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Install Rust + uses: dtolnay/rust-toolchain@stable + + - uses: Swatinem/rust-cache@v2 + + # not --all-targets, lints can't seem to tell integrations tests are tests + - name: Clippy + run: cargo clippy --features full -- -D warnings + test: name: Test ${{ matrix.rust }} on ${{ matrix.os }} needs: [style] diff --git a/Cargo.toml b/Cargo.toml index 419644d8eb..ee97a27133 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -108,6 +108,124 @@ check-cfg = [ 'cfg(hyper_unstable_quic)', ] +[lints.clippy] +pedantic = { level = "warn", priority = -1 } +restriction = { level = "warn", priority = -2 } + +# keep an allow list of lints + +# lints to decide on + +arithmetic_side_effects = "allow" # TODO: consider +as_conversions = "allow" # TODO: tricky +borrow_as_ptr = "allow" +cast_lossless = "allow" # TODO: easy fix +cast_possible_truncation = "allow" # TODO: consider +cast_precision_loss = "allow" # TODO: consider +checked_conversions = "allow" +collapsible_match = "allow" +decimal_literal_representation = "allow" # TODO: consider +default_trait_access = "allow" +else_if_without_else = "allow" +empty_structs_with_brackets = "allow" # TODO: easy fix +enum_glob_use = "allow" +explicit_iter_loop = "allow" # TODO: easy fix +float_arithmetic = "allow" +ignored_unit_patterns = "allow" +indexing_slicing = "allow" +integer_division = "allow" +integer_division_remainder_used = "allow" +large_enum_variant = "allow" +let_unit_value = "allow" +manual_assert = "allow" # TODO: easy fix +manual_assert_eq = "allow" # TODO: easy fix +map_err_ignore = "allow" +map_unwrap_or = "allow" +match_wild_err_arm = "allow" +missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive +missing_errors_doc = "allow" # TODO: good to fix +missing_panics_doc = "allow" # TODO: might be false +multiple_inherent_impl = "allow" +multiple_unsafe_ops_per_block = "allow" +needless_continue = "allow" +needless_pass_by_value = "allow" +panic = "allow" +pattern_type_mismatch = "allow" +ptr_as_ptr = "allow" +question_mark = "allow" # TODO: probably easy fix +redundant_closure_for_method_calls = "allow" +redundant_else = "allow" +ref_option = "allow" +ref_patterns = "allow" # TODO: perhaps deny? +semicolon_if_nothing_returned = "allow" # TODO: easy fix +single_char_lifetime_names = "allow" +single_match_else = "allow" # TODO: easy fix +struct_excessive_bools = "allow" # TODO: bogus lint? +trivially_copy_pass_by_ref = "allow" +undocumented_unsafe_blocks = "allow" # TODO: fix me +uninlined_format_args = "allow" # TODO: easy fix +unnecessary_semicolon = "allow" # TODO: easy fix +unnecessary_trailing_comma = "allow" +unnested_or_patterns = "allow" +unused_async = "allow" # TODO: is it for API? +unused_trait_names = "allow" # TODO: kinda annoying, but might be good to deny +unwrap_in_result = "allow" +useless_borrows_in_formatting = "allow" +wildcard_enum_match_arm = "allow" +wildcard_imports = "allow" # TODO: never, except for tests + +# explicitly allowed +absolute_paths = "allow" # sometimes its cleaner +arbitrary_source_item_ordering = "allow" # order: std, deps, crate +blanket_clippy_restriction_lints = "allow" # allowlist is better +clone_on_ref_ptr = "allow" # Arc::clone(blah) is needlessly noisy +cognitive_complexity = "allow" # is this ever useful? +default_numeric_fallback = "allow" # too many false positives +expect_used = "allow" # expect is self-documenting +error_impl_error = "allow" # mod::Error is a fine name +field_scoped_visibility_modifiers = "allow" # possibly good idea, noisy for now +if_not_else = "allow" # order depends on which is more common, not truthiness +if_then_some_else_none = "allow" # control flow as if better than closures +items_after_statements = "allow" # these can be useful +implicit_return = "allow" # standard rust +impl_trait_in_params = "allow" # turbofish ignored on purpose +inline_modules = "allow" # common for sealed types +inline_trait_bounds = "allow" # more concise if shorter bounds +let_underscore_must_use = "allow" # the entire point was to ignore must_use +let_underscore_untyped = "allow" +mod_module_files = "allow" # easier to find than self-named modules +module_inception = "allow" # sometimes that happens +min_ident_chars = "allow" # not to be abused, nor forced +missing_assert_message = "allow" # not much value +missing_docs_in_private_items = "allow" # these docs aren't rendered +missing_inline_in_public_items = "allow" # bad lint +missing_trait_methods = "allow" +must_use_candidate = "allow" # bad lint +module_name_repetitions = "allow" # sometimes it happens, not bad at all +new_without_default = "allow" # not everything needs a Default impl +panic_in_result_fn = "allow" # panics are for invariants +pub_use = "allow" +pub_with_shorthand = "allow" +question_mark_used = "allow" # question mark is excellent +unreachable = "allow" # self-documenting invariants +renamed_function_params = "allow" # we can pick clearer names +same_name_method = "allow" +shadow_reuse = "allow" # shadowing is useful +shadow_same = "allow" # shadowing is useful +shadow_unrelated = "allow" # shadowing is useful +single_call_fn = "allow" # abstracting to a function is the point +self_named_module_files = "deny" # already denied but, rly, dont do it +semicolon_inside_block ="allow" # depends on context +semicolon_outside_block ="allow" # depends on context +std_instead_of_alloc = "allow" # std is more idiomatic +std_instead_of_core = "allow" # std is more idiomatic +struct_field_names = "allow" # not really helpful +too_many_lines = "allow" # reconsider someday? +type_complexity = "allow" # not helpful +used_underscore_items = "allow" +unnecessary_safety_comment = "allow" # more safety comments are a good thing +unseparated_literal_suffix = "allow" # i don't like 0_u8 (do i?) + [package.metadata.docs.rs] features = ["ffi", "full", "tracing"] rustdoc-args = ["--cfg", "hyper_unstable_ffi", "--cfg", "hyper_unstable_tracing"] From 4da2e855078974fbfcde36481dc05815db4c4878 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 26 May 2026 09:19:22 -0400 Subject: [PATCH 046/128] test(client): fix misuse of path_and_query in CONNECT test (#4078) --- tests/client.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client.rs b/tests/client.rs index 8695f6d34a..864bf53725 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -346,7 +346,7 @@ macro_rules! test { let mut builder = Uri::builder(); if req.method() == Method::CONNECT { - builder = builder.path_and_query(format!("{}:{}", req.uri().host().unwrap(), req.uri().port_u16().unwrap())); + builder = builder.authority(format!("{}:{}", req.uri().host().unwrap(), req.uri().port_u16().unwrap())); } else { builder = builder.path_and_query(req.uri().path_and_query().cloned().unwrap_or(PathAndQuery::from_static("/"))); } From 2cb0aa6460f24908df7e7afe711e2cab2c0fcd60 Mon Sep 17 00:00:00 2001 From: Lori-Shu Date: Wed, 27 May 2026 20:53:24 +0800 Subject: [PATCH 047/128] style(client): removing wildcard_imports lint allowance (#4080) The wildcard_imports used in http1.rs is replaced with explict type imports. --- Cargo.toml | 1 - src/client/conn/http1.rs | 11 ++++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ee97a27133..884e753831 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -172,7 +172,6 @@ unused_trait_names = "allow" # TODO: kinda annoying, but might be good to deny unwrap_in_result = "allow" useless_borrows_in_formatting = "allow" wildcard_enum_match_arm = "allow" -wildcard_imports = "allow" # TODO: never, except for tests # explicitly allowed absolute_paths = "allow" # sometimes its cleaner diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 433cfaf160..b78757b9ca 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -591,10 +591,11 @@ impl Builder { } mod upgrades { + use super::{Connection, Context, Future, Parts, Pin, Poll, Read, StdError, Write}; + use crate::body::Body; + use crate::proto::Dispatched; use crate::upgrade::Upgraded; - - use super::*; - + use futures_core::ready; // A future binding a connection with a Service with Upgrade support. // // This type is unnameable outside the crate. @@ -628,8 +629,8 @@ mod upgrades { ) .poll(cx)) { - Ok(proto::Dispatched::Shutdown) => Poll::Ready(Ok(())), - Ok(proto::Dispatched::Upgrade(pending)) => { + Ok(Dispatched::Shutdown) => Poll::Ready(Ok(())), + Ok(Dispatched::Upgrade(pending)) => { let Parts { io, read_buf } = self .inner .take() From 738eed0a6cf5d2450a03a66de41c24dbd90f074f Mon Sep 17 00:00:00 2001 From: xd009642 Date: Wed, 27 May 2026 22:24:27 +0900 Subject: [PATCH 048/128] style(ext): fix manual_assert lint (#4079) As per #4071 fix instances where the manual_assert lint triggered on the code. This was able to be done with a `clippy --fix` with further cleanup to change `!.is_some()` in the assert to `.is_none()`. --- Cargo.toml | 1 - src/ext/h1_reason_phrase.rs | 7 ++++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 884e753831..8b234d9215 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,7 +137,6 @@ integer_division = "allow" integer_division_remainder_used = "allow" large_enum_variant = "allow" let_unit_value = "allow" -manual_assert = "allow" # TODO: easy fix manual_assert_eq = "allow" # TODO: easy fix map_err_ignore = "allow" map_unwrap_or = "allow" diff --git a/src/ext/h1_reason_phrase.rs b/src/ext/h1_reason_phrase.rs index adb4363689..c4c615bc20 100644 --- a/src/ext/h1_reason_phrase.rs +++ b/src/ext/h1_reason_phrase.rs @@ -42,9 +42,10 @@ impl ReasonPhrase { /// Converts a static byte slice to a reason phrase. pub const fn from_static(reason: &'static [u8]) -> Self { // TODO: this can be made const once MSRV is >= 1.57.0 - if find_invalid_byte(reason).is_some() { - panic!("invalid byte in static reason phrase"); - } + assert!( + find_invalid_byte(reason).is_none(), + "invalid byte in static reason phrase" + ); Self(Bytes::from_static(reason)) } From 858b3d274feef739a66ebed676a51758f4358dd3 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 27 May 2026 10:21:43 -0400 Subject: [PATCH 049/128] v1.10.0 --- CHANGELOG.md | 21 +++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b1adf4accc..f852cdd7fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,24 @@ +## v1.10.0 (2026-05-27) + + +#### Bug Fixes + +* **http1:** + * send error when dispatcher is dropped mid-body (#4069) ([b7a679ba](https://github.com/hyperium/hyper/commit/b7a679bad5e17d106b4b54835153af4e72027f82), closes [#3995](https://github.com/hyperium/hyper/issues/3995), [#4016](https://github.com/hyperium/hyper/issues/4016)) + * fix reading large bodies on 32-bit systems (#4056) ([b12f6525](https://github.com/hyperium/hyper/commit/b12f6525432e7fbe80b749fec26f8ed7723006fc), closes [#4055](https://github.com/hyperium/hyper/issues/4055)) + * fix rare missed write wakeup on connections v2 ([743a3ba0](https://github.com/hyperium/hyper/commit/743a3ba0706fde95e2095ad42ffefe219d807117)) +* **http2:** + * do not reserve capacity before body data is available (#4061) ([99f24345](https://github.com/hyperium/hyper/commit/99f243450268cfc8125ff232e0b7de016a1dce5b), closes [#4003](https://github.com/hyperium/hyper/issues/4003)) + * reading trailers shouldn't propagate `NO_ERROR` from early response (#3998) ([e5ad96b1](https://github.com/hyperium/hyper/commit/e5ad96b1c511b568f086100538586231628b1eab)) + + +#### Features + +* **http2:** + * add `reset_stream_duration()` client option (#4068) ([156a6f6a](https://github.com/hyperium/hyper/commit/156a6f6aaacdc782861a4cab23ab3940a029d6ac), closes [#2599](https://github.com/hyperium/hyper/issues/2599)) + * Add 'header_table_size()' method to server builder (#4062) ([6c9182c4](https://github.com/hyperium/hyper/commit/6c9182c4e566e716d23e7b54ce2c75e064a9213a)) + + ## v1.9.0 (2026-03-31) diff --git a/Cargo.toml b/Cargo.toml index 8b234d9215..2e35490fee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.9.0" +version = "1.10.0" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From 5630124e3f6eaa69bc54597b39df41023f809f4e Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 29 May 2026 13:35:19 -0400 Subject: [PATCH 050/128] fix(http1): fix busy loop when peer half-closes and open body (#4086) Closes #4085 --- src/proto/h1/dispatch.rs | 21 ++++---- tests/client.rs | 103 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+), 12 deletions(-) diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 30e513fa4a..3a3e4d27f0 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -196,18 +196,15 @@ where return Poll::Ready(Ok(())); } - // If we are continuing only because "wants_write_again", check if write is ready. + // If we are continuing only because "wants_write_again", re-check whether a second + // write poll can make progress. `poll_flush` can be ready even when there is no + // buffered data and the request body is still pending, so relying on the previous + // readiness can hot-loop. if !wants_read_again && wants_write_again { - // If write was ready, just proceed with the loop - if write_ready { - continue; - } // Write was previously pending, but may have become ready since polling flush, so - // we need to check it again. If we simply proceeded, the case of an unbuffered - // writer where flush is always ready would cause us to hot loop. + // we need to check it again. If it is still pending, it is safe to yield and rely + // on wake-up from the connection futures. if self.poll_write(cx)?.is_pending() { - // write is pending, so it is safe to yield and rely on wake-up from connection - // futures. return Poll::Ready(Ok(())); } } @@ -457,10 +454,10 @@ where self.conn.close_write(); } - /// If there is pending data in `body_rx`, we can make progress writing if the connection is - /// ready. + /// If there is pending data in `body_rx`, and the connection is still in a body-writing state, + /// we can make progress writing if the connection is ready. fn can_write_again(&mut self) -> bool { - self.body_rx.is_some() + !self.is_closing && self.body_rx.is_some() && self.conn.can_write_body() } fn is_done(&self) -> bool { diff --git a/tests/client.rs b/tests/client.rs index 864bf53725..f46e042977 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1563,6 +1563,10 @@ mod conn { use std::io::{self, Read, Write}; use std::net::{SocketAddr, TcpListener}; use std::pin::Pin; + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; use std::task::{Context, Poll}; use std::thread; use std::time::Duration; @@ -3006,6 +3010,105 @@ mod conn { } } + struct CountingStream { + tcp: TokioIo, + flush_count: Arc, + } + + impl hyper::rt::Write for CountingStream { + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + Pin::new(&mut self.tcp).poll_shutdown(cx) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + ) -> Poll> { + self.flush_count.fetch_add(1, Ordering::Relaxed); + Pin::new(&mut self.tcp).poll_flush(cx) + } + + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Pin::new(&mut self.tcp).poll_write(cx, buf) + } + } + + impl hyper::rt::Read for CountingStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: hyper::rt::ReadBufCursor<'_>, + ) -> Poll> { + Pin::new(&mut self.tcp).poll_read(cx, buf) + } + } + + // https://github.com/hyperium/hyper/issues/4085 + #[tokio::test] + async fn http1_half_closed_peer_with_open_request_body_does_not_spin() { + let (listener, addr) = setup_tk_test_server().await; + let (headers_seen_tx, headers_seen_rx) = oneshot::channel(); + + tokio::spawn(async move { + let mut sock = listener.accept().await.unwrap().0; + let mut buf = [0; 1024]; + let mut received = Vec::new(); + + loop { + let n = sock.read(&mut buf).await.expect("server read request"); + assert_ne!(n, 0, "client closed before sending request headers"); + received.extend_from_slice(&buf[..n]); + + if received.windows(4).any(|window| window == b"\r\n\r\n") { + break; + } + } + + headers_seen_tx.send(()).unwrap(); + sock.shutdown().await.expect("server half-close write"); + tokio::time::sleep(Duration::from_millis(200)).await; + }); + + let flush_count = Arc::new(AtomicUsize::new(0)); + let io = CountingStream { + tcp: tcp_connect(&addr).await.unwrap(), + flush_count: flush_count.clone(), + }; + let (mut client, conn) = conn::http1::Builder::new() + .handshake::<_, StreamBody, io::Error>>>>(io) + .await + .expect("handshake"); + + let conn_task = tokio::spawn(async move { + let _ = conn.await; + }); + + let (_tx, rx) = mpsc::channel::, io::Error>>(0); + let req = Request::post("/a").body(StreamBody::new(rx)).unwrap(); + let response_task = tokio::spawn(async move { + let _ = client.send_request(req).await; + }); + + headers_seen_rx.await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + + let flushes = flush_count.load(Ordering::Relaxed); + assert!( + flushes < 100, + "client spun after peer half-close with open request body: poll_flush={flushes}", + ); + + response_task.abort(); + conn_task.abort(); + } + // https://github.com/hyperium/hyper/issues/4040 #[tokio::test] async fn h2_pipe_task_cancelled_on_response_future_drop() { From 9d5f9486201fefa3c778b36238d9cfc48df61c7a Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 29 May 2026 13:37:32 -0400 Subject: [PATCH 051/128] v1.10.1 --- CHANGELOG.md | 8 ++++++++ Cargo.toml | 2 +- 2 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f852cdd7fc..e3511984b9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +### v1.10.1 (2026-05-29) + + +#### Bug Fixes + +* **http1:** fix busy loop when peer half-closes and open body (#4086) ([c6cb906f](https://github.com/hyperium/hyper/commit/c6cb906fdcbc3df64fc3fb613be57430e0387e48), closes [#4085](https://github.com/hyperium/hyper/issues/4085)) + + ## v1.10.0 (2026-05-27) diff --git a/Cargo.toml b/Cargo.toml index 2e35490fee..0cc72684bf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.10.0" +version = "1.10.1" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From e05c24cb8ea1701f4fd8084139238f9221756a67 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Fri, 29 May 2026 15:59:42 -0400 Subject: [PATCH 052/128] style(lib): removing `cast_lossless` lint (#4087) --- Cargo.toml | 1 - src/common/date.rs | 2 +- src/headers.rs | 2 +- src/proto/h1/decode.rs | 12 ++++++------ src/proto/h2/ping.rs | 2 +- 5 files changed, 9 insertions(+), 10 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0cc72684bf..749dba2faa 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -119,7 +119,6 @@ restriction = { level = "warn", priority = -2 } arithmetic_side_effects = "allow" # TODO: consider as_conversions = "allow" # TODO: tricky borrow_as_ptr = "allow" -cast_lossless = "allow" # TODO: easy fix cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" diff --git a/src/common/date.rs b/src/common/date.rs index 59837cd60e..cda4b5da4e 100644 --- a/src/common/date.rs +++ b/src/common/date.rs @@ -74,7 +74,7 @@ impl CachedDate { .subsec_nanos(); self.render(now); - self.next_update = now + Duration::new(1, 0) - Duration::from_nanos(nanos as u64); + self.next_update = now + Duration::new(1, 0) - Duration::from_nanos(u64::from(nanos)); } fn render(&mut self, now: SystemTime) { diff --git a/src/headers.rs b/src/headers.rs index 8bebdb9bfa..ff1f1961bb 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -84,7 +84,7 @@ fn from_digits(bytes: &[u8]) -> Option { match b { b'0'..=b'9' => { result = result.checked_mul(RADIX)?; - result = result.checked_add((b - b'0') as u64)?; + result = result.checked_add(u64::from(b - b'0'))?; } _ => { // not a DIGIT, get outta here! diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index a7dd78837a..47cf249687 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -357,15 +357,15 @@ impl ChunkedState { match byte!(rdr, cx) { b @ b'0'..=b'9' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b - b'0') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b - b'0'))); } b @ b'a'..=b'f' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b + 10 - b'a') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b + 10 - b'a'))); } b @ b'A'..=b'F' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b + 10 - b'A') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b + 10 - b'A'))); } _ => { return Poll::Ready(Err(io::Error::new( @@ -389,15 +389,15 @@ impl ChunkedState { match byte!(rdr, cx) { b @ b'0'..=b'9' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b - b'0') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b - b'0'))); } b @ b'a'..=b'f' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b + 10 - b'a') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b + 10 - b'a'))); } b @ b'A'..=b'F' => { *size = or_overflow!(size.checked_mul(radix)); - *size = or_overflow!(size.checked_add((b + 10 - b'A') as u64)); + *size = or_overflow!(size.checked_add(u64::from(b + 10 - b'A'))); } b'\t' | b' ' => return Poll::Ready(Ok(ChunkedState::SizeLws)), b';' => return Poll::Ready(Ok(ChunkedState::Extension)), diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index 5c77778da0..f4caae148e 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -423,7 +423,7 @@ impl Bdp { fn seconds(dur: Duration) -> f64 { const NANOS_PER_SEC: f64 = 1_000_000_000.0; let secs = dur.as_secs() as f64; - secs + (dur.subsec_nanos() as f64) / NANOS_PER_SEC + secs + (f64::from(dur.subsec_nanos())) / NANOS_PER_SEC } // ===== impl KeepAlive ===== From d0d1e9ddbca5550d8a9604e6248826ed3cded092 Mon Sep 17 00:00:00 2001 From: xd009642 Date: Sat, 30 May 2026 19:27:29 +0900 Subject: [PATCH 053/128] style(rt): Fix the borrow_as_ptr lint (#4082) --- Cargo.toml | 1 - src/rt/io.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 749dba2faa..b9034c4def 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,7 +118,6 @@ restriction = { level = "warn", priority = -2 } arithmetic_side_effects = "allow" # TODO: consider as_conversions = "allow" # TODO: tricky -borrow_as_ptr = "allow" cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" diff --git a/src/rt/io.rs b/src/rt/io.rs index 54d00be2d3..21c8ed9c7c 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -258,7 +258,7 @@ impl<'data> ReadBuf<'data> { #[inline] pub fn filled(&self) -> &[u8] { // SAFETY: We only slice the filled part of the buffer, which is always valid - unsafe { &*(&self.raw[0..self.filled] as *const [MaybeUninit] as *const [u8]) } + unsafe { &*(std::ptr::addr_of!(self.raw[0..self.filled]) as *const [u8]) } } /// Get a cursor to the unfilled portion of the buffer. From 84d1bc2e4af5e8e48deb25bdc644aa30dbf4bfb1 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Sat, 30 May 2026 07:30:32 -0400 Subject: [PATCH 054/128] style(rt): removing empty_structs_with_brackets lint (#4088) --- Cargo.toml | 1 - src/rt/timer.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b9034c4def..cd59a48583 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -125,7 +125,6 @@ collapsible_match = "allow" decimal_literal_representation = "allow" # TODO: consider default_trait_access = "allow" else_if_without_else = "allow" -empty_structs_with_brackets = "allow" # TODO: easy fix enum_glob_use = "allow" explicit_iter_loop = "allow" # TODO: easy fix float_arithmetic = "allow" diff --git a/src/rt/timer.rs b/src/rt/timer.rs index 944e5d34f7..fdd654fa45 100644 --- a/src/rt/timer.rs +++ b/src/rt/timer.rs @@ -107,7 +107,7 @@ impl dyn Sleep { where T: Sleep + 'static, { - self.__type_id(private::Sealed {}) == TypeId::of::() + self.__type_id(private::Sealed) == TypeId::of::() } /// Downcast a pinned `&mut Sleep` object to its original type. @@ -130,5 +130,5 @@ impl dyn Sleep { mod private { #![allow(missing_debug_implementations)] - pub struct Sealed {} + pub struct Sealed; } From 26184cc4ebcdca15a59b744c01c2e84b25ee6990 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Sat, 30 May 2026 19:34:13 -0400 Subject: [PATCH 055/128] style(proto): remove explicit_iter_loop lint (#4089) --- Cargo.toml | 1 - src/proto/h1/decode.rs | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index cd59a48583..256c6d10a1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,7 +126,6 @@ decimal_literal_representation = "allow" # TODO: consider default_trait_access = "allow" else_if_without_else = "allow" enum_glob_use = "allow" -explicit_iter_loop = "allow" # TODO: easy fix float_arithmetic = "allow" ignored_unit_patterns = "allow" indexing_slicing = "allow" diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index 47cf249687..23c95b34d1 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -649,7 +649,7 @@ fn decode_trailers(buf: &mut BytesMut, count: usize) -> Result { - for header in headers.iter() { + for header in headers { use std::convert::TryFrom; let name = match HeaderName::try_from(header.name) { Ok(name) => name, From 835f7940d2a8f675a401669b1ab02510a69c89ee Mon Sep 17 00:00:00 2001 From: Nakaryo <128471892+nakaryo716@users.noreply.github.com> Date: Sun, 31 May 2026 20:31:00 +0900 Subject: [PATCH 056/128] style(lib): fix ptr_as_ptr lint (#4091) --- Cargo.toml | 1 - src/ffi/io.rs | 2 +- src/ffi/task.rs | 2 +- src/rt/timer.rs | 2 +- src/upgrade.rs | 2 +- 5 files changed, 4 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 256c6d10a1..8ee9bed77c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -146,7 +146,6 @@ needless_continue = "allow" needless_pass_by_value = "allow" panic = "allow" pattern_type_mismatch = "allow" -ptr_as_ptr = "allow" question_mark = "allow" # TODO: probably easy fix redundant_closure_for_method_calls = "allow" redundant_else = "allow" diff --git a/src/ffi/io.rs b/src/ffi/io.rs index 89978b9e57..a7bde95e24 100644 --- a/src/ffi/io.rs +++ b/src/ffi/io.rs @@ -147,7 +147,7 @@ impl Read for hyper_io { cx: &mut Context<'_>, mut buf: crate::rt::ReadBufCursor<'_>, ) -> Poll> { - let buf_ptr = unsafe { buf.as_mut() }.as_mut_ptr() as *mut u8; + let buf_ptr = unsafe { buf.as_mut() }.as_mut_ptr().cast::(); let buf_len = buf.remaining(); match (self.read)(self.userdata, hyper_context::wrap(cx), buf_ptr, buf_len) { diff --git a/src/ffi/task.rs b/src/ffi/task.rs index abef473d5c..172c13b4b9 100644 --- a/src/ffi/task.rs +++ b/src/ffi/task.rs @@ -415,7 +415,7 @@ ffi_fn! { let task = non_null!(&mut *task ?= ptr::null_mut()); if let Some(val) = task.output.take() { - let p = Box::into_raw(val) as *mut c_void; + let p = Box::into_raw(val).cast(); // protect from returning fake pointers to empty types if p == std::ptr::NonNull::::dangling().as_ptr() { ptr::null_mut() diff --git a/src/rt/timer.rs b/src/rt/timer.rs index fdd654fa45..c8c7991151 100644 --- a/src/rt/timer.rs +++ b/src/rt/timer.rs @@ -119,7 +119,7 @@ impl dyn Sleep { unsafe { let inner = Pin::into_inner_unchecked(self); Some(Pin::new_unchecked( - &mut *(&mut *inner as *mut dyn Sleep as *mut T), + &mut *(&mut *inner as *mut dyn Sleep).cast(), )) } } else { diff --git a/src/upgrade.rs b/src/upgrade.rs index 352ed5e1c7..19ca6e80d0 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -307,7 +307,7 @@ impl dyn Io + Send { // Taken from `std::error::Error::downcast()`. unsafe { let raw: *mut dyn Io = Box::into_raw(self); - Ok(Box::from_raw(raw as *mut T)) + Ok(Box::from_raw(raw.cast())) } } else { Err(self) From 634f5d5150a58c5cb32a4bbcf143184c4d4c1bc0 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Sun, 31 May 2026 09:11:00 -0400 Subject: [PATCH 057/128] style(lib): remove manual_assert_eq lint (#4090) --- Cargo.toml | 1 - src/common/date.rs | 2 +- src/proto/h1/io.rs | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8ee9bed77c..2103e98c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -133,7 +133,6 @@ integer_division = "allow" integer_division_remainder_used = "allow" large_enum_variant = "allow" let_unit_value = "allow" -manual_assert_eq = "allow" # TODO: easy fix map_err_ignore = "allow" map_unwrap_or = "allow" match_wild_err_arm = "allow" diff --git a/src/common/date.rs b/src/common/date.rs index cda4b5da4e..56aad0a180 100644 --- a/src/common/date.rs +++ b/src/common/date.rs @@ -80,7 +80,7 @@ impl CachedDate { fn render(&mut self, now: SystemTime) { self.pos = 0; let _ = write!(self, "{}", HttpDate::from(now)); - debug_assert!(self.pos == DATE_VALUE_LENGTH); + debug_assert_eq!(self.pos, DATE_VALUE_LENGTH); self.render_http2(); } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index e1778366e7..d080333282 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -101,14 +101,14 @@ where pub(crate) fn set_write_strategy_flatten(&mut self) { // this should always be called only at construction time, // so this assert is here to catch myself - debug_assert!(self.write_buf.queue.bufs_cnt() == 0); + debug_assert_eq!(self.write_buf.queue.bufs_cnt(), 0); self.write_buf.set_strategy(WriteStrategy::Flatten); } pub(crate) fn set_write_strategy_queue(&mut self) { // this should always be called only at construction time, // so this assert is here to catch myself - debug_assert!(self.write_buf.queue.bufs_cnt() == 0); + debug_assert_eq!(self.write_buf.queue.bufs_cnt(), 0); self.write_buf.set_strategy(WriteStrategy::Queue); } From b14faae8eef831a8a5ce3608d197cc37c03440ba Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 1 Jun 2026 11:14:05 -0400 Subject: [PATCH 058/128] style(http2): allow large_enum_variant (#4092) --- Cargo.toml | 1 - src/proto/h2/server.rs | 2 ++ 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 2103e98c60..76518f2c71 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -131,7 +131,6 @@ ignored_unit_patterns = "allow" indexing_slicing = "allow" integer_division = "allow" integer_division_remainder_used = "allow" -large_enum_variant = "allow" let_unit_value = "allow" map_err_ignore = "allow" map_unwrap_or = "allow" diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 6180ce8f80..30803643c8 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -94,6 +94,8 @@ pin_project! { } } +//#[expect(clippy::large_enum_variant, reason = "the whole future is boxed")] +#[allow(clippy::large_enum_variant)] enum State where B: Body, From f317e8d13091747c7e8944f280adc10f1b93ccd8 Mon Sep 17 00:00:00 2001 From: Lori-Shu Date: Mon, 1 Jun 2026 23:19:19 +0800 Subject: [PATCH 059/128] style(lib); fix 'undocumented_unsafe_blocks' lint (#4083) All uses of `unsafe` now include a SAFETY comment. --- Cargo.toml | 1 - src/common/io/compat.rs | 13 +++++++++++++ src/proto/h1/role.rs | 8 +++++++- src/rt/timer.rs | 9 +++++++++ src/upgrade.rs | 12 +++++++++++- 5 files changed, 40 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 76518f2c71..5f1578dc4f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,7 +154,6 @@ single_char_lifetime_names = "allow" single_match_else = "allow" # TODO: easy fix struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" -undocumented_unsafe_blocks = "allow" # TODO: fix me uninlined_format_args = "allow" # TODO: easy fix unnecessary_semicolon = "allow" # TODO: easy fix unnecessary_trailing_comma = "allow" diff --git a/src/common/io/compat.rs b/src/common/io/compat.rs index d026b6d38b..c390b130c1 100644 --- a/src/common/io/compat.rs +++ b/src/common/io/compat.rs @@ -23,6 +23,7 @@ impl tokio::io::AsyncRead for Compat where T: crate::rt::Read, { + /// `poll_read` fn implementation for `Compat`. fn poll_read( self: Pin<&mut Self>, cx: &mut Context<'_>, @@ -30,6 +31,13 @@ where ) -> Poll> { let init = tbuf.initialized().len(); let filled = tbuf.filled().len(); + // SAFETY: + // 1. `tbuf.inner_mut()` returns a raw pointer/mutable slice which we wrap into + // a `crate::rt::ReadBuf` that is layout-compatible with the source `tokio::io::ReadBuf`. + // 2. We explicitly restore the `init` and `filled` states from the original buffer + // to maintain the invariant that the new `ReadBuf` tracks the same progress. + // 3. The underlying memory remains valid and uniquely accessible via `tbuf` for + // the duration of this poll operation. let (new_init, new_filled) = unsafe { let mut buf = crate::rt::ReadBuf::uninit(tbuf.inner_mut()); buf.set_init(init); @@ -42,6 +50,11 @@ where }; let n_init = new_init - init; + // SAFETY: + // 1. `tbuf.assume_init(n_init)` is safe because `crate::rt::Read::poll_read` + // guarantees that the bytes written into the buffer were initialized. + // 2. `tbuf.set_filled(new_filled)` is safe because `new_filled` is derived + // directly from the buffer state after the successful read operation. unsafe { tbuf.assume_init(n_init); tbuf.set_filled(new_filled); diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 544f16ef80..cf4ff8cc16 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -43,10 +43,16 @@ macro_rules! header_name { } }}; } - +/// construct `HeaderValue` from a maybe shared expression. macro_rules! header_value { ($bytes:expr) => {{ { + // unsafe used because of the call of `HeaderValue::from_maybe_shared_unchecked`. + // SAFETY: + // 1. The input `$bytes` must be a valid header value as per RFC 7230. + // 2. Specifically, it must not contain any prohibited characters (like `\r`, `\n`, or non-visible ASCII characters outside of allowed ranges). + // 3. This is safe because the caller is responsible for ensuring the byte content + // has been validated or is known to be a constant/static valid header value. unsafe { HeaderValue::from_maybe_shared_unchecked($bytes) } } }}; diff --git a/src/rt/timer.rs b/src/rt/timer.rs index c8c7991151..b3b5aa0a39 100644 --- a/src/rt/timer.rs +++ b/src/rt/timer.rs @@ -116,6 +116,15 @@ impl dyn Sleep { T: Sleep + 'static, { if self.is::() { + // SAFETY: + // 1. `self.is::()` guarantees that the underlying object is indeed of type `T`. + // 2. We use `Pin::into_inner_unchecked` to gain mutable access to the underlying + // value because we are maintaining the pinning contract. + // 3. We convert the reference to `dyn Sleep` to a reference to `T` via raw pointers. + // Since we've verified the type, this is a sound downcast. + // 4. `Pin::new_unchecked` is safe here because the original object was already pinned, + // and by pinning the downcasted reference, we continue to uphold the pinning guarantee + // that the object will not be moved. unsafe { let inner = Pin::into_inner_unchecked(self); Some(Pin::new_unchecked( diff --git a/src/upgrade.rs b/src/upgrade.rs index 19ca6e80d0..2dd96ab465 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -301,10 +301,20 @@ impl dyn Io + Send { let t = TypeId::of::(); self.__hyper_type_id() == t } - + /// downcast a Box wrapped Type to a Box + /// implemented by raw pointer cast. fn __hyper_downcast(self: Box) -> Result, Box> { if self.__hyper_is::() { // Taken from `std::error::Error::downcast()`. + // SAFETY: + // 1. `self.__hyper_is::()` performs a runtime type check (typically via `TypeId`), + // guaranteeing that the underlying concrete type is indeed `T`. + // 2. We use `Box::into_raw` to obtain a pointer to the trait object, which + // has the same memory layout as the underlying concrete type `T` at the + // location identified by the runtime check. + // 3. `Box::from_raw` is safe to call here because we are reconstructing the + // box from the pointer that was originally created by `Box::into_raw`, + // and the type `T` matches the original type of the allocated memory. unsafe { let raw: *mut dyn Io = Box::into_raw(self); Ok(Box::from_raw(raw.cast())) From 6764184ab0ca83b70553d40f11bb316643750ff7 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 1 Jun 2026 14:38:32 -0400 Subject: [PATCH 060/128] style(http2): use an enum instead of bool in strip_connection_headers() (#4094) --- src/proto/h2/client.rs | 2 +- src/proto/h2/mod.rs | 22 ++++++++++++++++------ src/proto/h2/server.rs | 5 ++++- 3 files changed, 21 insertions(+), 8 deletions(-) diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index dae92ae410..ca3b663a26 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -706,7 +706,7 @@ where } let (head, body) = req.into_parts(); let mut req = ::http::Request::from_parts(head, ()); - super::strip_connection_headers(req.headers_mut(), true); + super::strip_connection_headers(req.headers_mut(), super::MessageKind::Request); if let Some(len) = body.size_hint().exact() { if len != 0 || headers::method_has_defined_payload_semantics(req.method()) { headers::set_content_length_if_missing(req.headers_mut(), len); diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index f3159cf96c..e3033ec5f5 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -7,7 +7,7 @@ use std::task::{Context, Poll}; use bytes::Buf; use futures_core::ready; use h2::SendStream; -use http::header::{HeaderName, CONNECTION, TE, TRANSFER_ENCODING, UPGRADE}; +use http::header::{HeaderName, CONNECTION, TRANSFER_ENCODING, UPGRADE}; use http::HeaderMap; use pin_project_lite::pin_project; @@ -40,22 +40,32 @@ static CONNECTION_HEADERS: [HeaderName; 4] = [ UPGRADE, ]; -fn strip_connection_headers(headers: &mut HeaderMap, is_request: bool) { +enum MessageKind { + #[cfg(feature = "client")] + Request, + #[cfg(feature = "server")] + Response, +} + +fn strip_connection_headers(headers: &mut HeaderMap, kind: MessageKind) { for header in &CONNECTION_HEADERS { if headers.remove(header).is_some() { warn!("Connection header illegal in HTTP/2: {}", header.as_str()); } } - if is_request { + #[cfg(not(feature = "client"))] + let _ = kind; + #[cfg(feature = "client")] + if matches!(kind, MessageKind::Request) { if headers - .get(TE) + .get(http::header::TE) .map_or(false, |te_header| te_header != "trailers") { warn!("TE headers not set to \"trailers\" are illegal in HTTP/2 requests"); - headers.remove(TE); + headers.remove(http::header::TE); } - } else if headers.remove(TE).is_some() { + } else if headers.remove(http::header::TE).is_some() { warn!("TE headers illegal in HTTP/2 responses"); } diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 30803643c8..6645734f31 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -473,7 +473,10 @@ where let (head, body) = res.into_parts(); let mut res = ::http::Response::from_parts(head, ()); - super::strip_connection_headers(res.headers_mut(), false); + super::strip_connection_headers( + res.headers_mut(), + super::MessageKind::Response, + ); // set Date header if it isn't already set if instructed if *me.date_header { From 61ac04797e060afecd84715bb400a12eb6183ad5 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 2 Jun 2026 11:55:04 -0400 Subject: [PATCH 061/128] fix(http1): more strictly enforce max_buf_size when parsing (#4093) Closes #4081 --- src/proto/h1/io.rs | 9 ++++++++- tests/server.rs | 32 ++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index d080333282..5719e3ca04 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -222,7 +222,14 @@ where pub(crate) fn poll_read_from_io(&mut self, cx: &mut Context<'_>) -> Poll> { self.read_blocked = false; - let next = self.read_buf_strategy.next(); + // Get the next amount to allocate, but make sure we don't go over + // the max read buf size configured. + let next = cmp::min( + self.read_buf_strategy.next(), + self.read_buf_strategy + .max() + .saturating_sub(self.read_buf.len()), + ); if self.read_buf_remaining_mut() < next { self.read_buf.reserve(next); } diff --git a/tests/server.rs b/tests/server.rs index 651fbdf40d..b4c66022a6 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2387,6 +2387,38 @@ async fn max_buf_size() { .expect_err("should TooLarge error"); } +#[cfg(feature = "http1")] +#[tokio::test] +async fn max_buf_size_split_header_boundary() { + let (listener, addr) = setup_tcp_listener(); + + const MAX: usize = 8192; + + thread::spawn(move || { + let mut tcp = connect(&addr); + tcp.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\nX: ") + .expect("write 1"); + tcp.write_all(&[b'a'; 7000]).expect("write 2"); + thread::sleep(Duration::from_millis(100)); + tcp.write_all(&[b'a'; 5000]).expect("write 3"); + tcp.write_all(b"\r\n\r\n").expect("write 4"); + + let mut buf = String::new(); + tcp.read_to_string(&mut buf).expect("read response"); + + let expected = "HTTP/1.1 431 "; + assert_eq!(&buf[..expected.len()], expected); + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http1::Builder::new() + .max_buf_size(MAX) + .serve_connection(socket, HelloWorld) + .await + .expect_err("should TooLarge error"); +} + #[cfg(feature = "http1")] #[tokio::test] async fn graceful_shutdown_before_first_request_no_block() { From a724726322e8f0755be66ed265b8f069e0a85ffe Mon Sep 17 00:00:00 2001 From: Diretnan Domnan Date: Thu, 11 Jun 2026 23:02:18 +0200 Subject: [PATCH 062/128] fix(http1): flush buffered data before shutdown (#4018) Closes #4022 --- Cargo.toml | 5 + src/proto/h1/conn.rs | 2 +- src/proto/h1/io.rs | 15 +- tests/h1_shutdown_while_buffered.rs | 205 ++++++++++++++++++++++++++++ tests/ready_on_poll_stream.rs | 5 + 5 files changed, 224 insertions(+), 8 deletions(-) create mode 100644 tests/h1_shutdown_while_buffered.rs diff --git a/Cargo.toml b/Cargo.toml index 5f1578dc4f..105f92519d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -365,3 +365,8 @@ required-features = ["full"] name = "server" path = "tests/server.rs" required-features = ["full"] + +[[test]] +name = "h1_shutdown_while_buffered" +path = "tests/h1_shutdown_while_buffered.rs" +required-features = ["full"] diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index f14d6f5959..45e5ab2280 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -833,7 +833,7 @@ where } pub(crate) fn poll_shutdown(&mut self, cx: &mut Context<'_>) -> Poll> { - match ready!(Pin::new(self.io.io_mut()).poll_shutdown(cx)) { + match ready!(self.io.poll_shutdown(cx)) { Ok(()) => { trace!("shut down IO complete"); Poll::Ready(Ok(())) diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 5719e3ca04..c5ec8a1b47 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -242,10 +242,10 @@ where Poll::Ready(Ok(_)) => { let n = buf.filled().len(); trace!("received {} bytes", n); + // Safety: we just read that many bytes into the + // uninitialized part of the buffer, so this is okay. + // @tokio pls give me back `poll_read_buf` thanks unsafe { - // Safety: we just read that many bytes into the - // uninitialized part of the buffer, so this is okay. - // @tokio pls give me back `poll_read_buf` thanks self.read_buf.advance_mut(n); } self.read_buf_strategy.record(n); @@ -263,10 +263,6 @@ where (self.io, self.read_buf.freeze()) } - pub(crate) fn io_mut(&mut self) -> &mut T { - &mut self.io - } - pub(crate) fn is_read_blocked(&self) -> bool { self.read_blocked } @@ -330,6 +326,11 @@ where Pin::new(&mut self.io).poll_flush(cx) } + pub(crate) fn poll_shutdown(&mut self, cx: &mut Context<'_>) -> Poll> { + ready!(self.poll_flush(cx))?; + Pin::new(&mut self.io).poll_shutdown(cx) + } + #[cfg(test)] fn flush(&mut self) -> impl std::future::Future> + '_ { futures_util::future::poll_fn(move |cx| self.poll_flush(cx)) diff --git a/tests/h1_shutdown_while_buffered.rs b/tests/h1_shutdown_while_buffered.rs new file mode 100644 index 0000000000..a7695bdb38 --- /dev/null +++ b/tests/h1_shutdown_while_buffered.rs @@ -0,0 +1,205 @@ +// Test: Ensures poll_shutdown() is never called with buffered data +// +// Reproduces rare timing bug where HTTP/1.1 server calls shutdown() on a socket while response +// data is still buffered (not flushed), leading to data loss. +// +// Scenario: +// 1. Request fully received and read. +// 2. Server computes a "large" response with Full::new() +// 3. Socket accepts only a chunk of response and then pends +// 3. Flush returns Pending (remaining data still buffered), result ignored +// 4. self.conn.wants_read_again() is false and poll_loop returns Ready +// 5. BUG: poll_shutdown called despite body remaining and buffered body is lost +// 6. FIX: Ideally never try to shutdown without body being flushed completely. + +use std::{ + pin::Pin, + sync::{Arc, Mutex}, + task::Poll, + time::Duration, +}; + +use bytes::Bytes; +use http::{Request, Response}; +use http_body_util::Full; +use hyper::{body::Incoming, service::service_fn}; +use support::TokioIo; +use tokio::{ + io::{AsyncRead, AsyncWrite}, + net::{TcpListener, TcpStream}, + time::{sleep, timeout}, +}; +mod support; + +#[derive(Debug, Default)] +struct PendingStreamStatistics { + bytes_written: usize, + total_attempted: usize, + shutdown_called_with_buffered: bool, + buffered_at_shutdown: usize, +} + +// Simple struct that simply does one write and then pends perpetually +struct PendingStream { + inner: TcpStream, + // Keep track of how many times we entered poll_write so as to be able to write only the first + // time out + write_count: usize, + // Only write this chunk size out of full buffer + write_chunk_size: usize, + stats: Arc>, +} + +impl PendingStream { + fn new( + inner: TcpStream, + write_chunk_size: usize, + stats: Arc>, + ) -> Self { + Self { + inner, + stats, + write_chunk_size, + write_count: 0, + } + } +} + +impl AsyncRead for PendingStream { + fn poll_read( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + Pin::new(&mut self.inner).poll_read(cx, buf) + } +} + +impl AsyncWrite for PendingStream { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + buf: &[u8], + ) -> Poll> { + self.write_count += 1; + + let mut stats = self.stats.lock().unwrap(); + stats.total_attempted += buf.len(); + + if self.write_count == 1 { + // First write: partial only + let partial = std::cmp::min(buf.len(), self.write_chunk_size); + drop(stats); + + let result = Pin::new(&mut self.inner).poll_write(cx, &buf[..partial]); + if let Poll::Ready(Ok(n)) = result { + self.stats.lock().unwrap().bytes_written += n; + } + return result; + } + + // Block all further writes to simulate pending buffer + Poll::Pending + } + + fn poll_shutdown( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let mut stats = self.stats.lock().unwrap(); + let buffered = stats.total_attempted - stats.bytes_written; + + if buffered > 0 { + eprintln!( + "\n❌BUG: shutdown() called with {} bytes buffered", + buffered + ); + stats.shutdown_called_with_buffered = true; + stats.buffered_at_shutdown = buffered; + } + drop(stats); + Pin::new(&mut self.inner).poll_shutdown(cx) + } + + fn poll_flush( + mut self: Pin<&mut Self>, + cx: &mut std::task::Context<'_>, + ) -> Poll> { + let stats = self.stats.lock().unwrap(); + let buffered = stats.total_attempted - stats.bytes_written; + + if buffered > 0 { + return Poll::Pending; + } + + drop(stats); + Pin::new(&mut self.inner).poll_flush(cx) + } +} + +// Test doesn't necessarily check that the connections ended successfully but mainly that shutdown +// wasn't called with data still remaining within hyper's internal buffer +#[tokio::test] +async fn test_no_premature_shutdown_while_buffered() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let stats = Arc::new(Mutex::new(PendingStreamStatistics::default())); + + let stats_clone = stats.clone(); + let server = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let pending_stream = PendingStream::new(stream, 212_992, stats_clone); + let io = TokioIo::new(pending_stream); + + let service = service_fn(|_req: Request| async move { + // Larger Full response than write_chunk_size + let body = Full::new(Bytes::from(vec![b'X'; 500_000])); + Ok::<_, hyper::Error>(Response::new(body)) + }); + + hyper::server::conn::http1::Builder::new() + .serve_connection(io, service) + .await + }); + + // Wait for server to be ready + sleep(Duration::from_millis(50)).await; + + // Client sends request + tokio::spawn(async move { + let mut stream = TcpStream::connect(addr).await.unwrap(); + + use tokio::io::AsyncWriteExt; + + stream + .write_all( + b"POST / HTTP/1.1\r\n\ + Host: localhost\r\n\ + Transfer-Encoding: chunked\r\n\ + \r\n", + ) + .await + .unwrap(); + + stream.write_all(b"A\r\nHello World\r\n").await.unwrap(); + stream.write_all(b"0\r\n\r\n").await.unwrap(); + stream.flush().await.unwrap(); + + // keep connection open + sleep(Duration::from_secs(2)).await; + }); + + // Wait for completion + let result = timeout(Duration::from_millis(900), server).await; + + let stats = stats.lock().unwrap(); + + assert!( + !stats.shutdown_called_with_buffered, + "shutdown() called with {} bytes still buffered (wrote {} of {} bytes)", + stats.buffered_at_shutdown, stats.bytes_written, stats.total_attempted + ); + if let Ok(Ok(conn_result)) = result { + conn_result.ok(); + } +} diff --git a/tests/ready_on_poll_stream.rs b/tests/ready_on_poll_stream.rs index f393d31c0e..7e5b4dbe22 100644 --- a/tests/ready_on_poll_stream.rs +++ b/tests/ready_on_poll_stream.rs @@ -109,6 +109,11 @@ impl Write for ReadyOnPollStream { fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { self.flush_count += 1; + + if self.pending_write.is_none() { + return Poll::Ready(Ok(())); + } + // We require two flushes to complete each chunk, simulating a success at the end of the old // poll loop. After all chunks are written, we always succeed on flush to allow for finish. const TOTAL_CHUNKS: usize = 16; From 3928b82f0170eb678e948b53829ab6df2e4ff333 Mon Sep 17 00:00:00 2001 From: Nakaryo <128471892+nakaryo716@users.noreply.github.com> Date: Sat, 13 Jun 2026 20:57:21 +0900 Subject: [PATCH 063/128] style(http1): remove ref_option lint (#4101) Remove `ref_option = "allow"` from Cargo.toml and change the server methods to take `Option<&Method>` instead of `&Option`. --- Cargo.toml | 1 - src/proto/h1/role.rs | 28 ++++++++++++++-------------- 2 files changed, 14 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 105f92519d..16a85d95db 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -147,7 +147,6 @@ pattern_type_mismatch = "allow" question_mark = "allow" # TODO: probably easy fix redundant_closure_for_method_calls = "allow" redundant_else = "allow" -ref_option = "allow" ref_patterns = "allow" # TODO: perhaps deny? semicolon_if_nothing_returned = "allow" # TODO: easy fix single_char_lifetime_names = "allow" diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index cf4ff8cc16..d06d560164 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -500,13 +500,13 @@ impl Http1Transaction for Server { #[cfg(feature = "server")] impl Server { - fn can_have_body(method: &Option, status: StatusCode) -> bool { + fn can_have_body(method: Option<&Method>, status: StatusCode) -> bool { Server::can_chunked(method, status) } - fn can_chunked(method: &Option, status: StatusCode) -> bool { - if method == &Some(Method::HEAD) - || method == &Some(Method::CONNECT) && status.is_success() + fn can_chunked(method: Option<&Method>, status: StatusCode) -> bool { + if method == Some(&Method::HEAD) + || method == Some(&Method::CONNECT) && status.is_success() || status.is_informational() { false @@ -515,16 +515,16 @@ impl Server { } } - fn can_have_content_length(method: &Option, status: StatusCode) -> bool { - if status.is_informational() || method == &Some(Method::CONNECT) && status.is_success() { + fn can_have_content_length(method: Option<&Method>, status: StatusCode) -> bool { + if status.is_informational() || method == Some(&Method::CONNECT) && status.is_success() { false } else { !matches!(status, StatusCode::NO_CONTENT | StatusCode::NOT_MODIFIED) } } - fn can_have_implicit_zero_content_length(method: &Option, status: StatusCode) -> bool { - Server::can_have_content_length(method, status) && method != &Some(Method::HEAD) + fn can_have_implicit_zero_content_length(method: Option<&Method>, status: StatusCode) -> bool { + Server::can_have_content_length(method, status) && method != Some(&Method::HEAD) } fn encode_headers_with_lower_case( @@ -801,7 +801,7 @@ impl Server { } // check that we actually can send a chunked body... if msg.head.version == Version::HTTP_10 - || !Server::can_chunked(msg.req_method, msg.head.subject) + || !Server::can_chunked(msg.req_method.as_ref(), msg.head.subject) { continue; } @@ -849,7 +849,7 @@ impl Server { header::TRAILER => { // check that we actually can send a chunked body... if msg.head.version == Version::HTTP_10 - || !Server::can_chunked(msg.req_method, msg.head.subject) + || !Server::can_chunked(msg.req_method.as_ref(), msg.head.subject) { continue; } @@ -911,7 +911,7 @@ impl Server { encoder = match msg.body { Some(BodyLength::Unknown) => { if msg.head.version == Version::HTTP_10 - || !Server::can_chunked(msg.req_method, msg.head.subject) + || !Server::can_chunked(msg.req_method.as_ref(), msg.head.subject) { Encoder::close_delimited() } else { @@ -925,7 +925,7 @@ impl Server { } None | Some(BodyLength::Known(0)) => { if Server::can_have_implicit_zero_content_length( - msg.req_method, + msg.req_method.as_ref(), msg.head.subject, ) { header_name_writer.write_full_header_line( @@ -937,7 +937,7 @@ impl Server { Encoder::length(0) } Some(BodyLength::Known(len)) => { - if !Server::can_have_content_length(msg.req_method, msg.head.subject) { + if !Server::can_have_content_length(msg.req_method.as_ref(), msg.head.subject) { Encoder::length(0) } else { header_name_writer.write_header_name_with_colon( @@ -953,7 +953,7 @@ impl Server { }; } - if !Server::can_have_body(msg.req_method, msg.head.subject) { + if !Server::can_have_body(msg.req_method.as_ref(), msg.head.subject) { trace!( "server body forced to 0; method={:?}, status={:?}", msg.req_method, From 819fec37cbfcaeb45c401e5f7b93f09e7c4fb8f5 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Tue, 16 Jun 2026 08:26:52 -0400 Subject: [PATCH 064/128] style(lib): remove unnecessary_semicolon lint (#4103) --- Cargo.toml | 1 - src/client/dispatch.rs | 2 +- src/proto/h1/conn.rs | 2 +- src/proto/h2/client.rs | 4 ++-- src/server/conn/http1.rs | 4 ++-- 5 files changed, 6 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 16a85d95db..e6503d18bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -154,7 +154,6 @@ single_match_else = "allow" # TODO: easy fix struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" uninlined_format_args = "allow" # TODO: easy fix -unnecessary_semicolon = "allow" # TODO: easy fix unnecessary_trailing_comma = "allow" unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index d02caffc2d..0ff3e7c25f 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -370,7 +370,7 @@ where this.call_back.set(Some(call_back)); return Poll::Pending; } - }; + } trace!("send_when canceled"); // Tell pipe_task to reset the h2 stream so that // RST_STREAM is sent and flow-control capacity freed. diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 45e5ab2280..2fcf0d6d4a 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -529,7 +529,7 @@ where return } Reading::Init => (), - }; + } match self.state.writing { Writing::Body(..) => return, diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index ca3b663a26..4803dba0f1 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -510,7 +510,7 @@ where return Poll::Ready(()); } Poll::Pending => (), - }; + } Poll::Pending } } @@ -688,7 +688,7 @@ where Poll::Ready(Err(crate::Error::new_h2(err))) }; } - }; + } // If we were waiting on pending open // continue where we left off. diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index c4365c60f6..fb6993c82b 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -225,7 +225,7 @@ where // error letting them know about that. pending.manual(); } - }; + } Poll::Ready(Ok(())) } Err(e) => Poll::Ready(Err(e)), @@ -479,7 +479,7 @@ impl Builder { .check(self.h1_header_read_timeout, "header_read_timeout") { conn.set_http1_header_read_timeout(dur); - }; + } if let Some(writev) = self.h1_writev { if writev { conn.set_write_strategy_queue(); From dc3fc262a5fe77bee87ac6c4dcb3c592a287732f Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Tue, 16 Jun 2026 09:08:43 -0400 Subject: [PATCH 065/128] style(lib): remove uninlined_format_args lint (#4104) --- Cargo.toml | 1 - src/body/length.rs | 2 +- src/common/time.rs | 2 +- src/proto/h1/encode.rs | 2 +- src/proto/h1/io.rs | 3 +-- src/proto/h1/role.rs | 11 ++++------- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e6503d18bc..060b00bfda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -153,7 +153,6 @@ single_char_lifetime_names = "allow" single_match_else = "allow" # TODO: easy fix struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" -uninlined_format_args = "allow" # TODO: easy fix unnecessary_trailing_comma = "allow" unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? diff --git a/src/body/length.rs b/src/body/length.rs index 7dcde4c3ae..0a782b033f 100644 --- a/src/body/length.rs +++ b/src/body/length.rs @@ -102,7 +102,7 @@ impl fmt::Display for DecodedLength { DecodedLength::CLOSE_DELIMITED => f.write_str("close-delimited"), DecodedLength::CHUNKED => f.write_str("chunked encoding"), DecodedLength::ZERO => f.write_str("empty"), - DecodedLength(n) => write!(f, "content-length ({} bytes)", n), + DecodedLength(n) => write!(f, "content-length ({n} bytes)"), } } } diff --git a/src/common/time.rs b/src/common/time.rs index ad88cb3cde..64a2deed46 100644 --- a/src/common/time.rs +++ b/src/common/time.rs @@ -77,7 +77,7 @@ impl Time { Time::Timer(..) => Some(dur), }, Dur::Configured(Some(dur)) => match self { - Time::Empty => panic!("timeout `{}` set, but no timer set", name,), + Time::Empty => panic!("timeout `{name}` set, but no timer set",), Time::Timer(..) => Some(dur), }, Dur::Default(None) | Dur::Configured(None) => None, diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index ff8f21fdf5..fcf7940b16 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -352,7 +352,7 @@ impl ChunkSize { pos: 0, len: 0, }; - write!(&mut size, "{:X}\r\n", len).expect("CHUNK_SIZE_MAX_BYTES should fit any usize"); + write!(&mut size, "{len:X}\r\n").expect("CHUNK_SIZE_MAX_BYTES should fit any usize"); size } } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index c5ec8a1b47..2d79018f57 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -86,8 +86,7 @@ where pub(crate) fn set_max_buf_size(&mut self, max: usize) { assert!( max >= MINIMUM_MAX_BUFFER_SIZE, - "The max_buf_size cannot be smaller than {}.", - MINIMUM_MAX_BUFFER_SIZE, + "The max_buf_size cannot be smaller than {MINIMUM_MAX_BUFFER_SIZE}.", ); self.read_buf_strategy = ReadStrategy::with_max(max); self.write_buf.max_buf_size = max; diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index d06d560164..0ced7d8e1a 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -421,7 +421,7 @@ impl Http1Transaction for Server { debug!("response with HTTP2 version coerced to HTTP/1.1"); extend(dst, b"HTTP/1.1 "); } - other => panic!("unexpected response version: {:?}", other), + other => panic!("unexpected response version: {other:?}"), } extend(dst, msg.head.subject.as_str().as_bytes()); @@ -712,9 +712,7 @@ impl Server { if msg.req_method != &Some(Method::HEAD) || known_len != 0 { assert!( len == known_len, - "payload claims content-length of {}, custom content-length header claims {}", - known_len, - len, + "payload claims content-length of {known_len}, custom content-length header claims {len}", ); } } @@ -896,8 +894,7 @@ impl Server { // non-special write Name and Value debug_assert!( !is_name_written, - "{:?} set is_name_written and didn't continue loop", - name, + "{name:?} set is_name_written and didn't continue loop", ); header_name_writer.write_header_name(dst, name); extend(dst, b": "); @@ -1212,7 +1209,7 @@ impl Http1Transaction for Client { debug!("request with HTTP2 version coerced to HTTP/1.1"); extend(dst, b"HTTP/1.1"); } - other => panic!("unexpected request version: {:?}", other), + other => panic!("unexpected request version: {other:?}"), } extend(dst, b"\r\n"); From 2c7d9a3cbab661b03c46b15eb1c11e7abfc9241c Mon Sep 17 00:00:00 2001 From: MonkieeBoi <53400613+MonkieeBoi@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:34:01 +1000 Subject: [PATCH 066/128] style(lib): remove `semicolon_if_nothing_returned` lint (#4106) --- Cargo.toml | 1 - src/common/date.rs | 4 ++-- src/headers.rs | 2 +- src/proto/h1/conn.rs | 6 +++--- src/proto/h1/io.rs | 2 +- src/proto/h1/role.rs | 8 ++++---- src/rt/bounds.rs | 6 +++--- src/server/conn/http1.rs | 2 +- 8 files changed, 15 insertions(+), 16 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 060b00bfda..893e26fc70 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -148,7 +148,6 @@ question_mark = "allow" # TODO: probably easy fix redundant_closure_for_method_calls = "allow" redundant_else = "allow" ref_patterns = "allow" # TODO: perhaps deny? -semicolon_if_nothing_returned = "allow" # TODO: easy fix single_char_lifetime_names = "allow" single_match_else = "allow" # TODO: easy fix struct_excessive_bools = "allow" # TODO: bogus lint? diff --git a/src/common/date.rs b/src/common/date.rs index 56aad0a180..d348ff7bc7 100644 --- a/src/common/date.rs +++ b/src/common/date.rs @@ -14,14 +14,14 @@ pub(crate) const DATE_VALUE_LENGTH: usize = 29; pub(crate) fn extend(dst: &mut Vec) { CACHED.with(|cache| { dst.extend_from_slice(cache.borrow().buffer()); - }) + }); } #[cfg(feature = "http1")] pub(crate) fn update() { CACHED.with(|cache| { cache.borrow_mut().check(); - }) + }); } #[cfg(feature = "http2")] diff --git a/src/headers.rs b/src/headers.rs index ff1f1961bb..509f089992 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -53,7 +53,7 @@ pub(super) fn content_length_parse_all_values(values: ValueIter<'_, HeaderValue> for v in line.split(',') { if let Some(n) = from_digits(v.trim().as_bytes()) { if content_length.is_none() { - content_length = Some(n) + content_length = Some(n); } else if content_length != Some(n) { return None; } diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 2fcf0d6d4a..dfcadcee56 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -545,7 +545,7 @@ where if self.state.is_idle() { self.state.close(); } else { - self.close_read() + self.close_read(); } return; } @@ -1084,14 +1084,14 @@ impl State { } } (&Reading::Closed, &Writing::KeepAlive) | (&Reading::KeepAlive, &Writing::Closed) => { - self.close() + self.close(); } _ => (), } } fn disable_keep_alive(&mut self) { - self.keep_alive.disable() + self.keep_alive.disable(); } fn busy(&mut self) { diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 2d79018f57..583655f151 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -146,7 +146,7 @@ where } pub(crate) fn buffer>(&mut self, buf: BB) { - self.write_buf.buffer(buf) + self.write_buf.buffer(buf); } pub(crate) fn can_buffer(&self) -> bool { diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 0ced7d8e1a..740c8e1d56 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -544,7 +544,7 @@ impl Server { line: &str, _: (HeaderName, &str), ) { - extend(dst, line.as_bytes()) + extend(dst, line.as_bytes()); } #[inline] @@ -554,12 +554,12 @@ impl Server { name_with_colon: &str, _: HeaderName, ) { - extend(dst, name_with_colon.as_bytes()) + extend(dst, name_with_colon.as_bytes()); } #[inline] fn write_header_name(&mut self, dst: &mut Vec, name: &HeaderName) { - extend(dst, name.as_str().as_bytes()) + extend(dst, name.as_str().as_bytes()); } } @@ -929,7 +929,7 @@ impl Server { dst, "content-length: 0\r\n", (header::CONTENT_LENGTH, ": 0\r\n"), - ) + ); } Encoder::length(0) } diff --git a/src/rt/bounds.rs b/src/rt/bounds.rs index 176c8d7f55..6837776046 100644 --- a/src/rt/bounds.rs +++ b/src/rt/bounds.rs @@ -27,7 +27,7 @@ mod h2_common { E: Executor>, { fn execute_upgrade(&self, fut: UpgradedSendStreamTask) { - self.execute(fut) + self.execute(fut); } } } @@ -70,7 +70,7 @@ mod h2_client { T: Read + Write + Unpin, { fn execute_h2_future(&mut self, future: H2ClientFuture) { - self.execute(future) + self.execute(future); } } @@ -123,7 +123,7 @@ mod h2_server { B: Body, { fn execute_h2stream(&mut self, fut: H2Stream) { - self.execute(fut) + self.execute(fut); } } diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index fb6993c82b..9eb990ee91 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -526,7 +526,7 @@ where // Connection (`inner`) is `None` if it was upgraded (and `poll` is `Ready`). // In that case, we don't need to call `graceful_shutdown`. if let Some(conn) = self.inner.as_mut() { - Pin::new(conn).graceful_shutdown() + Pin::new(conn).graceful_shutdown(); } } From 572bdf6c1721ea8ec16a51334ecfb528549dee89 Mon Sep 17 00:00:00 2001 From: Yunus <112053367+yunz-dev@users.noreply.github.com> Date: Sat, 20 Jun 2026 00:48:50 +1000 Subject: [PATCH 067/128] style(lib): remove `single_match_else` lint (#4105) cc #4071 --- Cargo.toml | 1 - src/proto/h1/dispatch.rs | 12 +++++------- src/proto/h1/io.rs | 35 ++++++++++++++++------------------- 3 files changed, 21 insertions(+), 27 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 893e26fc70..0b2c2d78b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -149,7 +149,6 @@ redundant_closure_for_method_calls = "allow" redundant_else = "allow" ref_patterns = "allow" # TODO: perhaps deny? single_char_lifetime_names = "allow" -single_match_else = "allow" # TODO: easy fix struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" unnecessary_trailing_comma = "allow" diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 3a3e4d27f0..3a9b536ea4 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -291,13 +291,11 @@ where fn poll_read_head(&mut self, cx: &mut Context<'_>) -> Poll> { // can dispatch receive, or does it still care about other incoming message? - match ready!(self.dispatch.poll_ready(cx)) { - Ok(()) => (), - Err(()) => { - trace!("dispatch no longer receiving messages"); - self.close(); - return Poll::Ready(Ok(())); - } + if let Ok(()) = ready!(self.dispatch.poll_ready(cx)) { + } else { + trace!("dispatch no longer receiving messages"); + self.close(); + return Poll::Ready(Ok(())); } // dispatch is ready for a message, try to read one diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 583655f151..2aeabc7c60 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -175,7 +175,7 @@ where S: Http1Transaction, { loop { - match super::role::parse_headers::( + if let Some(msg) = super::role::parse_headers::( &mut self.read_buf, self.partial_len, ParseContext { @@ -191,25 +191,22 @@ where on_informational: parse_ctx.on_informational, }, )? { - Some(msg) => { - debug!("parsed {} headers", msg.head.headers.len()); - self.partial_len = None; - return Poll::Ready(Ok(msg)); + debug!("parsed {} headers", msg.head.headers.len()); + self.partial_len = None; + return Poll::Ready(Ok(msg)); + } else { + let max = self.read_buf_strategy.max(); + let curr_len = self.read_buf.len(); + if curr_len >= max { + debug!("max_buf_size ({}) reached, closing", max); + return Poll::Ready(Err(crate::Error::new_too_large())); } - None => { - let max = self.read_buf_strategy.max(); - let curr_len = self.read_buf.len(); - if curr_len >= max { - debug!("max_buf_size ({}) reached, closing", max); - return Poll::Ready(Err(crate::Error::new_too_large())); - } - if curr_len > 0 { - trace!("partial headers; {} bytes so far", curr_len); - self.partial_len = Some(curr_len); - } else { - // 1xx gobled some bytes - self.partial_len = None; - } + if curr_len > 0 { + trace!("partial headers; {} bytes so far", curr_len); + self.partial_len = Some(curr_len); + } else { + // 1xx gobled some bytes + self.partial_len = None; } } if ready!(self.poll_read_from_io(cx)).map_err(crate::Error::new_io)? == 0 { From 3eb311de2ee03185e1a05c45c0fbad15dbe3251f Mon Sep 17 00:00:00 2001 From: Nakaryo <128471892+nakaryo716@users.noreply.github.com> Date: Mon, 22 Jun 2026 23:19:16 +0900 Subject: [PATCH 068/128] style(lib): remove `default_trait_access` lint (#4111) Part of #4071 --- Cargo.toml | 1 - src/client/conn/http1.rs | 2 +- src/client/conn/http2.rs | 2 +- src/ext/mod.rs | 2 +- src/server/conn/http1.rs | 5 +++-- src/server/conn/http2.rs | 2 +- 6 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 0b2c2d78b0..810af6aa22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,6 @@ cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" collapsible_match = "allow" decimal_literal_representation = "allow" # TODO: consider -default_trait_access = "allow" else_if_without_else = "allow" enum_glob_use = "allow" float_arithmetic = "allow" diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index b78757b9ca..ec64543c23 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -332,7 +332,7 @@ impl Builder { h09_responses: false, h1_writev: None, h1_read_buf_exact_size: None, - h1_parser_config: Default::default(), + h1_parser_config: ParserConfig::default(), h1_title_case_headers: false, h1_preserve_header_case: false, h1_max_headers: None, diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index 5ffad524be..ec046e0a39 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -298,7 +298,7 @@ where Builder { exec, timer: Time::Empty, - h2_builder: Default::default(), + h2_builder: proto::h2::client::Config::default(), } } diff --git a/src/ext/mod.rs b/src/ext/mod.rs index ac78879fbe..0fccb5ffec 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -181,7 +181,7 @@ impl HeaderCaseMap { #[cfg(any(feature = "client", feature = "server"))] pub(crate) fn default() -> Self { - Self(Default::default()) + Self(HeaderMap::default()) } #[cfg(any(test, feature = "ffi"))] diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index 9eb990ee91..7bd3d72e2f 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -12,6 +12,7 @@ use crate::rt::{Read, Write}; use crate::upgrade::Upgraded; use bytes::Bytes; use futures_core::ready; +use httparse::ParserConfig; use crate::body::{Body, Incoming as IncomingBody}; use crate::proto; @@ -69,7 +70,7 @@ pin_project_lite::pin_project! { /// to bind the built connection to a service. #[derive(Clone, Debug)] pub struct Builder { - h1_parser_config: httparse::ParserConfig, + h1_parser_config: ParserConfig, timer: Time, h1_half_close: bool, h1_keep_alive: bool, @@ -239,7 +240,7 @@ impl Builder { /// Create a new connection builder. pub fn new() -> Self { Self { - h1_parser_config: Default::default(), + h1_parser_config: ParserConfig::default(), timer: Time::Empty, h1_half_close: false, h1_keep_alive: true, diff --git a/src/server/conn/http2.rs b/src/server/conn/http2.rs index 7d8bdb1741..e4dac78bea 100644 --- a/src/server/conn/http2.rs +++ b/src/server/conn/http2.rs @@ -116,7 +116,7 @@ impl Builder { Self { exec, timer: Time::Empty, - h2_builder: Default::default(), + h2_builder: proto::h2::server::Config::default(), } } From 10f4b0098565ad0112211829f0421dee148249c3 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 23 Jun 2026 15:31:56 -0400 Subject: [PATCH 069/128] fix(http1): use append for repeat trailers (#4107) --- src/proto/h1/decode.rs | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index 23c95b34d1..ef0ce77efc 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -671,7 +671,7 @@ fn decode_trailers(buf: &mut BytesMut, count: usize) -> Result>(); + + assert_eq!(values, ["first", "second"]); + } + #[tokio::test] async fn test_trailer_max_headers_enforced() { let h1_max_headers = 10; From b4c30ab7623f4dba45e275b116f2cd01b20a63b4 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 23 Jun 2026 15:32:08 -0400 Subject: [PATCH 070/128] fix(http1): allow up to max_headers trailers (#4108) --- src/proto/h1/decode.rs | 39 +++++++++++++++++++++++++++++++-------- 1 file changed, 31 insertions(+), 8 deletions(-) diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index ef0ce77efc..0a95dd58d3 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -201,13 +201,6 @@ impl Decoder { if trailers_buf.is_some() { trace!("found possible trailers"); - // decoder enforces that trailers count will not exceed h1_max_headers - if *trailers_cnt >= h1_max_headers { - return Poll::Ready(Err(io::Error::new( - io::ErrorKind::InvalidData, - "chunk trailers count overflow", - ))); - } match decode_trailers( &mut trailers_buf.take().expect("Trailer is None"), *trailers_cnt, @@ -1176,7 +1169,7 @@ mod tests { let h1_max_headers = 10; let mut scratch = vec![]; scratch.extend(b"10\r\n1234567890abcdef\r\n0\r\n"); - for i in 0..h1_max_headers { + for i in 0..=h1_max_headers { scratch.extend(format!("trailer{}: {}\r\n", i, i).as_bytes()); } scratch.extend(b"\r\n"); @@ -1201,6 +1194,36 @@ mod tests { assert_eq!(err.kind(), io::ErrorKind::InvalidData); } + #[tokio::test] + async fn test_trailer_max_headers_allows_exact_limit() { + let h1_max_headers = 10; + let mut scratch = vec![]; + scratch.extend(b"10\r\n1234567890abcdef\r\n0\r\n"); + for i in 0..h1_max_headers { + scratch.extend(format!("trailer{}: {}\r\n", i, i).as_bytes()); + } + scratch.extend(b"\r\n"); + let mut mock_buf = Bytes::from(scratch); + + let mut decoder = Decoder::chunked(Some(h1_max_headers), None); + + let buf = decoder + .decode_fut(&mut mock_buf) + .await + .unwrap() + .into_data() + .expect("unknown frame type"); + assert_eq!(16, buf.len()); + + let trailers = decoder + .decode_fut(&mut mock_buf) + .await + .expect("trailers at limit") + .into_trailers() + .expect("unknown frame type"); + assert_eq!(trailers.len(), h1_max_headers); + } + #[tokio::test] async fn test_trailer_max_header_size_huge_trailer() { let max_header_size = 1024; From 57ceac002b05873e28e5120cd3f37b016d04dac7 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 23 Jun 2026 16:01:19 -0400 Subject: [PATCH 071/128] fix(http2): avoid buffering `Upgraded` writes without send capacity (#4102) * fix(h2): return Poll::Pending when poll_capacity is not ready in UpgradedSendStreamTask Fix a backpressure bypass bug in UpgradedSendStreamTask::tick() where poll_capacity() returning Poll::Pending caused a 'break 'capacity' that fell through to rx.poll_next() -> send_data(), pushing data into the h2 send buffer without available capacity. This broke the HTTP/2 flow control chain, causing unbounded memory growth (OOM) when downstream consumers were slower than upstream producers. The fix changes 'break 'capacity' to 'return Poll::Pending', which correctly suspends the task until a WINDOW_UPDATE frame restores send capacity. The now-unused 'capacity label is also removed. This bug was introduced in hyper v1.8.0 (PR #3967) and affects v1.8.0, v1.8.1, and v1.9.0. A single HTTP/2 CONNECT tunnel with asymmetric upstream/downstream speeds could trigger OOM within seconds. Add four integration tests covering H2 CONNECT backpressure scenarios: - h2_connect_backpressure_respected: small window + large data transfer - h2_connect_zero_window_then_release: normal path regression guard - h2_connect_reset_during_backpressure: RST_STREAM error propagation - h2_connect_backpressure_bidirectional: bidirectional data + backpressure * refactor(h2): check poll_reset before returning Pending on capacity wait When poll_capacity returns Pending, defer the return and check poll_reset first. This ensures the reset waker is registered via poll_reset (which shares the same send_task slot in h2 internally), enabling earlier RST_STREAM detection without an extra poll round-trip. While h2 internally calls notify_send() on RST_STREAM/EOF/error (so poll_capacity's waker alone would eventually be woken), polling poll_reset here provides immediate detection if RST_STREAM has already arrived, avoiding one unnecessary suspend/wake cycle. The h2_has_capacity flag cleanly separates the capacity check from the control flow, making the manual select-over-3-futures pattern more readable. Co-authored-by: sunhuanran --- Cargo.toml | 2 +- src/proto/h2/upgrade.rs | 84 ++++++++- tests/server.rs | 365 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 444 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 810af6aa22..9a30ffefee 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -83,7 +83,7 @@ full = [ # HTTP versions http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa"] -http2 = ["dep:futures-channel", "dep:futures-core", "dep:h2"] +http2 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:h2"] http3 = ["dep:h3"] # Client/Server diff --git a/src/proto/h2/upgrade.rs b/src/proto/h2/upgrade.rs index 80a110b5c2..f4e65466a2 100644 --- a/src/proto/h2/upgrade.rs +++ b/src/proto/h2/upgrade.rs @@ -1,8 +1,11 @@ use std::future::Future; use std::io::Cursor; use std::pin::Pin; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; use std::task::{Context, Poll}; +use atomic_waker::AtomicWaker; use bytes::{Buf, Bytes}; use futures_channel::{mpsc, oneshot}; use futures_core::{ready, Stream}; @@ -20,10 +23,15 @@ pub(super) fn pair( ) -> (H2Upgraded, UpgradedSendStreamTask) { let (tx, rx) = mpsc::channel(1); let (error_tx, error_rx) = oneshot::channel(); + let close_notify = Arc::new(UpgradedCloseNotify::new()); ( H2Upgraded { - send_stream: UpgradedSendStreamBridge { tx, error_rx }, + send_stream: UpgradedSendStreamBridge { + tx, + error_rx, + close_notify: close_notify.clone(), + }, recv_stream, ping, buf: Bytes::new(), @@ -31,6 +39,7 @@ pub(super) fn pair( UpgradedSendStreamTask { h2_tx: send_stream, rx, + close_notify, error_tx: Some(error_tx), }, ) @@ -46,6 +55,46 @@ pub(super) struct H2Upgraded { struct UpgradedSendStreamBridge { tx: mpsc::Sender>>, error_rx: oneshot::Receiver, + close_notify: Arc, +} + +impl Drop for UpgradedSendStreamBridge { + fn drop(&mut self) { + self.close_notify.close(); + } +} + +struct UpgradedCloseNotify { + closed: AtomicBool, + task: AtomicWaker, +} + +impl UpgradedCloseNotify { + fn new() -> Self { + Self { + closed: AtomicBool::new(false), + task: AtomicWaker::new(), + } + } + + fn close(&self) { + self.closed.store(true, Ordering::Release); + self.task.wake(); + } + + fn poll_closed(&self, cx: &mut Context<'_>) -> Poll<()> { + if self.closed.load(Ordering::Acquire) { + return Poll::Ready(()); + } + + self.task.register(cx.waker()); + + if self.closed.load(Ordering::Acquire) { + Poll::Ready(()) + } else { + Poll::Pending + } + } } pin_project! { @@ -55,6 +104,7 @@ pin_project! { h2_tx: SendStream>, #[pin] rx: mpsc::Receiver>>, + close_notify: Arc, error_tx: Option>, } } @@ -78,12 +128,12 @@ where // for the actual body chunk. me.h2_tx.reserve_capacity(1); - if me.h2_tx.capacity() == 0 { + let h2_has_capacity = if me.h2_tx.capacity() == 0 { // poll_capacity oddly needs a loop - 'capacity: loop { + loop { match me.h2_tx.poll_capacity(cx) { Poll::Ready(Some(Ok(0))) => {} - Poll::Ready(Some(Ok(_))) => break, + Poll::Ready(Some(Ok(_))) => break true, Poll::Ready(Some(Err(e))) => { return Poll::Ready(Err(crate::Error::new_body_write(e))) } @@ -95,10 +145,12 @@ where "send stream capacity unexpectedly closed", ))); } - Poll::Pending => break 'capacity, + Poll::Pending => break false, } } - } + } else { + true + }; match me.h2_tx.poll_reset(cx) { Poll::Ready(Ok(reason)) => { @@ -113,6 +165,25 @@ where Poll::Pending => (), } + // If h2 has no capacity, don't pull another item from the mpsc + // receiver. That would free a channel slot and let the writer + // enqueue more data without h2 backpressure. + // + // Still allow the task to finish once the upgraded write side is + // gone and the mpsc queue is empty. + if !h2_has_capacity { + // `size_hint` reads the queued message count without popping, + // so an accepted write stays queued until h2 capacity returns. + if me.rx.size_hint().0 == 0 && me.close_notify.poll_closed(cx).is_ready() { + me.h2_tx + .send_data(SendBuf::None, true) + .map_err(crate::Error::new_body_write)?; + return Poll::Ready(Ok(())); + } + + return Poll::Pending; + } + match me.rx.as_mut().poll_next(cx) { Poll::Ready(Some(cursor)) => { me.h2_tx @@ -259,6 +330,7 @@ impl Write for H2Upgraded { cx: &mut Context<'_>, ) -> Poll> { self.send_stream.tx.close_channel(); + self.send_stream.close_notify.close(); match Pin::new(&mut self.send_stream.error_rx).poll(cx) { Poll::Ready(Ok(reason)) => Poll::Ready(Err(io_error(reason))), Poll::Ready(Err(_task_dropped)) => Poll::Ready(Ok(())), diff --git a/tests/server.rs b/tests/server.rs index b4c66022a6..b22f50b11a 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2300,6 +2300,371 @@ async fn h2_connect_empty_frames() { .unwrap(); } +#[tokio::test] +async fn h2_connect_backpressure_respected() { + let (listener, addr) = setup_tcp_listener(); + let conn = connect_async(addr).await; + + let mut builder = h2::client::Builder::new(); + builder.initial_window_size(1024); + builder.initial_connection_window_size(1024); + let (h2, connection) = builder.handshake::<_, Bytes>(conn).await.unwrap(); + tokio::spawn(async move { + connection.await.unwrap(); + }); + let mut h2 = h2.ready().await.unwrap(); + + const CHUNK: &[u8] = b"backpressure test data chunk!\n"; + const TOTAL_LEN: usize = CHUNK.len() * 2000; + + let client_handle = tokio::spawn(async move { + let request = Request::connect("localhost").body(()).unwrap(); + let (response, _send_stream) = h2.send_request(request, false).unwrap(); + let response = response.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut body = response.into_body(); + let mut received = 0usize; + + while let Some(chunk) = body.data().await { + let chunk = chunk.unwrap(); + if chunk.is_empty() { + break; + } + let len = chunk.len(); + received += len; + let _ = body.flow_control().release_capacity(len); + } + + assert_eq!(received, TOTAL_LEN); + }); + + let svc = service_fn(move |req: Request| { + let on_upgrade = hyper::upgrade::on(req); + + tokio::spawn(async move { + let mut upgraded = TokioIo::new(on_upgrade.await.expect("on_upgrade")); + + for _ in 0..2000 { + upgraded.write_all(CHUNK).await.unwrap(); + } + + upgraded.shutdown().await.unwrap(); + }); + + future::ok::<_, hyper::Error>( + Response::builder() + .status(200) + .body(Empty::::new()) + .unwrap(), + ) + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http2::Builder::new(TokioExecutor) + .serve_connection(socket, svc) + .await + .unwrap(); + + client_handle.await.unwrap(); +} + +#[tokio::test] +async fn h2_connect_zero_window_then_release() { + let (listener, addr) = setup_tcp_listener(); + let conn = connect_async(addr).await; + + let mut builder = h2::client::Builder::new(); + builder.initial_window_size(65535); + let (h2, connection) = builder.handshake::<_, Bytes>(conn).await.unwrap(); + tokio::spawn(async move { + connection.await.unwrap(); + }); + let mut h2 = h2.ready().await.unwrap(); + + const DATA: &[u8] = b"Hello from upgraded stream"; + + let client_handle = tokio::spawn(async move { + let request = Request::connect("localhost").body(()).unwrap(); + let (response, _send_stream) = h2.send_request(request, false).unwrap(); + let response = response.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut body = response.into_body(); + let mut received = Vec::new(); + + while let Some(chunk) = body.data().await { + let chunk = chunk.unwrap(); + if chunk.is_empty() { + break; + } + let len = chunk.len(); + received.extend_from_slice(&chunk); + let _ = body.flow_control().release_capacity(len); + } + + assert_eq!(&received[..], DATA); + }); + + let svc = service_fn(move |req: Request| { + let on_upgrade = hyper::upgrade::on(req); + + tokio::spawn(async move { + let mut upgraded = TokioIo::new(on_upgrade.await.expect("on_upgrade")); + upgraded.write_all(DATA).await.unwrap(); + upgraded.shutdown().await.unwrap(); + }); + + future::ok::<_, hyper::Error>( + Response::builder() + .status(200) + .body(Empty::::new()) + .unwrap(), + ) + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http2::Builder::new(TokioExecutor) + .serve_connection(socket, svc) + .await + .unwrap(); + + client_handle.await.unwrap(); +} + +#[tokio::test] +async fn h2_connect_shutdown_while_send_backpressured() { + let (listener, addr) = setup_tcp_listener(); + let conn = connect_async(addr).await; + + let mut builder = h2::client::Builder::new(); + builder.initial_window_size(1024); + builder.initial_connection_window_size(1024); + let (h2, connection) = builder.handshake::<_, Bytes>(conn).await.unwrap(); + tokio::spawn(async move { + let _ = connection.await; + }); + let mut h2 = h2.ready().await.unwrap(); + + let (shutdown_tx, shutdown_rx) = oneshot::channel::(); + let shutdown_tx = Arc::new(Mutex::new(Some(shutdown_tx))); + + let client_handle = tokio::spawn(async move { + let request = Request::connect("localhost").body(()).unwrap(); + let (response, _send_stream) = h2.send_request(request, false).unwrap(); + let response = response.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut body = response.into_body(); + let bytes = body.data().await.unwrap().unwrap(); + assert_eq!(bytes.len(), 1024); + + // Do not release capacity. The server-side upgraded writer should + // still observe shutdown of its mpsc sender instead of waiting for + // more h2 send capacity. + let shutdown_completed = shutdown_rx.await.unwrap_or(false); + assert!( + shutdown_completed, + "upgraded shutdown should not wait for h2 capacity after the writer closes" + ); + }); + + let svc = service_fn(move |req: Request| { + let on_upgrade = hyper::upgrade::on(req); + let shutdown_tx = shutdown_tx.clone(); + + tokio::spawn(async move { + let mut upgraded = TokioIo::new(on_upgrade.await.expect("on_upgrade")); + upgraded.write_all(&[b'x'; 1024]).await.unwrap(); + + // Regression trigger: shutdown closes the mpsc sender while the + // send task is already parked waiting for h2 capacity. + let shutdown_completed = + tokio::time::timeout(Duration::from_secs(1), upgraded.shutdown()) + .await + .is_ok(); + + if let Some(tx) = shutdown_tx.lock().unwrap().take() { + let _ = tx.send(shutdown_completed); + } + }); + + future::ok::<_, hyper::Error>( + Response::builder() + .status(200) + .body(Empty::::new()) + .unwrap(), + ) + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + let _ = http2::Builder::new(TokioExecutor) + .serve_connection(socket, svc) + .await; + + client_handle.await.unwrap(); +} + +#[tokio::test] +async fn h2_connect_reset_during_backpressure() { + let (listener, addr) = setup_tcp_listener(); + let conn = connect_async(addr).await; + + let mut builder = h2::client::Builder::new(); + builder.initial_window_size(1024); + builder.initial_connection_window_size(1024); + let (h2, connection) = builder.handshake::<_, Bytes>(conn).await.unwrap(); + tokio::spawn(async move { + let _ = connection.await; + }); + let mut h2 = h2.ready().await.unwrap(); + + let (write_err_tx, write_err_rx) = oneshot::channel::(); + let write_err_tx = Arc::new(Mutex::new(Some(write_err_tx))); + let (reset_tx, reset_rx) = oneshot::channel::<()>(); + let reset_rx = Arc::new(Mutex::new(Some(reset_rx))); + + let client_handle = tokio::spawn(async move { + let request = Request::connect("localhost").body(()).unwrap(); + let (response, mut send_stream) = h2.send_request(request, false).unwrap(); + let response = response.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut body = response.into_body(); + let mut received = 0; + while received < 1024 { + let bytes = body.data().await.unwrap().unwrap(); + received += bytes.len(); + } + assert_eq!(received, 1024); + + send_stream.send_reset(h2::Reason::CANCEL); + let _ = reset_tx.send(()); + drop(body); + drop(send_stream); + + let got_err = write_err_rx.await.unwrap_or(false); + assert!(got_err, "server write side should have observed RST_STREAM"); + }); + + let svc = service_fn(move |req: Request| { + let on_upgrade = hyper::upgrade::on(req); + let write_err_tx = write_err_tx.clone(); + let reset_rx = reset_rx.clone(); + + tokio::spawn(async move { + let mut upgraded = TokioIo::new(on_upgrade.await.expect("on_upgrade")); + upgraded.write_all(&[b'x'; 1024]).await.unwrap(); + + let reset_rx = reset_rx.lock().unwrap().take().unwrap(); + reset_rx.await.unwrap(); + + let large_data = vec![b'x'; 1024 * 1024]; + let write = upgraded.write_all(&large_data).await; + let shutdown = upgraded.shutdown().await; + + if let Some(tx) = write_err_tx.lock().unwrap().take() { + let _ = tx.send(write.is_err() || shutdown.is_err()); + } + }); + + future::ok::<_, hyper::Error>( + Response::builder() + .status(200) + .body(Empty::::new()) + .unwrap(), + ) + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + let _ = http2::Builder::new(TokioExecutor) + .serve_connection(socket, svc) + .await; + + client_handle.await.unwrap(); +} + +#[tokio::test] +async fn h2_connect_backpressure_bidirectional() { + let (listener, addr) = setup_tcp_listener(); + let conn = connect_async(addr).await; + + let mut builder = h2::client::Builder::new(); + builder.initial_window_size(2048); + builder.initial_connection_window_size(4096); + let (h2, connection) = builder.handshake::<_, Bytes>(conn).await.unwrap(); + tokio::spawn(async move { + connection.await.unwrap(); + }); + let mut h2 = h2.ready().await.unwrap(); + + const PATTERN: &[u8] = b"All work and no bread makes nox a dull boy.\n"; + const REPEAT: usize = 500; + let expected_len = PATTERN.len() * REPEAT; + + let client_handle = tokio::spawn(async move { + let request = Request::connect("localhost").body(()).unwrap(); + let (response, mut send_stream) = h2.send_request(request, false).unwrap(); + let response = response.await.unwrap(); + assert_eq!(response.status(), StatusCode::OK); + + let mut body = response.into_body(); + let mut received = 0usize; + + while let Some(chunk) = body.data().await { + let chunk = chunk.unwrap(); + if chunk.is_empty() { + break; + } + let len = chunk.len(); + received += len; + let _ = body.flow_control().release_capacity(len); + } + + assert_eq!(received, expected_len); + + send_stream.send_data("client done".into(), true).unwrap(); + }); + + let svc = service_fn(move |req: Request| { + let on_upgrade = hyper::upgrade::on(req); + + tokio::spawn(async move { + let mut upgraded = TokioIo::new(on_upgrade.await.expect("on_upgrade")); + + for _ in 0..REPEAT { + upgraded.write_all(PATTERN).await.unwrap(); + } + + upgraded.shutdown().await.unwrap(); + + let mut response_buf = vec![0u8; 64]; + let n = upgraded.read(&mut response_buf).await.unwrap(); + assert_eq!(&response_buf[..n], b"client done"); + }); + + future::ok::<_, hyper::Error>( + Response::builder() + .status(200) + .body(Empty::::new()) + .unwrap(), + ) + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http2::Builder::new(TokioExecutor) + .serve_connection(socket, svc) + .await + .unwrap(); + + client_handle.await.unwrap(); +} + #[tokio::test] async fn parse_errors_send_4xx_response() { let (listener, addr) = setup_tcp_listener(); From 92d3d9a8acb0a06089f6cfcac0985275e5142c54 Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Thu, 25 Jun 2026 22:03:22 -0400 Subject: [PATCH 072/128] style(headers): small refactor to remove question_mark lint (#4116) --- Cargo.toml | 1 - src/headers.rs | 12 +++++------- 2 files changed, 5 insertions(+), 8 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 9a30ffefee..e91b45da0b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -143,7 +143,6 @@ needless_continue = "allow" needless_pass_by_value = "allow" panic = "allow" pattern_type_mismatch = "allow" -question_mark = "allow" # TODO: probably easy fix redundant_closure_for_method_calls = "allow" redundant_else = "allow" ref_patterns = "allow" # TODO: perhaps deny? diff --git a/src/headers.rs b/src/headers.rs index 509f089992..0b36e060f0 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -51,13 +51,11 @@ pub(super) fn content_length_parse_all_values(values: ValueIter<'_, HeaderValue> for h in values { if let Ok(line) = h.to_str() { for v in line.split(',') { - if let Some(n) = from_digits(v.trim().as_bytes()) { - if content_length.is_none() { - content_length = Some(n); - } else if content_length != Some(n) { - return None; - } - } else { + let n = from_digits(v.trim().as_bytes())?; + + if content_length.is_none() { + content_length = Some(n); + } else if content_length != Some(n) { return None; } } From 67069b1d1636fc52dfd24067e042cfa15f353f6e Mon Sep 17 00:00:00 2001 From: greymoth Date: Tue, 30 Jun 2026 01:05:21 +0900 Subject: [PATCH 073/128] fix(http1): use append for repeat trailer values in encoder (#4118) The chunked trailer encoder built allowed_trailers with HeaderMap::insert, which overwrites prior values for the same name. When a user sets the same trailer field multiple times, only the last value was written to the wire. This mirrors the decode-side fix (#4107): use append so duplicate trailer values are preserved on encode, matching how regular headers are emitted. --- src/proto/h1/encode.rs | 28 +++++++++++++++++++++++++++- 1 file changed, 27 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index fcf7940b16..690d7e8452 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -181,7 +181,7 @@ impl Encoder { if allowed_set.contains(name) { if is_valid_trailer_field(name) { - allowed_trailers.insert(name, value); + allowed_trailers.append(name, value); } else { debug!("trailer field is not valid: {}", &name); } @@ -567,6 +567,32 @@ mod tests { ); } + #[test] + fn chunked_with_duplicate_trailer_values() { + let encoder = Encoder::chunked(); + let trailers = vec![HeaderName::from_static("chunky-trailer")]; + let encoder = encoder.into_chunked_with_trailing_fields(trailers); + + let mut headers = HeaderMap::new(); + headers.append( + HeaderName::from_static("chunky-trailer"), + HeaderValue::from_static("first"), + ); + headers.append( + HeaderName::from_static("chunky-trailer"), + HeaderValue::from_static("second"), + ); + + let buf1 = encoder.encode_trailers::<&[u8]>(headers, false).unwrap(); + + let mut dst = Vec::new(); + dst.put(buf1); + assert_eq!( + dst, + b"0\r\nchunky-trailer: first\r\nchunky-trailer: second\r\n\r\n" + ); + } + #[test] fn chunked_with_no_trailer_header() { let encoder = Encoder::chunked(); From 12e8e53786a538266d26ab0690c4c45a489124ad Mon Sep 17 00:00:00 2001 From: Jose <32043000+josetorrs@users.noreply.github.com> Date: Mon, 29 Jun 2026 12:07:30 -0400 Subject: [PATCH 074/128] style(proto): explicitly allow `decimal_literal_representation` lint (#4117) For readability purposes. For instance, 65_535 is more readable than 0xFFFF, when the spec uses doesn't use hexadecimal in that case. --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index e91b45da0b..d3041e1757 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,7 +122,6 @@ cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" collapsible_match = "allow" -decimal_literal_representation = "allow" # TODO: consider else_if_without_else = "allow" enum_glob_use = "allow" float_arithmetic = "allow" @@ -163,6 +162,7 @@ arbitrary_source_item_ordering = "allow" # order: std, deps, crate blanket_clippy_restriction_lints = "allow" # allowlist is better clone_on_ref_ptr = "allow" # Arc::clone(blah) is needlessly noisy cognitive_complexity = "allow" # is this ever useful? +decimal_literal_representation = "allow" # arguably more human-readable default_numeric_fallback = "allow" # too many false positives expect_used = "allow" # expect is self-documenting error_impl_error = "allow" # mod::Error is a fine name From 650a1161b04b2b4fbc1ea6774b43f46bd0356bf4 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 6 Jul 2026 11:53:21 -0400 Subject: [PATCH 075/128] docs(governance): define Advisor role (#4098) A way for busy experts to still contribute advice when needed. --- docs/GOVERNANCE.md | 21 ++++++++++++++++++++- 1 file changed, 20 insertions(+), 1 deletion(-) diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index e2d6b7f75f..b4548bef27 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -43,7 +43,6 @@ All contributors MUST follow the [Code of Conduct][coc]. 👋 **New here?** [This could be you!][contrib] - ### Triager Triagers assess issues on the issue tracker. They help make sure the work is @@ -100,6 +99,26 @@ How to become: Don't be afraid to ask a collaborator for what you could work on to reach this goal! +### Advisor + +Advisors are contributors with a lot of domain expertise. They offer advice on +proposals, complicated bugs or features, based on their experience elsewhere in +the industry. + +Responsibilities: + +- Adhere to the [Code of Conduct][coc] +- Try to offer advice when the `@hyperium/advisors` team is tagged for input. + (This is not a frequent occurence, so the time commitment should generally be + low.) + +How to become: + +- Advisors are invited by a maintainer. +- Sometimes they are experienced Collaborators who must step back from active + contributing. Sometimes they are experts placed elsehwere who are friendly to + the project. + ### Maintainer Maintainers are the project admins. Besides being a collaborator, they take care From 4f071c23e06b5181a2d3f04c0728cd79ace4c14f Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 6 Jul 2026 13:30:01 -0400 Subject: [PATCH 076/128] docs(maintainers): move some collaborators to emeriti (#4125) --- docs/MAINTAINERS.md | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index 5b6f48c6fa..ab9fe5e3d5 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -11,26 +11,26 @@ To see what these roles do, and how to become one, look at [GOVERNANCE](./GOVERN - Sean McArthur (@seanmonstar) - Steven Fackler (@sfackler) -- Oliver Gould (@olix0r) -- Eliza Weisman (@hawkw) -- Lucio Franco (@LucioFranco) -- Anthony Ramine (@nox) -- David Pedersen (@davidpdrsn) -- Adam Foltzer (@acfoltzer) -- Noah Kennedy (@Noah-Kennedy) - dswij (@dswij) - tottoto (@tottoto) - katelyn martin (@cratelyn) -

-Emeriti - ### Collaborator emeriti - Jonathan Reem (@reem) - Carl Lerche (@carllerche) +- Oliver Gould (@olix0r) +- Eliza Weisman (@hawkw) +- Lucio Franco (@LucioFranco) +- Adam Foltzer (@acfoltzer) +- David Pedersen (@davidpdrsn) +- Anthony Ramine (@nox) +- Noah Kennedy (@Noah-Kennedy) + +## Advisors -
+- Oliver Gould (@olix0r) +- Adam Foltzer (@acfoltzer) ## Maintainers From 45f12eb586a690d2d5de91f1292bb4abe769e5ab Mon Sep 17 00:00:00 2001 From: Abhinav Date: Tue, 7 Jul 2026 20:44:34 +0530 Subject: [PATCH 077/128] feat(rt): add `ReadBufCursor::initialized_unfilled()` method (#4115) --- src/rt/io.rs | 40 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/src/rt/io.rs b/src/rt/io.rs index 21c8ed9c7c..447774c6c0 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -382,6 +382,46 @@ impl ReadBufCursor<'_> { } self.buf.filled = end; } + + /// Returns a mutable reference to the unfilled part of the buffer, ensuring it is fully initialized. + #[inline] + pub fn initialize_unfilled(&mut self) -> &mut [u8] { + self.initialize_unfilled_to(self.remaining()) + } + + /// Returns a mutable reference to the first `n` bytes of the unfilled part of the buffer, ensuring it is + /// fully initialized. + /// + /// # Panics + /// + /// Panics if `self.remaining()` is less than `n`. + #[inline] + pub fn initialize_unfilled_to(&mut self, n: usize) -> &mut [u8] { + assert!(self.remaining() >= n, "n overflows remaining"); + + // This can't overflow as the assert above would have panicked otherwise. + let end = self.buf.filled + n; + + if self.buf.init < end { + // SAFETY: + // 1. The raw pointer is not null and not dangling either as the slice outlives the + // pointer's usage. The pointer is also properly aligned. + // 2. Further, `end - self.buf.init` is inbounds and hence safe to write + // to. + unsafe { + self.buf.raw[self.buf.init..end] + .as_mut_ptr() + .write_bytes(0, end - self.buf.init); + } + self.buf.init = end; + } + + let slice = &mut self.buf.raw[self.buf.filled..end]; + + // SAFETY: `MaybeUninit` has the same memory layout as u8 and we properly initialized + // the slice above. + unsafe { &mut *(slice as *mut [MaybeUninit] as *mut [u8]) } + } } macro_rules! deref_async_read { From f811754ebcabf4159435291c8008c9ed5d5ba76b Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 13 Jul 2026 10:56:37 -0400 Subject: [PATCH 078/128] fix(http1): discard content-length header when received before transfer-encoding (#4124) While hyper currently recognizes the correct message payload semantics when both content-length and transfer-encoding headers are sent, there are cases where it doesn't always strip the canceled content-length header. This fix now correctly removes the header if it was seen before transfer-encoding. It also now sets the connection to close at the end of the message, as recommended in the new RFC 9112. (To be clear, the semantics have always been correct. This reduces the possibility of forwarding confusing headers to a remote that doesn't know how to act correctly.) Closes #4123 --- src/proto/h1/role.rs | 87 +++++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 34 deletions(-) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 740c8e1d56..f467af8286 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -234,6 +234,7 @@ impl Http1Transaction for Server { let mut decoder = DecodedLength::ZERO; let mut expect_continue = false; let mut con_len = None; + let mut is_cl = false; let mut is_te = false; let mut is_te_chunked = false; let mut wants_upgrade = subject.0 == Method::CONNECT; @@ -272,6 +273,9 @@ impl Http1Transaction for Server { return Err(Parse::transfer_encoding_unexpected()); } is_te = true; + if is_cl && con_len.take().is_some() { + headers.remove(header::CONTENT_LENGTH); + } if headers::is_chunked_(&value) { is_te_chunked = true; decoder = DecodedLength::CHUNKED; @@ -280,6 +284,7 @@ impl Http1Transaction for Server { } } header::CONTENT_LENGTH => { + is_cl = true; if is_te { continue; } @@ -340,6 +345,10 @@ impl Http1Transaction for Server { return Err(Parse::transfer_encoding_invalid()); } + if is_te && is_cl { + keep_alive = false; + } + let mut extensions = http::Extensions::default(); if let Some(header_case_map) = header_case_map { @@ -2048,45 +2057,55 @@ mod tests { ); // transfer-encoding and content-length = chunked - assert_eq!( - parse( - "\ - POST / HTTP/1.1\r\n\ - content-length: 10\r\n\ - transfer-encoding: chunked\r\n\ - \r\n\ - " - ) - .decode, - DecodedLength::CHUNKED + let msg = parse( + "\ + POST / HTTP/1.1\r\n\ + content-length: 10\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + ", ); + assert_eq!(msg.decode, DecodedLength::CHUNKED); + assert!(!msg.head.headers.contains_key(header::CONTENT_LENGTH)); + assert!(!msg.keep_alive); - assert_eq!( - parse( - "\ - POST / HTTP/1.1\r\n\ - transfer-encoding: chunked\r\n\ - content-length: 10\r\n\ - \r\n\ - " - ) - .decode, - DecodedLength::CHUNKED + let msg = parse( + "\ + POST / HTTP/1.1\r\n\ + transfer-encoding: chunked\r\n\ + content-length: 10\r\n\ + \r\n\ + ", ); + assert_eq!(msg.decode, DecodedLength::CHUNKED); + assert!(!msg.head.headers.contains_key(header::CONTENT_LENGTH)); + assert!(!msg.keep_alive); - assert_eq!( - parse( - "\ - POST / HTTP/1.1\r\n\ - transfer-encoding: gzip\r\n\ - content-length: 10\r\n\ - transfer-encoding: chunked\r\n\ - \r\n\ - " - ) - .decode, - DecodedLength::CHUNKED + let msg = parse( + "\ + POST / HTTP/1.1\r\n\ + transfer-encoding: gzip\r\n\ + content-length: 10\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + ", + ); + assert_eq!(msg.decode, DecodedLength::CHUNKED); + assert!(!msg.head.headers.contains_key(header::CONTENT_LENGTH)); + assert!(!msg.keep_alive); + + let msg = parse( + "\ + POST / HTTP/1.1\r\n\ + connection: keep-alive\r\n\ + content-length: 10\r\n\ + transfer-encoding: chunked\r\n\ + \r\n\ + ", ); + assert_eq!(msg.decode, DecodedLength::CHUNKED); + assert!(!msg.head.headers.contains_key(header::CONTENT_LENGTH)); + assert!(!msg.keep_alive); // multiple content-lengths of same value are fine assert_eq!( From 42d9596e3a9052166c72f8fa563e277591f68e32 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 20 Jul 2026 09:04:28 -0400 Subject: [PATCH 079/128] v1.11.0 --- CHANGELOG.md | 20 ++++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e3511984b9..b58e22bbf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,23 @@ +## v1.11.0 (2026-07-20) + + +#### Bug Fixes + +* **http1:** + * discard content-length header when received before transfer-encoding (#4124) ([540fff91](https://github.com/hyperium/hyper/commit/540fff9180ce47ee5fab01b6cc2126eb6c286eda), closes [#4123](https://github.com/hyperium/hyper/issues/4123)) + * use append for repeat trailer values in encoder (#4118) ([de1483d7](https://github.com/hyperium/hyper/commit/de1483d7db70477cc8799a344634ae6ee020a7db)) + * allow up to max_headers trailers (#4108) ([f584091a](https://github.com/hyperium/hyper/commit/f584091ac096bd5dd478f73256188c3261a945b9)) + * use append for repeat trailers (#4107) ([876effe1](https://github.com/hyperium/hyper/commit/876effe10fd8f8ad4535ade1f84e88f199f2cf6b)) + * flush buffered data before shutdown (#4018) ([72046cc7](https://github.com/hyperium/hyper/commit/72046cc72e7aa82c439eed00850b8b1ad3f7e4dc), closes [#4022](https://github.com/hyperium/hyper/issues/4022)) + * more strictly enforce max_buf_size when parsing (#4093) ([90ede307](https://github.com/hyperium/hyper/commit/90ede307470dba98b4e184ad88d6f5aae8b0afd7), closes [#4081](https://github.com/hyperium/hyper/issues/4081)) +* **http2:** avoid buffering `Upgraded` writes without send capacity (#4102) ([aecf5abf](https://github.com/hyperium/hyper/commit/aecf5abfbc3dc95f21ac1538db1aa3f690fa6ab6)) + + +#### Features + +* **rt:** add `ReadBufCursor::initialized_unfilled()` method (#4115) ([ccc1e850](https://github.com/hyperium/hyper/commit/ccc1e850dc0cda3e71b0acd11f60ca3d48d09034)) + + ### v1.10.1 (2026-05-29) diff --git a/Cargo.toml b/Cargo.toml index d3041e1757..b43295e040 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.10.1" +version = "1.11.0" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From 2cd2a78bf276c80b695c991418bb8430990e25ee Mon Sep 17 00:00:00 2001 From: Sam Landfried Date: Tue, 28 Jul 2026 15:16:32 -0600 Subject: [PATCH 080/128] fix(http1): evict pooled conn on request-side Connection: close (#4110) hyper's client derives connection reuse from the response alone, so a request carrying `Connection: close` whose backend response omits it (keeps the socket alive) leaves the connection pooled and reusable. Disable keep-alive at request-encode time when the outgoing request carries a `Connection: close` token so the connection is evicted regardless of the response. Every `Connection` header line is inspected via a `connection_any_close` helper (over `get_all`), not just the first, so a `close` on a later line or within a comma-separated value is honored. Adds a conn-level regression test (client Conn + write_head) asserting a `Connection: close` request disables keep-alive -- including comma-separated and multi-line forms -- while a keep-alive request stays reusable. Co-authored-by: Sam Landfried --- src/headers.rs | 11 +++++++ src/proto/h1/conn.rs | 68 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/headers.rs b/src/headers.rs index 0b36e060f0..89b70fd45e 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -19,6 +19,17 @@ pub(super) fn connection_close(value: &HeaderValue) -> bool { connection_has(value, "close") } +// Returns true if any `Connection` header field carries a `close` token. +// A message may have more than one `Connection` header line, so all of them +// must be inspected (`get`/`connection_close` alone only sees the first). +#[cfg(feature = "http1")] +pub(super) fn connection_any_close(headers: &http::HeaderMap) -> bool { + headers + .get_all(http::header::CONNECTION) + .iter() + .any(connection_close) +} + #[cfg(feature = "http1")] fn connection_has(value: &HeaderValue, needle: &str) -> bool { if let Ok(s) = value.to_str() { diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index dfcadcee56..9a968dd0df 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -613,6 +613,15 @@ where if !T::should_read_first() { self.state.busy(); + // A client request carrying `Connection: close` must not be pooled or + // reused. hyper otherwise derives connection reuse from the response + // alone, so a backend that ignores the request-side close (omits + // `Connection: close` in its response) would leave the connection in + // the pool. Disable keep-alive up front so the connection is evicted + // regardless of the response. + if headers::connection_any_close(&head.headers) { + self.state.disable_keep_alive(); + } } self.enforce_version(&mut head); @@ -1196,6 +1205,65 @@ mod tests { }); } + // A client request carrying `Connection: close` must evict the connection + // (disable keep-alive) at request-encode time, so it is never returned to the + // pool for reuse — independent of whether the backend response echoes + // `Connection: close`. hyper otherwise derives reuse from the response alone. + #[test] + fn client_request_connection_close_disables_keep_alive() { + use super::*; + use crate::common::io::Compat; + use crate::proto::RequestLine; + + // Encodes a client GET (with the given Connection header lines, one + // `append` each) on a fresh client Conn and returns whether the + // connection remains reusable. + fn remains_reusable_after_get(connection_values: &[&'static str]) -> bool { + let io = Compat(tokio_test::io::Builder::new().build()); + let mut conn = Conn::<_, bytes::Bytes, crate::proto::h1::ClientTransaction>::new(io); + assert!( + conn.state.wants_keep_alive(), + "a fresh client connection should want keep-alive" + ); + + let mut headers = HeaderMap::new(); + for value in connection_values { + headers.append(CONNECTION, HeaderValue::from_static(value)); + } + let head = MessageHead { + version: Version::HTTP_11, + subject: RequestLine(Method::GET, "/".parse().unwrap()), + headers, + extensions: http::Extensions::new(), + }; + conn.write_head(head, None); + conn.state.wants_keep_alive() + } + + // Control: a request without `Connection: close` leaves the connection reusable. + assert!( + remains_reusable_after_get(&[]), + "a keep-alive request must leave the connection reusable" + ); + // Fix: a `Connection: close` request must disable keep-alive so the + // connection is evicted regardless of the response. + assert!( + !remains_reusable_after_get(&["close"]), + "a `Connection: close` request must disable keep-alive (connection evicted)" + ); + // A `close` token in a comma-separated value is honored. + assert!( + !remains_reusable_after_get(&["keep-alive, close"]), + "a `close` token in a comma-separated Connection value must disable keep-alive" + ); + // A `close` in ANY of multiple Connection header lines is honored + // (get_all, not just the first line). + assert!( + !remains_reusable_after_get(&["keep-alive", "close"]), + "a `close` in any Connection header line must disable keep-alive" + ); + } + /* //TODO: rewrite these using dispatch... someday... use futures::{Async, Future, Stream, Sink}; From 948049b7760c5e6f35f2c4f8ffa33c0606503b7e Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 28 Jul 2026 18:46:30 -0400 Subject: [PATCH 081/128] test(http1): rewrite disabled old 0.1-futures conn tests (#4138) --- src/proto/h1/conn.rs | 432 ++++++++++++++----------------------------- 1 file changed, 135 insertions(+), 297 deletions(-) diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 9a968dd0df..7bcad85970 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -1209,6 +1209,7 @@ mod tests { // (disable keep-alive) at request-encode time, so it is never returned to the // pool for reuse — independent of whether the backend response echoes // `Connection: close`. hyper otherwise derives reuse from the response alone. + #[cfg(feature = "client")] #[test] fn client_request_connection_close_disables_keep_alive() { use super::*; @@ -1264,336 +1265,173 @@ mod tests { ); } - /* - //TODO: rewrite these using dispatch... someday... - use futures::{Async, Future, Stream, Sink}; - use futures::future; - - use proto::{self, ClientTransaction, MessageHead, ServerTransaction}; - use super::super::Encoder; - use mock::AsyncIo; - - use super::{Conn, Decoder, Reading, Writing}; - use ::uri::Uri; - - use std::str::FromStr; - - #[test] - fn test_conn_init_read() { - let good_message = b"GET / HTTP/1.1\r\n\r\n".to_vec(); - let len = good_message.len(); - let io = AsyncIo::new_buf(good_message, len); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - - match conn.poll().unwrap() { - Async::Ready(Some(Frame::Message { message, body: false })) => { - assert_eq!(message, MessageHead { - subject: ::proto::RequestLine(::Get, Uri::from_str("/").unwrap()), - .. MessageHead::default() - }) - }, - f => panic!("frame is not Frame::Message: {:?}", f) - } - } - - #[test] - fn test_conn_parse_partial() { - let _: Result<(), ()> = future::lazy(|| { - let good_message = b"GET / HTTP/1.1\r\nHost: foo.bar\r\n\r\n".to_vec(); - let io = AsyncIo::new_buf(good_message, 10); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - assert!(conn.poll().unwrap().is_not_ready()); - conn.io.io_mut().block_in(50); - let async = conn.poll().unwrap(); - assert!(async.is_ready()); - match async { - Async::Ready(Some(Frame::Message { .. })) => (), - f => panic!("frame is not Message: {:?}", f), - } - Ok(()) - }).wait(); - } - - #[test] - fn test_conn_init_read_eof_idle() { - let io = AsyncIo::new_buf(vec![], 1); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.idle(); - - match conn.poll().unwrap() { - Async::Ready(None) => {}, - other => panic!("frame is not None: {:?}", other) - } - } - - #[test] - fn test_conn_init_read_eof_idle_partial_parse() { - let io = AsyncIo::new_buf(b"GET / HTTP/1.1".to_vec(), 100); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.idle(); - - match conn.poll() { - Err(ref err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {}, - other => panic!("unexpected frame: {:?}", other) + use super::*; + use crate::common::io::Compat; + #[cfg(feature = "client")] + use crate::proto::h1::ClientTransaction; + #[cfg(feature = "server")] + use crate::proto::h1::ServerTransaction; + #[cfg(feature = "server")] + use crate::proto::RequestLine; + use bytes::Bytes; + + fn poll_head( + conn: &mut Conn, Bytes, T>, + ) -> Poll, DecodedLength, Wants)>>> + where + I: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin, + T: Http1Transaction + Unpin, + { + tokio_test::task::spawn(()).enter(|cx, _| conn.poll_read_head(cx)) + } + + fn ready(poll: Poll) -> T { + match poll { + Poll::Ready(value) => value, + Poll::Pending => panic!("expected ready"), } } + #[cfg(feature = "server")] #[test] - fn test_conn_init_read_eof_busy() { - let _: Result<(), ()> = future::lazy(|| { - // server ignores - let io = AsyncIo::new_eof(); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.busy(); - - match conn.poll().unwrap() { - Async::Ready(None) => {}, - other => panic!("unexpected frame: {:?}", other) - } + fn conn_reads_request_head() { + let io = tokio_test::io::Builder::new() + .read(b"GET / HTTP/1.1\r\n\r\n") + .build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); - // client - let io = AsyncIo::new_eof(); - let mut conn = Conn::<_, proto::Bytes, ClientTransaction>::new(io); - conn.state.busy(); - - match conn.poll() { - Err(ref err) if err.kind() == std::io::ErrorKind::UnexpectedEof => {}, - other => panic!("unexpected frame: {:?}", other) - } - Ok(()) - }).wait(); + let (head, body, _) = ready(poll_head(&mut conn)) + .expect("message") + .expect("valid request"); + assert_eq!(head.subject, RequestLine(Method::GET, "/".parse().unwrap())); + assert_eq!(body, DecodedLength::ZERO); } + #[cfg(feature = "server")] #[test] - fn test_conn_body_finish_read_eof() { - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_eof(); - let mut conn = Conn::<_, proto::Bytes, ClientTransaction>::new(io); - conn.state.busy(); - conn.state.writing = Writing::KeepAlive; - conn.state.reading = Reading::Body(Decoder::length(0)); - - match conn.poll() { - Ok(Async::Ready(Some(Frame::Body { chunk: None }))) => (), - other => panic!("unexpected frame: {:?}", other) - } - - // conn eofs, but tokio-proto will call poll() again, before calling flush() - // the conn eof in this case is perfectly fine - - match conn.poll() { - Ok(Async::Ready(None)) => (), - other => panic!("unexpected frame: {:?}", other) - } - Ok(()) - }).wait(); + fn conn_reads_partial_request_head() { + tokio_test::task::spawn(()).enter(|cx, _| { + let (io, mut handle) = tokio_test::io::Builder::new().build_with_handle(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + handle.read(b"GET / HTTP"); + assert!(conn.poll_read_head(cx).is_pending()); + handle.read(b"/1.1\r\nHost: foo.bar\r\n\r\n"); + assert!(conn.poll_read_head(cx).is_ready()); + }); } + #[cfg(feature = "server")] #[test] - fn test_conn_message_empty_body_read_eof() { - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_buf(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n".to_vec(), 1024); - let mut conn = Conn::<_, proto::Bytes, ClientTransaction>::new(io); - conn.state.busy(); - conn.state.writing = Writing::KeepAlive; - - match conn.poll() { - Ok(Async::Ready(Some(Frame::Message { body: false, .. }))) => (), - other => panic!("unexpected frame: {:?}", other) - } - - // conn eofs, but tokio-proto will call poll() again, before calling flush() - // the conn eof in this case is perfectly fine - - match conn.poll() { - Ok(Async::Ready(None)) => (), - other => panic!("unexpected frame: {:?}", other) - } - Ok(()) - }).wait(); + fn conn_accepts_eof_when_idle() { + let io = tokio_test::io::Builder::new().build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + conn.state.idle::(); + assert!(matches!(poll_head(&mut conn), Poll::Ready(None))); } + #[cfg(feature = "server")] #[test] - fn test_conn_read_body_end() { - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_buf(b"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n12345".to_vec(), 1024); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.busy(); - - match conn.poll() { - Ok(Async::Ready(Some(Frame::Message { body: true, .. }))) => (), - other => panic!("unexpected frame: {:?}", other) - } - - match conn.poll() { - Ok(Async::Ready(Some(Frame::Body { chunk: Some(_) }))) => (), - other => panic!("unexpected frame: {:?}", other) - } - - // When the body is done, `poll` MUST return a `Body` frame with chunk set to `None` - match conn.poll() { - Ok(Async::Ready(Some(Frame::Body { chunk: None }))) => (), - other => panic!("unexpected frame: {:?}", other) - } - - match conn.poll() { - Ok(Async::NotReady) => (), - other => panic!("unexpected frame: {:?}", other) - } - Ok(()) - }).wait(); - } - - #[test] - fn test_conn_closed_read() { - let io = AsyncIo::new_buf(vec![], 0); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.close(); - - match conn.poll().unwrap() { - Async::Ready(None) => {}, - other => panic!("frame is not None: {:?}", other) - } + fn conn_rejects_eof_during_partial_head() { + let io = tokio_test::io::Builder::new() + .read(b"GET / HTTP/1.1") + .build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + conn.state.idle::(); + let err = ready(poll_head(&mut conn)) + .expect("error result") + .expect_err("partial head must fail"); + assert!(err.is_incomplete_message(), "unexpected error: {err:?}"); } + #[cfg(feature = "client")] #[test] - fn test_conn_body_write_length() { - let _ = pretty_env_logger::try_init(); - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 0); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - let max = super::super::io::DEFAULT_MAX_BUFFER_SIZE + 4096; - conn.state.writing = Writing::Body(Encoder::length((max * 2) as u64)); - - assert!(conn.start_send(Frame::Body { chunk: Some(vec![b'a'; max].into()) }).unwrap().is_ready()); - assert!(!conn.can_buffer_body()); - - assert!(conn.start_send(Frame::Body { chunk: Some(vec![b'b'; 1024 * 8].into()) }).unwrap().is_not_ready()); - - conn.io.io_mut().block_in(1024 * 3); - assert!(conn.poll_complete().unwrap().is_not_ready()); - conn.io.io_mut().block_in(1024 * 3); - assert!(conn.poll_complete().unwrap().is_not_ready()); - conn.io.io_mut().block_in(max * 2); - assert!(conn.poll_complete().unwrap().is_ready()); - - assert!(conn.start_send(Frame::Body { chunk: Some(vec![b'c'; 1024 * 8].into()) }).unwrap().is_ready()); - Ok(()) - }).wait(); + fn client_rejects_eof_while_busy() { + let io = tokio_test::io::Builder::new().build(); + let mut client = Conn::<_, Bytes, ClientTransaction>::new(Compat::new(io)); + client.state.busy(); + client.state.writing = Writing::KeepAlive; + let err = ready(poll_head(&mut client)) + .expect("error result") + .expect_err("client EOF must fail"); + assert!(err.is_incomplete_message(), "unexpected error: {err:?}"); } + #[cfg(feature = "server")] #[test] - fn test_conn_body_write_chunked() { - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 4096); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.writing = Writing::Body(Encoder::chunked()); - - assert!(conn.start_send(Frame::Body { chunk: Some("headers".into()) }).unwrap().is_ready()); - assert!(conn.start_send(Frame::Body { chunk: Some(vec![b'x'; 8192].into()) }).unwrap().is_ready()); - Ok(()) - }).wait(); + fn server_accepts_eof_while_busy() { + let io = tokio_test::io::Builder::new().build(); + let mut server = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + server.state.busy(); + assert!(matches!(poll_head(&mut server), Poll::Ready(None))); } + #[cfg(feature = "client")] #[test] - fn test_conn_body_flush() { - let _: Result<(), ()> = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 1024 * 1024 * 5); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.writing = Writing::Body(Encoder::length(1024 * 1024)); - assert!(conn.start_send(Frame::Body { chunk: Some(vec![b'a'; 1024 * 1024].into()) }).unwrap().is_ready()); - assert!(!conn.can_buffer_body()); - conn.io.io_mut().block_in(1024 * 1024 * 5); - assert!(conn.poll_complete().unwrap().is_ready()); - assert!(conn.can_buffer_body()); - assert!(conn.io.io_mut().flushed()); - - Ok(()) - }).wait(); + fn conn_reads_empty_response_before_eof() { + let io = tokio_test::io::Builder::new() + .read(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .build(); + let mut conn = Conn::<_, Bytes, ClientTransaction>::new(Compat::new(io)); + conn.state.busy(); + conn.state.writing = Writing::KeepAlive; + let (_, body, _) = ready(poll_head(&mut conn)) + .expect("response") + .expect("valid response"); + assert_eq!(body, DecodedLength::ZERO); } + #[cfg(feature = "server")] #[test] - fn test_conn_parking() { - use std::sync::Arc; - use futures::executor::Notify; - use futures::executor::NotifyHandle; - - struct Car { - permit: bool, - } - impl Notify for Car { - fn notify(&self, _id: usize) { - assert!(self.permit, "unparked without permit"); - } - } - - fn car(permit: bool) -> NotifyHandle { - Arc::new(Car { - permit: permit, - }).into() - } - - // test that once writing is done, unparks - let f = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 4096); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.reading = Reading::KeepAlive; - assert!(conn.poll().unwrap().is_not_ready()); - - conn.state.writing = Writing::KeepAlive; - assert!(conn.poll_complete().unwrap().is_ready()); - Ok::<(), ()>(()) - }); - ::futures::executor::spawn(f).poll_future_notify(&car(true), 0).unwrap(); - - - // test that flushing when not waiting on read doesn't unpark - let f = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 4096); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.writing = Writing::KeepAlive; - assert!(conn.poll_complete().unwrap().is_ready()); - Ok::<(), ()>(()) - }); - ::futures::executor::spawn(f).poll_future_notify(&car(false), 0).unwrap(); - - - // test that flushing and writing isn't done doesn't unpark - let f = future::lazy(|| { - let io = AsyncIo::new_buf(vec![], 4096); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.reading = Reading::KeepAlive; - assert!(conn.poll().unwrap().is_not_ready()); - conn.state.writing = Writing::Body(Encoder::length(5_000)); - assert!(conn.poll_complete().unwrap().is_ready()); - Ok::<(), ()>(()) + fn conn_reads_body_and_reports_end() { + let io = tokio_test::io::Builder::new() + .read(b"POST / HTTP/1.1\r\nContent-Length: 5\r\n\r\n12345") + .wait(std::time::Duration::from_secs(1)) + .build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + let (_, body, _) = ready(poll_head(&mut conn)) + .expect("request") + .expect("valid request"); + assert_eq!(body, DecodedLength::new(5)); + + tokio_test::task::spawn(()).enter(|cx, _| { + let frame = conn.poll_read_body(cx); + let data = ready(frame) + .expect("body frame") + .expect("valid body") + .into_data() + .expect("data frame"); + assert_eq!(data, "12345"); + assert!( + !conn.can_read_body(), + "the complete body must return to head-reading state" + ); }); - ::futures::executor::spawn(f).poll_future_notify(&car(false), 0).unwrap(); } + #[cfg(feature = "server")] #[test] - fn test_conn_closed_write() { - let io = AsyncIo::new_buf(vec![], 0); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); + fn closed_conn_cannot_read_or_write() { + let io = tokio_test::io::Builder::new().build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); conn.state.close(); - - match conn.start_send(Frame::Body { chunk: Some(b"foobar".to_vec().into()) }) { - Err(_e) => {}, - other => panic!("did not return Err: {:?}", other) - } - - assert!(conn.state.is_write_closed()); + assert!(conn.is_read_closed()); + assert!(conn.is_write_closed()); + assert!(!conn.can_read_head()); + assert!(!conn.can_write_head()); } + #[cfg(feature = "server")] #[test] - fn test_conn_write_empty_chunk() { - let io = AsyncIo::new_buf(vec![], 0); - let mut conn = Conn::<_, proto::Bytes, ServerTransaction>::new(io); - conn.state.writing = Writing::KeepAlive; - - assert!(conn.start_send(Frame::Body { chunk: None }).unwrap().is_ready()); - assert!(conn.start_send(Frame::Body { chunk: Some(Vec::new().into()) }).unwrap().is_ready()); - conn.start_send(Frame::Body { chunk: Some(vec![b'a'].into()) }).unwrap_err(); + fn conn_writes_chunked_body() { + let io = tokio_test::io::Builder::new() + .write(b"7\r\nheaders\r\n0\r\n\r\n") + .build(); + let mut conn = Conn::<_, Bytes, ServerTransaction>::new(Compat::new(io)); + conn.state.writing = Writing::Body(Encoder::chunked()); + conn.write_body(Bytes::from_static(b"headers")); + conn.end_body().unwrap(); + tokio_test::task::spawn(()).enter(|cx, _| { + assert!(conn.poll_flush(cx).is_ready()); + }); } - */ } From f6fb1681e8aee458a19d2cdaef79d11c6801749e Mon Sep 17 00:00:00 2001 From: Catwoman08 <91108185+Catwoman08@users.noreply.github.com> Date: Fri, 31 Jul 2026 14:21:38 -0400 Subject: [PATCH 082/128] docs(contrib): update CONTRIBUTING.md (#4140) --- CONTRIBUTING.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 66043e1cb3..1ca6639734 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,11 +2,11 @@ You want to contribute? You're awesome! -Contributions come in all shapes and sizes. Let's take a tour of some of the different wants you could contribute. +Contributions come in all shapes and sizes. Let's take a tour of some of the different ways you could contribute. ## [Code of Conduct](./docs/CODE_OF_CONDUCT.md) -Firstly, all interactions with the project need to abide by the code of conduct. This is to make sure everyone is treated kindly. +Firstly, all interactions with the project need to abide by the code of conduct. This makes sure everyone is treated kindly. ## [Issues](./docs/ISSUES.md) @@ -29,7 +29,7 @@ By the way, consider checking the [list of easy issues](https://github.com/hyper Improving hyper's documentation is a huge help for everyone who is trying to _use_ hyper. -- The API documentation (rendered at https://docs.rs/hyper) is stored as rustdoc comments directly in the source. +- The API [documentation](https://docs.rs/hyper) is stored as rustdoc comments directly in the source. - The main website has [tutorial-style guides](https://hyper.rs/guides). As of v1, they are currently in a [revamp](https://github.com/hyperium/hyper/issues/3411), and would greatly benefit from being filled out. ## Help From 982fb06a06859ae7ed8048091f79fac457a969c4 Mon Sep 17 00:00:00 2001 From: Logan Date: Fri, 7 Aug 2026 16:58:12 +0100 Subject: [PATCH 083/128] style(lib): move from the use of ref patterns to `&` (#4141) And remove allow for lint `ref_patterns` Part of #4071 --- Cargo.toml | 1 - benches/end_to_end.rs | 10 +++----- src/body/incoming.rs | 41 +++++++++++++++---------------- src/body/length.rs | 6 ++--- src/client/dispatch.rs | 18 +++++++------- src/common/time.rs | 16 ++++++------- src/error.rs | 2 +- src/ffi/client.rs | 6 ++--- src/ffi/task.rs | 4 ++-- src/proto/h1/conn.rs | 38 ++++++++++++++--------------- src/proto/h1/decode.rs | 22 ++++++++--------- src/proto/h1/dispatch.rs | 6 ++--- src/proto/h1/encode.rs | 52 ++++++++++++++++++++-------------------- src/proto/h1/io.rs | 8 +++---- src/proto/h1/role.rs | 18 +++++++------- src/proto/h2/mod.rs | 24 +++++++++---------- src/proto/h2/ping.rs | 18 +++++++------- src/proto/h2/server.rs | 17 ++++++------- src/upgrade.rs | 22 +++++++++-------- tests/client.rs | 2 +- tests/server.rs | 7 ------ tests/support/mod.rs | 2 +- 22 files changed, 162 insertions(+), 178 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index b43295e040..fa0697abeb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -144,7 +144,6 @@ panic = "allow" pattern_type_mismatch = "allow" redundant_closure_for_method_calls = "allow" redundant_else = "allow" -ref_patterns = "allow" # TODO: perhaps deny? single_char_lifetime_names = "allow" struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" diff --git a/benches/end_to_end.rs b/benches/end_to_end.rs index d47029c44e..127d8ca9a4 100644 --- a/benches/end_to_end.rs +++ b/benches/end_to_end.rs @@ -368,13 +368,9 @@ impl Opts { }; let mut send_request = |req| { - let fut = match client { - Client::Http1(ref mut tx) => { - futures_util::future::Either::Left(tx.send_request(req)) - } - Client::Http2(ref mut tx) => { - futures_util::future::Either::Right(tx.send_request(req)) - } + let fut = match &mut client { + Client::Http1(tx) => futures_util::future::Either::Left(tx.send_request(req)), + Client::Http2(tx) => futures_util::future::Either::Right(tx.send_request(req)), }; async { let res = fut.await.expect("client wait"); diff --git a/src/body/incoming.rs b/src/body/incoming.rs index 4b0ca60e04..a0677e7e58 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -172,15 +172,12 @@ impl Incoming { #[cfg(feature = "ffi")] pub(crate) fn as_ffi_mut(&mut self) -> &mut crate::ffi::UserBody { - match self.kind { - Kind::Ffi(ref mut body) => return body, - _ => { - self.kind = Kind::Ffi(crate::ffi::UserBody::new()); - } + if !matches!(self.kind, Kind::Ffi(_)) { + self.kind = Kind::Ffi(crate::ffi::UserBody::new()); } - match self.kind { - Kind::Ffi(ref mut body) => body, + match &mut self.kind { + Kind::Ffi(body) => body, _ => unreachable!(), } } @@ -208,14 +205,14 @@ impl Body for Incoming { )] cx: &mut Context<'_>, ) -> Poll, Self::Error>>> { - match self.kind { + match &mut self.kind { Kind::Empty => Poll::Ready(None), #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] Kind::Chan { - content_length: ref mut len, - ref mut data_rx, - ref mut want_tx, - ref mut trailers_rx, + content_length: len, + data_rx, + want_tx, + trailers_rx, } => { want_tx.send(WANT_READY); @@ -234,10 +231,10 @@ impl Body for Incoming { } #[cfg(all(feature = "http2", any(feature = "client", feature = "server")))] Kind::H2 { - ref mut data_done, - ref ping, - recv: ref mut h2, - content_length: ref mut len, + data_done, + ping, + recv: h2, + content_length: len, } => { if !*data_done { match ready!(h2.poll_data(cx)) { @@ -284,17 +281,17 @@ impl Body for Incoming { } #[cfg(feature = "ffi")] - Kind::Ffi(ref mut body) => body.poll_data(cx), + Kind::Ffi(body) => body.poll_data(cx), } } fn is_end_stream(&self) -> bool { - match self.kind { + match &self.kind { Kind::Empty => true, #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] - Kind::Chan { content_length, .. } => content_length == DecodedLength::ZERO, + Kind::Chan { content_length, .. } => *content_length == DecodedLength::ZERO, #[cfg(all(feature = "http2", any(feature = "client", feature = "server")))] - Kind::H2 { recv: ref h2, .. } => h2.is_end_stream(), + Kind::H2 { recv: h2, .. } => h2.is_end_stream(), #[cfg(feature = "ffi")] Kind::Ffi(..) => false, } @@ -633,8 +630,8 @@ mod tests { drop(rx); assert!(tx_ready.is_woken(), "dropping rx wakes tx"); - match tx_ready.poll() { - Poll::Ready(Err(ref e)) if e.is_closed() => (), + match &tx_ready.poll() { + Poll::Ready(Err(e)) if e.is_closed() => (), unexpected => panic!("tx poll ready unexpected: {:?}", unexpected), } } diff --git a/src/body/length.rs b/src/body/length.rs index 0a782b033f..1ed5e0d031 100644 --- a/src/body/length.rs +++ b/src/body/length.rs @@ -67,9 +67,9 @@ impl DecodedLength { any(feature = "client", feature = "server") ))] pub(crate) fn sub_if(&mut self, amt: u64) { - match *self { - DecodedLength::CHUNKED | DecodedLength::CLOSE_DELIMITED => (), - DecodedLength(ref mut known) => { + match self { + &mut DecodedLength::CHUNKED | &mut DecodedLength::CLOSE_DELIMITED => (), + DecodedLength(known) => { *known -= amt; } } diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 0ff3e7c25f..69c9b8cdaa 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -266,30 +266,30 @@ fn dispatch_gone() -> crate::Error { impl Callback { #[cfg(feature = "http2")] pub(crate) fn is_canceled(&self) -> bool { - match *self { - Callback::Retry(Some(ref tx)) => tx.is_closed(), - Callback::NoRetry(Some(ref tx)) => tx.is_closed(), + match self { + Callback::Retry(Some(tx)) => tx.is_closed(), + Callback::NoRetry(Some(tx)) => tx.is_closed(), _ => unreachable!(), } } pub(crate) fn poll_canceled(&mut self, cx: &mut Context<'_>) -> Poll<()> { - match *self { - Callback::Retry(Some(ref mut tx)) => tx.poll_closed(cx), - Callback::NoRetry(Some(ref mut tx)) => tx.poll_closed(cx), + match self { + Callback::Retry(Some(tx)) => tx.poll_closed(cx), + Callback::NoRetry(Some(tx)) => tx.poll_closed(cx), _ => unreachable!(), } } pub(crate) fn send(mut self, val: Result>) { - match self { - Callback::Retry(ref mut tx) => { + match &mut self { + Callback::Retry(tx) => { let _ = tx .take() .expect("callback sender not dropped before send") .send(val); } - Callback::NoRetry(ref mut tx) => { + Callback::NoRetry(tx) => { let _ = tx .take() .expect("callback sender not dropped before send") diff --git a/src/common/time.rs b/src/common/time.rs index 64a2deed46..b3534f1580 100644 --- a/src/common/time.rs +++ b/src/common/time.rs @@ -32,37 +32,37 @@ impl fmt::Debug for Time { impl Time { #[cfg(all(any(feature = "client", feature = "server"), feature = "http2"))] pub(crate) fn sleep(&self, duration: Duration) -> Pin> { - match *self { + match &self { Time::Empty => { panic!("You must supply a timer.") } - Time::Timer(ref t) => t.sleep(duration), + Time::Timer(t) => t.sleep(duration), } } #[cfg(all(feature = "server", feature = "http1"))] pub(crate) fn sleep_until(&self, deadline: Instant) -> Pin> { - match *self { + match &self { Time::Empty => { panic!("You must supply a timer.") } - Time::Timer(ref t) => t.sleep_until(deadline), + Time::Timer(t) => t.sleep_until(deadline), } } pub(crate) fn now(&self) -> Instant { - match *self { + match &self { Time::Empty => Instant::now(), - Time::Timer(ref t) => t.now(), + Time::Timer(t) => t.now(), } } pub(crate) fn reset(&self, sleep: &mut Pin>, new_deadline: Instant) { - match *self { + match &self { Time::Empty => { panic!("You must supply a timer.") } - Time::Timer(ref t) => t.reset(sleep, new_deadline), + Time::Timer(t) => t.reset(sleep, new_deadline), } } diff --git a/src/error.rs b/src/error.rs index 5134b581a3..1ccd09b7a9 100644 --- a/src/error.rs +++ b/src/error.rs @@ -602,7 +602,7 @@ impl fmt::Debug for Error { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { let mut f = f.debug_tuple("hyper::Error"); f.field(&self.inner.kind); - if let Some(ref cause) = self.inner.cause { + if let Some(cause) = &self.inner.cause { f.field(cause); } f.finish() diff --git a/src/ffi/client.rs b/src/ffi/client.rs index 63b03d874a..fcd3ef7179 100644 --- a/src/ffi/client.rs +++ b/src/ffi/client.rs @@ -147,9 +147,9 @@ ffi_fn! { // Update request with original-case map of headers req.finalize_request(); - let fut = match non_null! { &mut *conn ?= ptr::null_mut() }.tx { - Tx::Http1(ref mut tx) => futures_util::future::Either::Left(tx.send_request(req.0)), - Tx::Http2(ref mut tx) => futures_util::future::Either::Right(tx.send_request(req.0)), + let fut = match &mut non_null! { &mut *conn ?= ptr::null_mut() }.tx { + Tx::Http1(tx) => futures_util::future::Either::Left(tx.send_request(req.0)), + Tx::Http2(tx) => futures_util::future::Either::Right(tx.send_request(req.0)), }; let fut = async move { diff --git a/src/ffi/task.rs b/src/ffi/task.rs index 172c13b4b9..9c7fba6a26 100644 --- a/src/ffi/task.rs +++ b/src/ffi/task.rs @@ -355,9 +355,9 @@ impl hyper_task { } fn output_type(&self) -> hyper_task_return_type { - match self.output { + match &self.output { None => hyper_task_return_type::HYPER_TASK_EMPTY, - Some(ref val) => val.as_task_type(), + Some(val) => val.as_task_type(), } } } diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 7bcad85970..8f196a0093 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -220,8 +220,8 @@ where if let Some(h1_header_read_timeout) = self.state.h1_header_read_timeout { let deadline = self.state.timer.now() + h1_header_read_timeout; self.state.h1_header_read_timeout_running = true; - match self.state.h1_header_read_timeout_fut { - Some(ref mut h1_header_read_timeout_fut) => { + match &mut self.state.h1_header_read_timeout_fut { + Some(h1_header_read_timeout_fut) => { trace!("resetting h1 header read timeout timer"); self.state.timer.reset(h1_header_read_timeout_fut, deadline); } @@ -254,8 +254,8 @@ where Poll::Pending => { #[cfg(feature = "server")] if self.state.h1_header_read_timeout_running { - if let Some(ref mut h1_header_read_timeout_fut) = - self.state.h1_header_read_timeout_fut + if let Some(h1_header_read_timeout_fut) = + &mut self.state.h1_header_read_timeout_fut { if Pin::new(h1_header_read_timeout_fut).poll(cx).is_ready() { self.state.h1_header_read_timeout_running = false; @@ -366,8 +366,8 @@ where ) -> Poll>>> { debug_assert!(self.can_read_body()); - let (reading, ret) = match self.state.reading { - Reading::Body(ref mut decoder) => { + let (reading, ret) = match &mut self.state.reading { + Reading::Body(decoder) => { match ready!(decoder.decode(cx, &mut self.io)) { Ok(frame) => { if frame.is_data() { @@ -406,7 +406,7 @@ where } } } - Reading::Continue(ref decoder) => { + Reading::Continue(decoder) => { // Write the 100 Continue if not already responded... if let Writing::Init = self.state.writing { trace!("automatically sending 100 Continue"); @@ -715,8 +715,8 @@ where // empty chunks should be discarded at Dispatcher level debug_assert!(chunk.remaining() != 0); - let state = match self.state.writing { - Writing::Body(ref mut encoder) => { + let state = match &mut self.state.writing { + Writing::Body(encoder) => { self.io.buffer(encoder.encode(chunk)); if !encoder.is_eof() { @@ -742,8 +742,8 @@ where } debug_assert!(self.can_write_body() && self.can_buffer_body()); - match self.state.writing { - Writing::Body(ref encoder) => { + match &mut self.state.writing { + Writing::Body(encoder) => { if let Some(enc_buf) = encoder.encode_trailers(trailers, self.state.title_case_headers) { @@ -765,8 +765,8 @@ where // empty chunks should be discarded at Dispatcher level debug_assert!(chunk.remaining() != 0); - let state = match self.state.writing { - Writing::Body(ref encoder) => { + let state = match &mut self.state.writing { + Writing::Body(encoder) => { let can_keep_alive = encoder.encode_and_end(chunk, self.io.write_buf()); if can_keep_alive { Writing::KeepAlive @@ -783,8 +783,8 @@ where pub(crate) fn end_body(&mut self) -> crate::Result<()> { debug_assert!(self.can_write_body()); - let encoder = match self.state.writing { - Writing::Body(ref mut enc) => enc, + let encoder = match &mut self.state.writing { + Writing::Body(enc) => enc, _ => return Ok(()), }; @@ -856,7 +856,7 @@ where /// If the read side can be cheaply drained, do so. Otherwise, close. pub(super) fn poll_drain_or_close_read(&mut self, cx: &mut Context<'_>) { - if let Reading::Continue(ref decoder) = self.state.reading { + if let Reading::Continue(decoder) = &mut self.state.reading { // skip sending the 100-continue // just move forward to a read, in case a tiny body was included self.state.reading = Reading::Body(decoder.clone()); @@ -994,7 +994,7 @@ impl fmt::Debug for State { .field("keep_alive", &self.keep_alive); // Only show error field if it's interesting... - if let Some(ref error) = self.error { + if let Some(error) = &self.error { builder.field("error", error); } @@ -1010,9 +1010,9 @@ impl fmt::Debug for State { impl fmt::Debug for Writing { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - match *self { + match self { Writing::Init => f.write_str("Init"), - Writing::Body(ref enc) => f.debug_tuple("Body").field(enc).finish(), + Writing::Body(enc) => f.debug_tuple("Body").field(enc).finish(), Writing::KeepAlive => f.write_str("KeepAlive"), Writing::Closed => f.write_str("Closed"), } diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index 0a95dd58d3..bdfdf79abf 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -147,8 +147,8 @@ impl Decoder { body: &mut R, ) -> Poll, io::Error>> { trace!("decode; state={:?}", self.kind); - match self.kind { - Length(ref mut remaining) => { + match &mut self.kind { + Length(remaining) => { if *remaining == 0 { Poll::Ready(Ok(Frame::data(Bytes::new()))) } else { @@ -169,13 +169,13 @@ impl Decoder { } } Chunked { - ref mut state, - ref mut chunk_len, - ref mut extensions_cnt, - ref mut trailers_buf, - ref mut trailers_cnt, - ref h1_max_headers, - ref h1_max_header_size, + state, + chunk_len, + extensions_cnt, + trailers_buf, + trailers_cnt, + h1_max_headers, + h1_max_header_size, } => { let h1_max_headers = h1_max_headers.unwrap_or(DEFAULT_MAX_HEADERS); let h1_max_header_size = h1_max_header_size.unwrap_or(TRAILER_LIMIT); @@ -221,7 +221,7 @@ impl Decoder { } } } - Eof(ref mut is_eof) => { + Eof(is_eof) => { if *is_eof { Poll::Ready(Ok(Frame::data(Bytes::new()))) } else { @@ -604,7 +604,7 @@ impl ChunkedState { buf.put_u8(byte); *trailers_buf = Some(buf); } - Some(ref mut trailers_buf) => { + Some(trailers_buf) => { put_u8!(trailers_buf, byte, h1_max_header_size); } } diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 3a9b536ea4..a054ea80a1 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -591,7 +591,7 @@ cfg_server! { cx: &mut Context<'_>, ) -> Poll>> { let mut this = self.as_mut(); - let ret = if let Some(ref mut fut) = this.in_flight.as_mut().as_pin_mut() { + let ret = if let Some(fut) = &mut this.in_flight.as_mut().as_pin_mut() { let resp = ready!(fut.as_mut().poll(cx)?); let (parts, body) = resp.into_parts(); let head = MessageHead { @@ -741,8 +741,8 @@ cfg_client! { } fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - match self.callback { - Some(ref mut cb) => match cb.poll_canceled(cx) { + match &mut self.callback { + Some(cb) => match cb.poll_canceled(cx) { Poll::Ready(()) => { trace!("callback receiver has dropped"); Poll::Ready(Err(())) diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index 690d7e8452..a1eebafa12 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -132,7 +132,7 @@ impl Encoder { let len = msg.remaining(); debug_assert!(len > 0, "encode() called with empty buf"); - let kind = match self.kind { + let kind = match &mut self.kind { Kind::Chunked(_) => { trace!("encoding chunked {}B", len); let buf = ChunkSize::new(len) @@ -140,7 +140,7 @@ impl Encoder { .chain(b"\r\n" as &'static [u8]); BufKind::Chunked(buf) } - Kind::Length(ref mut remaining) => { + Kind::Length(remaining) => { trace!("sized write, len = {}", len); if len as u64 > *remaining { let limit = *remaining as usize; @@ -285,45 +285,45 @@ where { #[inline] fn remaining(&self) -> usize { - match self.kind { - BufKind::Exact(ref b) => b.remaining(), - BufKind::Limited(ref b) => b.remaining(), - BufKind::Chunked(ref b) => b.remaining(), - BufKind::ChunkedEnd(ref b) => b.remaining(), - BufKind::Trailers(ref b) => b.remaining(), + match &self.kind { + BufKind::Exact(b) => b.remaining(), + BufKind::Limited(b) => b.remaining(), + BufKind::Chunked(b) => b.remaining(), + BufKind::ChunkedEnd(b) => b.remaining(), + BufKind::Trailers(b) => b.remaining(), } } #[inline] fn chunk(&self) -> &[u8] { - match self.kind { - BufKind::Exact(ref b) => b.chunk(), - BufKind::Limited(ref b) => b.chunk(), - BufKind::Chunked(ref b) => b.chunk(), - BufKind::ChunkedEnd(ref b) => b.chunk(), - BufKind::Trailers(ref b) => b.chunk(), + match &self.kind { + BufKind::Exact(b) => b.chunk(), + BufKind::Limited(b) => b.chunk(), + BufKind::Chunked(b) => b.chunk(), + BufKind::ChunkedEnd(b) => b.chunk(), + BufKind::Trailers(b) => b.chunk(), } } #[inline] fn advance(&mut self, cnt: usize) { - match self.kind { - BufKind::Exact(ref mut b) => b.advance(cnt), - BufKind::Limited(ref mut b) => b.advance(cnt), - BufKind::Chunked(ref mut b) => b.advance(cnt), - BufKind::ChunkedEnd(ref mut b) => b.advance(cnt), - BufKind::Trailers(ref mut b) => b.advance(cnt), + match &mut self.kind { + BufKind::Exact(b) => b.advance(cnt), + BufKind::Limited(b) => b.advance(cnt), + BufKind::Chunked(b) => b.advance(cnt), + BufKind::ChunkedEnd(b) => b.advance(cnt), + BufKind::Trailers(b) => b.advance(cnt), } } #[inline] fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize { - match self.kind { - BufKind::Exact(ref b) => b.chunks_vectored(dst), - BufKind::Limited(ref b) => b.chunks_vectored(dst), - BufKind::Chunked(ref b) => b.chunks_vectored(dst), - BufKind::ChunkedEnd(ref b) => b.chunks_vectored(dst), - BufKind::Trailers(ref b) => b.chunks_vectored(dst), + match &self.kind { + BufKind::Exact(b) => b.chunks_vectored(dst), + BufKind::Limited(b) => b.chunks_vectored(dst), + BufKind::Chunked(b) => b.chunks_vectored(dst), + BufKind::ChunkedEnd(b) => b.chunks_vectored(dst), + BufKind::Trailers(b) => b.chunks_vectored(dst), } } } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 2aeabc7c60..a068650584 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -394,15 +394,15 @@ impl ReadStrategy { } fn record(&mut self, bytes_read: usize) { - match *self { + match self { ReadStrategy::Adaptive { - ref mut decrease_now, - ref mut next, + decrease_now, + next, max, .. } => { if bytes_read >= *next { - *next = cmp::min(incr_power_of_two(*next), max); + *next = cmp::min(incr_power_of_two(*next), *max); *decrease_now = false; } else { let decr_to = prev_power_of_two(*next); diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f467af8286..f5c8db5ed0 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -328,12 +328,12 @@ impl Http1Transaction for Server { _ => (), } - if let Some(ref mut header_case_map) = header_case_map { + if let Some(header_case_map) = &mut header_case_map { header_case_map.append(&name, slice.slice(header.name.0..header.name.1)); } #[cfg(feature = "ffi")] - if let Some(ref mut header_order) = header_order { + if let Some(header_order) = &mut header_order { header_order.append(&name); } @@ -618,9 +618,9 @@ impl Server { fn write_header_name(&mut self, dst: &mut Vec, name: &HeaderName) { let Self { map, - ref mut current, + current, title_case_headers, - } = *self; + } = self; if current.as_ref().map_or(true, |(last, _)| last != name) { *current = None; } @@ -629,7 +629,7 @@ impl Server { if let Some(orig_name) = values.next() { extend(dst, orig_name); - } else if title_case_headers { + } else if *title_case_headers { title_case(dst, name.as_str().as_bytes()); } else { extend(dst, name.as_str().as_bytes()); @@ -883,8 +883,8 @@ impl Server { .filter_map(|s| HeaderName::from_bytes(s.trim().as_bytes()).ok()) .collect(); - match allowed_trailer_fields { - Some(ref mut fields) => { + match &mut allowed_trailer_fields { + Some(fields) => { fields.extend(names); } None => { @@ -1130,12 +1130,12 @@ impl Http1Transaction for Client { } } - if let Some(ref mut header_case_map) = header_case_map { + if let Some(header_case_map) = &mut header_case_map { header_case_map.append(&name, slice.slice(header.name.0..header.name.1)); } #[cfg(feature = "ffi")] - if let Some(ref mut header_order) = header_order { + if let Some(header_order) = &mut header_order { header_order.append(&name); } diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index e3033ec5f5..33e32558ec 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -285,35 +285,35 @@ enum SendBuf { impl Buf for SendBuf { #[inline] fn remaining(&self) -> usize { - match *self { - Self::Buf(ref b) => b.remaining(), - Self::Cursor(ref c) => Buf::remaining(c), + match self { + Self::Buf(b) => b.remaining(), + Self::Cursor(c) => Buf::remaining(c), Self::None => 0, } } #[inline] fn chunk(&self) -> &[u8] { - match *self { - Self::Buf(ref b) => b.chunk(), - Self::Cursor(ref c) => c.chunk(), + match self { + Self::Buf(b) => b.chunk(), + Self::Cursor(c) => c.chunk(), Self::None => &[], } } #[inline] fn advance(&mut self, cnt: usize) { - match *self { - Self::Buf(ref mut b) => b.advance(cnt), - Self::Cursor(ref mut c) => c.advance(cnt), + match self { + Self::Buf(b) => b.advance(cnt), + Self::Cursor(c) => c.advance(cnt), Self::None => {} } } fn chunks_vectored<'a>(&'a self, dst: &mut [IoSlice<'a>]) -> usize { - match *self { - Self::Buf(ref b) => b.chunks_vectored(dst), - Self::Cursor(ref c) => c.chunks_vectored(dst), + match self { + Self::Buf(b) => b.chunks_vectored(dst), + Self::Cursor(c) => c.chunks_vectored(dst), Self::None => 0, } } diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index f4caae148e..198bff465c 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -191,7 +191,7 @@ impl Config { impl Recorder { pub(crate) fn record_data(&self, len: usize) { - let shared = if let Some(ref shared) = self.shared { + let shared = if let Some(shared) = &self.shared { shared } else { return; @@ -204,7 +204,7 @@ impl Recorder { // are we ready to send another bdp ping? // if not, we don't need to record bytes either - if let Some(ref next_bdp_at) = locked.next_bdp_at { + if let Some(next_bdp_at) = &locked.next_bdp_at { if locked.timer.now() < *next_bdp_at { return; } else { @@ -212,7 +212,7 @@ impl Recorder { } } - if let Some(ref mut bytes) = locked.bytes { + if let Some(bytes) = &mut locked.bytes { *bytes += len; } else { // no need to send bdp ping if bdp is disabled @@ -225,7 +225,7 @@ impl Recorder { } pub(crate) fn record_non_data(&self) { - let shared = if let Some(ref shared) = self.shared { + let shared = if let Some(shared) = &self.shared { shared } else { return; @@ -248,7 +248,7 @@ impl Recorder { } pub(super) fn ensure_not_timed_out(&self) -> crate::Result<()> { - if let Some(ref shared) = self.shared { + if let Some(shared) = &self.shared { let locked = shared.lock().panic_if_poisoned(); if locked.is_keep_alive_timed_out { return Err(KeepAliveTimedOut.crate_error()); @@ -268,7 +268,7 @@ impl Ponger { let now = locked.timer.now(); // hoping this is fine to move within the lock let is_idle = self.is_idle(); - if let Some(ref mut ka) = self.keep_alive { + if let Some(ka) = &mut self.keep_alive { ka.maybe_schedule(is_idle, &locked); ka.maybe_ping(cx, is_idle, &mut locked); } @@ -287,13 +287,13 @@ impl Ponger { let rtt = now - start; trace!("recv pong"); - if let Some(ref mut ka) = self.keep_alive { + if let Some(ka) = &mut self.keep_alive { locked.update_last_read_at(); ka.maybe_schedule(is_idle, &locked); ka.maybe_ping(cx, is_idle, &mut locked); } - if let Some(ref mut bdp) = self.bdp { + if let Some(bdp) = &mut self.bdp { let bytes = locked.bytes.expect("bdp enabled implies bytes"); locked.bytes = Some(0); // reset trace!("received BDP ack; bytes = {}, rtt = {:?}", bytes, rtt); @@ -309,7 +309,7 @@ impl Ponger { debug!("pong error: {}", _e); } Poll::Pending => { - if let Some(ref mut ka) = self.keep_alive { + if let Some(ka) = &mut self.keep_alive { if let Err(KeepAliveTimedOut) = ka.maybe_timeout(cx) { self.keep_alive = None; locked.is_keep_alive_timed_out = true; diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 6645734f31..92026276aa 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -184,11 +184,11 @@ where pub(crate) fn graceful_shutdown(&mut self) { trace!("graceful_shutdown"); - match self.state { + match &mut self.state { State::Handshaking { .. } => { self.close_pending = true; } - State::Serving(ref mut srv) => { + State::Serving(srv) => { if srv.closing.is_none() { srv.conn.graceful_shutdown(); } @@ -210,11 +210,8 @@ where fn poll(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { let me = &mut *self; loop { - let next = match me.state { - State::Handshaking { - ref mut hs, - ref ping_config, - } => { + let next = match &mut me.state { + State::Handshaking { hs, ping_config } => { let mut conn = ready!(Pin::new(hs).poll(cx).map_err(crate::Error::new_h2))?; let ping = if ping_config.is_enabled() { let pp = conn.ping_pong().expect("conn.ping_pong"); @@ -229,7 +226,7 @@ where date_header: me.date_header, }) } - State::Serving(ref mut srv) => { + State::Serving(srv) => { // graceful_shutdown was called before handshaking finished, if me.close_pending && srv.closing.is_none() { srv.conn.graceful_shutdown(); @@ -324,7 +321,7 @@ where } None => { // no more incoming streams... - if let Some((ref ping, _)) = self.ping { + if let Some((ping, _)) = &self.ping { ping.ensure_not_timed_out()?; } @@ -346,7 +343,7 @@ where } fn poll_ping(&mut self, cx: &mut Context<'_>) { - if let Some((_, ref mut estimator)) = self.ping { + if let Some((_, estimator)) = &mut self.ping { match estimator.poll(cx) { Poll::Ready(ping::Ponged::SizeUpdate(wnd)) => { self.conn.set_target_window_size(wnd); diff --git a/src/upgrade.rs b/src/upgrade.rs index 2dd96ab465..470f4ca435 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -226,16 +226,18 @@ impl Future for OnUpgrade { type Output = Result; fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { - match self.rx { - Some(ref rx) => Pin::new(&mut *rx.lock().panic_if_poisoned()) - .poll(cx) - .map(|res| match res { - Ok(Ok(upgraded)) => Ok(upgraded), - Ok(Err(err)) => Err(err), - Err(_oneshot_canceled) => { - Err(crate::Error::new_canceled().with(UpgradeExpected)) - } - }), + match &self.rx { + Some(rx) => { + Pin::new(&mut *rx.lock().panic_if_poisoned()) + .poll(cx) + .map(|res| match res { + Ok(Ok(upgraded)) => Ok(upgraded), + Ok(Err(err)) => Err(err), + Err(_oneshot_canceled) => { + Err(crate::Error::new_canceled().with(UpgradeExpected)) + } + }) + } None => Poll::Ready(Err(crate::Error::new_user_no_upgrade())), } } diff --git a/tests/client.rs b/tests/client.rs index f46e042977..b512260cc5 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -2351,7 +2351,7 @@ mod conn { if let Poll::Ready(res) = fut2.as_mut().poll(cx) { return Poll::Ready(res); } - if let Some(ref mut conn) = conn_opt { + if let Some(conn) = &mut conn_opt { match Pin::new(conn).poll(cx) { Poll::Ready(_) => { conn_opt = None; diff --git a/tests/server.rs b/tests/server.rs index b22f50b11a..855840b388 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -3561,13 +3561,6 @@ impl Drop for Serve { fn drop(&mut self) { drop(self.shutdown_signal.take()); drop(self.thread.take()); - /* - let r = self.thread.take().unwrap().join(); - if let Err(ref e) = r { - println!("{:?}", e); - } - r.unwrap(); - */ } } diff --git a/tests/support/mod.rs b/tests/support/mod.rs index cf3f1bc366..608deb8849 100644 --- a/tests/support/mod.rs +++ b/tests/support/mod.rs @@ -235,7 +235,7 @@ macro_rules! __internal_headers_eq { (@val $name: expr, $val:expr) => ({ let __val = Option::from($val); std::sync::Arc::new(move |__hdrs: &hyper::HeaderMap| { - if let Some(ref val) = __val { + if let Some(val) = &__val { assert_eq!(__hdrs.get($name).expect(stringify!($name)), val.to_string().as_str(), stringify!($name)); } else { assert_eq!(__hdrs.get($name), None, stringify!($name)); From de5b4a7ec5331eab5602a7376a6dda7440f831b3 Mon Sep 17 00:00:00 2001 From: Bailey Hayes Date: Fri, 7 Aug 2026 12:11:31 -0400 Subject: [PATCH 084/128] fix(http1): flush bytes buffered by the write re-check before yielding (#4143) `poll_loop`'s main path always calls `poll_flush` after `poll_write`. The "wants_write_again" re-check added in #3988 calls `poll_write` a second time and returns straight out of the loop when it pends, skipping that flush. That second write can buffer bytes before it pends. When a response body reaches end-of-stream between the two write polls, `end_body()` buffers the end of the message and the write then pends on the *next* message (`poll_msg`). Returning there strands the terminating chunk in the write buffer: the wake-ups the connection is left waiting on are for reads, so nothing flushes it. The peer receives the body but never the terminator and waits until it gives up, at which point the connection reports `IncompleteMessage` from `mid_message_detect_eof`. Observed on a server streaming a chunked body fed from another thread, at roughly one connection in 600k. hyper's own trace shows the divergence: healthy: buf.len=24, buf.len=5, flushed 29 bytes stalled: buf.len=24, flushed 24 bytes, buf.len=5, Flush what the re-check buffered before yielding. Guard the flush on there being buffered bytes so the call pattern is otherwise unchanged. Add a test that drives the interleaving deterministically: a body that yields one data frame, then pends, then ends the stream on the very next poll, all within a single `poll_loop` iteration. --- src/proto/h1/conn.rs | 5 ++ src/proto/h1/dispatch.rs | 9 +++ src/proto/h1/io.rs | 5 ++ tests/h1_flush_before_yield.rs | 118 +++++++++++++++++++++++++++++++++ 4 files changed, 137 insertions(+) create mode 100644 tests/h1_flush_before_yield.rs diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 8f196a0093..a2d2670e6b 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -592,6 +592,11 @@ where self.io.can_buffer() } + /// Whether bytes are sitting in the write buffer waiting to be flushed. + pub(crate) fn has_buffered_write(&self) -> bool { + self.io.has_buffered_write() + } + pub(crate) fn write_head(&mut self, head: MessageHead, body: Option) { if let Some(encoder) = self.encode_head(head, body) { self.state.writing = if !encoder.is_eof() { diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index a054ea80a1..03aaba1a9e 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -205,6 +205,15 @@ where // we need to check it again. If it is still pending, it is safe to yield and rely // on wake-up from the connection futures. if self.poll_write(cx)?.is_pending() { + // That write can have buffered bytes before going pending: a body that + // reached end-of-stream between the two write polls buffers the end of the + // message here, and then the write goes pending on the *next* message. + // Yielding without flushing would strand those bytes in the write buffer + // until the peer gives up, since the wake-ups we then rely on are for + // reads. Flush what was just buffered before yielding. + if self.conn.has_buffered_write() { + let _ = self.poll_flush(cx)?; + } return Poll::Ready(Ok(())); } } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index a068650584..efec6fef4a 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -149,6 +149,11 @@ where self.write_buf.buffer(buf); } + /// Whether there are bytes waiting in the write buffer to be flushed. + pub(crate) fn has_buffered_write(&self) -> bool { + self.write_buf.remaining() > 0 + } + pub(crate) fn can_buffer(&self) -> bool { self.flush_pipeline || self.write_buf.can_buffer() } diff --git a/tests/h1_flush_before_yield.rs b/tests/h1_flush_before_yield.rs new file mode 100644 index 0000000000..0e6db9bddb --- /dev/null +++ b/tests/h1_flush_before_yield.rs @@ -0,0 +1,118 @@ +// Test: `poll_loop` must never yield leaving buffered bytes unflushed. +// +// `poll_loop`'s main path always calls `poll_flush` after `poll_write`. The +// "wants_write_again" re-check added a *second* `poll_write` whose `Pending` +// returned straight out of the loop, skipping that flush. +// +// That second write can buffer bytes before it pends. A response body that +// reaches end-of-stream between the two write polls has its end-of-message +// buffered by `end_body()`, and the write then pends on the *next* message +// (`poll_msg`). Returning there strands the terminating chunk in the write +// buffer: the wake-ups the connection then waits on are for reads, so nothing +// flushes it and the peer waits for a response that is already written but +// never sent. +// +// The body below drives exactly that interleaving deterministically: one data +// frame, then `Pending`, then end-of-stream on the very next poll, both of +// which happen inside a single `poll_loop` iteration. + +use std::convert::Infallible; +use std::pin::Pin; +use std::task::{Context, Poll}; +use std::time::Duration; + +use bytes::Bytes; +use hyper::body::{Body, Frame}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::Response; +use support::TokioIo; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::{TcpListener, TcpStream}; +use tokio::time::timeout; + +mod support; + +/// Yields one data frame, then pends once, then ends the stream. +/// +/// The `Pending` deliberately arranges no wake-up: it stands in for a body +/// whose readiness changes between hyper's two write polls of the same +/// `poll_loop` iteration, which is what leaves the end of the message buffered +/// by the re-check write. +#[derive(Default)] +struct PendOnceThenEnd { + polls: u8, +} + +impl Body for PendOnceThenEnd { + type Data = Bytes; + type Error = Infallible; + + fn poll_frame( + mut self: Pin<&mut Self>, + _cx: &mut Context<'_>, + ) -> Poll, Self::Error>>> { + self.polls += 1; + match self.polls { + 1 => Poll::Ready(Some(Ok(Frame::data(Bytes::from_static(b"hello"))))), + 2 => Poll::Pending, + _ => Poll::Ready(None), + } + } +} + +#[tokio::test] +async fn h1_server_flushes_end_of_body_buffered_by_write_recheck() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + + tokio::spawn(async move { + let (socket, _) = listener.accept().await.unwrap(); + let service = service_fn(|_req| async { + Ok::<_, Infallible>(Response::new(PendOnceThenEnd::default())) + }); + let _ = http1::Builder::new() + .serve_connection(TokioIo::new(socket), service) + .await; + }); + + let mut client = TcpStream::connect(addr).await.unwrap(); + client + .write_all(b"GET / HTTP/1.1\r\nHost: localhost\r\n\r\n") + .await + .unwrap(); + + // The whole response must arrive, terminating chunk included. Before the + // fix the body's chunk arrives but `0\r\n\r\n` never does, so this read + // waits forever. + let mut received = Vec::new(); + let read = timeout(Duration::from_secs(5), async { + let mut buf = [0u8; 256]; + loop { + let n = client.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + received.extend_from_slice(&buf[..n]); + if received.ends_with(b"\r\n0\r\n\r\n") { + break; + } + } + }) + .await; + + assert!( + read.is_ok(), + "response never completed; got {:?}", + String::from_utf8_lossy(&received) + ); + let received = String::from_utf8_lossy(&received); + assert!( + received.ends_with("\r\n0\r\n\r\n"), + "missing terminating chunk; got {received:?}" + ); + assert!( + received.contains("hello"), + "missing body content; got {received:?}" + ); +} From 15ab365b4ea35dffd8e17fd4d552a93df217202f Mon Sep 17 00:00:00 2001 From: Recoordinate <296084221+latent-9@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:33:06 +1200 Subject: [PATCH 085/128] docs(governance): fix typo (#4146) --- docs/GOVERNANCE.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index b4548bef27..f4366dba8a 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -109,7 +109,7 @@ Responsibilities: - Adhere to the [Code of Conduct][coc] - Try to offer advice when the `@hyperium/advisors` team is tagged for input. - (This is not a frequent occurence, so the time commitment should generally be + (This is not a frequent occurrence, so the time commitment should generally be low.) How to become: From b1c87fd077fb38bc4ea21559b022c5cc3488284e Mon Sep 17 00:00:00 2001 From: Sander Saares Date: Mon, 10 Aug 2026 16:25:32 +0300 Subject: [PATCH 086/128] perf(http2): reserve minimal send capacity when piping request bodies (#4149) Reserving the full chunk length makes every in-flight stream a heavyweight claimant in the connection-window distribution for as long as it waits for capacity, which is costly once the streams sharing a connection collectively demand more than the window the peer advertises. The chunk is still only reserved against once it is in hand, so capacity can never be pinned by a body that produces nothing (#4003). h2 raises the requested send capacity to the buffered length inside `send_data`, so the demand eventually signalled to the peer is unchanged; only the transient claim held while the stream waits for its first byte of capacity differs. --- src/proto/h2/mod.rs | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index 33e32558ec..f0db9b64eb 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -216,12 +216,21 @@ where continue; } - // Reserve exactly the chunk size so we never pin more - // connection-level flow-control window than we are - // about to consume. Stash the chunk in `self` so it - // survives the upcoming `poll_capacity` wait even if - // it returns `Poll::Pending`. - me.body_tx.reserve_capacity(len); + // Reserve a minimal claim on the connection-level + // flow-control window rather than the whole chunk. The + // chunk is already in hand, so this still cannot pin + // capacity against a body that never produces data + // (#4003), and h2 raises the request to the buffered + // length inside `send_data`, so the demand eventually + // signalled to the peer is unchanged. Claiming the full + // length up front instead makes every in-flight stream a + // heavyweight claimant while it waits, which is costly + // once the streams on a connection collectively demand + // more than the window the peer advertises. Stash the + // chunk in `self` so it survives the upcoming + // `poll_capacity` wait even if it returns + // `Poll::Pending`. + me.body_tx.reserve_capacity(1); *me.buffered_data = Some(Peeked { data: chunk, is_eos, From 9ca93af3b373a9b815d962eb131f9c22e6d4e5a3 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 10 Aug 2026 09:28:53 -0400 Subject: [PATCH 087/128] chore(docs): delete empty .github/PULL_REQUEST_TEMPLATE --- .github/PULL_REQUEST_TEMPLATE | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 .github/PULL_REQUEST_TEMPLATE diff --git a/.github/PULL_REQUEST_TEMPLATE b/.github/PULL_REQUEST_TEMPLATE deleted file mode 100644 index e69de29bb2..0000000000 From eaf5e9b773ef2147ed56c273595dbd4c5d62edfe Mon Sep 17 00:00:00 2001 From: dswij Date: Mon, 10 Aug 2026 23:19:24 +0800 Subject: [PATCH 088/128] chore(lib): old commented code cleanup (#4151) --- src/body/incoming.rs | 2 -- src/common/io/rewind.rs | 4 ---- src/proto/h1/io.rs | 13 ------------- src/upgrade.rs | 1 - 4 files changed, 20 deletions(-) diff --git a/src/body/incoming.rs b/src/body/incoming.rs index a0677e7e58..93c99a01a6 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -491,8 +491,6 @@ mod tests { body_expected_size, ); - //assert_eq!(body_size, mem::size_of::>(), "Option"); - assert_eq!( mem::size_of::(), mem::size_of::() * 5, diff --git a/src/common/io/rewind.rs b/src/common/io/rewind.rs index b692f438ee..7acd809685 100644 --- a/src/common/io/rewind.rs +++ b/src/common/io/rewind.rs @@ -46,10 +46,6 @@ impl Rewind { pub(crate) fn into_inner(self) -> (T, Bytes) { (self.inner, self.pre.unwrap_or_default()) } - - // pub(crate) fn get_mut(&mut self) -> &mut T { - // &mut self.inner - // } } impl Read for Rewind diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index efec6fef4a..6b3f10fbae 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -657,19 +657,6 @@ mod tests { use tokio_test::io::Builder as Mock; - // #[cfg(feature = "nightly")] - // use test::Bencher; - - /* - impl MemRead for AsyncIo { - fn read_mem(&mut self, len: usize) -> Poll { - let mut v = vec![0; len]; - let n = try_nb!(self.read(v.as_mut_slice())); - Ok(Async::Ready(BytesMut::from(&v[..n]).freeze())) - } - } - */ - #[tokio::test] #[ignore] async fn iobuf_write_empty_slice() { diff --git a/src/upgrade.rs b/src/upgrade.rs index 470f4ca435..df1b9e6847 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -405,7 +405,6 @@ mod tests { _: &mut Context<'_>, buf: &[u8], ) -> Poll> { - // panic!("poll_write shouldn't be called"); Poll::Ready(Ok(buf.len())) } From ec5b5f963b16036786a0a643e69a5e9250992dca Mon Sep 17 00:00:00 2001 From: Nam2ee <81401376+nam2ee@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:08:24 +0900 Subject: [PATCH 089/128] fix(http1): recognize `\n\r\n` as a head terminator in the partial-read fast path (#4147) `is_complete_fast` recognizes `\r\n\r\n` and `\n\n` as head terminators but not `\n\r\n`, while the full parser (httparse) accepts all three. So a request whose head ends with `\n\r\n` parses fine when it arrives in a single read, but stalls when it arrives split across reads: the fast path never reports the head complete and the connection keeps waiting for more bytes. This extends the `\n` branch to also accept a following `\r\n`, using the same panic-safe slicing idiom as the `\r` branch. Added the `\n\r\n` witness and the `\n\r` negative to `test_is_complete_fast`, plus a parse-level test documenting that the full parser accepts this terminator. Closes #4145 --- src/proto/h1/role.rs | 39 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 38 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f5c8db5ed0..29bcd44b0d 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -106,7 +106,10 @@ fn is_complete_fast(bytes: &[u8], prev_len: usize) -> bool { if bytes[i + 1..].chunks(3).next() == Some(&b"\n\r\n"[..]) { return true; } - } else if b == b'\n' && bytes.get(i + 1) == Some(&b'\n') { + } else if b == b'\n' + && (bytes.get(i + 1) == Some(&b'\n') + || bytes[i + 1..].chunks(2).next() == Some(&b"\r\n"[..])) + { return true; } } @@ -2977,6 +2980,10 @@ mod tests { for n in 0..s.len() { assert!(is_complete_fast(s, n)); } + let s = b"GET / HTTP/1.1\r\na: b\n\r\n"; + for n in 0..s.len() { + assert!(is_complete_fast(s, n), "{:?}; {}", s, n); + } // Not let s = b"GET / HTTP/1.1\r\na: b\r\n\r"; @@ -2987,6 +2994,36 @@ mod tests { for n in 0..s.len() { assert!(!is_complete_fast(s, n)); } + let s = b"GET / HTTP/1.1\r\na: b\n\r"; + for n in 0..s.len() { + assert!(!is_complete_fast(s, n)); + } + } + + #[cfg(feature = "server")] + #[test] + fn test_parse_accepts_lf_crlf_terminator() { + // The full parser (httparse) accepts a bare-LF line ending followed + // by a CRLF blank line as the end of the head, so the partial-read + // fast path must recognize it too. + let mut bytes = BytesMut::from("GET / HTTP/1.1\r\na: b\n\r\n"); + Server::parse( + &mut bytes, + ParseContext { + cached_headers: &mut None, + req_method: &mut None, + h1_parser_config: Default::default(), + h1_max_headers: None, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }, + ) + .expect("parse ok") + .expect("parse complete"); } #[test] From fa918613e774095f881788746e71373b8b17d3ab Mon Sep 17 00:00:00 2001 From: Morax <100508620+fzlzjerry@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:09:05 +0800 Subject: [PATCH 090/128] docs(body): add streaming read examples (#4148) Show how to process data and trailer frames while preserving back-pressure, and contrast that with intentionally collecting a bounded body in memory. Closes #2201 --- src/body/mod.rs | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/body/mod.rs b/src/body/mod.rs index 2df3319dd5..c34d019d21 100644 --- a/src/body/mod.rs +++ b/src/body/mod.rs @@ -17,7 +17,52 @@ //! There are additional implementations available in [`http-body-util`][], //! such as a `Full` or `Empty` body. //! +//! ## Reading a body +//! +//! The [`BodyExt`][] extension trait provides an asynchronous way to read the +//! frames of a body. A frame can contain either data or trailers: +//! +//! ``` +//! use http_body_util::BodyExt as _; +//! use hyper::body::Incoming; +//! +//! async fn read_body(mut body: Incoming) -> Result<(), hyper::Error> { +//! while let Some(frame) = body.frame().await { +//! let frame = frame?; +//! +//! if let Some(data) = frame.data_ref() { +//! println!("received {} bytes", data.len()); +//! } +//! +//! if let Some(trailers) = frame.trailers_ref() { +//! println!("received trailers: {trailers:?}"); +//! } +//! } +//! +//! Ok(()) +//! } +//! ``` +//! +//! A body only advances when it is polled. Processing each frame before +//! polling for the next one preserves back-pressure on the connection. +//! +//! If a body is known to be small, it can be collected into memory instead: +//! +//! ``` +//! use http_body_util::BodyExt as _; +//! use hyper::body::{Bytes, Incoming}; +//! +//! /// Consider using `Limited` if the body is untrusted. +//! async fn read_entire_body(body: Incoming) -> Result { +//! Ok(body.collect().await?.to_bytes()) +//! } +//! ``` +//! +//! Collecting buffers the whole body, so it should be avoided for large or +//! untrusted bodies unless their size is limited. +//! //! [`http-body-util`]: https://docs.rs/http-body-util +//! [`BodyExt`]: https://docs.rs/http-body-util/latest/http_body_util/trait.BodyExt.html pub use bytes::{Buf, Bytes}; pub use http_body::Body; From ac769247eb16eaadc1e48e4f6f9beded8427d796 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 11 Aug 2026 03:16:47 -0400 Subject: [PATCH 091/128] fix(http1): detect TE: trailers caselessly and with other values (#4152) --- src/headers.rs | 52 +++++++++++++++++++++++++++++++ src/proto/h1/conn.rs | 8 ++--- tests/server.rs | 73 ++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 127 insertions(+), 6 deletions(-) diff --git a/src/headers.rs b/src/headers.rs index 89b70fd45e..caace71f2a 100644 --- a/src/headers.rs +++ b/src/headers.rs @@ -42,6 +42,26 @@ fn connection_has(value: &HeaderValue, needle: &str) -> bool { false } +#[cfg(feature = "http1")] +pub(super) fn te_is_trailers(headers: &http::HeaderMap) -> bool { + header_value_list_has(headers.get_all(http::header::TE).into_iter(), "trailers") +} + +#[cfg(feature = "http1")] +fn header_value_list_has(values: http::header::ValueIter<'_, HeaderValue>, needle: &str) -> bool { + for value in values { + if let Ok(line) = value.to_str() { + for token in line.split(',') { + if token.trim().eq_ignore_ascii_case(needle) { + return true; + } + } + } + } + + false +} + #[cfg(all(feature = "http1", feature = "server"))] pub(super) fn content_length_parse(value: &HeaderValue) -> Option { from_digits(value.as_bytes()) @@ -166,3 +186,35 @@ pub(super) fn add_chunked(mut entry: http::header::OccupiedEntry<'_, HeaderValue entry.insert(HeaderValue::from_static(CHUNKED)); } + +#[cfg(all(test, feature = "http1"))] +mod tests { + use super::te_is_trailers; + use http::header::{HeaderValue, TE}; + use http::HeaderMap; + + #[test] + fn te_is_trailers_accepts_comma_separated_values() { + let mut headers = HeaderMap::new(); + headers.insert(TE, HeaderValue::from_static("gzip, Trailers")); + + assert!(te_is_trailers(&headers)); + } + + #[test] + fn te_is_trailers_accepts_multiple_header_lines() { + let mut headers = HeaderMap::new(); + headers.append(TE, HeaderValue::from_static("gzip")); + headers.append(TE, HeaderValue::from_static("trailers")); + + assert!(te_is_trailers(&headers)); + } + + #[test] + fn te_is_trailers_rejects_missing_trailers_token() { + let mut headers = HeaderMap::new(); + headers.insert(TE, HeaderValue::from_static("gzip")); + + assert!(!te_is_trailers(&headers)); + } +} diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index a2d2670e6b..19c1283df7 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -11,7 +11,7 @@ use std::time::Duration; use crate::rt::{Read, Write}; use bytes::{Buf, Bytes}; use futures_core::ready; -use http::header::{HeaderValue, CONNECTION, TE}; +use http::header::{HeaderValue, CONNECTION}; use http::{HeaderMap, Method, Version}; use http_body::Frame; use httparse::ParserConfig; @@ -325,11 +325,7 @@ where )); } - self.state.allow_trailer_fields = msg - .head - .headers - .get(TE) - .map_or(false, |te_header| te_header == "trailers"); + self.state.allow_trailer_fields = headers::te_is_trailers(&msg.head.headers); Poll::Ready(Some(Ok((msg.head, msg.decode, wants)))) } diff --git a/tests/server.rs b/tests/server.rs index 855840b388..1843610e88 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -3335,6 +3335,79 @@ fn http1_trailer_fields_not_allowed() { assert_eq!(body, expected_body); } +#[test] +fn http1_trailer_fields_allowed_with_comma_separated_te() { + let body = futures_util::stream::once(async move { Ok("hello".into()) }); + let mut headers = HeaderMap::new(); + headers.insert("chunky-trailer", "header data".parse().unwrap()); + + let server = serve(); + server + .reply() + .header("transfer-encoding", "chunked") + .header("trailer", "chunky-trailer") + .body_stream_with_trailers(body, headers); + let mut req = connect(server.addr()); + req.write_all( + b"\ + GET / HTTP/1.1\r\n\ + Host: example.domain\r\n\ + Connection: keep-alive\r\n\ + TE: gzip, Trailers\r\n\ + \r\n\ + ", + ) + .expect("writing"); + + let chunky_trailer_chunk = b"\r\nchunky-trailer: header data\r\n\r\n"; + let res = read_until(&mut req, |buf| buf.ends_with(chunky_trailer_chunk)).expect("reading"); + let sres = s(&res); + + let date_fragment = "GMT\r\n\r\n"; + let pos = sres.find(date_fragment).expect("find GMT"); + let body = &sres[pos + date_fragment.len()..]; + + let expected_body = "5\r\nhello\r\n0\r\nchunky-trailer: header data\r\n\r\n"; + assert_eq!(body, expected_body); +} + +#[test] +fn http1_trailer_fields_allowed_with_multiple_te_headers() { + let body = futures_util::stream::once(async move { Ok("hello".into()) }); + let mut headers = HeaderMap::new(); + headers.insert("chunky-trailer", "header data".parse().unwrap()); + + let server = serve(); + server + .reply() + .header("transfer-encoding", "chunked") + .header("trailer", "chunky-trailer") + .body_stream_with_trailers(body, headers); + let mut req = connect(server.addr()); + req.write_all( + b"\ + GET / HTTP/1.1\r\n\ + Host: example.domain\r\n\ + Connection: keep-alive\r\n\ + TE: gzip\r\n\ + TE: trailers\r\n\ + \r\n\ + ", + ) + .expect("writing"); + + let chunky_trailer_chunk = b"\r\nchunky-trailer: header data\r\n\r\n"; + let res = read_until(&mut req, |buf| buf.ends_with(chunky_trailer_chunk)).expect("reading"); + let sres = s(&res); + + let date_fragment = "GMT\r\n\r\n"; + let pos = sres.find(date_fragment).expect("find GMT"); + let body = &sres[pos + date_fragment.len()..]; + + let expected_body = "5\r\nhello\r\n0\r\nchunky-trailer: header data\r\n\r\n"; + assert_eq!(body, expected_body); +} + #[test] fn http1_trailer_recv_fields() { let server = serve(); From c739026fd54236dd8ace5aeb278f6ed77329f8c5 Mon Sep 17 00:00:00 2001 From: Murilo Silva Date: Wed, 12 Aug 2026 12:24:55 -0300 Subject: [PATCH 092/128] style(lib): fix missing_errors_doc lint (#4154) Refs: #4071 --- Cargo.toml | 1 - src/client/conn/http1.rs | 21 ++++++++++++++++++++- src/client/conn/http2.rs | 17 ++++++++++++++++- src/upgrade.rs | 6 ++++-- 4 files changed, 40 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index fa0697abeb..ffe6ff9d6b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -134,7 +134,6 @@ map_err_ignore = "allow" map_unwrap_or = "allow" match_wild_err_arm = "allow" missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive -missing_errors_doc = "allow" # TODO: good to fix missing_panics_doc = "allow" # TODO: might be false multiple_inherent_impl = "allow" multiple_unsafe_ops_per_block = "allow" diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index ec64543c23..7468507623 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -97,6 +97,10 @@ where /// Prevent shutdown of the underlying IO object at the end of service the request, /// instead run `into_parts`. This is a convenience wrapper over `poll_without_shutdown`. + /// + /// # Errors + /// + /// Returns an error if the connection encounters an error while being polled to completion. pub async fn without_shutdown(self) -> crate::Result> { let mut conn = Some(self); crate::common::future::poll_fn(move |cx| -> Poll>> { @@ -137,6 +141,10 @@ pub struct Builder { /// /// This is a shortcut for `Builder::new().handshake(io)`. /// See [`client::conn`](crate::client::conn) for more. +/// +/// # Errors +/// +/// Returns an error if the HTTP/1 connection handshake fails. pub async fn handshake(io: T) -> crate::Result<(SendRequest, Connection)> where T: Read + Write + Unpin, @@ -159,6 +167,8 @@ impl SendRequest { /// Waits until the dispatcher is ready. /// + /// # Errors + /// /// If the associated connection is closed, this returns an Error. pub async fn ready(&mut self) -> crate::Result<()> { crate::common::future::poll_fn(|cx| self.poll_ready(cx)).await @@ -210,6 +220,11 @@ where /// hyper closes the underlying connection when a request future is /// dropped before completion. Any subsequent calls on the same /// [`SendRequest`] will return a `canceled` error. + /// + /// # Errors + /// + /// Returns an error if the connection is not ready or if an error occurs while + /// processing the request. pub fn send_request( &mut self, req: Request, @@ -236,7 +251,7 @@ where /// /// Returns a future that if successful, yields the `Response`. /// - /// # Error + /// # Errors /// /// If there was an error before trying to serialize the request to the /// connection, the message will be returned as part of this error. @@ -533,6 +548,10 @@ impl Builder { /// /// Note, if [`Connection`] is not `await`-ed, [`SendRequest`] will /// do nothing. + /// + /// # Errors + /// + /// Returns an error if the HTTP/1connection handshake fails. pub fn handshake( &self, io: T, diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index ec046e0a39..d1dfcfd416 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -74,6 +74,10 @@ pub struct Builder { /// /// This is a shortcut for `Builder::new(exec).handshake(io)`. /// See [`client::conn`](crate::client::conn) for more. +/// +/// # Errors +/// +/// Returns an error if the HTTP/2 connection handshake fails. pub async fn handshake( exec: E, io: T, @@ -104,6 +108,8 @@ impl SendRequest { /// Waits until the dispatcher is ready. /// + /// # Errors + /// /// If the associated connection is closed, this returns an Error. pub async fn ready(&mut self) -> crate::Result<()> { crate::common::future::poll_fn(|cx| self.poll_ready(cx)).await @@ -147,6 +153,11 @@ where /// other in-flight and future requests. The peer is notified /// immediately rather than continuing to send a response body that /// would be discarded. + /// + /// # Errors + /// + /// Returns an error if the connection is not ready or if an error occurs while + /// processing the request. pub fn send_request( &mut self, req: Request, @@ -174,7 +185,7 @@ where /// /// Returns a future that if successful, yields the `Response`. /// - /// # Error + /// # Errors /// /// If there was an error before trying to serialize the request to the /// connection, the message will be returned as part of this error. @@ -547,6 +558,10 @@ where /// /// Note, if [`Connection`] is not `await`-ed, [`SendRequest`] will /// do nothing. + /// + /// # Errors + /// + /// Returns an error if the HTTP/2 connection handshake fails. pub fn handshake( &self, io: T, diff --git a/src/upgrade.rs b/src/upgrade.rs index df1b9e6847..bb350e78ed 100644 --- a/src/upgrade.rs +++ b/src/upgrade.rs @@ -147,8 +147,10 @@ impl Upgraded { /// Tries to downcast the internal trait object to the type passed. /// - /// On success, returns the downcasted parts. On error, returns the - /// `Upgraded` back. + /// On success, returns the downcasted parts. + /// + /// # Errors + /// On error, returns the `Upgraded` back. pub fn downcast(self) -> Result, Self> { let (io, buf) = self.io.into_inner(); match io.__hyper_downcast() { From e0651460f5a6875398010a17d53adbae58d94013 Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Sat, 15 Aug 2026 13:53:09 +0200 Subject: [PATCH 093/128] style(lib): fix needless_continue lint (#4157) --- Cargo.toml | 1 - src/proto/h1/dispatch.rs | 1 - src/proto/h2/client.rs | 1 - src/proto/h2/upgrade.rs | 4 +--- 4 files changed, 1 insertion(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index ffe6ff9d6b..16033c7904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -137,7 +137,6 @@ missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive missing_panics_doc = "allow" # TODO: might be false multiple_inherent_impl = "allow" multiple_unsafe_ops_per_block = "allow" -needless_continue = "allow" needless_pass_by_value = "allow" panic = "allow" pattern_type_mismatch = "allow" diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index 03aaba1a9e..f250f55863 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -430,7 +430,6 @@ where ); } else { trace!("discarding unknown frame"); - continue; } } else { *clear_body = true; diff --git a/src/proto/h2/client.rs b/src/proto/h2/client.rs index 4803dba0f1..eb768baa76 100644 --- a/src/proto/h2/client.rs +++ b/src/proto/h2/client.rs @@ -772,7 +772,6 @@ where } } self.poll_pipe(f, cx); - continue; } Poll::Ready(None) => { diff --git a/src/proto/h2/upgrade.rs b/src/proto/h2/upgrade.rs index f4e65466a2..217aa27e88 100644 --- a/src/proto/h2/upgrade.rs +++ b/src/proto/h2/upgrade.rs @@ -236,9 +236,7 @@ impl Read for H2Upgraded { self.buf = loop { match ready!(self.recv_stream.poll_data(cx)) { None => return Poll::Ready(Ok(())), - Some(Ok(buf)) if buf.is_empty() && !self.recv_stream.is_end_stream() => { - continue - } + Some(Ok(buf)) if buf.is_empty() && !self.recv_stream.is_end_stream() => {} Some(Ok(buf)) => { self.ping.record_data(buf.len()); break buf; From 16f2f0318a9296f43b6878121c96cd7321179a11 Mon Sep 17 00:00:00 2001 From: MsfPablo <129399053+MsfPablo@users.noreply.github.com> Date: Mon, 17 Aug 2026 21:46:48 +0200 Subject: [PATCH 094/128] docs(lib): expand crate-level cancel safety section with HTTP/1 vs HTTP/2 (#4156) Closes #4054 --- src/lib.rs | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 7be9926076..8d6e2d2d5a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -33,10 +33,23 @@ //! # Cancel safety //! //! Futures returned by hyper are cancel safe: dropping a future before it -//! completes is the supported way to cancel the operation. See the -//! documentation on individual futures — for example `SendRequest::send_request` -//! in `client::conn::http1` and `client::conn::http2` — for the protocol- -//! specific behavior on cancellation. +//! completes is the supported way to cancel the operation. The protocol in +//! use changes what that cancellation actually does on the wire: +//! +//! - **HTTP/1** has no in-protocol way to abort a single request without +//! affecting the shared connection, so dropping an in-flight request future +//! closes the underlying TCP connection. Any subsequent call on the same +//! `SendRequest` returns a `canceled` error; the connection cannot be +//! reused. +//! - **HTTP/2** resets the single stream with `RST_STREAM` (`CANCEL` error +//! code) and notifies the peer immediately rather than continuing to +//! deliver a response body that would be discarded. The shared connection +//! stays usable for other in-flight and future requests. +//! +//! See the documentation on individual futures — for example +//! `SendRequest::send_request` in `client::conn::http1` and the equivalent +//! in `client::conn::http2` — for the protocol-specific behavior on +//! cancellation. //! //! # Optional Features //! From 5521b8f9f759bd9d5ee11e2d996612b3f7df83a0 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 24 Aug 2026 08:09:52 -0400 Subject: [PATCH 095/128] style(lib): temporarily allow some new clippy lints (#4165) --- Cargo.toml | 3 +++ src/body/incoming.rs | 1 + src/client/conn/http1.rs | 1 + src/client/conn/http2.rs | 1 + 4 files changed, 6 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index 16033c7904..a5a8a31876 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,9 +142,11 @@ panic = "allow" pattern_type_mismatch = "allow" redundant_closure_for_method_calls = "allow" redundant_else = "allow" +rest_pattern_accessible_field = "allow" # TODO: consider, might catch bugs single_char_lifetime_names = "allow" struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" +unnecessary_rest_pattern = "allow" # TODO: simple fix unnecessary_trailing_comma = "allow" unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? @@ -161,6 +163,7 @@ clone_on_ref_ptr = "allow" # Arc::clone(blah) is needlessly noisy cognitive_complexity = "allow" # is this ever useful? decimal_literal_representation = "allow" # arguably more human-readable default_numeric_fallback = "allow" # too many false positives +definition_in_module_root = "allow" # this is normal expect_used = "allow" # expect is self-documenting error_impl_error = "allow" # mod::Error is a fine name field_scoped_visibility_modifiers = "allow" # possibly good idea, noisy for now diff --git a/src/body/incoming.rs b/src/body/incoming.rs index 93c99a01a6..6ef6c0963a 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -390,6 +390,7 @@ impl Sender { /// Send trailers on trailers channel. #[allow(unused)] + #[allow(clippy::unused_async_trait_impl)] pub(crate) async fn send_trailers(&mut self, trailers: HeaderMap) -> crate::Result<()> { let tx = match self.trailers_tx.take() { Some(tx) => tx, diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 7468507623..bdb03171d4 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -255,6 +255,7 @@ where /// /// If there was an error before trying to serialize the request to the /// connection, the message will be returned as part of this error. + #[allow(clippy::result_large_err)] pub fn try_send_request( &mut self, req: Request, diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index d1dfcfd416..10613b354a 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -189,6 +189,7 @@ where /// /// If there was an error before trying to serialize the request to the /// connection, the message will be returned as part of this error. + #[allow(clippy::result_large_err)] pub fn try_send_request( &mut self, req: Request, From 26bc2323d654ce7a1884be6aca754787150cedaa Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 25 Aug 2026 14:35:05 -0400 Subject: [PATCH 096/128] docs(contrib): add initial COLLABORATORS guide (#4158) --- docs/COLLABORATORS.md | 68 +++++++++++++++++++++++++++++++++++++++++++ docs/GOVERNANCE.md | 1 + 2 files changed, 69 insertions(+) create mode 100644 docs/COLLABORATORS.md diff --git a/docs/COLLABORATORS.md b/docs/COLLABORATORS.md new file mode 100644 index 0000000000..37c590a7ec --- /dev/null +++ b/docs/COLLABORATORS.md @@ -0,0 +1,68 @@ +# hyper Collaborator Guide + +This document describes how collaborators manage hyper. + +- First off, a collaborator doesn't need to do all of the following perfectly. + - It takes time to learn. + - Instead, focus on growth. + - Get in the habit of checking this guide with some frequency, and improve rather than stagnate. + +## Exemplify the Code of Conduct + +- Our [code of conduct](./CODE_OF_CONDUCT.md) is not elaborate. It doesn't require much study, nor explication. +- To be a good collaborator, it does require internalizing it. +- The code of conduct is proactive, positive. +- A collaborator must be a good example of being kind. + - It should not be possible to find significant fault in a collaborator against the simple principles in the code of conduct. + +## Internalize the Vision + +- Understand and use the project's [vision](./VISION.md) when helping guide decisions. + +## Review Pull Requests + +- Review pull requests promptly and kindly. Approve CI for new contributors. + - Consider telling a maintainer to add you to the automated review queue. +- Collaborators should feel comfortable approving and merging straightforward changes. Such as documentation, chores, styling, simple refactors, and basic bug fixes. +- When reviewing larger changes, keep in mind the points on [being API caretakers](#api-caretakers). +- When approving a PR, allow time for other collaborators to look before merging. + - Especially if the PR is newer, or has a more significant change. +- When merging, prefer squashing (it adds in the PR number). Touch up the commit message to match our [COMMITS](./COMMITS.md) style. + +## Provide Feedback on Features and Designs + +Collaborators try to participate early and often when new features are proposed. They also actively consider and recommend new features that users may need. + +### API Caretakers + +Collaborators are caretakers of hyper's API. When proposing, discussing, or reviewing changes to hyper, keep the following in mind: + +- Conservative additions + - Conservative APIs reduce breaking changes +- Don't expose internal implementation details + - [Accessors can reveal internal repr](https://seanmonstar.com/micro/20260721-accessors-reveal-internal-repr/) +- hyper-util is a place to explore first + - [Vision: Not quite stable, but utile](./VISION.md#not-quite-stable-but-utile-useful) + - [Roadmap: hyper-util](./ROADMAP.md#hyper-util) +- Unstable features + - Unstable features are not bound by our stability promise. + - We don't just name a crate feature with unstable. + - This has the problem of an intermediary crate enabling it, and someone depending on _that_ crate and not realizing they are accessing unstable hyper features. + - All unstable features require a conditional config flag passed to the Rust compiler. + - For example, `hyper_unstable_ffi`. +- Breaking changes + - These would usually be reserved for a new SemVer major release, such as v2.0, and thus would usually be very rare. + - An exception is fixing a mistake quickly after release of a new feature. + - All breaking changes require approval by a maintainer. + +## Welcome New Contributors + +- Help new contributors find their way around the project and its processes. Follow the guidance on [acknowledging people when triaging](./ISSUES.md#acknowledge). + +## Answer Questions + +- Answer questions in issues or chat when you can, or help find someone who can. See the project's [help channels](../CONTRIBUTING.md#help). + +## Mentor Contributors + +- Help contributors grow toward becoming project members. Issue triaging often provides opportunities for [mentoring](./ISSUES.md#mentoring), but the ideas can be applied to reviews, or when answering questions. diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index f4366dba8a..7a1becf07c 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -71,6 +71,7 @@ How to become: ### Collaborator Collaborators are contributors who have been helping out in a consistent basis. +See the [collaborator guide](./COLLABORATORS.md) for practical guidance on the role. Responsibilities: From edc3f9c1ec88c6c3b9487e32382960e336920e11 Mon Sep 17 00:00:00 2001 From: tangaac Date: Wed, 26 Aug 2026 20:22:52 +0800 Subject: [PATCH 097/128] style(lib): remove lints that have been removed in newer clippy (#4167) The following lints no longer exist in recent clippy versions and cause `unknown lint` errors (promoted to errors by `-D warnings` / `-W unknown-lints`): - clippy::unnecessary_rest_pattern - clippy::rest_pattern_accessible_field - clippy::definition_in_module_root Remove them from the allowed lints list in Cargo.toml. --- Cargo.toml | 3 --- 1 file changed, 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index a5a8a31876..16033c7904 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,11 +142,9 @@ panic = "allow" pattern_type_mismatch = "allow" redundant_closure_for_method_calls = "allow" redundant_else = "allow" -rest_pattern_accessible_field = "allow" # TODO: consider, might catch bugs single_char_lifetime_names = "allow" struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" -unnecessary_rest_pattern = "allow" # TODO: simple fix unnecessary_trailing_comma = "allow" unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? @@ -163,7 +161,6 @@ clone_on_ref_ptr = "allow" # Arc::clone(blah) is needlessly noisy cognitive_complexity = "allow" # is this ever useful? decimal_literal_representation = "allow" # arguably more human-readable default_numeric_fallback = "allow" # too many false positives -definition_in_module_root = "allow" # this is normal expect_used = "allow" # expect is self-documenting error_impl_error = "allow" # mod::Error is a fine name field_scoped_visibility_modifiers = "allow" # possibly good idea, noisy for now From 7ce843b6b68761c575fb1eb480c6a7a11dfe3f8c Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Thu, 27 Aug 2026 12:07:16 -0400 Subject: [PATCH 098/128] v1.11.1 --- CHANGELOG.md | 12 ++++++++++++ Cargo.toml | 2 +- 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index b58e22bbf7..309188a22f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,15 @@ +### v1.11.1 (2026-08-27) + + +#### Bug Fixes + +* **http1:** + * detect TE: trailers caselessly and with other values (#4152) ([72fcf545](https://github.com/hyperium/hyper/commit/72fcf545c9b4a261f31d89bafd3019a2d4e9e1d7)) + * recognize `\n\r\n` as a head terminator in the partial-read fast path (#4147) ([3534d75c](https://github.com/hyperium/hyper/commit/3534d75c898da17b023e6bff9b4cf71afed123ad), closes [#4145](https://github.com/hyperium/hyper/issues/4145)) + * flush bytes buffered by the write re-check before yielding (#4143) ([f660f5bf](https://github.com/hyperium/hyper/commit/f660f5bf6eed3fe793f899507ff5bb9e266d4b0d)) + * evict pooled conn on request-side Connection: close (#4110) ([da19be08](https://github.com/hyperium/hyper/commit/da19be08f34d3da01c4c0f9cfe2837e7d377f73d)) + + ## v1.11.0 (2026-07-20) diff --git a/Cargo.toml b/Cargo.toml index 16033c7904..17d7217d8d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.11.0" +version = "1.11.1" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From 4b45560c92a58447f1674e1866812cc157ff5fba Mon Sep 17 00:00:00 2001 From: Vaibhav Srivastava Date: Sat, 29 Aug 2026 19:57:19 +0530 Subject: [PATCH 099/128] docs(security): fix typo (#4177) --- SECURITY.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index aa5ac773bf..056e82a20d 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -1,6 +1,6 @@ # Security Policy -hyper (and related projects in hyperium) take security seriously, and greatly appreciate responsibile disclosure. +hyper (and related projects in hyperium) take security seriously, and greatly appreciate responsible disclosure. ## Report a security issue From 7f04adb717a9b36406be76463a90dffcf1ce2cf6 Mon Sep 17 00:00:00 2001 From: dox4 <61588545+dox4@users.noreply.github.com> Date: Sun, 30 Aug 2026 00:12:04 +0800 Subject: [PATCH 100/128] style(lib): replace single-character lifetime names with descriptive names (#4173) --- Cargo.toml | 1 - src/client/dispatch.rs | 2 +- src/common/buf.rs | 2 +- src/ext/informational.rs | 2 +- src/ext/mod.rs | 10 +++++----- src/ffi/task.rs | 6 ++++-- src/proto/h1/decode.rs | 12 ++++++------ src/proto/h1/dispatch.rs | 6 +++--- src/proto/h1/encode.rs | 2 +- src/proto/h1/io.rs | 2 +- src/proto/h1/mod.rs | 14 +++++++------- src/proto/h1/role.rs | 2 +- src/proto/h2/mod.rs | 2 +- src/rt/io.rs | 8 ++++---- tests/server.rs | 4 ++-- 15 files changed, 38 insertions(+), 37 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 17d7217d8d..af1dc2d259 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -142,7 +142,6 @@ panic = "allow" pattern_type_mismatch = "allow" redundant_closure_for_method_calls = "allow" redundant_else = "allow" -single_char_lifetime_names = "allow" struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" unnecessary_trailing_comma = "allow" diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 69c9b8cdaa..a9375f627a 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -408,7 +408,7 @@ mod tests { } /// Helper to check if the future is ready after polling once. - struct PollOnce<'a, F>(&'a mut F); + struct PollOnce<'fut, F>(&'fut mut F); impl Future for PollOnce<'_, F> where diff --git a/src/common/buf.rs b/src/common/buf.rs index d00071551b..0bf02497f4 100644 --- a/src/common/buf.rs +++ b/src/common/buf.rs @@ -56,7 +56,7 @@ impl Buf for BufList { } #[inline] - fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize { + fn chunks_vectored<'data>(&'data self, dst: &mut [IoSlice<'data>]) -> usize { if dst.is_empty() { return 0; } diff --git a/src/ext/informational.rs b/src/ext/informational.rs index e728580fa5..6c86b4974e 100644 --- a/src/ext/informational.rs +++ b/src/ext/informational.rs @@ -66,7 +66,7 @@ where // being either a real reference, or moving the http::Response into the closure, // in a backwards-compatible change in the future. #[derive(Debug)] -pub struct Response<'a>(&'a http::Response<()>); +pub struct Response<'resp>(&'resp http::Response<()>); impl Response<'_> { #[inline] diff --git a/src/ext/mod.rs b/src/ext/mod.rs index 0fccb5ffec..82f7cd7d07 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -113,8 +113,8 @@ impl Protocol { } #[cfg(feature = "http2")] -impl<'a> From<&'a str> for Protocol { - fn from(value: &'a str) -> Self { +impl<'proto> From<&'proto str> for Protocol { + fn from(value: &'proto str) -> Self { Self { inner: h2::ext::Protocol::from(value), } @@ -165,10 +165,10 @@ impl HeaderCaseMap { /// Returns a view of all spellings associated with that header name, /// in the order they were found. #[cfg(feature = "client")] - pub(crate) fn get_all<'a>( - &'a self, + pub(crate) fn get_all<'hdr>( + &'hdr self, name: &HeaderName, - ) -> impl Iterator + 'a> + 'a { + ) -> impl Iterator + 'hdr> + 'hdr { self.get_all_internal(name) } diff --git a/src/ffi/task.rs b/src/ffi/task.rs index 9c7fba6a26..b02de525ac 100644 --- a/src/ffi/task.rs +++ b/src/ffi/task.rs @@ -128,7 +128,7 @@ struct TaskFuture { /// its only purpose is to provide access to the waker. See `hyper_waker`. /// /// Corresponding Rust type: -pub struct hyper_context<'a>(Context<'a>); +pub struct hyper_context<'ctx>(Context<'ctx>); /// A waker that is saved and used to waken a pending task. /// @@ -511,7 +511,9 @@ where // ===== impl hyper_context ===== impl hyper_context<'_> { - pub(crate) fn wrap<'a, 'b>(cx: &'a mut Context<'b>) -> &'a mut hyper_context<'b> { + pub(crate) fn wrap<'borrow, 'ctx>( + cx: &'borrow mut Context<'ctx>, + ) -> &'borrow mut hyper_context<'ctx> { // A struct with only one field has the same layout as that field. unsafe { std::mem::transmute::<&mut Context<'_>, &mut hyper_context<'_>>(cx) } } diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index bdfdf79abf..65adaa784b 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -286,12 +286,12 @@ macro_rules! put_u8 { }; } -struct StepArgs<'a> { - chunk_size: &'a mut u64, - chunk_buf: &'a mut Option, - extensions_cnt: &'a mut u64, - trailers_buf: &'a mut Option, - trailers_cnt: &'a mut usize, +struct StepArgs<'args> { + chunk_size: &'args mut u64, + chunk_buf: &'args mut Option, + extensions_cnt: &'args mut u64, + trailers_buf: &'args mut Option, + trailers_cnt: &'args mut usize, max_headers_cnt: usize, max_headers_bytes: usize, } diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index f250f55863..d98aebcf1f 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -509,10 +509,10 @@ where /// A drop guard to allow a mutable borrow of an Option while being able to /// set whether the `Option` should be cleared on drop. -struct OptGuard<'a, T>(Pin<&'a mut Option>, bool); +struct OptGuard<'opt, T>(Pin<&'opt mut Option>, bool); -impl<'a, T> OptGuard<'a, T> { - fn new(pin: Pin<&'a mut Option>) -> Self { +impl<'opt, T> OptGuard<'opt, T> { + fn new(pin: Pin<&'opt mut Option>) -> Self { OptGuard(pin, false) } diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index a1eebafa12..266b4dd6dd 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -317,7 +317,7 @@ where } #[inline] - fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize { + fn chunks_vectored<'data>(&'data self, dst: &mut [IoSlice<'data>]) -> usize { match &self.kind { BufKind::Exact(b) => b.chunks_vectored(dst), BufKind::Limited(b) => b.chunks_vectored(dst), diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 6b3f10fbae..b49e48e5c8 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -637,7 +637,7 @@ impl Buf for WriteBuf { } #[inline] - fn chunks_vectored<'t>(&'t self, dst: &mut [IoSlice<'t>]) -> usize { + fn chunks_vectored<'data>(&'data self, dst: &mut [IoSlice<'data>]) -> usize { let n = self.headers.chunks_vectored(dst); self.queue.chunks_vectored(&mut dst[n..]) + n } diff --git a/src/proto/h1/mod.rs b/src/proto/h1/mod.rs index 0a442c4c7e..a17dbae83c 100644 --- a/src/proto/h1/mod.rs +++ b/src/proto/h1/mod.rs @@ -68,9 +68,9 @@ pub(crate) struct ParsedMessage { wants_upgrade: bool, } -pub(crate) struct ParseContext<'a> { - cached_headers: &'a mut Option, - req_method: &'a mut Option, +pub(crate) struct ParseContext<'ctx> { + cached_headers: &'ctx mut Option, + req_method: &'ctx mut Option, h1_parser_config: ParserConfig, h1_max_headers: Option, preserve_header_case: bool, @@ -78,16 +78,16 @@ pub(crate) struct ParseContext<'a> { preserve_header_order: bool, h09_responses: bool, #[cfg(feature = "client")] - on_informational: &'a mut Option, + on_informational: &'ctx mut Option, } /// Passed to `Http1Transaction::encode`. -pub(crate) struct Encode<'a, T> { - head: &'a mut MessageHead, +pub(crate) struct Encode<'encode, T> { + head: &'encode mut MessageHead, body: Option, #[cfg(feature = "server")] keep_alive: bool, - req_method: &'a mut Option, + req_method: &'encode mut Option, title_case_headers: bool, #[cfg(feature = "server")] date_header: bool, diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 29bcd44b0d..d083d2a912 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -1652,7 +1652,7 @@ fn write_headers_original_case( } #[cfg(feature = "client")] -struct FastWrite<'a>(&'a mut Vec); +struct FastWrite<'data>(&'data mut Vec); #[cfg(feature = "client")] impl fmt::Write for FastWrite<'_> { diff --git a/src/proto/h2/mod.rs b/src/proto/h2/mod.rs index f0db9b64eb..393d4179ab 100644 --- a/src/proto/h2/mod.rs +++ b/src/proto/h2/mod.rs @@ -319,7 +319,7 @@ impl Buf for SendBuf { } } - fn chunks_vectored<'a>(&'a self, dst: &mut [IoSlice<'a>]) -> usize { + fn chunks_vectored<'data>(&'data self, dst: &mut [IoSlice<'data>]) -> usize { match self { Self::Buf(b) => b.chunks_vectored(dst), Self::Cursor(c) => c.chunks_vectored(dst), diff --git a/src/rt/io.rs b/src/rt/io.rs index 447774c6c0..9e633e69e8 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -164,8 +164,8 @@ pub trait Write { /// It is undefined behavior to de-initialize any bytes from the uninitialized /// region, since it is merely unknown whether this region is uninitialized or /// not, and if part of it turns out to be initialized, it must stay initialized. -pub struct ReadBuf<'a> { - raw: &'a mut [MaybeUninit], +pub struct ReadBuf<'data> { + raw: &'data mut [MaybeUninit], filled: usize, init: usize, } @@ -227,8 +227,8 @@ pub struct ReadBuf<'a> { /// assert_eq!(read_buf.filled(), b"hello"); /// ``` #[derive(Debug)] -pub struct ReadBufCursor<'a> { - buf: &'a mut ReadBuf<'a>, +pub struct ReadBufCursor<'buf> { + buf: &'buf mut ReadBuf<'buf>, } impl<'data> ReadBuf<'data> { diff --git a/tests/server.rs b/tests/server.rs index 1843610e88..098ed7a6ee 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -3542,8 +3542,8 @@ impl Serve { type BoxError = Box; type BoxFuture = Pin, BoxError>> + Send>>; -struct ReplyBuilder<'a> { - tx: &'a Mutex>, +struct ReplyBuilder<'mtx> { + tx: &'mtx Mutex>, } impl ReplyBuilder<'_> { From 07745040d2ff56a676515a83877c9639dcc50177 Mon Sep 17 00:00:00 2001 From: Nakaryo <128471892+nakaryo716@users.noreply.github.com> Date: Mon, 31 Aug 2026 18:29:17 +0900 Subject: [PATCH 101/128] style(lib): resolve collapsible_match lint (#4178) --- Cargo.toml | 1 - src/proto/h1/conn.rs | 8 +++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index af1dc2d259..1fd9616fda 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -121,7 +121,6 @@ as_conversions = "allow" # TODO: tricky cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" -collapsible_match = "allow" else_if_without_else = "allow" enum_glob_use = "allow" float_arithmetic = "allow" diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 19c1283df7..593d3d9eda 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -676,11 +676,9 @@ where Version::HTTP_10 => self.state.disable_keep_alive(), // If response is version 1.1 and keep-alive is wanted, add // Connection: keep-alive header when not present - Version::HTTP_11 => { - if self.state.wants_keep_alive() { - head.headers - .insert(CONNECTION, HeaderValue::from_static("keep-alive")); - } + Version::HTTP_11 if self.state.wants_keep_alive() => { + head.headers + .insert(CONNECTION, HeaderValue::from_static("keep-alive")); } _ => (), } From b50ffd014f986fc53c2354ef0547806dea26ca2c Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 1 Sep 2026 16:47:00 -0400 Subject: [PATCH 102/128] docs(hips): hyper improvement proposals (#4131) --- docs/hips/0000-template.md | 41 ++++++ docs/hips/0001-hyper-improvement-proposals.md | 130 ++++++++++++++++++ 2 files changed, 171 insertions(+) create mode 100644 docs/hips/0000-template.md create mode 100644 docs/hips/0001-hyper-improvement-proposals.md diff --git a/docs/hips/0000-template.md b/docs/hips/0000-template.md new file mode 100644 index 0000000000..88c469754d --- /dev/null +++ b/docs/hips/0000-template.md @@ -0,0 +1,41 @@ +# HIP-0000: Template + +- Authors: +- Created: (date) +- PR: (link to pull request) + +## Summary + +(The tl;dr. Concise statement of the reason for the document and recommendation.) + +## Tenets + +(List of tenets, values, guiding principles that are used to evaluate the proposal.) + +## Motivation + +(Clearly explain the problem.) + +## Recommendation + +### User Experience + +### Implementation + +### Testing Plan + +(Optional: are there any special considerations for testing required?) + +### Security Considerations + +(What security risks might exist for the recommendation? Can we reduce them? What should users consider when using it?) + +## Alternatives + +(List alternatives considered, and especially why they were not recommended. This portion of "documenting the why" helps Future Us to see if anything has changed since last considered.) + +## Unresolved Questions + + +## References + diff --git a/docs/hips/0001-hyper-improvement-proposals.md b/docs/hips/0001-hyper-improvement-proposals.md new file mode 100644 index 0000000000..03a4bfc42f --- /dev/null +++ b/docs/hips/0001-hyper-improvement-proposals.md @@ -0,0 +1,130 @@ +# HIP-0001: hyper Improvement Proposals + +- Authors: seanmonstar +- Created: 2026-09-01 +- PR: https://github.com/hyperium/hyper/pull/4131 + +## Summary + +HIPs establish a predictable, written‑first process for proposing and evaluating significant changes to hyper. They provide an authoritative place to articulate the problem, the design rationale, and the tradeoffs so contributors can make informed, durable decisions. + +## Tenets + +- Open: the decision-making process should be out in the open, for all to see and any to participate. +- Understandable: Code and changelogs are not the complete theory. Others should be able to understand what we meant. +- Writing is thinking: while the written artifact is extremely valuable, the process of creating is thinking itself. It refines our thought, sharpens it, makes it better. +- Document the why: just as important is understanding why we thought that way. This allows evaluating when the decision should be reconsidered. + +## Motivation + +hyper's existing contribution process does not adequately support proposals for large features and design changes. + +Much of the shape of hyper can be seen in the [VISION](../VISION.md) and [1.0 ROADMAP](../ROADMAP-1.0.md). Adding new small features is very easy. However, designing large new systems in hyper must be handled with care. hyper is deployed at massive scale. The stability promise requires we be deliberate with what API to expose. + +The current process is to ask people to write up larger designs in an issue. GitHub issues are terrible places to discuss multiple points at once. Or people submit massive pull requests, and the discussion is sprinkled through the code reviews. + +It's extremely difficult for reviewers to be sure they understand the design, and to ensure that all critical aspects have been considered. If an implementation is denied for a desirable feature, new pull requests tend to be submitted which don't address the previous concerns. The barrier for someone to contribute goes up due to the lack of structure. And the review process slows down as reviewers struggle to continuously rebuild mental context. + +## Recommendation + +Introduce a structured, written process for proposing, discussing, and deciding large design changes. We call these **hyper Improvement Proposals** (HIPs). They are similar in spirit to [RFCs](https://en.wikipedia.org/wiki/Request_for_Comments). + +### Scope + +When should HIPs be used? + +HIPs are intended for changes that significantly affect hyper's architecture, public API, performance characteristics, or long‑term maintenance burden. They are useful when a decision is difficult to reverse, when there appear to be several strong options, or as a foundation for future proposals. + +When are they not needed? + +Small, localized changes that do not alter core design or guarantees generally do not require a HIP. + +### Content + +What should be included in a HIP? + +This defines at a high level the purpose of what content is included. The exact sections might change over time, and so are defined in a separate `0000-template.md` file. + +But in general, HIPs should provide the following information: + +- Objective - the problem, the motivation, the what are we even doing here. +- Tenets - how to measure solutions + - A meta point here: spend more time on the objective, tenets, and alternatives. The recommendation will fall into place. +- A single recommendation + - The job of a proposal is to propose a solution, not to propose a problem. + - There will often be several ways to solve anything. + - The proposal should research and measure the options using the facts and tenets, and decide which is the best. + - The discussion during the proposal period may change what is the recommended solution. + - The essence of those discussions should be recaptured into the proposal itself. Both in the recommendation section, and also potentially in an FAQ appendix. + - Future readers should not need to reconstruct the design from the review comments. +- The whys (and why nots) of the solution + - A proposal should always include why the recommendation is the best option, and why the other options were not chosen +- References + - Links to all the relevant research that was done + - Links to previous discussions +- Appendices + - FAQ + +#### What not to include + +- Minor details that do not need to be discussed to accept the proposal. + +### Lifecycle + +- Start with an issue. + - (A HIP should almost never be started without a previous discussion) +- If the feature request seems large enough, a collaborator may ask for a HIP. +- In a new branch, copy the template file to `0000-your-feature-name.md`. +- Fill out the sections based on the instructions in the template. + - You don't need to have fully polished prose in every section to submit for discussion. + - It can even be beneficial to submit initially with bullet points. + - Balance giving proper thought to the sections, with moving quickly to get appropriate feedback. +- Consider sharing an early draft with someone else first. +- Submit a pull request, and include a big reminder to prefer line comments over top-level PR comments. +- As discussion occurs, revise the sections. + - Consider adding to an FAQ appendix. +- Eventually, a [maintainer](../GOVERNANCE.md#maintainer) will decide whether the proposal is accepted or rejected. + +Once a proposal is accepted, a number will be assigned. The file in the PR can be renamed with the assigned number, and then merged into trunk. + +#### Rejection + +If a proposal is rejected or withdrawn, its pull request is closed without assigning a HIP number or merging the document. The pull request remains the record of that discussion. If the discussion reveals a generally applicable constraint or principle, that information should instead be incorporated into the appropriate project documentation. + +#### Mutability and History + +HIPs are mutable, even after being accepted. + +Significant changes can be added to a `## History` section of the document, inserted before any `## References` or appendices. Besides explaining what changed, the reasoning for _why_ should be included. + +## Alternatives + +- Ad-hoc design docs + - While design documents are an ideal artifact, without defining a process behind them, it's hard for contributors to know when to use one or how. +- Module documentation + - A reader of module documentation is not the right audience. + - The purpose of module documentation is to explain how to use the library. + - It is not a great place to document the why. +- Issues and Pull Requests + - This is the status quo. + - Discussion in issues is a horrible experience. + - Spreads out the full design across disconnected areas. + - Silos the knowledge directly in GitHub. +- A chat application, such as Zulip + - Chat-style messages are extremely poor for having thoughtful discussion and decisions. + - They become littered with messages that have no longer-term value, and it makes it hard to piece back together what a decision is. + - No contributor should ever _have_ to participate in chat-style messaging. + +## Unresolved Questions + +- Is there a point when significant changes to an accepted HIP should itself be a new HIP? + - Possibly, but I'm not sure it's worth deciding yet. + +## References + +- https://rust-lang.github.io/rfcs/0002-rfc-process.html +- https://rfd.shared.oxide.computer/rfd/0001 +- https://peps.python.org/pep-0001/ +- https://docs.jj-vcs.dev/latest/design_docs/ +- https://blog.ceejbot.com/posts/design-docs/ +- https://adr.github.io/ From 76fea4299dfa23b2a24bc0edd5db2808a5500e5f Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 8 Sep 2026 08:57:15 -0400 Subject: [PATCH 103/128] perf(body): simpler custom Incoming channel (#4168) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The previous channel variant for `Incoming` used several individual channels to handle the various mechanisms: sending data, signaling error/abort, sending trailers, waiting for optional expect-101 polling. Each individual channel would allocate its own internal shared state in an Arc. Now the `Incoming` channel variant uses a single combined implementation, putting all the required shared pieces in a single Arc. This resulted in less allocations, and less places to potentially store wakers. The following performance improvements were noticed with microbenchmarks: | Benchmark | Before | After | Improvement | | --------------- | ------ | ---------- | ----------- | | Create/drop | 400 ns | 130–139 ns | ~66% | | Data handoff | 229 ns | 147–155 ns | ~34% | | Expect-101 wait | 492 ns | 267–298 ns | ~40–46% | --- src/body/chan.rs | 264 +++++++++++++++++++++++++++++++++++++++++++ src/body/incoming.rs | 237 ++++++++++---------------------------- src/body/mod.rs | 2 + src/common/mod.rs | 2 - src/common/watch.rs | 73 ------------ 5 files changed, 327 insertions(+), 251 deletions(-) create mode 100644 src/body/chan.rs delete mode 100644 src/common/watch.rs diff --git a/src/body/chan.rs b/src/body/chan.rs new file mode 100644 index 0000000000..b45bea866b --- /dev/null +++ b/src/body/chan.rs @@ -0,0 +1,264 @@ +use std::fmt; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll}; + +use atomic_waker::AtomicWaker; +use bytes::Bytes; +use http::HeaderMap; + +use crate::common::lock::LockResultExt; + +pub(crate) fn channel(wanter: bool) -> (Sender, Receiver) { + let shared = Arc::new(Shared { + state: Mutex::new(State { + item: None, + pending_error: None, + trailers: None, + sender_open: true, + receiver_open: true, + want: !wanter, + }), + sender_waker: AtomicWaker::new(), + receiver_waker: AtomicWaker::new(), + }); + + ( + Sender { + shared: Arc::clone(&shared), + trailers_sent: false, + }, + Receiver { + shared, + terminated: false, + }, + ) +} + +#[must_use = "Sender does nothing unless sent on"] +pub(crate) struct Sender { + shared: Arc, + trailers_sent: bool, +} + +pub(crate) struct Receiver { + shared: Arc, + terminated: bool, +} + +struct Shared { + state: Mutex, + sender_waker: AtomicWaker, + receiver_waker: AtomicWaker, +} + +struct State { + item: Option>, + // An error must not displace data which was already accepted. The old + // mpsc channel achieved this by sending the error from a cloned sender. + pending_error: Option, + trailers: Option, + sender_open: bool, + receiver_open: bool, + want: bool, +} + +impl Sender { + pub(crate) fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { + self.shared.sender_waker.register(cx.waker()); + let state = self.shared.state.lock().panic_if_poisoned(); + if !state.receiver_open { + Poll::Ready(Err(crate::Error::new_closed())) + } else if state.want && state.item.is_none() && state.pending_error.is_none() { + Poll::Ready(Ok(())) + } else { + Poll::Pending + } + } + + #[cfg(test)] + pub(crate) async fn ready(&mut self) -> crate::Result<()> { + futures_util::future::poll_fn(|cx| self.poll_ready(cx)).await + } + + #[cfg(test)] + #[allow(dead_code)] + pub(crate) async fn send_data(&mut self, chunk: Bytes) -> crate::Result<()> { + self.ready().await?; + self.try_send_data(chunk) + .map_err(|_| crate::Error::new_closed()) + } + + pub(crate) fn try_send_data(&mut self, chunk: Bytes) -> Result<(), Bytes> { + let mut state = self.shared.state.lock().panic_if_poisoned(); + if !state.receiver_open + || !state.want + || state.item.is_some() + || state.pending_error.is_some() + { + return Err(chunk); + } + state.item = Some(Ok(chunk)); + drop(state); + self.shared.receiver_waker.wake(); + Ok(()) + } + + pub(crate) fn try_send_trailers( + &mut self, + trailers: HeaderMap, + ) -> Result<(), Option> { + if self.trailers_sent { + return Err(None); + } + self.trailers_sent = true; + + let mut state = self.shared.state.lock().panic_if_poisoned(); + if !state.receiver_open { + return Err(Some(trailers)); + } + state.trailers = Some(trailers); + drop(state); + self.shared.receiver_waker.wake(); + Ok(()) + } + + #[allow(dead_code)] + #[allow(clippy::unused_async_trait_impl)] + pub(crate) async fn send_trailers(&mut self, trailers: HeaderMap) -> crate::Result<()> { + self.try_send_trailers(trailers) + .map_err(|_| crate::Error::new_closed()) + } + + pub(crate) fn send_error(&mut self, err: crate::Error) { + let mut state = self.shared.state.lock().panic_if_poisoned(); + if !state.receiver_open { + return; + } + if state.item.is_none() { + state.item = Some(Err(err)); + } else if state.pending_error.is_none() { + state.pending_error = Some(err); + } + drop(state); + self.shared.receiver_waker.wake(); + } + + #[cfg(test)] + pub(crate) fn abort(mut self) { + self.send_error(crate::Error::new_body_write_aborted()); + } + + fn is_closed(&self) -> bool { + !self.shared.state.lock().panic_if_poisoned().receiver_open + } +} + +impl Receiver { + pub(crate) fn poll_next( + &mut self, + cx: &mut Context<'_>, + ) -> Poll>> { + if self.terminated { + return Poll::Ready(None); + } + + self.shared.receiver_waker.register(cx.waker()); + let mut state = self.shared.state.lock().panic_if_poisoned(); + let wake_sender = if !state.want { + state.want = true; + true + } else { + false + }; + if let Some(item) = state.item.take() { + drop(state); + self.shared.sender_waker.wake(); + return Poll::Ready(Some(item)); + } + if let Some(err) = state.pending_error.take() { + drop(state); + if wake_sender { + self.shared.sender_waker.wake(); + } + return Poll::Ready(Some(Err(err))); + } + let sender_open = state.sender_open; + drop(state); + if wake_sender { + self.shared.sender_waker.wake(); + } + if sender_open { + Poll::Pending + } else { + self.terminated = true; + Poll::Ready(None) + } + } + + pub(crate) fn take_trailers(&mut self) -> Option { + debug_assert!(self.terminated, "data channel still open before trailers"); + let mut state = self.shared.state.lock().panic_if_poisoned(); + state.trailers.take() + } +} + +impl Drop for Sender { + fn drop(&mut self) { + let mut state = self.shared.state.lock().panic_if_poisoned(); + state.sender_open = false; + drop(state); + self.shared.receiver_waker.wake(); + } +} + +impl Drop for Receiver { + fn drop(&mut self) { + let mut state = self.shared.state.lock().panic_if_poisoned(); + state.receiver_open = false; + drop(state); + self.shared.sender_waker.wake(); + } +} + +impl fmt::Debug for Sender { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_closed() { + f.debug_tuple("Sender").field(&"Closed").finish() + } else { + f.debug_tuple("Sender").field(&"Open").finish() + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + async fn recv(rx: &mut Receiver) -> Option> { + futures_util::future::poll_fn(|cx| rx.poll_next(cx)).await + } + + #[tokio::test] + async fn error_queued_behind_accepted_data() { + let (mut tx, mut rx) = channel(false); + tx.try_send_data(Bytes::from_static(b"data")).unwrap(); + tx.send_error(crate::Error::new_incomplete()); + drop(tx); + + assert_eq!(recv(&mut rx).await.unwrap().unwrap(), "data"); + assert!(recv(&mut rx).await.unwrap().is_err()); + assert!(recv(&mut rx).await.is_none()); + } + + #[tokio::test] + async fn trailers_follow_data_close() { + let (mut tx, mut rx) = channel(false); + let mut trailers = HeaderMap::new(); + trailers.insert("x-trailer", "value".parse().unwrap()); + tx.try_send_trailers(trailers).unwrap(); + drop(tx); + + assert!(recv(&mut rx).await.is_none()); + assert_eq!(rx.take_trailers().unwrap()["x-trailer"], "value"); + } +} diff --git a/src/body/incoming.rs b/src/body/incoming.rs index 6ef6c0963a..b026a24d6b 100644 --- a/src/body/incoming.rs +++ b/src/body/incoming.rs @@ -1,38 +1,25 @@ use std::fmt; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -use std::future::Future; use std::pin::Pin; use std::task::{Context, Poll}; use bytes::Bytes; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -use futures_channel::{mpsc, oneshot}; #[cfg(all( any(feature = "http1", feature = "http2"), any(feature = "client", feature = "server") ))] use futures_core::ready; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -use futures_core::{stream::FusedStream, Stream}; // for mpsc::Receiver -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -use http::HeaderMap; use http_body::{Body, Frame, SizeHint}; +#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] +use super::chan; #[cfg(all( any(feature = "http1", feature = "http2"), any(feature = "client", feature = "server") ))] use super::DecodedLength; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -use crate::common::watch; #[cfg(all(feature = "http2", any(feature = "client", feature = "server")))] use crate::proto::h2::ping; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -type BodySender = mpsc::Sender>; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -type TrailersSender = oneshot::Sender; - /// A stream of `Bytes`, used when receiving bodies from the network. /// /// Note that Users should not instantiate this struct directly. When working with the hyper client, @@ -58,9 +45,7 @@ enum Kind { #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] Chan { content_length: DecodedLength, - want_tx: watch::Sender, - data_rx: mpsc::Receiver>, - trailers_rx: oneshot::Receiver, + rx: chan::Receiver, }, #[cfg(all(feature = "http2", any(feature = "client", feature = "server")))] H2 { @@ -86,18 +71,8 @@ enum Kind { /// /// [`Body::channel()`]: struct.Body.html#method.channel /// [`Sender::abort()`]: struct.Sender.html#method.abort -#[must_use = "Sender does nothing unless sent on"] #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -pub(crate) struct Sender { - want_rx: watch::Receiver, - data_tx: BodySender, - trailers_tx: Option, -} - -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -const WANT_PENDING: usize = 1; -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -const WANT_READY: usize = 2; +pub(crate) use super::chan::Sender; impl Incoming { /// Create a `Body` stream with an associated sender half. @@ -112,26 +87,8 @@ impl Incoming { #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] pub(crate) fn new_channel(content_length: DecodedLength, wanter: bool) -> (Sender, Incoming) { - let (data_tx, data_rx) = mpsc::channel(0); - let (trailers_tx, trailers_rx) = oneshot::channel(); - - // If wanter is true, `Sender::poll_ready()` won't becoming ready - // until the `Body` has been polled for data once. - let want = if wanter { WANT_PENDING } else { WANT_READY }; - - let (want_tx, want_rx) = watch::channel(want); - - let tx = Sender { - want_rx, - data_tx, - trailers_tx: Some(trailers_tx), - }; - let rx = Incoming::new(Kind::Chan { - content_length, - want_tx, - data_rx, - trailers_rx, - }); + let (tx, rx) = chan::channel(wanter); + let rx = Incoming::new(Kind::Chan { content_length, rx }); (tx, rx) } @@ -210,24 +167,13 @@ impl Body for Incoming { #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] Kind::Chan { content_length: len, - data_rx, - want_tx, - trailers_rx, + rx, } => { - want_tx.send(WANT_READY); - - if !data_rx.is_terminated() { - if let Some(chunk) = ready!(Pin::new(data_rx).poll_next(cx)?) { - len.sub_if(chunk.len() as u64); - return Poll::Ready(Some(Ok(Frame::data(chunk)))); - } - } - - // check trailers after data is terminated - match ready!(Pin::new(trailers_rx).poll(cx)) { - Ok(t) => Poll::Ready(Some(Ok(Frame::trailers(t)))), - Err(_) => Poll::Ready(None), + if let Some(chunk) = ready!(rx.poll_next(cx)?) { + len.sub_if(chunk.len() as u64); + return Poll::Ready(Some(Ok(Frame::data(chunk)))); } + Poll::Ready(rx.take_trailers().map(Frame::trailers).map(Ok)) } #[cfg(all(feature = "http2", any(feature = "client", feature = "server")))] Kind::H2 { @@ -353,120 +299,12 @@ impl fmt::Debug for Incoming { } } -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -impl Sender { - /// Check to see if this `Sender` can send more data. - pub(crate) fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll> { - // Check if the receiver end has tried polling for the body yet - ready!(self.poll_want(cx)?); - self.data_tx - .poll_ready(cx) - .map_err(|_| crate::Error::new_closed()) - } - - fn poll_want(&mut self, cx: &mut Context<'_>) -> Poll> { - match self.want_rx.load(cx) { - WANT_READY => Poll::Ready(Ok(())), - WANT_PENDING => Poll::Pending, - watch::CLOSED => Poll::Ready(Err(crate::Error::new_closed())), - unexpected => unreachable!("want_rx value: {}", unexpected), - } - } - - #[cfg(test)] - async fn ready(&mut self) -> crate::Result<()> { - futures_util::future::poll_fn(|cx| self.poll_ready(cx)).await - } - - /// Send data on data channel when it is ready. - #[cfg(test)] - #[allow(unused)] - pub(crate) async fn send_data(&mut self, chunk: Bytes) -> crate::Result<()> { - self.ready().await?; - self.data_tx - .try_send(Ok(chunk)) - .map_err(|_| crate::Error::new_closed()) - } - - /// Send trailers on trailers channel. - #[allow(unused)] - #[allow(clippy::unused_async_trait_impl)] - pub(crate) async fn send_trailers(&mut self, trailers: HeaderMap) -> crate::Result<()> { - let tx = match self.trailers_tx.take() { - Some(tx) => tx, - None => return Err(crate::Error::new_closed()), - }; - tx.send(trailers).map_err(|_| crate::Error::new_closed()) - } - - /// Try to send data on this channel. - /// - /// # Errors - /// - /// Returns `Err(Bytes)` if the channel could not (currently) accept - /// another `Bytes`. - /// - /// # Note - /// - /// This is mostly useful for when trying to send from some other thread - /// that doesn't have an async context. If in an async context, prefer - /// `send_data()` instead. - #[cfg(feature = "http1")] - pub(crate) fn try_send_data(&mut self, chunk: Bytes) -> Result<(), Bytes> { - self.data_tx - .try_send(Ok(chunk)) - .map_err(|err| err.into_inner().expect("just sent Ok")) - } - - #[cfg(feature = "http1")] - pub(crate) fn try_send_trailers( - &mut self, - trailers: HeaderMap, - ) -> Result<(), Option> { - let tx = match self.trailers_tx.take() { - Some(tx) => tx, - None => return Err(None), - }; - - tx.send(trailers).map_err(Some) - } - - #[cfg(test)] - pub(crate) fn abort(mut self) { - self.send_error(crate::Error::new_body_write_aborted()); - } - - pub(crate) fn send_error(&mut self, err: crate::Error) { - let _ = self - .data_tx - // clone so the send works even if buffer is full - .clone() - .try_send(Err(err)); - } -} - -#[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] -impl fmt::Debug for Sender { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - #[derive(Debug)] - struct Open; - #[derive(Debug)] - struct Closed; - - let mut builder = f.debug_tuple("Sender"); - match self.want_rx.peek() { - watch::CLOSED => builder.field(&Closed), - _ => builder.field(&Open), - }; - - builder.finish() - } -} - #[cfg(test)] mod tests { #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use std::mem; + #[cfg(all(feature = "nightly", not(miri)))] + use std::pin::Pin; #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use std::task::Poll; @@ -474,6 +312,8 @@ mod tests { use super::{Body, Incoming, SizeHint}; #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use super::{DecodedLength, Sender}; + #[cfg(all(feature = "nightly", not(miri)))] + use bytes::Bytes; #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] use http_body_util::BodyExt; @@ -494,7 +334,7 @@ mod tests { assert_eq!( mem::size_of::(), - mem::size_of::() * 5, + mem::size_of::() * 2, "Sender" ); @@ -505,6 +345,51 @@ mod tests { ); } + #[cfg(all(feature = "nightly", not(miri)))] + #[bench] + fn bench_channel_create_and_drop(b: &mut test::Bencher) { + b.iter(|| { + let _ = test::black_box(Incoming::new_channel( + DecodedLength::CHUNKED, + /* wanter = */ false, + )); + }); + } + + #[cfg(all(feature = "nightly", not(miri)))] + #[bench] + fn bench_channel_data_handoff(b: &mut test::Bencher) { + let (mut tx, mut body) = + Incoming::new_channel(DecodedLength::CHUNKED, /* wanter = */ false); + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + + b.iter(|| { + assert!(tx.poll_ready(&mut cx).is_ready()); + tx.try_send_data(Bytes::from_static(b"hello world")) + .unwrap(); + let frame = match Pin::new(&mut body).poll_frame(&mut cx) { + Poll::Ready(Some(Ok(frame))) => frame, + unexpected => panic!("unexpected body poll: {unexpected:?}"), + }; + test::black_box(frame); + }); + } + + #[cfg(all(feature = "nightly", not(miri)))] + #[bench] + fn bench_channel_want_transition(b: &mut test::Bencher) { + let mut cx = std::task::Context::from_waker(std::task::Waker::noop()); + + b.iter(|| { + let (mut tx, mut body) = + Incoming::new_channel(DecodedLength::CHUNKED, /* wanter = */ true); + assert!(tx.poll_ready(&mut cx).is_pending()); + assert!(Pin::new(&mut body).poll_frame(&mut cx).is_pending()); + assert!(tx.poll_ready(&mut cx).is_ready()); + let _ = test::black_box((tx, body)); + }); + } + #[cfg(all(feature = "http1", any(feature = "client", feature = "server")))] #[test] fn size_hint() { diff --git a/src/body/mod.rs b/src/body/mod.rs index c34d019d21..42678b63d1 100644 --- a/src/body/mod.rs +++ b/src/body/mod.rs @@ -79,6 +79,8 @@ pub(crate) use self::incoming::Sender; ))] pub(crate) use self::length::DecodedLength; +#[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] +mod chan; mod incoming; #[cfg(all( any(feature = "http1", feature = "http2"), diff --git a/src/common/mod.rs b/src/common/mod.rs index 9a8bd0a1d4..b2986a300f 100644 --- a/src/common/mod.rs +++ b/src/common/mod.rs @@ -19,5 +19,3 @@ pub(crate) mod task; all(any(feature = "client", feature = "server"), feature = "http2"), ))] pub(crate) mod time; -#[cfg(all(any(feature = "client", feature = "server"), feature = "http1"))] -pub(crate) mod watch; diff --git a/src/common/watch.rs b/src/common/watch.rs deleted file mode 100644 index 81acd5df70..0000000000 --- a/src/common/watch.rs +++ /dev/null @@ -1,73 +0,0 @@ -//! An SPSC broadcast channel. -//! -//! - The value can only be a `usize`. -//! - The consumer is only notified if the value is different. -//! - The value `0` is reserved for closed. - -use atomic_waker::AtomicWaker; -use std::sync::{ - atomic::{AtomicUsize, Ordering}, - Arc, -}; -use std::task; - -type Value = usize; - -pub(crate) const CLOSED: usize = 0; - -pub(crate) fn channel(initial: Value) -> (Sender, Receiver) { - debug_assert!( - initial != CLOSED, - "watch::channel initial state of 0 is reserved" - ); - - let shared = Arc::new(Shared { - value: AtomicUsize::new(initial), - waker: AtomicWaker::new(), - }); - - ( - Sender { - shared: shared.clone(), - }, - Receiver { shared }, - ) -} - -pub(crate) struct Sender { - shared: Arc, -} - -pub(crate) struct Receiver { - shared: Arc, -} - -struct Shared { - value: AtomicUsize, - waker: AtomicWaker, -} - -impl Sender { - pub(crate) fn send(&mut self, value: Value) { - if self.shared.value.swap(value, Ordering::SeqCst) != value { - self.shared.waker.wake(); - } - } -} - -impl Drop for Sender { - fn drop(&mut self) { - self.send(CLOSED); - } -} - -impl Receiver { - pub(crate) fn load(&mut self, cx: &mut task::Context<'_>) -> Value { - self.shared.waker.register(cx.waker()); - self.shared.value.load(Ordering::SeqCst) - } - - pub(crate) fn peek(&self) -> Value { - self.shared.value.load(Ordering::Relaxed) - } -} From cbd65e0335ac26bc0f23881b5daf25b4859910f5 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 9 Sep 2026 08:06:03 -0400 Subject: [PATCH 104/128] docs(maintainers): add 0x676e67 as a collaborator (#4184) --- docs/MAINTAINERS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/MAINTAINERS.md b/docs/MAINTAINERS.md index ab9fe5e3d5..3bd87307a0 100644 --- a/docs/MAINTAINERS.md +++ b/docs/MAINTAINERS.md @@ -14,6 +14,7 @@ To see what these roles do, and how to become one, look at [GOVERNANCE](./GOVERN - dswij (@dswij) - tottoto (@tottoto) - katelyn martin (@cratelyn) +- 0x676e67 (@0x676e67) ### Collaborator emeriti From 6177f70580e170be2da9e0aa405f3de5a1481f16 Mon Sep 17 00:00:00 2001 From: katelyn martin Date: Thu, 10 Sep 2026 14:26:46 -0400 Subject: [PATCH 105/128] docs(ext): remove broken `OriginalHeaderOrder` doctest (#4188) i stumbled across this while investigating a solution to #3914. in #2798, a documentation test was introduced that appears to have been broken since its inception. after spending some time figuring out how to run this documentation test, i found the following errors: ``` ; RUSTDOCFLAGS='--cfg hyper_unstable_ffi' RUSTFLAGS='--cfg hyper_unstable_ffi' cargo test --doc --features client,server,http1,http2,ffi get_in_order Compiling hyper v1.11.1 (/hyper) Finished `test` profile [unoptimized + debuginfo] target(s) in 1.39s Doc-tests hyper running 1 test test src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) - compile ... FAILED failures: ---- src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) stdout ---- error: expected item, found keyword `let` --> src/ext/mod.rs:266:1 | 266 | let mut h_order = OriginalHeaderOrder::default(); | ^^^ `let` cannot be used for global variables | = help: consider using `static` and a `Mutex` instead of `let mut` = note: for a full list of items that can appear in modules, see error: aborting due to 1 previous error Couldn't compile the test. failures: src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) ``` after wrapping the code in the example in a `fn main() {}` block: ``` ; RUSTDOCFLAGS='--cfg hyper_unstable_ffi' RUSTFLAGS='--cfg hyper_unstable_ffi' cargo test --doc --features client,server,http1,http2,ffi get_in_order Compiling hyper v1.11.1 (/hyper) Finished `test` profile [unoptimized + debuginfo] target(s) in 0.37s Doc-tests hyper running 1 test test src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) - compile ... FAILED failures: ---- src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) stdout ---- error: expected `;`, found keyword `let` --> src/ext/mod.rs:284:22 | 284 | h_order.append(name3) | ^ help: add `;` here 285 | 286 | let mut iter = h_order.get_in_order() | --- unexpected token error: expected `;`, found keyword `let` --> src/ext/mod.rs:286:38 | 286 | let mut iter = h_order.get_in_order() | ^ help: add `;` here 287 | 288 | let (name, idx) = iter.next(); | --- unexpected token error[E0603]: struct `OriginalHeaderOrder` is private --> src/ext/mod.rs:264:17 | 264 | use hyper::ext::OriginalHeaderOrder; | ^^^^^^^^^^^^^^^^^^^ private struct | note: the struct `OriginalHeaderOrder` is defined here --> src/ext/mod.rs:204:1 | 204 | pub(crate) struct OriginalHeaderOrder { | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ error[E0433]: cannot find type `Headermap` in this scope --> src/ext/mod.rs:269:17 | 269 | let mut h_map = Headermap::new(); | ^^^^^^^^^ use of undeclared type `Headermap` | help: a struct with a similar name exists | 269 - let mut h_map = Headermap::new(); 269 + let mut h_map = HeaderMap::new(); | error: aborting due to 4 previous errors Some errors have detailed explanations: E0433, E0603. For more information about an error, try `rustc --explain E0433`. Couldn't compile the test. failures: src/ext/mod.rs - ext::OriginalHeaderOrder::get_in_order (line 262) ``` after addressing these errors, i observed: ``` error[E0277]: the trait bound `&[u8; 10]: IntoHeaderName` is not satisfied --> src/ext/mod.rs:273:14 | 273 | h_map.append(name1); | ------ ^^^^^ the trait `IntoHeaderName` is not implemented for `&[u8; 10]` | | | required by a bound introduced by this call | help: the following other types implement trait `IntoHeaderName` --> /.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/http-1.3.1/src/header/map.rs:3690:5 | 3690 | impl IntoHeaderName for HeaderName {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `HeaderName` ... 3712 | impl<'a> IntoHeaderName for &'a HeaderName {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&'a HeaderName` ... 3734 | impl IntoHeaderName for &'static str {} | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `&'static str` note: required by a bound in `HeaderMap::::append` --> /.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/http-1.3.1/src/header/map.rs:1373:12 | 1371 | pub fn append(&mut self, key: K, value: T) -> bool | ------ required by a bound in this associated function 1372 | where 1373 | K: IntoHeaderName, | ^^^^^^^^^^^^^^ required by this bound in `HeaderMap::::append` error[E0061]: this method takes 2 arguments but 1 argument was supplied --> src/ext/mod.rs:273:7 | 273 | h_map.append(name1); | ^^^^^^------- argument #2 of type `HeaderValue` is missing | note: method defined here --> /.cargo/registry/src/index.crates.io-1949cf8c6b5b557f/http-1.3.1/src/header/map.rs:1371:12 | 1371 | pub fn append(&mut self, key: K, value: T) -> bool | ^^^^^^ help: provide the argument | 273 | h_map.append(name1, /* HeaderValue */); | +++++++++++++++++++ ``` because these errors point to missing arguments and incorrect types, this commit removes this example altogether. i'm not convinced it was ever run. Signed-off-by: katelyn martin --- src/ext/mod.rs | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/src/ext/mod.rs b/src/ext/mod.rs index 82f7cd7d07..ba6886b835 100644 --- a/src/ext/mod.rs +++ b/src/ext/mod.rs @@ -257,41 +257,6 @@ impl OriginalHeaderOrder { // here. /// This returns an iterator that provides header names and indexes /// in the original order received. - /// - /// # Examples - /// ```no_run - /// use hyper::ext::OriginalHeaderOrder; - /// use hyper::header::{HeaderName, HeaderValue, HeaderMap}; - /// - /// let mut h_order = OriginalHeaderOrder::default(); - /// let mut h_map = Headermap::new(); - /// - /// let name1 = b"Set-CookiE"; - /// let value1 = b"a=b"; - /// h_map.append(name1); - /// h_order.append(name1); - /// - /// let name2 = b"Content-Encoding"; - /// let value2 = b"gzip"; - /// h_map.append(name2, value2); - /// h_order.append(name2); - /// - /// let name3 = b"SET-COOKIE"; - /// let value3 = b"c=d"; - /// h_map.append(name3, value3); - /// h_order.append(name3) - /// - /// let mut iter = h_order.get_in_order() - /// - /// let (name, idx) = iter.next(); - /// assert_eq!(b"a=b", h_map.get_all(name).nth(idx).unwrap()); - /// - /// let (name, idx) = iter.next(); - /// assert_eq!(b"gzip", h_map.get_all(name).nth(idx).unwrap()); - /// - /// let (name, idx) = iter.next(); - /// assert_eq!(b"c=d", h_map.get_all(name).nth(idx).unwrap()); - /// ``` pub(crate) fn get_in_order(&self) -> impl Iterator { self.entry_order.iter() } From d8eff174636bd91e9dbc1fa3f2a7c3f71cf5c868 Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Fri, 11 Sep 2026 05:34:11 +0800 Subject: [PATCH 106/128] chore(dependencies): remove unused futures-channel from http1 feature (#4185) --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 1fd9616fda..6b4bd3cac9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -82,7 +82,7 @@ full = [ ] # HTTP versions -http1 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:httparse", "dep:itoa"] +http1 = ["dep:atomic-waker", "dep:futures-core", "dep:httparse", "dep:itoa"] http2 = ["dep:atomic-waker", "dep:futures-channel", "dep:futures-core", "dep:h2"] http3 = ["dep:h3"] From c593c9d3d644c8f6afc93df9a35b4d3c8f0ed3f6 Mon Sep 17 00:00:00 2001 From: tottoto Date: Fri, 11 Sep 2026 17:27:39 +0900 Subject: [PATCH 107/128] chore(ci): update to cargo-check-external-types 0.5.0 (#4189) --- .github/workflows/CI.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/CI.yml b/.github/workflows/CI.yml index a5d35e10ff..5d625ae140 100644 --- a/.github/workflows/CI.yml +++ b/.github/workflows/CI.yml @@ -298,12 +298,12 @@ jobs: - name: Install Rust uses: dtolnay/rust-toolchain@master with: - toolchain: nightly-2025-10-18 # Compatible version for cargo-check-external-types + toolchain: nightly-2026-03-20 # Compatible version for cargo-check-external-types - name: Install cargo-check-external-types uses: taiki-e/cache-cargo-install-action@v2 with: - tool: cargo-check-external-types@0.4.0 + tool: cargo-check-external-types@0.5.0 - uses: Swatinem/rust-cache@v2 From cbefe3865c2b8b48fb5f3aa6823c9a4e94cc14ad Mon Sep 17 00:00:00 2001 From: Nakaryo <128471892+nakaryo716@users.noreply.github.com> Date: Mon, 14 Sep 2026 01:02:20 +0900 Subject: [PATCH 108/128] style(lib): address ignored_unit_patterns lint (#4193) --- Cargo.toml | 1 - src/client/dispatch.rs | 8 ++++---- src/proto/h1/io.rs | 2 +- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 6b4bd3cac9..1bf1de4aca 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -124,7 +124,6 @@ checked_conversions = "allow" else_if_without_else = "allow" enum_glob_use = "allow" float_arithmetic = "allow" -ignored_unit_patterns = "allow" indexing_slicing = "allow" integer_division = "allow" integer_division_remainder_used = "allow" diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index a9375f627a..0d91a1d92e 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -111,7 +111,7 @@ impl Sender { let (tx, rx) = oneshot::channel(); self.inner .send(Envelope(Some((val, Callback::Retry(Some(tx)))))) - .map(move |_| rx) + .map(move |()| rx) .map_err(|mut e| (e.0).0.take().expect("envelope not dropped").0) } @@ -123,7 +123,7 @@ impl Sender { let (tx, rx) = oneshot::channel(); self.inner .send(Envelope(Some((val, Callback::NoRetry(Some(tx)))))) - .map(move |_| rx) + .map(move |()| rx) .map_err(|mut e| (e.0).0.take().expect("envelope not dropped").0) } @@ -150,7 +150,7 @@ impl UnboundedSender { let (tx, rx) = oneshot::channel(); self.inner .send(Envelope(Some((val, Callback::Retry(Some(tx)))))) - .map(move |_| rx) + .map(move |()| rx) .map_err(|mut e| (e.0).0.take().expect("envelope not dropped").0) } @@ -158,7 +158,7 @@ impl UnboundedSender { let (tx, rx) = oneshot::channel(); self.inner .send(Envelope(Some((val, Callback::NoRetry(Some(tx)))))) - .map(move |_| rx) + .map(move |()| rx) .map_err(|mut e| (e.0).0.take().expect("envelope not dropped").0) } } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index b49e48e5c8..386f1be35b 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -240,7 +240,7 @@ where let dst = unsafe { self.read_buf.chunk_mut().as_uninit_slice_mut() }; let mut buf = ReadBuf::uninit(dst); match Pin::new(&mut self.io).poll_read(cx, buf.unfilled()) { - Poll::Ready(Ok(_)) => { + Poll::Ready(Ok(())) => { let n = buf.filled().len(); trace!("received {} bytes", n); // Safety: we just read that many bytes into the From 3ab733d120d4b7f985469aba69ec1546c7190a4c Mon Sep 17 00:00:00 2001 From: David Tolnay Date: Tue, 15 Sep 2026 12:14:50 -0700 Subject: [PATCH 109/128] fix(http1): preserve hop-by-hop when setting close or keep-alive (#4196) This adjusts https://github.com/hyperium/hyper/pull/4110 to use `append` instead of `insert` when a nonzero number of "connection" headers are already present, and only if none of the existing headers already include the value being added. Fixes https://github.com/hyperium/hyper/issues/4195 --- src/proto/h1/conn.rs | 35 ++++++++++++++++++++++++----------- tests/client.rs | 29 +++++++++++++++++++++++++++++ 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 593d3d9eda..69ca50b1a4 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -11,7 +11,7 @@ use std::time::Duration; use crate::rt::{Read, Write}; use bytes::{Buf, Bytes}; use futures_core::ready; -use http::header::{HeaderValue, CONNECTION}; +use http::header::{Entry, HeaderValue, CONNECTION}; use http::{HeaderMap, Method, Version}; use http_body::Frame; use httparse::ParserConfig; @@ -664,10 +664,11 @@ where // Fix keep-alive when Connection: keep-alive header is not present fn fix_keep_alive(&mut self, head: &mut MessageHead) { - let outgoing_is_keep_alive = head - .headers - .get(CONNECTION) - .map_or(false, headers::connection_keep_alive); + let connection_entry = head.headers.entry(CONNECTION); + let outgoing_is_keep_alive = match &connection_entry { + Entry::Occupied(entry) => entry.iter().any(headers::connection_keep_alive), + Entry::Vacant(_) => false, + }; if !outgoing_is_keep_alive { match head.version { @@ -676,10 +677,14 @@ where Version::HTTP_10 => self.state.disable_keep_alive(), // If response is version 1.1 and keep-alive is wanted, add // Connection: keep-alive header when not present - Version::HTTP_11 if self.state.wants_keep_alive() => { - head.headers - .insert(CONNECTION, HeaderValue::from_static("keep-alive")); - } + Version::HTTP_11 if self.state.wants_keep_alive() => match connection_entry { + Entry::Occupied(mut entry) => { + entry.append(HeaderValue::from_static("keep-alive")); + } + Entry::Vacant(entry) => { + entry.insert(HeaderValue::from_static("keep-alive")); + } + }, _ => (), } } @@ -698,8 +703,16 @@ where } Version::HTTP_11 => { if let KA::Disabled = self.state.keep_alive.status() { - head.headers - .insert(CONNECTION, HeaderValue::from_static("close")); + match head.headers.entry(CONNECTION) { + Entry::Occupied(mut entry) => { + if !entry.iter().any(headers::connection_close) { + entry.append(HeaderValue::from_static("close")); + } + } + Entry::Vacant(entry) => { + entry.insert(HeaderValue::from_static("close")); + } + } } } _ => (), diff --git a/tests/client.rs b/tests/client.rs index b512260cc5..e138f09efe 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -1558,6 +1558,35 @@ test! { body: None, } +// https://github.com/hyperium/hyper/issues/4195 +test! { + name: client_hop_by_hop_headers, + + server: + expected: "\ + GET / HTTP/1.1\r\n\ + connection: close, x-hop\r\n\ + x-hop: ...\r\n\ + host: {addr}\r\n\ + \r\n\ + ", + reply: REPLY_OK, + + client: + request: { + method: GET, + url: "http://{addr}/", + headers: { + "connection" => "close, x-hop", + "x-hop" => "...", + }, + }, + response: + status: OK, + headers: {}, + body: None, +} + mod conn { use std::error::Error; use std::io::{self, Read, Write}; From edcf6a2cba0168efd5fd93d8b98d47bb955cd85f Mon Sep 17 00:00:00 2001 From: Leo Camus Date: Fri, 18 Sep 2026 21:05:44 +0200 Subject: [PATCH 110/128] fix(http2): do not reserve capacity for idle Upgraded streams (#4198) The send task of an HTTP/2 `Upgraded` stream reserved one byte of capacity at the top of every loop, before any write was queued. As with `PipeToSendStream` before #4061, an idle tunnel then pins the last byte of the connection-level window, and a second stream can deadlock against peers that only send WINDOW_UPDATE once their window is exhausted. Pull the next write from the channel first, hold it until h2 has capacity, and only then reserve. The next write is still not pulled before the held one is handed to h2, so the writer keeps seeing backpressure. The task now always polls the channel when it holds nothing, which makes the separate close notification unnecessary, so remove it. Refs #4003 --- src/proto/h2/upgrade.rs | 133 ++++++++++------------------------------ tests/client.rs | 112 +++++++++++++++++++++++++++++++++ 2 files changed, 146 insertions(+), 99 deletions(-) diff --git a/src/proto/h2/upgrade.rs b/src/proto/h2/upgrade.rs index 217aa27e88..def1fcfc96 100644 --- a/src/proto/h2/upgrade.rs +++ b/src/proto/h2/upgrade.rs @@ -1,11 +1,8 @@ use std::future::Future; use std::io::Cursor; use std::pin::Pin; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::sync::Arc; use std::task::{Context, Poll}; -use atomic_waker::AtomicWaker; use bytes::{Buf, Bytes}; use futures_channel::{mpsc, oneshot}; use futures_core::{ready, Stream}; @@ -23,15 +20,10 @@ pub(super) fn pair( ) -> (H2Upgraded, UpgradedSendStreamTask) { let (tx, rx) = mpsc::channel(1); let (error_tx, error_rx) = oneshot::channel(); - let close_notify = Arc::new(UpgradedCloseNotify::new()); ( H2Upgraded { - send_stream: UpgradedSendStreamBridge { - tx, - error_rx, - close_notify: close_notify.clone(), - }, + send_stream: UpgradedSendStreamBridge { tx, error_rx }, recv_stream, ping, buf: Bytes::new(), @@ -39,7 +31,7 @@ pub(super) fn pair( UpgradedSendStreamTask { h2_tx: send_stream, rx, - close_notify, + buffered: None, error_tx: Some(error_tx), }, ) @@ -55,46 +47,6 @@ pub(super) struct H2Upgraded { struct UpgradedSendStreamBridge { tx: mpsc::Sender>>, error_rx: oneshot::Receiver, - close_notify: Arc, -} - -impl Drop for UpgradedSendStreamBridge { - fn drop(&mut self) { - self.close_notify.close(); - } -} - -struct UpgradedCloseNotify { - closed: AtomicBool, - task: AtomicWaker, -} - -impl UpgradedCloseNotify { - fn new() -> Self { - Self { - closed: AtomicBool::new(false), - task: AtomicWaker::new(), - } - } - - fn close(&self) { - self.closed.store(true, Ordering::Release); - self.task.wake(); - } - - fn poll_closed(&self, cx: &mut Context<'_>) -> Poll<()> { - if self.closed.load(Ordering::Acquire) { - return Poll::Ready(()); - } - - self.task.register(cx.waker()); - - if self.closed.load(Ordering::Acquire) { - Poll::Ready(()) - } else { - Poll::Pending - } - } } pin_project! { @@ -104,7 +56,7 @@ pin_project! { h2_tx: SendStream>, #[pin] rx: mpsc::Receiver>>, - close_notify: Arc, + buffered: Option>>, error_tx: Option>, } } @@ -123,35 +75,6 @@ where // one of the sides hanging up, so the task doesn't live around // longer than it's meant to. loop { - // we don't have the next chunk of data yet, so just reserve 1 byte to make - // sure there's some capacity available. h2 will handle the capacity management - // for the actual body chunk. - me.h2_tx.reserve_capacity(1); - - let h2_has_capacity = if me.h2_tx.capacity() == 0 { - // poll_capacity oddly needs a loop - loop { - match me.h2_tx.poll_capacity(cx) { - Poll::Ready(Some(Ok(0))) => {} - Poll::Ready(Some(Ok(_))) => break true, - Poll::Ready(Some(Err(e))) => { - return Poll::Ready(Err(crate::Error::new_body_write(e))) - } - Poll::Ready(None) => { - // None means the stream is no longer in a - // streaming state, we either finished it - // somehow, or the remote reset us. - return Poll::Ready(Err(crate::Error::new_body_write( - "send stream capacity unexpectedly closed", - ))); - } - Poll::Pending => break false, - } - } - } else { - true - }; - match me.h2_tx.poll_reset(cx) { Poll::Ready(Ok(reason)) => { trace!("stream received RST_STREAM: {:?}", reason); @@ -165,30 +88,43 @@ where Poll::Pending => (), } - // If h2 has no capacity, don't pull another item from the mpsc - // receiver. That would free a channel slot and let the writer - // enqueue more data without h2 backpressure. - // - // Still allow the task to finish once the upgraded write side is - // gone and the mpsc queue is empty. - if !h2_has_capacity { - // `size_hint` reads the queued message count without popping, - // so an accepted write stays queued until h2 capacity returns. - if me.rx.size_hint().0 == 0 && me.close_notify.poll_closed(cx).is_ready() { - me.h2_tx - .send_data(SendBuf::None, true) - .map_err(crate::Error::new_body_write)?; - return Poll::Ready(Ok(())); + // A write taken from the mpsc receiver waits here for h2 capacity, + // and the next one isn't pulled until it has been handed to h2, so + // the writer still sees h2 backpressure. + if me.buffered.is_some() { + // poll_capacity oddly needs a loop + while me.h2_tx.capacity() == 0 { + match ready!(me.h2_tx.poll_capacity(cx)) { + Some(Ok(0)) => {} + Some(Ok(_)) => break, + Some(Err(e)) => return Poll::Ready(Err(crate::Error::new_body_write(e))), + None => { + // None means the stream is no longer in a + // streaming state, we either finished it + // somehow, or the remote reset us. + return Poll::Ready(Err(crate::Error::new_body_write( + "send stream capacity unexpectedly closed", + ))); + } + } } - return Poll::Pending; + let cursor = me.buffered.take().expect("checked is_some above"); + me.h2_tx + .send_data(SendBuf::Cursor(cursor), false) + .map_err(crate::Error::new_body_write)?; + continue; } match me.rx.as_mut().poll_next(cx) { Poll::Ready(Some(cursor)) => { - me.h2_tx - .send_data(SendBuf::Cursor(cursor), false) - .map_err(crate::Error::new_body_write)?; + // Only reserve capacity once there is something to send. + // Reserving while idle, even a single byte, pins that + // capacity on the connection-level window (#4003). As in + // `PipeToSendStream`, h2 raises the request to the + // buffered length inside `send_data`. + me.h2_tx.reserve_capacity(1); + *me.buffered = Some(cursor); } Poll::Ready(None) => { me.h2_tx @@ -328,7 +264,6 @@ impl Write for H2Upgraded { cx: &mut Context<'_>, ) -> Poll> { self.send_stream.tx.close_channel(); - self.send_stream.close_notify.close(); match Pin::new(&mut self.send_stream.error_rx).poll(cx) { Poll::Ready(Ok(reason)) => Poll::Ready(Err(io_error(reason))), Poll::Ready(Err(_task_dropped)) => Poll::Ready(Ok(())), diff --git a/tests/client.rs b/tests/client.rs index e138f09efe..e4c8d12cf6 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -3344,6 +3344,118 @@ mod conn { drop(tx_a); let _ = tokio::time::timeout(Duration::from_secs(5), a_handle).await; } + + // https://github.com/hyperium/hyper/issues/4003, for HTTP/2 CONNECT + // + // Like `h2_idle_stream_does_not_pin_connection_window`, but the idle + // stream is the send side of an `Upgraded` tunnel. It must not reserve + // connection-level flow control capacity while it has nothing to write. + #[tokio::test] + async fn h2_idle_upgraded_does_not_pin_connection_window() { + // One byte short of the initial connection-level window. + const STREAM_A_LEN: usize = 65534; + + let (client_io, server_io, _) = setup_duplex_test_server(); + let (stream_a_full_tx, stream_a_full_rx) = oneshot::channel::<()>(); + let (stream_a_done_tx, stream_a_done_rx) = oneshot::channel::<()>(); + let (stream_b_got_tx, stream_b_got_rx) = oneshot::channel::(); + + // Raw h2 server that never calls `release_capacity`, so it never + // sends a connection-level WINDOW_UPDATE. + tokio::spawn(async move { + let mut h2 = h2::server::handshake(server_io).await.unwrap(); + let mut stream_a_full_tx = Some(stream_a_full_tx); + let mut stream_a_done_rx = Some(stream_a_done_rx); + let mut stream_b_got_tx = Some(stream_b_got_tx); + while let Some(result) = h2.accept().await { + let (req, mut respond) = result.unwrap(); + if req.method() == Method::CONNECT { + let full_tx = stream_a_full_tx.take().unwrap(); + let done_rx = stream_a_done_rx.take().unwrap(); + tokio::spawn(async move { + let _send = respond.send_response(Response::new(()), false).unwrap(); + let mut body = req.into_body(); + let mut received = 0usize; + while received < STREAM_A_LEN { + match body.data().await { + Some(Ok(frame)) => received += frame.len(), + _ => return, + } + } + let _ = full_tx.send(()); + // Hold on to the recv stream, dropping it would release + // its capacity and send a WINDOW_UPDATE. + let _ = done_rx.await; + drop(body); + }); + } else { + let got_tx = stream_b_got_tx.take().unwrap(); + tokio::spawn(async move { + let mut body = req.into_body(); + let mut received = 0usize; + if let Some(Ok(frame)) = body.data().await { + received += frame.len(); + } + let _ = got_tx.send(received); + let mut send = respond.send_response(Response::new(()), false).unwrap(); + let _ = send.send_data(Bytes::from_static(b"ok"), true); + }); + } + } + }); + + let io = TokioIo::new(client_io); + let (mut client, conn) = conn::http2::Builder::new(TokioExecutor) + .handshake::<_, Full>(io) + .await + .expect("http handshake"); + tokio::spawn(async move { + let _ = conn.await; + }); + + // Stream A: a tunnel that writes STREAM_A_LEN bytes and then stays + // open, leaving one byte of connection window. + let req_a = Request::connect("localhost") + .body(Full::new(Bytes::new())) + .unwrap(); + let res_a = client.send_request(req_a).await.expect("send_request A"); + assert_eq!(res_a.status(), StatusCode::OK); + let mut upgraded = TokioIo::new(hyper::upgrade::on(res_a).await.unwrap()); + upgraded + .write_all(&vec![b'A'; STREAM_A_LEN]) + .await + .expect("write to tunnel"); + + tokio::time::timeout(Duration::from_secs(5), stream_a_full_rx) + .await + .expect("server should receive all of stream A in time") + .expect("stream_a_full_rx"); + + // Let the tunnel's send task park waiting for more writes. + for _ in 0..16 { + tokio::task::yield_now().await; + } + + // Stream B: one byte of body, which needs the last byte of window. + let req_b = Request::post("http://localhost/b") + .body(Full::new(Bytes::from_static(b"b"))) + .unwrap(); + let b_fut = client.send_request(req_b); + + let received_b = tokio::time::timeout(Duration::from_secs(5), stream_b_got_rx) + .await + .expect("stream B must reach the server even while the tunnel is idle") + .expect("stream_b_got_rx"); + assert_eq!( + received_b, 1, + "stream B should deliver its single body byte" + ); + + let _ = tokio::time::timeout(Duration::from_secs(5), b_fut).await; + + let _ = stream_a_done_tx.send(()); + drop(upgraded); + } } trait FutureHyperExt: TryFuture { From 61045ff8a2030f0127898e5af8e84b195d2daac6 Mon Sep 17 00:00:00 2001 From: "Gavin D." Date: Thu, 24 Sep 2026 16:52:24 -0400 Subject: [PATCH 111/128] style(lib): address clippy panics and missing_panics_doc lints (#4191) cc #4071 --- Cargo.toml | 2 -- src/client/conn/http1.rs | 5 +++-- src/client/conn/http2.rs | 4 ++-- src/common/lock.rs | 1 + src/common/time.rs | 4 ++++ src/ext/h1_reason_phrase.rs | 6 +++++- src/proto/h1/role.rs | 2 ++ src/rt/io.rs | 5 +++++ src/server/conn/http1.rs | 1 + 9 files changed, 23 insertions(+), 7 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 1bf1de4aca..bf62ba3857 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -132,11 +132,9 @@ map_err_ignore = "allow" map_unwrap_or = "allow" match_wild_err_arm = "allow" missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive -missing_panics_doc = "allow" # TODO: might be false multiple_inherent_impl = "allow" multiple_unsafe_ops_per_block = "allow" needless_pass_by_value = "allow" -panic = "allow" pattern_type_mismatch = "allow" redundant_closure_for_method_calls = "allow" redundant_else = "allow" diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index bdb03171d4..8e59c1f185 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -101,6 +101,7 @@ where /// # Errors /// /// Returns an error if the connection encounters an error while being polled to completion. + #[allow(clippy::missing_panics_doc)] pub async fn without_shutdown(self) -> crate::Result> { let mut conn = Some(self); crate::common::future::poll_fn(move |cx| -> Poll>> { @@ -237,7 +238,7 @@ where Ok(Ok(resp)) => Ok(resp), Ok(Err(err)) => Err(err), // this is definite bug if it happens, but it shouldn't happen! - Err(_canceled) => panic!("dispatch dropped without returning error"), + Err(_canceled) => unreachable!("dispatch dropped without returning error"), }, Err(_req) => { debug!("connection was not ready"); @@ -267,7 +268,7 @@ where Ok(Ok(res)) => Ok(res), Ok(Err(err)) => Err(err), // this is definite bug if it happens, but it shouldn't happen! - Err(_) => panic!("dispatch dropped without returning error"), + Err(_) => unreachable!("dispatch dropped without returning error"), }, Err(req) => { debug!("connection was not ready"); diff --git a/src/client/conn/http2.rs b/src/client/conn/http2.rs index 10613b354a..4df19a10cc 100644 --- a/src/client/conn/http2.rs +++ b/src/client/conn/http2.rs @@ -170,7 +170,7 @@ where Ok(Ok(resp)) => Ok(resp), Ok(Err(err)) => Err(err), // this is definite bug if it happens, but it shouldn't happen! - Err(_canceled) => panic!("dispatch dropped without returning error"), + Err(_canceled) => unreachable!("dispatch dropped without returning error"), }, Err(_req) => { debug!("connection was not ready"); @@ -201,7 +201,7 @@ where Ok(Ok(res)) => Ok(res), Ok(Err(err)) => Err(err), // this is definite bug if it happens, but it shouldn't happen! - Err(_) => panic!("dispatch dropped without returning error"), + Err(_) => unreachable!("dispatch dropped without returning error"), }, Err(req) => { debug!("connection was not ready"); diff --git a/src/common/lock.rs b/src/common/lock.rs index 4e2b70bff2..24b5d104f1 100644 --- a/src/common/lock.rs +++ b/src/common/lock.rs @@ -4,6 +4,7 @@ pub(crate) trait LockResultExt { fn panic_if_poisoned(self) -> T; } +#[allow(clippy::panic)] impl LockResultExt for LockResult { #[track_caller] fn panic_if_poisoned(self) -> T { diff --git a/src/common/time.rs b/src/common/time.rs index b3534f1580..2115c0e239 100644 --- a/src/common/time.rs +++ b/src/common/time.rs @@ -33,6 +33,7 @@ impl Time { #[cfg(all(any(feature = "client", feature = "server"), feature = "http2"))] pub(crate) fn sleep(&self, duration: Duration) -> Pin> { match &self { + #[allow(clippy::panic)] Time::Empty => { panic!("You must supply a timer.") } @@ -43,6 +44,7 @@ impl Time { #[cfg(all(feature = "server", feature = "http1"))] pub(crate) fn sleep_until(&self, deadline: Instant) -> Pin> { match &self { + #[allow(clippy::panic)] Time::Empty => { panic!("You must supply a timer.") } @@ -59,6 +61,7 @@ impl Time { pub(crate) fn reset(&self, sleep: &mut Pin>, new_deadline: Instant) { match &self { + #[allow(clippy::panic)] Time::Empty => { panic!("You must supply a timer.") } @@ -77,6 +80,7 @@ impl Time { Time::Timer(..) => Some(dur), }, Dur::Configured(Some(dur)) => match self { + #[allow(clippy::panic)] Time::Empty => panic!("timeout `{name}` set, but no timer set",), Time::Timer(..) => Some(dur), }, diff --git a/src/ext/h1_reason_phrase.rs b/src/ext/h1_reason_phrase.rs index c4c615bc20..3ebdfcf9a9 100644 --- a/src/ext/h1_reason_phrase.rs +++ b/src/ext/h1_reason_phrase.rs @@ -40,8 +40,12 @@ impl ReasonPhrase { } /// Converts a static byte slice to a reason phrase. + /// + /// # Panics + /// + /// This method will panic if 'reason' contains an invalid byte. In a const context, + /// this is a compile-time error instead. pub const fn from_static(reason: &'static [u8]) -> Self { - // TODO: this can be made const once MSRV is >= 1.57.0 assert!( find_invalid_byte(reason).is_none(), "invalid byte in static reason phrase" diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index d083d2a912..b83c8ab12b 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -433,6 +433,7 @@ impl Http1Transaction for Server { debug!("response with HTTP2 version coerced to HTTP/1.1"); extend(dst, b"HTTP/1.1 "); } + #[allow(clippy::panic)] other => panic!("unexpected response version: {other:?}"), } @@ -1221,6 +1222,7 @@ impl Http1Transaction for Client { debug!("request with HTTP2 version coerced to HTTP/1.1"); extend(dst, b"HTTP/1.1"); } + #[allow(clippy::panic)] other => panic!("unexpected request version: {other:?}"), } extend(dst, b"\r\n"); diff --git a/src/rt/io.rs b/src/rt/io.rs index 9e633e69e8..31e699d73a 100644 --- a/src/rt/io.rs +++ b/src/rt/io.rs @@ -337,6 +337,11 @@ impl ReadBufCursor<'_> { /// # Safety /// /// The caller must take care that `n` more bytes have been initialized. + /// + /// # Panics + /// + /// This method will panic if advancing would cause the filled cursor to + /// overflow `usize`. #[inline] pub unsafe fn advance(&mut self, n: usize) { self.buf.filled = self.buf.filled.checked_add(n).expect("overflow"); diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index 7bd3d72e2f..180d507e90 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -178,6 +178,7 @@ where /// # Error /// /// This errors if the underlying connection protocol is not HTTP/1. + #[allow(clippy::missing_panics_doc)] pub fn without_shutdown(self) -> impl Future>> { let mut zelf = Some(self); crate::common::future::poll_fn(move |cx| { From 846b7f550422edffd1cfd566bf244654924bacc8 Mon Sep 17 00:00:00 2001 From: Toyeshh Medikonda Date: Fri, 25 Sep 2026 21:25:48 -0500 Subject: [PATCH 112/128] docs: fix three typos (#4204) - capi/README.md: "staring with" -> "starting with" in the build instructions - docs/MSRV.md: "Minimum Support Rust Version" -> "Minimum Supported Rust Version" - docs/GOVERNANCE.md: "elsehwere" -> "elsewhere" --- capi/README.md | 2 +- docs/GOVERNANCE.md | 2 +- docs/MSRV.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/capi/README.md b/capi/README.md index becd57862f..490bb5dae7 100644 --- a/capi/README.md +++ b/capi/README.md @@ -10,7 +10,7 @@ Because of that, it's only accessible if `--cfg hyper_unstable_ffi` is passed to ## Building -The C API is part of the Rust library, but isn't compiled by default. Using `cargo`, staring with `1.64.0`, it can be compiled with the following command: +The C API is part of the Rust library, but isn't compiled by default. Using `cargo`, starting with `1.64.0`, it can be compiled with the following command: ``` RUSTFLAGS="--cfg hyper_unstable_ffi" cargo rustc --features client,http1,http2,ffi --crate-type cdylib diff --git a/docs/GOVERNANCE.md b/docs/GOVERNANCE.md index 7a1becf07c..45d7a515c9 100644 --- a/docs/GOVERNANCE.md +++ b/docs/GOVERNANCE.md @@ -117,7 +117,7 @@ How to become: - Advisors are invited by a maintainer. - Sometimes they are experienced Collaborators who must step back from active - contributing. Sometimes they are experts placed elsehwere who are friendly to + contributing. Sometimes they are experts placed elsewhere who are friendly to the project. ### Maintainer diff --git a/docs/MSRV.md b/docs/MSRV.md index 70752c9138..b8b68287eb 100644 --- a/docs/MSRV.md +++ b/docs/MSRV.md @@ -1,4 +1,4 @@ -# Minimum Support Rust Version (MSRV) +# Minimum Supported Rust Version (MSRV) hyper's current policy is to always support a Rust version at least 6 months old. That is, a compiler version released within the last 6 months can compile From fd5570111589e4d9ad477e2c0e627af4cf85b310 Mon Sep 17 00:00:00 2001 From: "Gavin D." Date: Tue, 29 Sep 2026 16:05:40 -0400 Subject: [PATCH 113/128] style(lib): address trivial, low quantity clippy lints (#4190) --- Cargo.toml | 6 ------ src/body/chan.rs | 1 + src/common/time.rs | 2 +- src/proto/h1/decode.rs | 5 ++++- src/proto/h1/io.rs | 5 ----- src/proto/h2/ping.rs | 2 ++ src/proto/h2/server.rs | 3 +-- 7 files changed, 9 insertions(+), 15 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index bf62ba3857..b721d076eb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -122,14 +122,9 @@ cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" else_if_without_else = "allow" -enum_glob_use = "allow" float_arithmetic = "allow" indexing_slicing = "allow" -integer_division = "allow" -integer_division_remainder_used = "allow" let_unit_value = "allow" -map_err_ignore = "allow" -map_unwrap_or = "allow" match_wild_err_arm = "allow" missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive multiple_inherent_impl = "allow" @@ -140,7 +135,6 @@ redundant_closure_for_method_calls = "allow" redundant_else = "allow" struct_excessive_bools = "allow" # TODO: bogus lint? trivially_copy_pass_by_ref = "allow" -unnecessary_trailing_comma = "allow" unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? unused_trait_names = "allow" # TODO: kinda annoying, but might be good to deny diff --git a/src/body/chan.rs b/src/body/chan.rs index b45bea866b..314a7d38e2 100644 --- a/src/body/chan.rs +++ b/src/body/chan.rs @@ -124,6 +124,7 @@ impl Sender { #[allow(dead_code)] #[allow(clippy::unused_async_trait_impl)] + #[allow(clippy::map_err_ignore)] pub(crate) async fn send_trailers(&mut self, trailers: HeaderMap) -> crate::Result<()> { self.try_send_trailers(trailers) .map_err(|_| crate::Error::new_closed()) diff --git a/src/common/time.rs b/src/common/time.rs index 2115c0e239..c238b4b3d4 100644 --- a/src/common/time.rs +++ b/src/common/time.rs @@ -81,7 +81,7 @@ impl Time { }, Dur::Configured(Some(dur)) => match self { #[allow(clippy::panic)] - Time::Empty => panic!("timeout `{name}` set, but no timer set",), + Time::Empty => panic!("timeout `{name}` set, but no timer set"), Time::Timer(..) => Some(dur), }, Dur::Default(None) | Dur::Configured(None) => None, diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index 65adaa784b..fe2c8d9cdf 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -314,7 +314,10 @@ impl ChunkedState { max_headers_bytes, }: StepArgs<'_>, ) -> Poll> { - use self::ChunkedState::*; + use self::ChunkedState::{ + Body, BodyCr, BodyLf, End, EndCr, EndLf, Extension, Size, SizeLf, SizeLws, Start, + Trailer, TrailerLf, + }; match *self { Start => ChunkedState::read_start(cx, body, chunk_size), Size => ChunkedState::read_size(cx, body, chunk_size), diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 386f1be35b..5833004768 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -534,12 +534,7 @@ impl WriteBuf { strategy, } } -} -impl WriteBuf -where - B: Buf, -{ fn set_strategy(&mut self, strategy: WriteStrategy) { self.strategy = strategy; } diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index 198bff465c..ab3af13b83 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -395,6 +395,8 @@ impl Bdp { // if the current `bytes` sample is at least 2/3 the previous // bdp, increase to double the current sample. + #[allow(clippy::integer_division_remainder_used)] + #[allow(clippy::integer_division)] if bytes >= self.bdp as usize * 2 / 3 { self.bdp = (bytes * 2).min(BDP_LIMIT) as WindowSize; trace!("BDP increased to {}", self.bdp); diff --git a/src/proto/h2/server.rs b/src/proto/h2/server.rs index 92026276aa..2098758d9e 100644 --- a/src/proto/h2/server.rs +++ b/src/proto/h2/server.rs @@ -267,8 +267,7 @@ where let ping = self .ping .as_ref() - .map(|ping| ping.0.clone()) - .unwrap_or_else(ping::disabled); + .map_or_else(ping::disabled, |ping| ping.0.clone()); // Record the headers received ping.record_non_data(); From d832275f557dc1b7d9c438e702928d2a074a96e0 Mon Sep 17 00:00:00 2001 From: dswij Date: Fri, 2 Oct 2026 22:24:27 +0800 Subject: [PATCH 114/128] chore(clippy): allow clippy lints following 1.99.0 release (#4219) --- Cargo.toml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Cargo.toml b/Cargo.toml index b721d076eb..e14caedf40 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -141,6 +141,10 @@ unused_trait_names = "allow" # TODO: kinda annoying, but might be good to deny unwrap_in_result = "allow" useless_borrows_in_formatting = "allow" wildcard_enum_match_arm = "allow" +definition_in_module_root = "allow" +unnecessary_rest_pattern = "allow" +rest_pattern_accessible_field = "allow" +needless_bool = "allow" # explicitly allowed absolute_paths = "allow" # sometimes its cleaner From 8ed71e06894526bf64a955a399cdd41b5b6857c9 Mon Sep 17 00:00:00 2001 From: ump45nose <52391318+ump45nose@users.noreply.github.com> Date: Sat, 3 Oct 2026 06:03:46 +0800 Subject: [PATCH 115/128] style(lib): remove useless_borrows_in_formatting from the clippy allowlist (#4220) Fix the two remaining violations in src/proto/h1/decode.rs by inlining the header variable into the format string, which also satisfies the uninlined_format_args lint that the old &header borrow was dodging. Refs: #4071 --- Cargo.toml | 1 - src/proto/h1/decode.rs | 4 ++-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e14caedf40..e8b8d33d22 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -139,7 +139,6 @@ unnested_or_patterns = "allow" unused_async = "allow" # TODO: is it for API? unused_trait_names = "allow" # TODO: kinda annoying, but might be good to deny unwrap_in_result = "allow" -useless_borrows_in_formatting = "allow" wildcard_enum_match_arm = "allow" definition_in_module_root = "allow" unnecessary_rest_pattern = "allow" diff --git a/src/proto/h1/decode.rs b/src/proto/h1/decode.rs index fe2c8d9cdf..9d8ed7f408 100644 --- a/src/proto/h1/decode.rs +++ b/src/proto/h1/decode.rs @@ -652,7 +652,7 @@ fn decode_trailers(buf: &mut BytesMut, count: usize) -> Result { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("Invalid header name: {:?}", &header), + format!("Invalid header name: {header:?}"), )); } }; @@ -662,7 +662,7 @@ fn decode_trailers(buf: &mut BytesMut, count: usize) -> Result { return Err(io::Error::new( io::ErrorKind::InvalidInput, - format!("Invalid header value: {:?}", &header), + format!("Invalid header value: {header:?}"), )); } }; From 34b31d785b4f1a71f1d5b65138461bdb671b4aa2 Mon Sep 17 00:00:00 2001 From: "Dmitry B." <4666600+badimalex@users.noreply.github.com> Date: Sat, 3 Oct 2026 18:39:34 +0300 Subject: [PATCH 116/128] style(lib): mark partial Debug impls as non-exhaustive (#4218) --- Cargo.toml | 1 - src/proto/h1/conn.rs | 2 +- src/proto/h1/io.rs | 4 ++-- 3 files changed, 3 insertions(+), 4 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index e8b8d33d22..971d748060 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -126,7 +126,6 @@ float_arithmetic = "allow" indexing_slicing = "allow" let_unit_value = "allow" match_wild_err_arm = "allow" -missing_fields_in_debug = "allow" # TODO: use finish_non_exhaustive multiple_inherent_impl = "allow" multiple_unsafe_ops_per_block = "allow" needless_pass_by_value = "allow" diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 69ca50b1a4..11059cd4e3 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -1016,7 +1016,7 @@ impl fmt::Debug for State { // Purposefully leaving off other fields.. - builder.finish() + builder.finish_non_exhaustive() } } diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index 5833004768..ab7438a28b 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -47,7 +47,7 @@ where f.debug_struct("Buffered") .field("read_buf", &self.read_buf) .field("write_buf", &self.write_buf) - .finish() + .finish_non_exhaustive() } } @@ -596,7 +596,7 @@ impl fmt::Debug for WriteBuf { f.debug_struct("WriteBuf") .field("remaining", &self.remaining()) .field("strategy", &self.strategy) - .finish() + .finish_non_exhaustive() } } From b1639094398a07331a517d97e5a450c751435d87 Mon Sep 17 00:00:00 2001 From: "Gavin D." Date: Mon, 5 Oct 2026 09:14:18 -0400 Subject: [PATCH 117/128] style(lib): address cast-possible-truncation clippy findings (#4192) --- Cargo.toml | 1 - src/proto/h1/encode.rs | 31 ++++++++++++++++++------------- src/proto/h2/ping.rs | 7 ++++--- 3 files changed, 22 insertions(+), 17 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 971d748060..cd096c0a46 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -118,7 +118,6 @@ restriction = { level = "warn", priority = -2 } arithmetic_side_effects = "allow" # TODO: consider as_conversions = "allow" # TODO: tricky -cast_possible_truncation = "allow" # TODO: consider cast_precision_loss = "allow" # TODO: consider checked_conversions = "allow" else_if_without_else = "allow" diff --git a/src/proto/h1/encode.rs b/src/proto/h1/encode.rs index 266b4dd6dd..4a9fd4348b 100644 --- a/src/proto/h1/encode.rs +++ b/src/proto/h1/encode.rs @@ -142,13 +142,18 @@ impl Encoder { } Kind::Length(remaining) => { trace!("sized write, len = {}", len); - if len as u64 > *remaining { - let limit = *remaining as usize; - *remaining = 0; - BufKind::Limited(msg.take(limit)) - } else { - *remaining -= len as u64; - BufKind::Exact(msg) + match usize::try_from(*remaining) { + // Holding more than is owed, so write only what is left. + Ok(limit) if limit < len => { + *remaining = 0; + BufKind::Limited(msg.take(limit)) + } + // Ok(_) => Owed at least what we hold, write all of it. + // Err(_) => Owed more than `usize` can represent, write all of it. + Ok(_) | Err(_) => { + *remaining -= len as u64; + BufKind::Exact(msg) + } } } #[cfg(feature = "server")] @@ -241,6 +246,7 @@ impl Encoder { dst.buffer(msg); !self.is_last } + #[allow(clippy::cast_possible_truncation)] Ordering::Greater => { dst.buffer(msg.take(remaining as usize)); !self.is_last @@ -328,11 +334,7 @@ where } } -#[cfg(target_pointer_width = "32")] -const USIZE_BYTES: usize = 4; - -#[cfg(target_pointer_width = "64")] -const USIZE_BYTES: usize = 8; +const USIZE_BYTES: usize = std::mem::size_of::(); // each byte will become 2 hex const CHUNK_SIZE_MAX_BYTES: usize = USIZE_BYTES * 2; @@ -369,6 +371,7 @@ impl Buf for ChunkSize { } #[inline] + #[allow(clippy::cast_possible_truncation)] fn advance(&mut self, cnt: usize) { assert!(cnt <= self.remaining()); self.pos += cnt as u8; // just asserted cnt fits in u8 @@ -385,12 +388,14 @@ impl fmt::Debug for ChunkSize { } impl fmt::Write for ChunkSize { + #[allow(clippy::cast_possible_truncation)] fn write_str(&mut self, num: &str) -> fmt::Result { use std::io::Write; (&mut self.bytes[self.len.into()..]) .write_all(num.as_bytes()) .expect("&mut [u8].write() cannot error"); - self.len += num.len() as u8; // safe because bytes is never bigger than 256 + debug_assert!(u8::try_from(num.len()).is_ok()); + self.len += num.len() as u8; // safe because bytes is never bigger than 255 Ok(()) } } diff --git a/src/proto/h2/ping.rs b/src/proto/h2/ping.rs index ab3af13b83..33016a28d4 100644 --- a/src/proto/h2/ping.rs +++ b/src/proto/h2/ping.rs @@ -361,12 +361,12 @@ impl Shared { // ===== impl Bdp ===== /// Any higher than this likely will be hitting the TCP flow control. -const BDP_LIMIT: usize = 1024 * 1024 * 16; +const BDP_LIMIT: WindowSize = 1024 * 1024 * 16; impl Bdp { fn calculate(&mut self, bytes: usize, rtt: Duration) -> Option { // No need to do any math if we're at the limit. - if self.bdp as usize == BDP_LIMIT { + if self.bdp == BDP_LIMIT { self.stabilize_delay(); return None; } @@ -398,7 +398,8 @@ impl Bdp { #[allow(clippy::integer_division_remainder_used)] #[allow(clippy::integer_division)] if bytes >= self.bdp as usize * 2 / 3 { - self.bdp = (bytes * 2).min(BDP_LIMIT) as WindowSize; + self.bdp = + WindowSize::try_from((bytes * 2).min(BDP_LIMIT as usize)).unwrap_or(BDP_LIMIT); trace!("BDP increased to {}", self.bdp); self.stable_count = 0; From 6790e4e8ddd566103f634811ac8664d1c32f23bb Mon Sep 17 00:00:00 2001 From: Dahale Aditya Dnyaneshwar Date: Mon, 5 Oct 2026 21:17:22 +0530 Subject: [PATCH 118/128] feat(http1): add max_header_size limit for server and client (#4183) Adds max_header_size(val: usize) on both server and client HTTP/1 builders. This allows configuring an explicit byte limit for request/response headers (including the start line and chunked trailers). When the limit is exceeded: - On server: responds with 431 Request Header Fields Too Large and closes the connection. - On client: returns a Parse::TooLarge error (is_parse_too_large). - On chunked decoder: replaces existing TODO in proto/h1/conn.rs with the configured h1_max_header_size. Closes #3832. --- src/client/conn/http1.rs | 20 ++++++++ src/proto/h1/conn.rs | 11 ++++- src/proto/h1/io.rs | 10 +++- src/proto/h1/mod.rs | 1 + src/proto/h1/role.rs | 103 +++++++++++++++++++++++++++++++++++++++ src/server/conn/http1.rs | 20 ++++++++ tests/client.rs | 65 ++++++++++++++++++++++++ tests/server.rs | 87 +++++++++++++++++++++++++++++++++ 8 files changed, 314 insertions(+), 3 deletions(-) diff --git a/src/client/conn/http1.rs b/src/client/conn/http1.rs index 8e59c1f185..43c2564280 100644 --- a/src/client/conn/http1.rs +++ b/src/client/conn/http1.rs @@ -132,6 +132,7 @@ pub struct Builder { h1_title_case_headers: bool, h1_preserve_header_case: bool, h1_max_headers: Option, + h1_max_header_size: Option, #[cfg(feature = "ffi")] h1_preserve_header_order: bool, h1_read_buf_exact_size: Option, @@ -353,6 +354,7 @@ impl Builder { h1_title_case_headers: false, h1_preserve_header_case: false, h1_max_headers: None, + h1_max_header_size: None, #[cfg(feature = "ffi")] h1_preserve_header_order: false, h1_max_buf_size: None, @@ -501,6 +503,21 @@ impl Builder { self } + /// Set the maximum size of response headers (including the status line) in bytes. + /// + /// If the server sends headers exceeding this limit, the error "message head is too large" + /// is returned. + /// + /// If not configured, then the [`max_buf_size`](Builder::max_buf_size) will naturally be reached and applied. + /// + /// This value is also used as the maximum size limit for chunked trailers. + /// + /// Default is `None`. + pub fn max_header_size(&mut self, val: usize) -> &mut Self { + self.h1_max_header_size = Some(val); + self + } + /// Set whether to support preserving original header order. /// /// Currently, this will record the order in which headers are received, and store this @@ -588,6 +605,9 @@ impl Builder { if let Some(max_headers) = opts.h1_max_headers { conn.set_http1_max_headers(max_headers); } + if let Some(max_header_size) = opts.h1_max_header_size { + conn.set_http1_max_header_size(max_header_size); + } #[cfg(feature = "ffi")] if opts.h1_preserve_header_order { conn.set_preserve_header_order(); diff --git a/src/proto/h1/conn.rs b/src/proto/h1/conn.rs index 11059cd4e3..8a5a734940 100644 --- a/src/proto/h1/conn.rs +++ b/src/proto/h1/conn.rs @@ -58,6 +58,7 @@ where method: None, h1_parser_config: ParserConfig::default(), h1_max_headers: None, + h1_max_header_size: None, #[cfg(feature = "server")] h1_header_read_timeout: None, #[cfg(feature = "server")] @@ -141,6 +142,10 @@ where self.state.h1_max_headers = Some(val); } + pub(crate) fn set_http1_max_header_size(&mut self, val: usize) { + self.state.h1_max_header_size = Some(val); + } + #[cfg(feature = "server")] pub(crate) fn set_http1_header_read_timeout(&mut self, val: Duration) { self.state.h1_header_read_timeout = Some(val); @@ -241,6 +246,7 @@ where req_method: &mut self.state.method, h1_parser_config: self.state.h1_parser_config.clone(), h1_max_headers: self.state.h1_max_headers, + h1_max_header_size: self.state.h1_max_header_size, preserve_header_case: self.state.preserve_header_case, #[cfg(feature = "ffi")] preserve_header_order: self.state.preserve_header_order, @@ -309,7 +315,7 @@ where self.try_keep_alive(cx); } } else if msg.expect_continue && msg.head.version.gt(&Version::HTTP_10) { - let h1_max_header_size = None; // TODO: remove this when we land h1_max_header_size support + let h1_max_header_size = self.state.h1_max_header_size; self.state.reading = Reading::Continue(Decoder::new( msg.decode, self.state.h1_max_headers, @@ -317,7 +323,7 @@ where )); wants = wants.add(Wants::EXPECT); } else { - let h1_max_header_size = None; // TODO: remove this when we land h1_max_header_size support + let h1_max_header_size = self.state.h1_max_header_size; self.state.reading = Reading::Body(Decoder::new( msg.decode, self.state.h1_max_headers, @@ -946,6 +952,7 @@ struct State { method: Option, h1_parser_config: ParserConfig, h1_max_headers: Option, + h1_max_header_size: Option, #[cfg(feature = "server")] h1_header_read_timeout: Option, #[cfg(feature = "server")] diff --git a/src/proto/h1/io.rs b/src/proto/h1/io.rs index ab7438a28b..bcd0204d2f 100644 --- a/src/proto/h1/io.rs +++ b/src/proto/h1/io.rs @@ -188,6 +188,7 @@ where req_method: parse_ctx.req_method, h1_parser_config: parse_ctx.h1_parser_config.clone(), h1_max_headers: parse_ctx.h1_max_headers, + h1_max_header_size: parse_ctx.h1_max_header_size, preserve_header_case: parse_ctx.preserve_header_case, #[cfg(feature = "ffi")] preserve_header_order: parse_ctx.preserve_header_order, @@ -200,8 +201,14 @@ where self.partial_len = None; return Poll::Ready(Ok(msg)); } else { - let max = self.read_buf_strategy.max(); let curr_len = self.read_buf.len(); + if let Some(max_header_size) = parse_ctx.h1_max_header_size { + if curr_len >= max_header_size { + debug!("max_header_size ({}) reached, closing", max_header_size); + return Poll::Ready(Err(crate::Error::new_too_large())); + } + } + let max = self.read_buf_strategy.max(); if curr_len >= max { debug!("max_buf_size ({}) reached, closing", max); return Poll::Ready(Err(crate::Error::new_too_large())); @@ -697,6 +704,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, diff --git a/src/proto/h1/mod.rs b/src/proto/h1/mod.rs index a17dbae83c..344d5a8a4f 100644 --- a/src/proto/h1/mod.rs +++ b/src/proto/h1/mod.rs @@ -73,6 +73,7 @@ pub(crate) struct ParseContext<'ctx> { req_method: &'ctx mut Option, h1_parser_config: ParserConfig, h1_max_headers: Option, + h1_max_header_size: Option, preserve_header_case: bool, #[cfg(feature = "ffi")] preserve_header_order: bool, diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index b83c8ab12b..262de2480a 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -180,6 +180,11 @@ impl Http1Transaction for Server { ) { Ok(httparse::Status::Complete(parsed_len)) => { trace!("Request.parse Complete({})", parsed_len); + if let Some(max_header_size) = ctx.h1_max_header_size { + if parsed_len > max_header_size { + return Err(Parse::TooLarge); + } + } len = parsed_len; let uri = req.path.expect("httparse completed"); if uri.len() > MAX_URI_LEN { @@ -1053,6 +1058,11 @@ impl Http1Transaction for Client { ) { Ok(httparse::Status::Complete(len)) => { trace!("Response.parse Complete({})", len); + if let Some(max_header_size) = ctx.h1_max_header_size { + if len > max_header_size { + return Err(Parse::TooLarge); + } + } let status = StatusCode::from_u16(res.code.expect("httparse completed"))?; let reason = { @@ -1694,6 +1704,7 @@ mod tests { req_method: &mut method, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1722,6 +1733,7 @@ mod tests { req_method: &mut Some(crate::Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1746,6 +1758,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1767,6 +1780,7 @@ mod tests { req_method: &mut Some(crate::Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1790,6 +1804,7 @@ mod tests { req_method: &mut Some(crate::Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1817,6 +1832,7 @@ mod tests { req_method: &mut Some(crate::Method::GET), h1_parser_config, h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1841,6 +1857,7 @@ mod tests { req_method: &mut Some(crate::Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1869,6 +1886,7 @@ mod tests { req_method: &mut method, h1_parser_config, h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1896,6 +1914,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1916,6 +1935,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: true, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1955,6 +1975,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -1976,6 +1997,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2216,6 +2238,7 @@ mod tests { req_method: &mut Some(Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2237,6 +2260,7 @@ mod tests { req_method: &mut Some(m), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2258,6 +2282,7 @@ mod tests { req_method: &mut Some(Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2828,6 +2853,7 @@ mod tests { req_method: &mut Some(Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2872,6 +2898,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: max_headers, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2896,6 +2923,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: max_headers, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2972,6 +3000,78 @@ mod tests { parse(Some(200), 210, false); } + #[test] + #[cfg(feature = "server")] + fn test_h1_server_max_header_size() { + let _ = pretty_env_logger::try_init(); + + let req_str = "GET / HTTP/1.1\r\nHost: example.com\r\n\r\n"; + assert_eq!(req_str.len(), 37); + + let parse_req = |max_header_size: Option| { + let mut bytes = BytesMut::from(req_str); + Server::parse( + &mut bytes, + ParseContext { + cached_headers: &mut None, + req_method: &mut None, + h1_parser_config: Default::default(), + h1_max_headers: None, + h1_max_header_size: max_header_size, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }, + ) + }; + + // Server checks + parse_req(None).unwrap().unwrap(); + parse_req(Some(37)).unwrap().unwrap(); + parse_req(Some(50)).unwrap().unwrap(); + assert!(matches!(parse_req(Some(36)), Err(Parse::TooLarge))); + assert!(matches!(parse_req(Some(10)), Err(Parse::TooLarge))); + } + + #[test] + #[cfg(feature = "client")] + fn test_h1_client_max_header_size() { + let _ = pretty_env_logger::try_init(); + + let resp_str = "HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n"; + assert_eq!(resp_str.len(), 38); + + let parse_resp = |max_header_size: Option| { + let mut bytes = BytesMut::from(resp_str); + Client::parse( + &mut bytes, + ParseContext { + cached_headers: &mut None, + req_method: &mut None, + h1_parser_config: Default::default(), + h1_max_headers: None, + h1_max_header_size: max_header_size, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }, + ) + }; + + // Client checks + parse_resp(None).unwrap().unwrap(); + parse_resp(Some(38)).unwrap().unwrap(); + parse_resp(Some(50)).unwrap().unwrap(); + assert!(matches!(parse_resp(Some(37)), Err(Parse::TooLarge))); + assert!(matches!(parse_resp(Some(10)), Err(Parse::TooLarge))); + } + #[test] fn test_is_complete_fast() { let s = b"GET / HTTP/1.1\r\na: b\r\n\r\n"; @@ -3016,6 +3116,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -3099,6 +3200,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -3144,6 +3246,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, diff --git a/src/server/conn/http1.rs b/src/server/conn/http1.rs index 180d507e90..8dc83ab586 100644 --- a/src/server/conn/http1.rs +++ b/src/server/conn/http1.rs @@ -77,6 +77,7 @@ pub struct Builder { h1_title_case_headers: bool, h1_preserve_header_case: bool, h1_max_headers: Option, + h1_max_header_size: Option, h1_header_read_timeout: Dur, h1_writev: Option, max_buf_size: Option, @@ -248,6 +249,7 @@ impl Builder { h1_title_case_headers: false, h1_preserve_header_case: false, h1_max_headers: None, + h1_max_header_size: None, h1_header_read_timeout: Dur::Default(Some(Duration::from_secs(30))), h1_writev: None, max_buf_size: None, @@ -341,6 +343,21 @@ impl Builder { self } + /// Set the maximum size of request headers (including the start line) in bytes. + /// + /// If the client sends headers exceeding this limit, the server responds to the + /// client with "431 Request Header Fields Too Large" and closes the connection. + /// + /// If not configured, then the [`max_buf_size`](Builder::max_buf_size) will naturally be reached and applied. + /// + /// This value is also used as the maximum size limit for chunked trailers. + /// + /// Default is `None`. + pub fn max_header_size(&mut self, val: usize) -> &mut Self { + self.h1_max_header_size = Some(val); + self + } + /// Set a timeout for reading client request headers. If a client does not /// transmit the entire header within this time, the connection is closed. /// @@ -476,6 +493,9 @@ impl Builder { if let Some(max_headers) = self.h1_max_headers { conn.set_http1_max_headers(max_headers); } + if let Some(max_header_size) = self.h1_max_header_size { + conn.set_http1_max_header_size(max_header_size); + } if let Some(dur) = self .timer .check(self.h1_header_read_timeout, "header_read_timeout") diff --git a/tests/client.rs b/tests/client.rs index e4c8d12cf6..4cb6615657 100644 --- a/tests/client.rs +++ b/tests/client.rs @@ -2276,6 +2276,71 @@ mod conn { let _res = client.send_request(req).await.expect("send_request"); } + #[tokio::test] + async fn client_max_header_size_exceeded() { + let (server, addr) = setup_std_test_server(); + + thread::spawn(move || { + let mut sock = server.accept().unwrap().0; + let mut buf = [0; 1024]; + sock.read(&mut buf).unwrap(); + sock.write_all(b"HTTP/1.1 200 OK\r\nX-Long: ").unwrap(); + sock.write_all(&[b'a'; 1000]).unwrap(); + sock.write_all(b"\r\n\r\n").unwrap(); + }); + + let tcp = tcp_connect(&addr).await.unwrap(); + + let (mut client, conn) = conn::http1::Builder::new() + .max_header_size(512) + .handshake(tcp) + .await + .unwrap(); + + tokio::spawn(async move { + let _ = conn.await; + }); + + let req = Request::builder() + .uri("/a") + .body(Empty::::new()) + .unwrap(); + let err = client.send_request(req).await.unwrap_err(); + assert!(err.is_parse_too_large()); + } + + #[tokio::test] + async fn client_max_header_size_accepted() { + let (server, addr) = setup_std_test_server(); + + thread::spawn(move || { + let mut sock = server.accept().unwrap().0; + let mut buf = [0; 1024]; + sock.read(&mut buf).unwrap(); + sock.write_all(b"HTTP/1.1 200 OK\r\nContent-Length: 0\r\n\r\n") + .unwrap(); + }); + + let tcp = tcp_connect(&addr).await.unwrap(); + + let (mut client, conn) = conn::http1::Builder::new() + .max_header_size(512) + .handshake(tcp) + .await + .unwrap(); + + tokio::spawn(async move { + let _ = conn.await; + }); + + let req = Request::builder() + .uri("/a") + .body(Empty::::new()) + .unwrap(); + let res = client.send_request(req).await.expect("send_request"); + assert_eq!(res.status(), StatusCode::OK); + } + #[tokio::test] async fn client_on_informational_ext() { use std::sync::atomic::{AtomicUsize, Ordering}; diff --git a/tests/server.rs b/tests/server.rs index 098ed7a6ee..094558cb61 100644 --- a/tests/server.rs +++ b/tests/server.rs @@ -2784,6 +2784,93 @@ async fn max_buf_size_split_header_boundary() { .expect_err("should TooLarge error"); } +#[cfg(feature = "http1")] +#[tokio::test] +async fn max_header_size_exceeded() { + let (listener, addr) = setup_tcp_listener(); + + const MAX_HEADER: usize = 512; + + thread::spawn(move || { + let mut tcp = connect(&addr); + tcp.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nX-Long: ") + .expect("write 1"); + tcp.write_all(&[b'a'; 1000]).expect("write 2"); + tcp.write_all(b"\r\n\r\n").expect("write 3"); + let mut buf = [0; 256]; + tcp.read(&mut buf).expect("read 1"); + + let expected = "HTTP/1.1 431 "; + assert_eq!(s(&buf[..expected.len()]), expected); + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http1::Builder::new() + .max_header_size(MAX_HEADER) + .serve_connection(socket, HelloWorld) + .await + .expect_err("should TooLarge error"); +} + +#[cfg(feature = "http1")] +#[tokio::test] +async fn max_header_size_accepted() { + let (listener, addr) = setup_tcp_listener(); + + const MAX_HEADER: usize = 512; + + thread::spawn(move || { + let mut tcp = connect(&addr); + tcp.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nConnection: close\r\n\r\n") + .expect("write"); + let mut buf = String::new(); + tcp.read_to_string(&mut buf).expect("read"); + + let expected = "HTTP/1.1 200 "; + assert_eq!(&buf[..expected.len()], expected); + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http1::Builder::new() + .max_header_size(MAX_HEADER) + .serve_connection(socket, HelloWorld) + .await + .expect("should succeed"); +} + +#[cfg(feature = "http1")] +#[tokio::test] +async fn max_header_size_with_large_max_buf_size() { + let (listener, addr) = setup_tcp_listener(); + + const MAX_HEADER: usize = 512; + const MAX_BUF: usize = 64 * 1024; + + thread::spawn(move || { + let mut tcp = connect(&addr); + tcp.write_all(b"GET / HTTP/1.1\r\nHost: x\r\nX-Long: ") + .expect("write 1"); + tcp.write_all(&[b'a'; 1000]).expect("write 2"); + tcp.write_all(b"\r\n\r\n").expect("write 3"); + let mut buf = [0; 256]; + tcp.read(&mut buf).expect("read 1"); + + let expected = "HTTP/1.1 431 "; + assert_eq!(s(&buf[..expected.len()]), expected); + }); + + let (socket, _) = listener.accept().await.unwrap(); + let socket = TokioIo::new(socket); + http1::Builder::new() + .max_buf_size(MAX_BUF) + .max_header_size(MAX_HEADER) + .serve_connection(socket, HelloWorld) + .await + .expect_err("should TooLarge error even with large max_buf_size"); +} + #[cfg(feature = "http1")] #[tokio::test] async fn graceful_shutdown_before_first_request_no_block() { From c49ee1320f068ddec8ade76015e375bbd6ad1e49 Mon Sep 17 00:00:00 2001 From: Jeremy Schoemaker Date: Mon, 5 Oct 2026 10:48:45 -0500 Subject: [PATCH 119/128] fix(http1): let a Connection close token win over a later keep-alive (#4201) When parsing a message with more than one `Connection` header line, the per-line logic keyed the branch off the current value of `keep_alive`, so a line that set `keep_alive` to false made the next line take the HTTP/1.0 arm and set it back to true. `Connection: close` followed by `Connection: keep-alive` therefore parsed as keep-alive, while the reverse order parsed correctly. Track whether any `Connection` line carried a `close` token and let that win for the rest of the loop, in both `Server::parse` and `Client::parse`. HTTP/1.0 messages that opt in with `Connection: keep-alive` are unchanged. --- src/proto/h1/role.rs | 188 ++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 176 insertions(+), 12 deletions(-) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 262de2480a..f8556db637 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -261,6 +261,7 @@ impl Http1Transaction for Server { }; let mut headers = ctx.cached_headers.take().unwrap_or_default(); + let mut saw_connection_close = false; headers.reserve(headers_len); @@ -313,12 +314,14 @@ impl Http1Transaction for Server { con_len = Some(len); } header::CONNECTION => { - // keep_alive was previously set to default for Version - if keep_alive { - // HTTP/1.1 - keep_alive = !headers::connection_close(&value); - } else { - // HTTP/1.0 + // A message may carry more than one `Connection` line. Once + // any of them asks to close, a later `keep-alive` must not + // undo it. + if saw_connection_close || headers::connection_close(&value) { + saw_connection_close = true; + keep_alive = false; + } else if !keep_alive { + // HTTP/1.0, which defaults to close keep_alive = headers::connection_keep_alive(&value); } } @@ -1112,6 +1115,7 @@ impl Http1Transaction for Client { let mut headers = ctx.cached_headers.take().unwrap_or_default(); let mut keep_alive = version == Version::HTTP_11; + let mut saw_connection_close = false; let mut header_case_map = if ctx.preserve_header_case { Some(HeaderCaseMap::default()) @@ -1134,12 +1138,14 @@ impl Http1Transaction for Client { let value = header_value!(slice.slice(header.value.0..header.value.1)); if let header::CONNECTION = name { - // keep_alive was previously set to default for Version - if keep_alive { - // HTTP/1.1 - keep_alive = !headers::connection_close(&value); - } else { - // HTTP/1.0 + // A message may carry more than one `Connection` line. Once + // any of them asks to close, a later `keep-alive` must not + // undo it. + if saw_connection_close || headers::connection_close(&value) { + saw_connection_close = true; + keep_alive = false; + } else if !keep_alive { + // HTTP/1.0, which defaults to close keep_alive = headers::connection_keep_alive(&value); } } @@ -2554,6 +2560,164 @@ mod tests { ); } + #[cfg(feature = "server")] + #[test] + fn test_parse_request_multiple_connection_headers() { + fn parse(s: &str) -> ParsedMessage { + let mut bytes = BytesMut::from(s); + Server::parse( + &mut bytes, + ParseContext { + cached_headers: &mut None, + req_method: &mut None, + h1_parser_config: Default::default(), + h1_max_headers: None, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }, + ) + .unwrap() + .unwrap() + } + + assert!( + !parse( + "\ + GET / HTTP/1.1\r\n\ + connection: close\r\n\ + connection: keep-alive\r\n\ + \r\n\ + " + ) + .keep_alive, + "close before keep-alive is still close" + ); + + assert!( + !parse( + "\ + GET / HTTP/1.1\r\n\ + connection: keep-alive\r\n\ + connection: close\r\n\ + \r\n\ + " + ) + .keep_alive, + "keep-alive before close is still close" + ); + + assert!( + parse( + "\ + GET / HTTP/1.0\r\n\ + connection: keep-alive\r\n\ + connection: foo\r\n\ + \r\n\ + " + ) + .keep_alive, + "HTTP/1.0 keep-alive is not undone by an unrelated token" + ); + + assert!( + !parse( + "\ + GET / HTTP/1.0\r\n\ + connection: keep-alive\r\n\ + connection: close\r\n\ + \r\n\ + " + ) + .keep_alive, + "HTTP/1.0 close wins over an earlier keep-alive" + ); + } + + #[cfg(feature = "client")] + #[test] + fn test_parse_response_multiple_connection_headers() { + fn parse(s: &str) -> ParsedMessage { + let mut bytes = BytesMut::from(s); + Client::parse( + &mut bytes, + ParseContext { + cached_headers: &mut None, + req_method: &mut Some(Method::GET), + h1_parser_config: Default::default(), + h1_max_headers: None, + preserve_header_case: false, + #[cfg(feature = "ffi")] + preserve_header_order: false, + h09_responses: false, + #[cfg(feature = "client")] + on_informational: &mut None, + }, + ) + .unwrap() + .unwrap() + } + + assert!( + !parse( + "\ + HTTP/1.1 200 OK\r\n\ + content-length: 0\r\n\ + connection: close\r\n\ + connection: keep-alive\r\n\ + \r\n\ + " + ) + .keep_alive, + "close before keep-alive is still close" + ); + + assert!( + !parse( + "\ + HTTP/1.1 200 OK\r\n\ + content-length: 0\r\n\ + connection: keep-alive\r\n\ + connection: close\r\n\ + \r\n\ + " + ) + .keep_alive, + "keep-alive before close is still close" + ); + + assert!( + parse( + "\ + HTTP/1.0 200 OK\r\n\ + content-length: 0\r\n\ + connection: keep-alive\r\n\ + connection: foo\r\n\ + \r\n\ + " + ) + .keep_alive, + "HTTP/1.0 keep-alive is not undone by an unrelated token" + ); + + assert!( + !parse( + "\ + HTTP/1.0 200 OK\r\n\ + content-length: 0\r\n\ + connection: keep-alive\r\n\ + connection: close\r\n\ + \r\n\ + " + ) + .keep_alive, + "HTTP/1.0 close wins over an earlier keep-alive" + ); + } + #[cfg(feature = "client")] #[test] fn test_client_obs_fold_line() { From f148aa0e752121ac7d7f0603cd25d05b0d780c62 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 5 Oct 2026 17:47:40 -0400 Subject: [PATCH 120/128] fix(http1): always remove content-length if transfer-encoding is sent (#4223) --- src/proto/h1/role.rs | 64 +++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index f8556db637..4c91319bc7 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -1455,7 +1455,7 @@ impl Client { // This is because we need a second mutable borrow to remove // content-length header. if let Some(encoder) = encoder { - if should_remove_con_len && existing_con_len.is_some() { + if should_remove_con_len { headers.remove(header::CONTENT_LENGTH); } return encoder; @@ -2572,6 +2572,7 @@ mod tests { req_method: &mut None, h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2649,6 +2650,7 @@ mod tests { req_method: &mut Some(Method::GET), h1_parser_config: Default::default(), h1_max_headers: None, + h1_max_header_size: None, preserve_header_case: false, #[cfg(feature = "ffi")] preserve_header_order: false, @@ -2736,6 +2738,66 @@ mod tests { assert_eq!(unfold("obs\r\n fold\r\n\t line"), "obs fold line",); } + #[test] + fn test_client_request_encode_explicit_transfer_encoding_removes_content_length() { + use crate::proto::BodyLength; + use http::header::HeaderValue; + + for values in [ + vec!["10"], + vec![""], + vec!["abc"], + vec!["+10"], + vec!["18446744073709551616"], + vec!["10", "11"], + vec!["10, 11"], + vec!["10", "abc"], + ] { + for transfer_encoding in ["chunked", "gzip"] { + for body in [BodyLength::Known(10), BodyLength::Unknown] { + let mut head = MessageHead::default(); + for value in &values { + head.headers + .append("content-length", HeaderValue::from_static(value)); + } + head.headers.insert( + "transfer-encoding", + HeaderValue::from_static(transfer_encoding), + ); + + let mut vec = Vec::new(); + let encoder = Client::encode( + Encode { + head: &mut head, + body: Some(body), + #[cfg(feature = "server")] + keep_alive: true, + req_method: &mut None, + title_case_headers: false, + #[cfg(feature = "server")] + date_header: true, + }, + &mut vec, + ) + .unwrap(); + + assert!(encoder.is_chunked()); + let expected_te = if transfer_encoding == "chunked" { + "chunked" + } else { + "gzip, chunked" + }; + assert_eq!( + vec, + format!("GET / HTTP/1.1\r\ntransfer-encoding: {expected_te}\r\n\r\n") + .as_bytes(), + "content-length: {values:?}, transfer-encoding: {transfer_encoding}" + ); + } + } + } + } + #[test] fn test_client_request_encode_title_case() { use crate::proto::BodyLength; From a96624def30c7d85fc196be6119ab554a1bd02f6 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Mon, 5 Oct 2026 18:40:22 -0400 Subject: [PATCH 121/128] fix(http1): remove content-length if transfer-encoding is auto-added (#4224) --- src/proto/h1/role.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 55 insertions(+) diff --git a/src/proto/h1/role.rs b/src/proto/h1/role.rs index 4c91319bc7..fce1df78af 100644 --- a/src/proto/h1/role.rs +++ b/src/proto/h1/role.rs @@ -1422,6 +1422,9 @@ impl Client { Method::GET | Method::HEAD | Method::CONNECT => Some(Encoder::length(0)), _ => { te.insert(HeaderValue::from_static("chunked")); + // A valid Content-Length would have taken precedence above. + // Remove any invalid values now that we're using chunked. + should_remove_con_len = true; Some(Encoder::chunked()) } } @@ -2798,6 +2801,58 @@ mod tests { } } + #[test] + fn test_client_request_encode_automatic_transfer_encoding_removes_content_length() { + use crate::proto::BodyLength; + use http::header::HeaderValue; + + for values in [ + vec![], + vec![""], + vec!["abc"], + vec!["+10"], + vec!["18446744073709551616"], + vec!["10", "11"], + vec!["10, 11"], + vec!["10", "abc"], + vec!["10"], + ] { + let mut head = RequestHead::default(); + head.subject.0 = Method::POST; + for value in &values { + head.headers + .append("content-length", HeaderValue::from_static(value)); + } + + let mut vec = Vec::new(); + let encoder = Client::encode( + Encode { + head: &mut head, + body: Some(BodyLength::Unknown), + #[cfg(feature = "server")] + keep_alive: true, + req_method: &mut None, + title_case_headers: false, + #[cfg(feature = "server")] + date_header: true, + }, + &mut vec, + ) + .unwrap(); + + if values == ["10"] { + assert_eq!(encoder, Encoder::length(10)); + assert_eq!(vec, b"POST / HTTP/1.1\r\ncontent-length: 10\r\n\r\n"); + } else { + assert!(encoder.is_chunked()); + assert_eq!( + vec, b"POST / HTTP/1.1\r\ntransfer-encoding: chunked\r\n\r\n", + "content-length: {values:?}" + ); + } + } + } + #[test] fn test_client_request_encode_title_case() { use crate::proto::BodyLength; From 39f422a6d1761d0cc064cdb4e8409965cc7790bc Mon Sep 17 00:00:00 2001 From: shodoco Date: Tue, 6 Oct 2026 04:56:04 -0700 Subject: [PATCH 122/128] fix(http1): withdraw a stale want when the client dispatcher takes a request (#4208) The Receiver signals want when its queue reports Pending, and SendRequest::is_ready reports that want. A want signaled after finding the queue empty can land after the Sender's give() for the request that follows, and tokio's coop budget can make the queue report Pending with a request already queued. Either way the request is taken with want set, and the connection reports ready until the response completes. hyper-util's legacy pool returns connections on is_ready, so it hands a busy connection to the next request, which then waits behind the whole response. Withdraw the want with want::Taker::unwant when a request is taken. Want is signaled again once the dispatcher is idle. Requires want with Taker::unwant (seanmonstar/want#6). --- Cargo.toml | 7 ++- src/client/dispatch.rs | 6 +++ tests/h1_ready_in_flight.rs | 95 +++++++++++++++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/h1_ready_in_flight.rs diff --git a/Cargo.toml b/Cargo.toml index cd096c0a46..7a30b770b1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -40,7 +40,7 @@ itoa = { version = "1", optional = true } pin-project-lite = { version = "0.2.4", optional = true } smallvec = { version = "1.12", features = ["const_generics", "const_new"], optional = true } tracing = { version = "0.1", default-features = false, features = ["std"], optional = true } -want = { version = "0.3", optional = true } +want = { version = "0.3.2", optional = true } [dev-dependencies] form_urlencoded = "1" @@ -350,3 +350,8 @@ required-features = ["full"] name = "h1_shutdown_while_buffered" path = "tests/h1_shutdown_while_buffered.rs" required-features = ["full"] + +[[test]] +name = "h1_ready_in_flight" +path = "tests/h1_ready_in_flight.rs" +required-features = ["full"] diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 0d91a1d92e..37afa9f78d 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -182,6 +182,12 @@ impl Receiver { pub(crate) fn poll_recv(&mut self, cx: &mut Context<'_>) -> Poll)>> { match self.inner.poll_recv(cx) { Poll::Ready(item) => { + // A want signaled after finding the queue empty can land after + // the Sender's `give()` for the message taken here, as can one + // signaled on a coop-budget Pending with a message queued. + // Withdraw it, or the Sender reports this connection as ready + // while it is still serving this message. + self.taker.unwant(); Poll::Ready(item.map(|mut env| env.0.take().expect("envelope not dropped"))) } Poll::Pending => { diff --git a/tests/h1_ready_in_flight.rs b/tests/h1_ready_in_flight.rs new file mode 100644 index 0000000000..f2317ba308 --- /dev/null +++ b/tests/h1_ready_in_flight.rs @@ -0,0 +1,95 @@ +// Test: an HTTP/1 client connection must not report ready while a request is +// in flight on it. +// +// The dispatch `Receiver` signals want when its queue reports `Pending`, and +// `SendRequest::is_ready` reports that want. The signal can go stale: the +// connection task can find its queue empty and a request land before it +// signals, or tokio's coop budget can make the queue report `Pending` with a +// request already in it. The request is then taken with want still set, so +// `is_ready` stays true until the response is complete. Pools that return a +// connection on `is_ready`, such as hyper-util's legacy client, then give that +// connection to the next request, which waits behind the whole response. +// +// The coop path is deterministic, so the test drives that one. + +use std::future::Future; +use std::io; +use std::pin::Pin; +use std::task::{Context, Poll}; + +use bytes::Bytes; +use futures_util::future::poll_fn; +use http_body_util::Empty; +use hyper::client::conn::http1::{self, Connection}; +use hyper::rt::{Read, ReadBufCursor, Write}; +use hyper::Request; + +/// Accepts every write and never answers, so a sent request stays in flight. +struct SilentIo; + +impl Read for SilentIo { + fn poll_read( + self: Pin<&mut Self>, + _: &mut Context<'_>, + _: ReadBufCursor<'_>, + ) -> Poll> { + Poll::Pending + } +} + +impl Write for SilentIo { + fn poll_write( + self: Pin<&mut Self>, + _: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } + + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +/// Runs the connection task once; it never finishes, as the peer never answers. +fn poll_conn(conn: &mut Connection>, cx: &mut Context<'_>) -> Poll<()> { + assert!(Pin::new(conn).poll(cx).is_pending()); + Poll::Ready(()) +} + +#[tokio::test] +async fn h1_connection_is_not_ready_while_a_request_is_in_flight() { + let (mut sender, mut conn) = http1::handshake(SilentIo).await.unwrap(); + + // Idle, the connection finds its queue empty and signals it is ready. + poll_fn(|cx| poll_conn(&mut conn, cx)).await; + assert!(sender.is_ready()); + + // Kept alive: dropping the response future cancels the request. + let _response = sender.send_request(Request::new(Empty::new())); + assert!(!sender.is_ready()); + + // Out of coop budget, the queue reports Pending with the request in it, so + // the connection signals ready again. A connection task preempted between + // finding its queue empty and signaling leaves the same stale signal. + poll_fn(|cx| { + while let Poll::Ready(restore) = tokio::task::coop::poll_proceed(cx) { + restore.made_progress(); + } + poll_conn(&mut conn, cx) + }) + .await; + tokio::task::yield_now().await; + + // With a fresh budget it takes and writes the request, which then waits + // for a response that never comes. + poll_fn(|cx| poll_conn(&mut conn, cx)).await; + assert!( + !sender.is_ready(), + "connection reports ready with a request in flight" + ); +} From 83721f3abb6e3588595e782bace3fde04b7be663 Mon Sep 17 00:00:00 2001 From: 0x676e67 Date: Tue, 6 Oct 2026 20:47:01 +0800 Subject: [PATCH 123/128] perf(body): skip waking a closed peer when a channel end drops (#4225) --- src/body/chan.rs | 55 ++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 53 insertions(+), 2 deletions(-) diff --git a/src/body/chan.rs b/src/body/chan.rs index 314a7d38e2..6e5f9f6f0d 100644 --- a/src/body/chan.rs +++ b/src/body/chan.rs @@ -207,8 +207,11 @@ impl Drop for Sender { fn drop(&mut self) { let mut state = self.shared.state.lock().panic_if_poisoned(); state.sender_open = false; + let receiver_open = state.receiver_open; drop(state); - self.shared.receiver_waker.wake(); + if receiver_open { + self.shared.receiver_waker.wake(); + } } } @@ -216,8 +219,11 @@ impl Drop for Receiver { fn drop(&mut self) { let mut state = self.shared.state.lock().panic_if_poisoned(); state.receiver_open = false; + let sender_open = state.sender_open; drop(state); - self.shared.sender_waker.wake(); + if sender_open { + self.shared.sender_waker.wake(); + } } } @@ -233,6 +239,11 @@ impl fmt::Debug for Sender { #[cfg(test)] mod tests { + use std::{ + sync::atomic::{AtomicUsize, Ordering}, + task::{Wake, Waker}, + }; + use super::*; async fn recv(rx: &mut Receiver) -> Option> { @@ -262,4 +273,44 @@ mod tests { assert!(recv(&mut rx).await.is_none()); assert_eq!(rx.take_trailers().unwrap()["x-trailer"], "value"); } + + #[test] + fn drop_wakes_only_an_open_peer() { + struct Count(AtomicUsize); + + impl Wake for Count { + fn wake(self: Arc) { + self.0.fetch_add(1, Ordering::Relaxed); + } + } + + let count = Arc::new(Count(AtomicUsize::new(0))); + let waker = Waker::from(count.clone()); + let mut cx = Context::from_waker(&waker); + let wakes = || count.0.load(Ordering::Relaxed); + + // A receiver dropped after the sender does not wake the sender's stale waker. + let (mut tx, rx) = channel(true); + assert!(tx.poll_ready(&mut cx).is_pending()); + drop(tx); + drop(rx); + assert_eq!(wakes(), 0); + + // Nor does a sender dropped after the receiver. + let (tx, mut rx) = channel(false); + assert!(rx.poll_next(&mut cx).is_pending()); + drop(rx); + drop(tx); + assert_eq!(wakes(), 0); + + // An open peer is still woken to observe the close. + let (mut tx, rx) = channel(true); + assert!(tx.poll_ready(&mut cx).is_pending()); + drop(rx); + assert_eq!(wakes(), 1); + let (tx, mut rx) = channel(false); + assert!(rx.poll_next(&mut cx).is_pending()); + drop(tx); + assert_eq!(wakes(), 2); + } } From f26efbf3168a9a70ce19b100258d66d2c142af58 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Tue, 6 Oct 2026 11:38:03 -0400 Subject: [PATCH 124/128] v1.12.0 --- CHANGELOG.md | 19 +++++++++++++++++++ Cargo.toml | 2 +- 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 309188a22f..30fbb11685 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,22 @@ +## v1.12.0 (2026-10-06) + + +#### Bug Fixes + +* **http1:** + * withdraw a stale want when the client dispatcher takes a request (#4208) ([c44f1cc5](https://github.com/hyperium/hyper/commit/c44f1cc51c2bc45b502031a646d3d54315bd7a16)) + * remove content-length if transfer-encoding is auto-added (#4224) ([69396fdd](https://github.com/hyperium/hyper/commit/69396fdd960629d69b09cc6c9b367ef8650c001d)) + * always remove content-length if transfer-encoding is sent (#4223) ([a5d7fd78](https://github.com/hyperium/hyper/commit/a5d7fd7861598c0b8c857cb75240a75bcfbd96a3)) + * let a Connection close token win over a later keep-alive (#4201) ([5dadff76](https://github.com/hyperium/hyper/commit/5dadff76d1cb5d21046218df3568569fbba8ed21)) + * preserve hop-by-hop when setting close or keep-alive (#4196) ([74a39081](https://github.com/hyperium/hyper/commit/74a39081ff1496970fb75da75c5282b23a9b69e5)) +* **http2:** do not reserve capacity for idle Upgraded streams (#4198) ([e89d0d93](https://github.com/hyperium/hyper/commit/e89d0d93d9566210ee95aea234a00952ebc4a202)) + + +#### Features + +* **http1:** add max_header_size limit for server and client (#4183) ([d1cd14ef](https://github.com/hyperium/hyper/commit/d1cd14eff392be63a2318d690cc9380723ec61a1), closes [#3832](https://github.com/hyperium/hyper/issues/3832)) + + ### v1.11.1 (2026-08-27) diff --git a/Cargo.toml b/Cargo.toml index 7a30b770b1..de674405be 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "hyper" -version = "1.11.1" +version = "1.12.0" description = "A protective and efficient HTTP library for all." readme = "README.md" homepage = "https://hyper.rs" From 9dbd279a4e00a3a88d4852e04eb9e69c3042c7df Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Wed, 7 Oct 2026 10:57:19 -0400 Subject: [PATCH 125/128] docs(contrib): minor improvements to HIP-0001 (#4227) - Make sure the bare links render as clickable. - Add a note to refrain from using AI/LLMs when writing a HIP. --- docs/hips/0001-hyper-improvement-proposals.md | 18 +++++++++++------- 1 file changed, 11 insertions(+), 7 deletions(-) diff --git a/docs/hips/0001-hyper-improvement-proposals.md b/docs/hips/0001-hyper-improvement-proposals.md index 03a4bfc42f..fea679c90a 100644 --- a/docs/hips/0001-hyper-improvement-proposals.md +++ b/docs/hips/0001-hyper-improvement-proposals.md @@ -2,7 +2,7 @@ - Authors: seanmonstar - Created: 2026-09-01 -- PR: https://github.com/hyperium/hyper/pull/4131 +- PR: [#4131](https://github.com/hyperium/hyper/pull/4131) ## Summary @@ -65,6 +65,10 @@ But in general, HIPs should provide the following information: - Appendices - FAQ +#### Do not use AI to write + +AI (LLMs) have their benefits, but since a major reason for writing this is the thinking process, do not use AI to write the actual English in a HIP. + #### What not to include - Minor details that do not need to be discussed to accept the proposal. @@ -122,9 +126,9 @@ Significant changes can be added to a `## History` section of the document, inse ## References -- https://rust-lang.github.io/rfcs/0002-rfc-process.html -- https://rfd.shared.oxide.computer/rfd/0001 -- https://peps.python.org/pep-0001/ -- https://docs.jj-vcs.dev/latest/design_docs/ -- https://blog.ceejbot.com/posts/design-docs/ -- https://adr.github.io/ +- +- +- +- +- +- From 8c10474badc0d057bf360ff7371ecaea128f483f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B5=A4=E5=9D=82=E7=90=86=E5=AD=90?= Date: Sat, 10 Oct 2026 04:58:30 +0800 Subject: [PATCH 126/128] fix(http1): drain in-flight requests before dispatcher shutdown (#4222) Keep the closed dispatch receiver alive until all in-progress sends have published and canceled their callbacks Preserve the connection result across drain polls and keep upgrade handoffs unchanged Closes #4202 --- src/client/dispatch.rs | 9 ++ src/proto/h1/dispatch.rs | 188 ++++++++++++++++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 12 deletions(-) diff --git a/src/client/dispatch.rs b/src/client/dispatch.rs index 37afa9f78d..76a9c3e20c 100644 --- a/src/client/dispatch.rs +++ b/src/client/dispatch.rs @@ -203,6 +203,15 @@ impl Receiver { self.inner.close(); } + #[cfg(feature = "http1")] + pub(crate) fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll<()> { + self.close(); + // A send that started before close may still be publishing its envelope. + // Keep the receiver alive until all such envelopes have been canceled. + while futures_core::ready!(self.inner.poll_recv(cx)).is_some() {} + Poll::Ready(()) + } + #[cfg(feature = "http1")] pub(crate) fn try_recv(&mut self) -> Option<(T, Callback)> { match crate::common::task::now_or_never(self.inner.recv()) { diff --git a/src/proto/h1/dispatch.rs b/src/proto/h1/dispatch.rs index d98aebcf1f..6ccbecba94 100644 --- a/src/proto/h1/dispatch.rs +++ b/src/proto/h1/dispatch.rs @@ -25,6 +25,7 @@ pub(crate) struct Dispatcher { body_tx: SenderDropGuard, body_rx: Pin>>, is_closing: bool, + pending_result: Option>, } pub(crate) trait Dispatch { @@ -40,6 +41,9 @@ pub(crate) trait Dispatch { -> crate::Result<()>; fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll>; fn should_poll(&self) -> bool; + fn poll_close(&mut self, _cx: &mut Context<'_>) -> Poll<()> { + Poll::Ready(()) + } } cfg_server! { @@ -84,6 +88,7 @@ where body_tx: SenderDropGuard::none(), body_rx: Box::pin(None), is_closing: false, + pending_result: None, } } @@ -125,19 +130,33 @@ where cx: &mut Context<'_>, should_shutdown: bool, ) -> Poll> { - Poll::Ready(ready!(self.poll_inner(cx, should_shutdown)).or_else(|e| { - // Be sure to alert a streaming body of the failure with a - // more specific error than the drop guard would provide. - if let Some(mut body) = self.body_tx.take() { - body.send_error(crate::Error::new_body("connection error")); + if self.pending_result.is_none() { + let result = ready!(self.poll_inner(cx, should_shutdown)).or_else(|e| { + // Be sure to alert a streaming body of the failure with a + // more specific error than the drop guard would provide. + if let Some(mut body) = self.body_tx.take() { + body.send_error(crate::Error::new_body("connection error")); + } + // An error means we're shutting down either way. + // We just try to give the error to the user, + // and close the connection with an Ok. If we + // cannot give it to the user, then return the Err. + self.dispatch.recv_msg(Err(e))?; + Ok(Dispatched::Shutdown) + }); + // An upgrade hands off the connection rather than shutting it down. + // Preserve the sender's readiness until the dispatcher is dropped. + if matches!(result, Ok(Dispatched::Upgrade(_))) { + return Poll::Ready(result); } - // An error means we're shutting down either way. - // We just try to give the error to the user, - // and close the connection with an Ok. If we - // cannot give it to the user, then return the Err. - self.dispatch.recv_msg(Err(e))?; - Ok(Dispatched::Shutdown) - })) + self.pending_result = Some(result); + } + + // Closing a channel does not prevent an already-started send from + // publishing later. Drain it before completing so a caller holding + // the sender while awaiting its response cannot strand a callback. + ready!(self.dispatch.poll_close(cx)); + Poll::Ready(self.pending_result.take().expect("closing result missing")) } fn poll_inner( @@ -764,6 +783,10 @@ cfg_client! { fn should_poll(&self) -> bool { self.callback.is_none() } + + fn poll_close(&mut self, cx: &mut Context<'_>) -> Poll<()> { + self.rx.poll_close(cx) + } } } @@ -774,6 +797,147 @@ mod tests { use crate::proto::h1::ClientTransaction; use std::time::Duration; + #[cfg(not(miri))] + #[tokio::test] + async fn client_close_drains_pending_requests_on_reset() { + client_close_drains_pending_requests(true, true).await; + } + + #[cfg(not(miri))] + #[tokio::test] + async fn client_close_drains_pending_requests_on_eof() { + client_close_drains_pending_requests(false, true).await; + } + + #[cfg(not(miri))] + #[tokio::test] + async fn client_close_drains_pending_requests_without_shutdown() { + client_close_drains_pending_requests(false, false).await; + } + + #[cfg(not(miri))] + async fn client_close_drains_pending_requests(reset: bool, should_shutdown: bool) { + use std::{cell::Cell, io, rc::Rc}; + + #[derive(Clone, Copy)] + enum ReadState { + Pending, + Response, + Close, + Done, + } + + // Unlike Tokio IO, this reader does not consume cooperative budget, so + // the close is observed while the dispatch channel is forced to yield. + struct ClosingReader { + state: Rc>, + reset: bool, + } + + impl tokio::io::AsyncRead for ClosingReader { + fn poll_read( + self: Pin<&mut Self>, + _cx: &mut Context<'_>, + buf: &mut tokio::io::ReadBuf<'_>, + ) -> Poll> { + match self.state.get() { + ReadState::Pending => Poll::Pending, + ReadState::Response => { + buf.put_slice(b"HTTP/1.1 200 OK\r\ncontent-length: 0\r\n\r\n"); + self.state.set(ReadState::Pending); + Poll::Ready(Ok(())) + } + ReadState::Close => { + self.state.set(ReadState::Done); + if self.reset { + Poll::Ready(Err(io::Error::new( + io::ErrorKind::ConnectionReset, + "reset", + ))) + } else { + Poll::Ready(Ok(())) + } + } + ReadState::Done => panic!("closed IO must not be polled again"), + } + } + } + + let state = Rc::new(Cell::new(ReadState::Pending)); + let io = Compat::new(tokio::io::join( + ClosingReader { + state: state.clone(), + reset, + }, + Vec::::new(), + )); + let (mut tx, rx) = crate::client::dispatch::channel(); + let conn = Conn::<_, bytes::Bytes, ClientTransaction>::new(io); + let mut dispatcher = Dispatcher::new(Client::new(rx), conn); + + // Complete an exchange to put the connection into idle keep-alive. + let mut first = tokio_test::task::spawn( + tx.try_send(crate::Request::new(IncomingBody::empty())) + .unwrap(), + ); + tokio_test::task::spawn(()).enter(|cx, _| { + assert!(Pin::new(&mut dispatcher).poll(cx).is_pending()); + state.set(ReadState::Response); + assert!(Pin::new(&mut dispatcher).poll(cx).is_pending()); + }); + tokio_test::assert_ready_ok!(first.poll()).unwrap(); + assert!(tx.is_ready()); + state.set(ReadState::Close); + let req = crate::Request::builder() + .uri("/not-sent") + .body(IncomingBody::empty()) + .unwrap(); + let promise = tx.try_send(req).unwrap(); + let mut dispatcher = tokio_test::task::spawn(futures_util::future::poll_fn(|cx| { + dispatcher.poll_catch(cx, should_shutdown) + })); + + // Exhaust Tokio's cooperative budget so even a closed channel returns + // Pending when polled. This deterministically exercises the same drain + // path as a send that has reserved a slot but not yet published it. + let (budget_tx, mut budget_rx) = tokio::sync::mpsc::unbounded_channel(); + loop { + budget_tx.send(()).unwrap(); + if task::now_or_never(budget_rx.recv()).is_none() { + break; + } + } + + assert!( + dispatcher.poll().is_pending(), + "must finish draining before exiting" + ); + assert!(tx.is_closed(), "must reject new requests while draining"); + + // The dispatcher must retain its original result while the drain is + // pending, rather than polling the closed connection again. + tokio::task::yield_now().await; + assert!(dispatcher.is_woken()); + let result = tokio_test::assert_ready!(dispatcher.poll()); + if reset { + let err = result.err().expect("connection reset error"); + assert_eq!( + err.find_source::().unwrap().kind(), + std::io::ErrorKind::ConnectionReset, + ); + } else { + assert!(matches!(result, Ok(Dispatched::Shutdown))); + } + + // Keep both the sender and dispatcher alive: cancellation must not + // depend on either one's destructor running. + let mut promise = tokio_test::task::spawn(promise); + let mut err = tokio_test::assert_ready_ok!(promise.poll()).unwrap_err(); + assert!(err.error().is_canceled()); + assert_eq!(err.take_message().unwrap().uri(), "/not-sent"); + assert!(tx.is_closed()); + } + #[test] fn client_read_bytes_before_writing_request() { let _ = pretty_env_logger::try_init(); From 75d2355f77eb8c64781d9f951521e922bb7cbd19 Mon Sep 17 00:00:00 2001 From: Sean McArthur Date: Fri, 9 Oct 2026 17:55:03 -0400 Subject: [PATCH 127/128] chore(ci): check that PR descriptions assert that the words are human (#4229) This should be able to run automatically even without our approving of new contributors PRs, so they will hopefully be alerted before we have to read some generated PR description that is overly long. Organization members are skipped, since there is already trust established. --- .github/workflows/pr-template-check.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) create mode 100644 .github/workflows/pr-template-check.yml diff --git a/.github/workflows/pr-template-check.yml b/.github/workflows/pr-template-check.yml new file mode 100644 index 0000000000..3d73e536b4 --- /dev/null +++ b/.github/workflows/pr-template-check.yml @@ -0,0 +1,11 @@ +name: PR Template Verification + +on: + pull_request_target: + types: [opened, edited, reopened, synchronize] + +permissions: {} + +jobs: + verify-template: + uses: hyperium/.github/.github/workflows/pr-template-check.yml@master From ccdf2e971398b5a28fa561fa628d2d18f9e1b28c Mon Sep 17 00:00:00 2001 From: katelyn martin Date: Tue, 13 Oct 2026 13:47:23 -0400 Subject: [PATCH 128/128] style(proto): enforce clippy:let_unit_value (#4231) see https://github.com/hyperium/hyper/issues/4071. this commit addresses the `let_unit_value` clippy lint. only two sites are affected, relating to tracing spans. this commit introduces a `hyper::trace::Disabled` type to act as a ZST marker in places that can sometimes conditionally compile to a `tracing::span::Entered` guard. this is based upon experimenting in . to restate some of those learnings here: ### background hyper contains a `trace` submodule, introduced in hyperium/hyper#3326, that contains macros like the following: ```rust macro_rules! trace { ($($arg:tt)*) => { #[cfg(feature = "tracing")] { tracing::trace!($($arg)+); } } } macro_rules! trace_span { ($($arg:tt)*) => { { #[cfg(feature = "tracing")] { let _span = tracing::trace_span!($($arg)+); _span.entered() } } } } ``` these thinly wrap `tracing`'s macros, to account for the fact that tracing support in hyper is opt-in. it is gated behind a feature flag, and additionally mandates that the `hyper_unstable_tracing` flag be enabled. however, this leads to violations of clippy's `let_unit_value` rule when compiled without the `tracing` feature flag is enabled. this is because `trace_span!` only returns a `tracing::span::Entered` when that flag is active, otherwise it returns `()`. thus, we *must* bind these calls to a variable, so that we properly hold on to the span guard when the tracing flag is active. however, we must contend with the fact that when this flag is not active, strict clippy linting yields us warnings like this: ```text cargo clippy --no-default-features Checking hyper-span-macro-example v0.1.0 error: this let-binding has unit value --> src/main.rs:52:5 | 52 | let _entered = trace_span!("instrumented"); | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ | = help: for further information visit https://rust-lang.github.io/rust-clippy/rust-1.99.0/index.html#let_unit_value ``` ### why not use `#[allow(..)]` one might try to selectively apply `#[allow(clippy::let_unit_value)]` attributes to stifle this. however, applying attributes to expressions is still a nightly feature we cannot rely on yet, so call-sites for our macros must be updated rather than modifying the code generated by the macro. see rust-lang/rust#15701. this would require us to do something like this throughout the codebase, wherever we create tracing spans: ```rust #[cfg_attr(not(feature = "tracing"), allow(clippy::let_unit_value))] let _entered = trace_span!("instrumented"); ``` `#[cfg_attr]` is a bit esoteric, and if we require that instrumenting code in hyper require this song and dance every time we create a span, it will generally discourage instrumentation. ### changes this commit introduce a `Disabled` type to replace the empty `()` when tracing instrumentation is disabled. this satisfies clippy's `let_unit_value` rule because macros like `trace_span!` will never return `()` no matter which feature flags are active. this _also_ prevents accidentally writing code that would drop span guards once the tracing flag is enabled, because an inert `#[must_use]` guard acts as a stand-in when the flag isn't present. Signed-off-by: katelyn martin --- Cargo.toml | 1 - src/trace.rs | 41 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index de674405be..a937dceba8 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -123,7 +123,6 @@ checked_conversions = "allow" else_if_without_else = "allow" float_arithmetic = "allow" indexing_slicing = "allow" -let_unit_value = "allow" match_wild_err_arm = "allow" multiple_inherent_impl = "allow" multiple_unsafe_ops_per_block = "allow" diff --git a/src/trace.rs b/src/trace.rs index 88f9a243a0..54f931f1c7 100644 --- a/src/trace.rs +++ b/src/trace.rs @@ -10,6 +10,27 @@ compile_error!( " ); +/// A type representing a RAII guard from a disabled tracing span. +/// +/// When the `tracing` feature flag is not active we do not enter tracing +/// spans. This type does nothing when dropped, and does not guard any +/// resources. +/// +/// This helps avoid expressions that conditionally compile to either `()` or +/// `tracing::span::Entered` depending on which feature flags are enabled. +// +// This ZST (zero sized type) marker only exists when the tracing flag is not +// active. +#[cfg(not(feature = "tracing"))] +// This module allows `unused_macros`. Similarly, we allow dead code in case +// none of the macros exported by this module are used elsewhere. +#[allow(dead_code)] +// This type is marked as a `#[must_use]` type not for its own semantic +// constraints, but to enforce consistency with code paths associated with +// the `tracing` feature flag. +#[must_use = "once a span has been entered, it should be exited"] +pub(crate) struct Disabled; + macro_rules! debug { ($($arg:tt)+) => { #[cfg(feature = "tracing")] @@ -27,6 +48,10 @@ macro_rules! debug_span { let _span = tracing::debug_span!($($arg)+); _span.entered() } + #[cfg(not(feature = "tracing"))] + { + crate::trace::Disabled + } } } } @@ -48,6 +73,10 @@ macro_rules! error_span { let _span = tracing::error_span!($($arg)+); _span.entered() } + #[cfg(not(feature = "tracing"))] + { + crate::trace::Disabled + } } } } @@ -69,6 +98,10 @@ macro_rules! info_span { let _span = tracing::info_span!($($arg)+); _span.entered() } + #[cfg(not(feature = "tracing"))] + { + crate::trace::Disabled + } } } } @@ -90,6 +123,10 @@ macro_rules! trace_span { let _span = tracing::trace_span!($($arg)+); _span.entered() } + #[cfg(not(feature = "tracing"))] + { + crate::trace::Disabled + } } } } @@ -123,6 +160,10 @@ macro_rules! warn_span { let _span = tracing::warn_span!($($arg)+); _span.entered() } + #[cfg(not(feature = "tracing"))] + { + crate::trace::Disabled + } } } }