fix(http): advertise keep-alive on HTTP/1.0 responses we keep open

1.0 defaults to close: honoring 'Connection: Keep-Alive' without echoing
it back left spec-following clients waiting for an EOF that never came
(ab -k deadlocked to its poll timeout on response 1).

As agreed:
- serialise_response echoes 'connection: keep-alive' when keep_alive
  is on AND version is 1.0. 1.1 keep-alive stays implicit.
- Error statuses (>=400) on 1.0 force keep_alive off in the conn actor
  (joins the existing 1.0-stream force-close): we don't advertise reuse
  on a 4xx/5xx to a protocol generation where reuse is opt-in.
- HEAD needs nothing: the echo is framing-neutral.
- Parse-error responses already carried 'connection: close'.

Tests: unit pair (1.0 echo / 1.1 implicit) + integration pair (socket
actually reused on 1.0; 404 forces close + EOF). Validated against the
original repro: ab -k -n 5000 -c 50 GET /users -> 5000/5000 keep-alive,
0 failed, ~63k rps (~18.9k without keep-alive).
This commit is contained in:
Claude
2026-06-12 09:15:11 +00:00
parent c171be7ddc
commit 64137cccf0
4 changed files with 91 additions and 19 deletions
+5 -2
View File
@@ -248,10 +248,13 @@ pub fn run_connection(
// ----- 4. Write the response. -----
// A Stream body on HTTP/1.0 has no chunked framing: the body is
// delimited by EOF, so keep-alive is forced off for this response
// (and `connection: close` goes on the wire).
// (and `connection: close` goes on the wire). Error statuses on
// 1.0 also force close: we don't advertise keep-alive on a
// 4xx/5xx to a protocol generation where reuse is opt-in.
let is_stream = matches!(response_conn.resp_body, RespBody::Stream(_));
let is_error = response_conn.status.unwrap_or(200) >= 400;
let keep_alive = keep_alive
&& !(is_stream && version == HttpVersion::Http10);
&& !(version == HttpVersion::Http10 && (is_stream || is_error));
let head_bytes = parser::serialise_response(&response_conn, keep_alive);
if write_all(raw, &head_bytes, Instant::now() + limits.write_timeout).is_err() {
+28 -2
View File
@@ -252,8 +252,17 @@ pub fn serialise_response(conn: &Conn, keep_alive: bool) -> Vec<u8> {
out.extend_from_slice(b"\r\n");
}
if !wrote_connection && !keep_alive {
out.extend_from_slice(b"connection: close\r\n");
if !wrote_connection {
if !keep_alive {
out.extend_from_slice(b"connection: close\r\n");
} else if conn.version == HttpVersion::Http10 {
// HTTP/1.0 defaults to close: a connection we intend to keep
// open MUST be advertised back, or a spec-following client
// waits for an EOF that never comes (`ab -k` deadlocked on
// exactly this). 1.1 keep-alive is the default and stays
// implicit.
out.extend_from_slice(b"connection: keep-alive\r\n");
}
}
out.extend_from_slice(b"\r\n");
@@ -452,6 +461,23 @@ mod tests {
assert!(s.contains("connection: close"));
}
#[test]
fn serialise_http10_keepalive_is_echoed() {
let mut conn = Conn::new().put_status(200).put_body("hi");
conn.version = HttpVersion::Http10;
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(s.contains("connection: keep-alive"), "head: {s}");
}
#[test]
fn serialise_http11_keepalive_stays_implicit() {
let conn = Conn::new().put_status(200).put_body("hi");
let bytes = serialise_response(&conn, true);
let s = std::str::from_utf8(&bytes).unwrap();
assert!(!s.contains("connection:"), "head: {s}");
}
#[test]
fn serialise_user_content_length_is_respected() {
let conn = Conn::new().put_status(200)