feat(serve): burst-gated body stall eviction
The body budget from the prior commit is a generous absolute cap; alone it just hands a body-phase slowloris a bigger window. Add a stall gate under that cap that distinguishes a slowloris trickle from a slow-but-legit client by requiring BURSTS, not a mere average rate: - BodyStallGate: each body read is bounded by min(body cap, mark + stall). The stall mark advances only when body_burst_bytes accumulate since the last advance, so a steady sub-burst trickle never moves it and is evicted at ~body_stall_timeout, while a bursty slow client keeps resetting it. - Two words of state; one add + one compare per read. Raw socket bytes are counted, so chunked framing counts and an MSS-fragmented burst still accumulates. Reuses the existing read_some deadline plumbing. - Wired into read_body (fixed CL) and read_chunked_body (via fill_to, the single choke point all chunked reads pass through). - New knobs body_burst_bytes (4 KiB) + body_stall_timeout (20s); effective floor ~205 B/s enforced in bursts. Tests: body_smooth_trickle_evicted_at_stall_timeout (chunked; active sub-burst trickle evicted at ~stall while the cap is far away) and bursty_slow_body_survives_stall_gate (fixed CL; real bursts with sub-stall gaps complete intact). 79 lib + 45 integration green; clippy --lib clean.
This commit is contained in:
@@ -462,6 +462,39 @@ fn spawn_server_with_timeouts(
|
||||
panic!("server didn't come up on {addr}");
|
||||
}
|
||||
|
||||
/// Spawn a server with the body stall-gate knobs under test; keep-alive
|
||||
/// and head budgets are set out of the way so only the body path matters.
|
||||
fn spawn_server_with_body_gate(
|
||||
pipeline: Pipeline,
|
||||
head: Duration,
|
||||
body: Duration,
|
||||
burst_bytes: usize,
|
||||
stall: Duration,
|
||||
) -> u16 {
|
||||
let port = free_port();
|
||||
let addr: SocketAddr = format!("127.0.0.1:{port}").parse().unwrap();
|
||||
std::thread::spawn(move || {
|
||||
let cfg = Config {
|
||||
listener_pool: 2,
|
||||
scheduler_threads: Some(2),
|
||||
keep_alive_timeout: Duration::from_secs(30),
|
||||
head_timeout: head,
|
||||
body_timeout: body,
|
||||
body_burst_bytes: burst_bytes,
|
||||
body_stall_timeout: stall,
|
||||
..Config::new(addr)
|
||||
};
|
||||
serve_with(cfg, pipeline).unwrap();
|
||||
});
|
||||
for _ in 0..50 {
|
||||
if TcpStream::connect(addr).is_ok() {
|
||||
return port;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(50));
|
||||
}
|
||||
panic!("server didn't come up on {addr}");
|
||||
}
|
||||
|
||||
/// Read from `s` until the response head is complete (double CRLF). Only
|
||||
/// suitable for responses with an empty body.
|
||||
fn read_response_head(s: &mut TcpStream) -> Vec<u8> {
|
||||
@@ -1003,6 +1036,78 @@ fn fixed_body_stall_killed_at_body_timeout() {
|
||||
assert!(start.elapsed() < Duration::from_secs(3), "close took too long");
|
||||
}
|
||||
|
||||
/// Burst gate, NEGATIVE (chunked path): a client that ACTIVELY but SMOOTHLY
|
||||
/// trickles sub-burst bytes is evicted at ~body_stall_timeout — even though
|
||||
/// the absolute body_timeout is far away and the client never goes fully
|
||||
/// silent. This is the slowloris-body case the gate exists to catch, and
|
||||
/// exercises the fill_to gate in read_chunked_body.
|
||||
#[test]
|
||||
fn body_smooth_trickle_evicted_at_stall_timeout() {
|
||||
let port = spawn_server_with_body_gate(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30), // head_timeout out of the way
|
||||
Duration::from_secs(30), // body_timeout out of the way (prove it's the STALL gate)
|
||||
4096, // body_burst_bytes
|
||||
Duration::from_millis(800), // body_stall_timeout under test
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(5))).unwrap();
|
||||
// Head + a chunk-size line announcing a 4096-byte chunk, then trickle
|
||||
// its payload one byte at a time: never a full burst, so the stall mark
|
||||
// never advances.
|
||||
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nTransfer-Encoding: chunked\r\n\r\n1000\r\n")
|
||||
.unwrap();
|
||||
let start = std::time::Instant::now();
|
||||
let mut evicted = false;
|
||||
for _ in 0..200 { // up to ~20s; eviction expected at ~800ms
|
||||
if s.write_all(&[b'x']).is_err() {
|
||||
evicted = true; // server closed on us -> write failed
|
||||
break;
|
||||
}
|
||||
std::thread::sleep(Duration::from_millis(100));
|
||||
}
|
||||
assert!(evicted, "server never evicted the smooth sub-burst trickle");
|
||||
assert!(
|
||||
start.elapsed() < Duration::from_secs(3),
|
||||
"eviction took {:?}, expected ~800ms (stall gate, not the 30s cap)", start.elapsed()
|
||||
);
|
||||
}
|
||||
|
||||
/// Burst gate, POSITIVE (fixed-CL path): a slow-but-legit client that
|
||||
/// delivers real bursts with gaps SHORTER than body_stall_timeout keeps
|
||||
/// resetting the stall mark and completes intact. This is the slow IoT
|
||||
/// upload the gate must NOT punish; exercises the read_body gate.
|
||||
#[test]
|
||||
fn bursty_slow_body_survives_stall_gate() {
|
||||
let port = spawn_server_with_body_gate(
|
||||
echo_pipeline(),
|
||||
Duration::from_secs(30), // head_timeout out of the way
|
||||
Duration::from_secs(30), // body_timeout out of the way
|
||||
4096, // body_burst_bytes
|
||||
Duration::from_secs(2), // body_stall_timeout: gaps stay under this
|
||||
);
|
||||
let mut s = TcpStream::connect(("127.0.0.1", port)).unwrap();
|
||||
s.set_read_timeout(Some(Duration::from_secs(10))).unwrap();
|
||||
// Promise 3 * 4096 bytes, Connection: close so read_to_end lands the
|
||||
// full echo.
|
||||
let burst = vec![b'x'; 4096];
|
||||
s.write_all(b"POST /echo HTTP/1.1\r\nHost: x\r\nContent-Length: 12288\r\nConnection: close\r\n\r\n")
|
||||
.unwrap();
|
||||
for i in 0..3 {
|
||||
s.write_all(&burst).unwrap();
|
||||
if i < 2 {
|
||||
std::thread::sleep(Duration::from_millis(500)); // < 2s stall window
|
||||
}
|
||||
}
|
||||
let mut resp = Vec::new();
|
||||
s.read_to_end(&mut resp).expect("expected full response");
|
||||
assert_eq!(http_status(&resp), 200, "resp head: {:?}", String::from_utf8_lossy(&resp[..resp.len().min(120)]));
|
||||
let body_at = resp.windows(4).position(|w| w == b"\r\n\r\n").expect("no head terminator") + 4;
|
||||
let body = &resp[body_at..];
|
||||
assert_eq!(body.len(), 12288, "echoed body truncated: {} bytes", body.len());
|
||||
assert!(body.iter().all(|&b| b == b'x'), "echoed body corrupted");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// SSE (v0.3 chunk 3)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
Reference in New Issue
Block a user