Skip to content

ambiguous ChannelClosed errors due to a race in hyper-util #2649

@dare3path

Description

@dare3path

When using reqwest (v0.12) with hyper-util’s legacy HTTP client (backed by hyper v1.6.0), connection errors during HTTP/1.1 connection setup (e.g., TLS handshake failures, unexpected server responses) are sometimes masked as vague hyper::Error(ChannelClosed) errors. This occurs due to a race condition in hyper-util’s connection handling, where errors from the background connection task are discarded if the connection channel closes before readiness is confirmed. This makes debugging challenging, especially in scenarios like mTLS setups or servers sending unsolicited responses.

code to reproduce the issue (click me to expand)

Generate certs:
openssl req -x509 -newkey rsa:2048 -nodes -days 365 -keyout server.key -out server.crt -subj "/CN=localhost"
now you have server.crt and server.key in current dir.

Run this mtls_server.py python server which will serve errors(so to speak):

#!/usr/bin/python3
import socket
import ssl
import time

HOST = "127.0.0.1"
PORT = 8443
CERT_FILE = "server.crt"
KEY_FILE = "server.key"
CLIENT_CA_FILE = "client_ca.crt"

def create_ssl_context():
    context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
    context.minimum_version = ssl.TLSVersion.TLSv1_3
    context.maximum_version = ssl.TLSVersion.TLSv1_3
    context.load_cert_chain(certfile=CERT_FILE, keyfile=KEY_FILE)
    context.verify_mode = ssl.CERT_REQUIRED
    context.load_verify_locations(cafile=CLIENT_CA_FILE)
    return context

def main():
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
    server_socket.bind((HOST, PORT))
    server_socket.listen(1)
    
    print(f"Server started on {HOST}:{PORT} with mTLS")
    
    context = create_ssl_context()
    ssl_server_socket = context.wrap_socket(server_socket, server_side=True, do_handshake_on_connect=True)
    
    while True:
        try:
            client_socket, addr = ssl_server_socket.accept()
            print(f"(unreachable) Connection from {addr}")
            
        except ssl.SSLError as e:
            print(f"TLS handshake error: {e}")
        except Exception as e:
            print(f"Error: {e}")
            continue
    
    ssl_server_socket.close()

if __name__ == "__main__":
    main()

Make a rust project (cargo new mtls_test && cd mtls_test)
this will be a TLS client that doesn't give client certs,
and replace the two files:
Cargo.toml

[package]
name = "mtls_test"
version = "0.1.0"
edition = "2024"

[dependencies]
reqwest = { version = "0.12", features = ["default", "native-tls"] }
tokio = { version = "1.44", features = ["full"] }
tracing-subscriber = { version = "0.3.19", features = ["env-filter"] }

[profile.dev]
debug=1

[patch.crates-io]
# Use a patched hyper-util crate/repo like this:
# cd /tmp
# git clone https://github.com/hyperium/hyper-util.git
# make sure the PR or patch is applied in /tmp/hyper-util
hyper-util = { path = "/tmp/hyper-util" }

src/main.rs:

use reqwest;
use std::time::Duration;
use tokio::time::sleep;
use reqwest::Certificate;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
    // Initialize tracing, to see it do:
    // $ export RUST_LOG=trace
    tracing_subscriber::fmt()
        .with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
        .init();


    // Send requests to observe error consistency
    for i in 0..20 {
    // Create reqwest client with TLS but no client certificate
    let client = reqwest::Client::builder()
        .timeout(Duration::from_millis(500))
        .danger_accept_invalid_certs(false)
        .add_root_certificate(
            Certificate::from_pem(
                &std::fs::read("../server.crt")?)?)
        .build()?;
        println!("-------- Sending request {}", i + 1);
        match client.get("https://localhost:8443/").send().await {
            Ok(response) => {
                let text = response.text().await?;
                println!("Received: {}", text);
            }
            Err(e) => {
                // Check if the error is the expected TLS "certificate required" error
                if format!("{:?}", e).contains("tlsv13 alert certificate required") {
                    println!("Ok");
                } else {
                    println!("{:?}", e);
                }
            }
        }
        //sleep(Duration::from_millis(500)).await;
    }

    println!("Waiting 1 second before exiting");
    sleep(Duration::from_secs(1)).await;

    Ok(())
}
outputs (click me to expand):

cargo run:

  • without patch, outputs racy and wrong error(s):
-------- Sending request 1
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 2
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 3
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 4
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 5
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 6
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 7
Ok
-------- Sending request 8
Ok
-------- Sending request 9
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 10
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 11
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 12
Ok
-------- Sending request 13
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 14
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 15
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 16
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 17
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 18
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 19
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
-------- Sending request 20
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }
  • with patch, outputs:
     Running `target/debug/mtls_test`
-------- Sending request 1
Ok
-------- Sending request 2
Ok
-------- Sending request 3
Ok
-------- Sending request 4
Ok
-------- Sending request 5
Ok
-------- Sending request 6
Ok
-------- Sending request 7
Ok
-------- Sending request 8
Ok
-------- Sending request 9
Ok
-------- Sending request 10
Ok
-------- Sending request 11
Ok
-------- Sending request 12
Ok
-------- Sending request 13
Ok
-------- Sending request 14
Ok
-------- Sending request 15
Ok
-------- Sending request 16
Ok
-------- Sending request 17
Ok
-------- Sending request 18
Ok
-------- Sending request 19
Ok
-------- Sending request 20
Ok
Waiting 1 second before exiting

Those Ok are a placeholder for the correct error which is this:
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(Io, Custom { kind: Other, error: Error { code: ErrorCode(1), cause: Some(Ssl(ErrorStack([Error { code: 167773276, library: "SSL routines", function: "ssl3_read_bytes", reason: "tlsv13 alert certificate required", file: "ssl/record/rec_layer_s3.c", line: 911, data: "SSL alert number 116" }]))) } })) }

instead of the wrong error which is:
reqwest::Error { kind: Request, url: "https://localhost:8443/", source: hyper_util::client::legacy::Error(SendRequest, hyper::Error(ChannelClosed)) }

re #1808 (comment)

PR is here hyperium/hyper-util#184

Thanks to Grok 31 (created by xAI) for helping me debug the ChannelClosed issue, develop this patch, and test it with reqwest and custom clients.

Footnotes

  1. It literally would've been impossible without an AI/Grok3 because I would've given up long ago, heck I didn't even know what futures are and all that polling and async (not that I know now, but it's better than nothing).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions