This commit is contained in:
Mateusz Gruszczyński committed 2026-10-11 22:12:27 +02:00
1 parent 75104f87ba
commit 4cc994b3bc
86 files changed
+942 -223

No files matched your search

+37 -4
View File
@@ -342,8 +342,24 @@ async fn empty_for_cache(request: &mut Request) -> Result<bool, axum::Error> {
return Ok(false);
}
// Multiplexed transports may end a body with an empty DATA frame.
// On H3 document GET/HEAD, waiting without a bound can stall the entire
// cache/404-routing stage before proxy forwarding even starts. A timed
// probe that finds no FIN conservatively bypasses caching; it never drops
// or consumes a nonempty request body.
let bounded_h3_navigation = request.version() == Version::HTTP_3
&& (request.method() == Method::GET || request.method() == Method::HEAD);
for _ in 0..8 {
match request.body_mut().frame().await {
let next = if bounded_h3_navigation {
match tokio::time::timeout(
std::time::Duration::from_millis(35), request.body_mut().frame(),
).await {
Ok(frame) => frame,
Err(_) => return Ok(false),
}
} else {
request.body_mut().frame().await
};
match next {
None => {
*request.body_mut() = Body::empty();
return Ok(true);
@@ -381,8 +397,6 @@ async fn normalize_h3_h1_unknown_body(
let method = request.method();
if request.version() != Version::HTTP_3
|| upstream_version != Version::HTTP_11
|| method == Method::GET
|| method == Method::HEAD
|| method == Method::CONNECT
|| request.headers().contains_key(http::header::CONTENT_LENGTH)
|| request.body().size_hint().exact().is_some()
@@ -393,8 +407,27 @@ async fn normalize_h3_h1_unknown_body(
// Ignore a small number of empty DATA frames while looking for either the
// stream end or the first frame that carries request semantics. The body
// idle timer in MeteredBody still bounds a stalled peer.
// A full page navigation (e.g. clicking the RustProxy logo) is a GET.
// H3 HEADERS can precede FIN on a document GET. Resolve the receive half
// when FIN arrives promptly, before handing the request to an H1 origin.
// Hyper already special-cases GET/HEAD H1 encoding; this probe targets
// receive-stream completion, not a presumed chunked GET on the wire.
// When the peer is slow or sends a real request body, retain streaming and
// do not delay navigation by the general upload/body-idle deadline.
let navigation_method = method == Method::GET || method == Method::HEAD;
for _ in 0..8 {
match request.body_mut().frame().await {
let next = if navigation_method {
match tokio::time::timeout(
std::time::Duration::from_millis(35),
request.body_mut().frame(),
).await {
Ok(frame) => frame,
Err(_) => return Ok(()),
}
} else {
request.body_mut().frame().await
};
match next {
None => {
*request.body_mut() = Body::empty();
return Ok(());
+2
View File
@@ -8,6 +8,8 @@ include!("forward_entry.rs");
#[cfg(test)]
include!("tests/authority_tests.rs");
#[cfg(test)]
include!("tests/h3_navigation_tests.rs");
#[cfg(test)]
include!("tests/http_invalidation_tests.rs");
+75
View File
@@ -0,0 +1,75 @@
mod h3_navigation_tests {
use super::*;
#[tokio::test]
async fn h3_bodyless_get_and_head_are_normalized_for_http1_navigation() {
use bytes::Bytes;
for method in [Method::GET, Method::HEAD] {
let stream = futures_util::stream::empty::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder()
.method(method.clone())
.version(Version::HTTP_3)
.uri("https://rp.example.test/")
.body(Body::from_stream(stream))
.unwrap();
assert_eq!(request.body().size_hint().exact(), None);
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap();
assert!(request.body().is_end_stream(), "{method}");
assert_eq!(request.body().size_hint().exact(), Some(0), "{method}");
}
}
#[tokio::test]
async fn h3_get_with_payload_retains_payload_for_http1_upstream() {
use bytes::Bytes;
let stream = futures_util::stream::iter([Ok::<_, std::io::Error>(Bytes::from_static(b"unexpected-but-valid"))]);
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11).await.unwrap();
let body = request.into_body().collect().await.unwrap().to_bytes();
assert_eq!(body, Bytes::from_static(b"unexpected-but-valid"));
}
#[tokio::test]
async fn h3_stalled_cache_probe_bypasses_without_blocking_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
let empty = tokio::time::timeout(
std::time::Duration::from_millis(750), empty_for_cache(&mut request),
).await.expect("bounded cache preflight").unwrap();
assert!(!empty, "unknown body is not cacheable");
assert_eq!(request.body().size_hint().exact(), None);
}
#[tokio::test]
async fn h3_cache_probe_replays_nonempty_get_without_dropping_bytes() {
use bytes::Bytes;
let stream = futures_util::stream::iter([
Ok::<_, std::io::Error>(Bytes::from_static(b"intact")),
]);
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
assert!(!empty_for_cache(&mut request).await.unwrap());
assert_eq!(request.into_body().collect().await.unwrap().to_bytes(), Bytes::from_static(b"intact"));
}
#[tokio::test]
async fn h3_stalled_empty_get_does_not_block_navigation() {
use bytes::Bytes;
let stream = futures_util::stream::pending::<Result<Bytes, std::io::Error>>();
let mut request = Request::builder().method(Method::GET)
.version(Version::HTTP_3).uri("https://rp.example.test/")
.body(Body::from_stream(stream)).unwrap();
tokio::time::timeout(
std::time::Duration::from_millis(750),
normalize_h3_h1_unknown_body(&mut request, Version::HTTP_11),
).await.expect("bounded GET probe").unwrap();
assert_eq!(request.body().size_hint().exact(), None);
}
}