feat(serve): split request read budget into head_timeout + body_timeout
The single request_timeout covered head + body under one wall clock, so a slow-but-legit body upload (e.g. a trickling cellular IoT client) was judged by the short head deadline and killed mid-body. Split into: - head_timeout (default 30s): first byte -> full head parse; the classic slowloris surface, kept short. - body_timeout (default 300s): head parse -> full body; an absolute cap sized for slow links, anchored independently once the head has parsed. read_head no longer returns a shared deadline; run_connection anchors the body deadline itself. ReadHeadErr::RequestTimeout -> HeadTimeout. Config and ConnLimits gain body_timeout; request_timeout renamed to head_timeout (breaking, but this axis is unreleased). Tests: slow_body_outlives_head_timeout (positive: body survives past the head clock), fixed_/chunked_body_stall_killed_at_body_timeout (body cap still bites), slowloris_partial_head_killed_at_head_timeout (head clock unchanged). 79 lib + 43 integration green; clippy --lib clean.
This commit is contained in:
+79
-12
@@ -437,7 +437,8 @@ fn shutdown_force_stops_at_drain_deadline() {
|
||||
fn spawn_server_with_timeouts(
|
||||
pipeline: Pipeline,
|
||||
keep_alive: Duration,
|
||||
request: Duration,
|
||||
head: Duration,
|
||||
body: Duration,
|
||||
) -> u16 {
|
||||
let port = free_port();
|
||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
@@ -446,7 +447,8 @@ fn spawn_server_with_timeouts(
|
||||
listener_pool: 2,
|
||||
scheduler_threads: Some(2),
|
||||
keep_alive_timeout: keep_alive,
|
||||
request_timeout: request,
|
||||
head_timeout: head,
|
||||
body_timeout: body,
|
||||
..Config::new(addr)
|
||||
};
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
@@ -486,7 +488,8 @@ fn idle_keepalive_reaped_at_keep_alive_timeout() {
|
||||
let port = spawn_server_with_timeouts(
|
||||
pipe,
|
||||
Duration::from_millis(300), // keep_alive_timeout under test
|
||||
Duration::from_secs(10), // request_timeout out of the way
|
||||
Duration::from_secs(10), // head_timeout out of the way
|
||||
Duration::from_secs(10), // body_timeout out of the way
|
||||
);
|
||||
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
@@ -511,17 +514,18 @@ fn idle_keepalive_reaped_at_keep_alive_timeout() {
|
||||
}
|
||||
|
||||
/// A slowloris client that sends a partial head and then stalls is killed
|
||||
/// at request_timeout with a best-effort 408, even though the (large)
|
||||
/// keep-alive budget hasn't expired.
|
||||
/// at head_timeout with a best-effort 408, even though the (large)
|
||||
/// keep-alive and body budgets haven't expired.
|
||||
#[test]
|
||||
fn slowloris_partial_head_killed_at_request_timeout() {
|
||||
fn slowloris_partial_head_killed_at_head_timeout() {
|
||||
let pipe = Pipeline::new().plug(
|
||||
Router::new().get("/", |c: Conn, _n: Next| c.put_status(200))
|
||||
);
|
||||
let port = spawn_server_with_timeouts(
|
||||
pipe,
|
||||
Duration::from_secs(10), // keep_alive_timeout out of the way
|
||||
Duration::from_millis(300), // request_timeout under test
|
||||
Duration::from_millis(300), // head_timeout under test
|
||||
Duration::from_secs(10), // body_timeout out of the way
|
||||
);
|
||||
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
@@ -913,14 +917,16 @@ fn chunked_plus_content_length_400() {
|
||||
assert_eq!(http_status(&resp), 400);
|
||||
}
|
||||
|
||||
/// A chunked body that stalls mid-stream is killed by the request
|
||||
/// deadline: the connection just closes (no response owed mid-body).
|
||||
/// A chunked body that stalls mid-stream is killed by the BODY deadline
|
||||
/// (head budget generous): the connection just closes (no response owed
|
||||
/// mid-body).
|
||||
#[test]
|
||||
fn chunked_request_stall_killed_at_request_timeout() {
|
||||
fn chunked_body_stall_killed_at_body_timeout() {
|
||||
let port = spawn_server_with_timeouts(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30),
|
||||
Duration::from_millis(400), // request_timeout
|
||||
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||
Duration::from_secs(30), // head_timeout out of the way
|
||||
Duration::from_millis(400), // body_timeout under test
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
@@ -936,6 +942,67 @@ fn chunked_request_stall_killed_at_request_timeout() {
|
||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||
}
|
||||
|
||||
/// The core of the head/body split: a client that sends a COMPLETE head
|
||||
/// promptly and then trickles its (small) body over a span LONGER than
|
||||
/// head_timeout still succeeds, because the body runs on its own, larger
|
||||
/// budget. Under the old shared request clock this would have been killed
|
||||
/// mid-body at head_timeout. This is the slow-but-legit IoT upload we must
|
||||
/// not punish.
|
||||
#[test]
|
||||
fn slow_body_outlives_head_timeout() {
|
||||
let port = spawn_server_with_timeouts(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||
Duration::from_millis(500), // head_timeout: SHORT
|
||||
Duration::from_secs(8), // body_timeout: generous
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
// Full head at once (parses well within head_timeout), Connection:
|
||||
// close so the server closes after responding and read_to_end lands
|
||||
// the whole response.
|
||||
s.write_all(
|
||||
b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 4\r\nConnection: close\r\n\r\n",
|
||||
)
|
||||
.unwrap();
|
||||
// Trickle the 4-byte body at 250ms/byte => ~1s total, well past the
|
||||
// 500ms head_timeout but inside the 8s body_timeout.
|
||||
for b in b"test" {
|
||||
std::thread::sleep(Duration::from_millis(250));
|
||||
s.write_all(&[*b]).unwrap();
|
||||
}
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).expect("expected full response");
|
||||
assert_eq!(http_status(&resp), 200, "resp: {:?}", String::from_utf8_lossy(&resp));
|
||||
assert!(
|
||||
resp.ends_with(b"test"),
|
||||
"expected echoed body 'test', got: {:?}", String::from_utf8_lossy(&resp)
|
||||
);
|
||||
}
|
||||
|
||||
/// A fixed-Content-Length body that stalls before completing is killed by
|
||||
/// the BODY deadline (head budget generous): silent close, nothing owed
|
||||
/// mid-body. The fixed-path twin of chunked_body_stall_killed_at_body_timeout.
|
||||
#[test]
|
||||
fn fixed_body_stall_killed_at_body_timeout() {
|
||||
let port = spawn_server_with_timeouts(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30), // keep_alive_timeout out of the way
|
||||
Duration::from_secs(30), // head_timeout out of the way
|
||||
Duration::from_millis(400), // body_timeout under test
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
// Promises 100 bytes, sends a few, then stalls forever.
|
||||
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 100\r\n\r\npartial")
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).unwrap(); // server closes; EOF
|
||||
assert!(resp.is_empty(), "expected silent close, got: {:?}", String::from_utf8_lossy(&resp));
|
||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE (v0.3 chunk 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user