Skip to content

Conversation

jiahaoliang
Copy link

add http10_disable_keep_alive() for server and client to disable keep_alive connections for HTTP/1.0 requests and response

Server and Client can optionally disable HTTP/1.0 keepalive. If it's set, for http1.0 request and response, connection: keep-alive will be ignored and overridden with connection: close. Connections' state.keepalive is maintained accordingly.

@seanmonstar this is our vendored patch to fix following issues. Do you think if it is appropriate to merge into upstream?
fix #3588 and https://github.com/hyperium/hyper/security/advisories/GHSA-85g5-4q7m-6cgx

add http10_disable_keep_alive() for server and client to disable keep_alive connections for HTTP/1.0 requests and response
@seanmonstar
Copy link
Member

I don't expect to merge much more to 0.14.x in general... But even for 1.x, my initial feelings are that it's easier to do outside of hyper, simply with something like:

if resp.version() == Version::HTTP_10 {
    resp.headers.insert("connection", "close");
}

@jiahaoliang
Copy link
Author

jiahaoliang commented Aug 27, 2025

I don't expect to merge much more to 0.14.x in general... But even for 1.x, my initial feelings are that it's easier to do outside of hyper, simply with something like:

if resp.version() == Version::HTTP_10 {
    resp.headers.insert("connection", "close");
}

@seanmonstar
Thank you for the reply.

In certain cases you simply can’t force a connection to close outside of Hyper. For example, if you send an HTTP/1.1 keep-alive request but the server replies with HTTP/1.0 keep-alive, Hyper will put that connection back in the pool—and there’s no way to evict it, even if you add a Connection: close header after reading the response.

I fully respect the back-porting policy. If upstream decides this patch isn’t suitable, we can leave it in our own branch.

@seanmonstar
Copy link
Member

Ah, gotcha! Good point. For the 0.14 Client, you could make use of an existing extension that allows poisoning connections out of the pool:

let captured = hyper::client::connect::capture_connection(&mut req);
let resp = client.request(req).await?;
if resp.version() == Version::HTTP_10 {
    captured.connection_metadata().unwrap().poison();
}

For reference, the AWS SDK introduced this mechanism so they could evict connections based on custom criteria.

@jiahaoliang
Copy link
Author

Wow! Excellent! That's exactly what I am looking for!

@jiahaoliang
Copy link
Author

@seanmonstar I found something unexpected with poison().

Setup

client sent an HTTP1.0 request with "connection": "close", server responsed with HTTP1.1 response (connection header ommited, hyper treats it as keep-alive by default).

I implemented a dumb server which intentionally NOT closing tcp connection even recv a "connection": "close" request.

I observed that even with poison(), for the second request, conn evicted from pool, but conn stay connected for exactly 1min instead of closing immediately.

It seems that with poison(), hyper relies on the idle checking task to regularly check for poisoned connections and reap them off, intead of respecting "connection": "close" header and close the socket once the response is fully received.

If I use the patch in this PR, client closes the connection immediately every time as expected.

POC

steps

  1. curl -i --http1.0 http://127.0.0.1:8081/h11 --header "Connection: keep-alive" -- good, conn evicted from pool, client closes the connection immediately
  2. curl -i --http1.0 http://127.0.0.1:8081/h11 --header "Connection: keep-alive" -- bad, conn evicted from pool, but conn stay established for exactly 1min.
  3. retry multiple times, all good.

Client (a proxy)

// incomming req..
println!("request: {:?}", req);
if matches!(req.version(), Version::HTTP_10) {
    // if http1.0 req, rewrite to connection: close
    req.headers_mut().insert(CONNECTION, "close".parse().unwrap());
};
let req_version = req.version();
let result = client.request(req).await;
println!("response: {:?}", result);
result.map(|mut resp| {
  if matches!(resp.version(), Version::HTTP_10) || matches!(req_version, Version::HTTP_10) {
      // drop connection for http1.0
      if captured.connection_metadata().as_ref().map(|conn| {
          println!("poison connection");
          conn.poison();
      })
      resp.headers_mut().insert(CONNECTION, "close".parse().unwrap());
  };
  resp
})

Dumb server

#[tokio::main]
async fn main() {
    let listen: SocketAddr = "127.0.0.1:8084".parse().unwrap();
    println!("server h11 {listen:?}");
    let listener = TcpListener::bind(listen).await.unwrap();
    loop {
        let (mut stream, _) = listener.accept().await.unwrap();
        println!("new stream");
        tokio::spawn(async move {
            let mut data = [0u8; 1024 * 32];
            while let Ok(size) = stream.read(&mut data).await {
                if size == 0 {
                    continue
                }
                let data = String::from_utf8_lossy(&data[..size]);
                println!("[{}]", data);
                if data.contains("HTTP/1.0") {
                    println!("HTTP/1.0 received, I can't handle it correctly");
                    stream.write_all(b"HTTP/1.1 400 Bad Request\r\nContent-Length: 0\r\n\r\n").await.unwrap();
                } else {
                    stream.write_all(b"HTTP/1.1 200 OK\r\nconnection: keep-alive\r\nContent-Length: 0\r\n\r\n").await.unwrap();
                }
            }
        });
    }
}

LOG

new conn: 127.0.0.1:58802
--------------------------------------------------first good request------------------------------------------------------
Aug 29 15:32:15.884 TRACE hyper::proto::h1::conn: Conn::read_head
Aug 29 15:32:15.886 TRACE hyper::proto::h1::io: received 105 bytes
Aug 29 15:32:15.887 TRACE parse_headers: hyper::proto::h1::role: Request.parse bytes=105
Aug 29 15:32:15.887 TRACE parse_headers: hyper::proto::h1::role: Request.parse Complete(105)
Aug 29 15:32:15.889 DEBUG hyper::proto::h1::io: parsed 4 headers
Aug 29 15:32:15.889 DEBUG hyper::proto::h1::conn: incoming body is empty
Aug 29 15:32:15.889 TRACE hyper::proto::h1::dispatch: req is comming
request: Request { method: GET, uri: /h11, version: HTTP/1.0, headers: {"host": "127.0.0.1:8081", "user-agent": "curl/7.68.0", "accept": "*/*", "connection": "keep-alive"}, body: Body(Empty) }
forward to: 127.0.0.1:8084
Aug 29 15:32:15.890 TRACE hyper::client::pool: checkout waiting for idle connection: ("http", 127.0.0.1:8084)
Aug 29 15:32:15.890 TRACE hyper::client::connect::http: Http::connect; scheme=Some("http"), host=Some("127.0.0.1"), port=Some(Port(8084))
Aug 29 15:32:15.890 DEBUG hyper::client::connect::http: connecting to 127.0.0.1:8084
Aug 29 15:32:15.891 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:15.891 DEBUG hyper::client::connect::http: connected to 127.0.0.1:8084
Aug 29 15:32:15.891 TRACE hyper::client::conn: client handshake Http1
Aug 29 15:32:15.891 TRACE hyper::client::client: handshake complete, spawning background dispatcher task
Aug 29 15:32:15.891 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:15.891 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Busy }
Aug 29 15:32:15.891 TRACE hyper::client::pool: checkout dropped for ("http", 127.0.0.1:8084)
Aug 29 15:32:15.892 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:15.892 TRACE encode_headers: hyper::proto::h1::role: Client::encode method=GET, body=None
Aug 29 15:32:15.892 DEBUG hyper::proto::h1::io: flushed 100 bytes
Aug 29 15:32:15.892 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: KeepAlive, keep_alive: Busy }
Aug 29 15:32:15.892 TRACE hyper::proto::h1::conn: Conn::read_head
Aug 29 15:32:15.892 TRACE hyper::proto::h1::io: received 47 bytes
Aug 29 15:32:15.892 TRACE parse_headers: hyper::proto::h1::role: Response.parse bytes=47
Aug 29 15:32:15.893 TRACE parse_headers: hyper::proto::h1::role: Response.parse Complete(47)
Aug 29 15:32:15.893 DEBUG hyper::proto::h1::io: parsed 1 headers
Aug 29 15:32:15.893 DEBUG hyper::proto::h1::conn: incoming body is empty
Aug 29 15:32:15.893 TRACE hyper::proto::h1::conn: maybe_notify; read_from_io blocked
Aug 29 15:32:15.893 TRACE hyper::proto::h1::dispatch: req is comming
Aug 29 15:32:15.893 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:32:15.893 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:32:15.893 TRACE hyper::client::pool: put; add idle connection for ("http", 127.0.0.1:8084)
Aug 29 15:32:15.893 DEBUG hyper::client::pool: pooling idle connection for ("http", 127.0.0.1:8084)
response: Ok(Response { status: 400, version: HTTP/1.1, headers: {"content-length": "0"}, body: Body(Empty) })

poison connection
Aug 29 15:32:15.893 DEBUG hyper::client::connect: connection was poisoned poison_pill=PoisonPill@0x7a2f280099e0 { poisoned: true }
Aug 29 15:32:15.893 TRACE encode_headers: hyper::proto::h1::role: Server::encode status=400, body=None, req_method=Some(GET)
Aug 29 15:32:15.894 DEBUG hyper::proto::h1::io: flushed 108 bytes
Aug 29 15:32:15.894 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:32:15.894 TRACE hyper::client::pool: idle interval checking for expired
Aug 29 15:32:15.894 TRACE hyper::client::client: marking Connected { alpn: None, is_proxied: false, extra: Some(Extra), poisoned: PoisonPill@0x7a2f280099e0 { poisoned: true } } as closed because it was poisoned
Aug 29 15:32:15.894 TRACE hyper::client::pool: idle interval evicting closed for ("http", 127.0.0.1:8084)
Aug 29 15:32:15.894 TRACE hyper::proto::h1::dispatch: client tx closed
Aug 29 15:32:15.894 TRACE hyper::proto::h1::conn: State::close_read()
Aug 29 15:32:15.894 TRACE hyper::proto::h1::conn: State::close_write()
Aug 29 15:32:15.894 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Closed, writing: Closed, keep_alive: Disabled }
Aug 29 15:32:15.894 TRACE hyper::proto::h1::conn: shut down IO complete





--------------------------------------------------second bad request------------------------------------------------------
Aug 29 15:32:46.381 TRACE hyper::proto::h1::conn: Conn::read_head
Aug 29 15:32:46.381 TRACE hyper::proto::h1::io: received 105 bytes
Aug 29 15:32:46.381 TRACE parse_headers: hyper::proto::h1::role: Request.parse bytes=105
Aug 29 15:32:46.381 TRACE parse_headers: hyper::proto::h1::role: Request.parse Complete(105)
Aug 29 15:32:46.381 DEBUG hyper::proto::h1::io: parsed 4 headers
Aug 29 15:32:46.382 DEBUG hyper::proto::h1::conn: incoming body is empty
Aug 29 15:32:46.382 TRACE hyper::proto::h1::dispatch: req is comming
request: Request { method: GET, uri: /h11, version: HTTP/1.0, headers: {"host": "127.0.0.1:8081", "user-agent": "curl/7.68.0", "accept": "*/*", "connection": "keep-alive"}, body: Body(Empty) }
forward to: 127.0.0.1:8084
Aug 29 15:32:46.382 TRACE hyper::client::pool: checkout waiting for idle connection: ("http", 127.0.0.1:8084)
Aug 29 15:32:46.382 TRACE hyper::client::connect::http: Http::connect; scheme=Some("http"), host=Some("127.0.0.1"), port=Some(Port(8084))
Aug 29 15:32:46.382 DEBUG hyper::client::connect::http: connecting to 127.0.0.1:8084
Aug 29 15:32:46.382 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:46.382 DEBUG hyper::client::connect::http: connected to 127.0.0.1:8084
Aug 29 15:32:46.383 TRACE hyper::client::conn: client handshake Http1
Aug 29 15:32:46.383 TRACE hyper::client::client: handshake complete, spawning background dispatcher task
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Busy }
Aug 29 15:32:46.383 TRACE hyper::client::pool: checkout dropped for ("http", 127.0.0.1:8084)
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: KeepAlive, writing: Init, keep_alive: Busy }
Aug 29 15:32:46.383 TRACE encode_headers: hyper::proto::h1::role: Client::encode method=GET, body=None
Aug 29 15:32:46.383 DEBUG hyper::proto::h1::io: flushed 100 bytes
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: KeepAlive, keep_alive: Busy }
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: Conn::read_head
Aug 29 15:32:46.383 TRACE hyper::proto::h1::io: received 47 bytes
Aug 29 15:32:46.383 TRACE parse_headers: hyper::proto::h1::role: Response.parse bytes=47
Aug 29 15:32:46.383 TRACE parse_headers: hyper::proto::h1::role: Response.parse Complete(47)
Aug 29 15:32:46.383 DEBUG hyper::proto::h1::io: parsed 1 headers
Aug 29 15:32:46.383 DEBUG hyper::proto::h1::conn: incoming body is empty
Aug 29 15:32:46.383 TRACE hyper::proto::h1::conn: maybe_notify; read_from_io blocked
Aug 29 15:32:46.384 TRACE hyper::proto::h1::dispatch: req is comming
Aug 29 15:32:46.384 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:32:46.384 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:32:46.384 TRACE hyper::client::pool: put; add idle connection for ("http", 127.0.0.1:8084)
Aug 29 15:32:46.384 DEBUG hyper::client::pool: pooling idle connection for ("http", 127.0.0.1:8084)
response: Ok(Response { status: 400, version: HTTP/1.1, headers: {"content-length": "0"}, body: Body(Empty) })

poison connection
Aug 29 15:32:46.384 DEBUG hyper::client::connect: connection was poisoned poison_pill=PoisonPill@0x7a2f600069f0 { poisoned: true }
Aug 29 15:32:46.384 TRACE encode_headers: hyper::proto::h1::role: Server::encode status=400, body=None, req_method=Some(GET)
Aug 29 15:32:46.384 DEBUG hyper::proto::h1::io: flushed 108 bytes
Aug 29 15:32:46.384 TRACE hyper::proto::h1::conn: flushed({role=server}): State { reading: Init, writing: Init, keep_alive: Idle }
Aug 29 15:33:45.894 TRACE hyper::client::pool: idle interval checking for expired
Aug 29 15:33:45.894 TRACE hyper::client::client: marking Connected { alpn: None, is_proxied: false, extra: Some(Extra), poisoned: PoisonPill@0x7a2f600069f0 { poisoned: true } } as closed because it was poisoned
Aug 29 15:33:45.894 TRACE hyper::client::pool: idle interval evicting closed for ("http", 127.0.0.1:8084)
Aug 29 15:33:45.894 TRACE hyper::proto::h1::dispatch: client tx closed
Aug 29 15:33:45.894 TRACE hyper::proto::h1::conn: State::close_read()
Aug 29 15:33:45.894 TRACE hyper::proto::h1::conn: State::close_write()
Aug 29 15:33:45.894 TRACE hyper::proto::h1::conn: flushed({role=client}): State { reading: Closed, writing: Closed, keep_alive: Disabled }
Aug 29 15:33:45.894 TRACE hyper::proto::h1::conn: shut down IO complete

@jiahaoliang jiahaoliang reopened this Sep 1, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

2 participants