diff --git a/Cargo.lock b/Cargo.lock index fd2887b5a56ec..d20867da7ad5a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -5630,6 +5630,7 @@ dependencies = [ "backon", "base64 0.22.1", "byteorder", + "bytes", "clap", "console 0.16.2", "ctrlc", @@ -5642,6 +5643,9 @@ dependencies = [ "fs-err", "futures", "http", + "http-body-util", + "hyper", + "hyper-util", "ignore", "indexmap", "indicatif", @@ -5668,6 +5672,7 @@ dependencies = [ "textwrap", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "toml", "toml_edit 0.24.0+spec-1.1.0", diff --git a/Cargo.toml b/Cargo.toml index 04d429543e0a4..8da47422e25cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -212,6 +212,7 @@ zstd = { version = "0.13.3" } assert_cmd = { version = "2.0.16" } assert_fs = { version = "1.1.2" } byteorder = { version = "1.5.0" } +bytes = { version = "1.10.1" } filetime = { version = "0.2.25" } http-body-util = { version = "0.1.2" } hyper = { version = "1.4.1", features = ["server", "http1"] } diff --git a/crates/uv-client/src/base_client.rs b/crates/uv-client/src/base_client.rs index 5e50d82b688cb..cae6c52c62936 100644 --- a/crates/uv-client/src/base_client.rs +++ b/crates/uv-client/src/base_client.rs @@ -1246,6 +1246,8 @@ fn retryable_on_request_failure(err: &(dyn Error + 'static)) -> Option (MockServer, String) { (server, mock_server_uri) } +async fn time_out_response( + _req: hyper::Request, +) -> Result>, Infallible> { + let (tx, rx) = tokio::sync::mpsc::channel(1); + tokio::spawn(async move { + let _ = tx.send(Ok(Frame::data(Bytes::new()))).await; + tokio::time::sleep(Duration::from_secs(60)).await; + }); + let body = StreamBody::new(ReceiverStream::new(rx)).boxed(); + Ok(hyper::Response::builder() + .header("Content-Type", "text/html") + .body(body) + .unwrap()) +} + +/// Returns the server URL and a drop guard that shuts down the server. +/// +/// The server runs in a thread with its own tokio runtime, so it +/// won't be starved by the subprocess blocking the test thread. Dropping the +/// guard shuts down the runtime and all tasks running in it. +fn read_timeout_server() -> (String, impl Drop) { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let server = format!("http://{}", listener.local_addr().unwrap()); + + let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>(); + + std::thread::spawn(move || { + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + runtime.block_on(async move { + let listener = tokio::net::TcpListener::from_std(listener).unwrap(); + tokio::select! { + _ = async { + loop { + let (stream, _) = listener.accept().await.unwrap(); + let io = TokioIo::new(stream); + + tokio::spawn(async move { + let _ = hyper_util::server::conn::auto::Builder::new( + hyper_util::rt::TokioExecutor::new(), + ) + .serve_connection(io, service_fn(time_out_response)) + .await; + }); + } + } => {} + _ = shutdown_rx => {} + } + }); + }); + + (server, shutdown_tx) +} + /// Check the simple index error message when the server returns HTTP status 500, a retryable error. #[tokio::test] async fn simple_http_500() { @@ -928,11 +993,11 @@ async fn proxy_schemeless_url_in_uv_toml() { } #[test] -fn connect_timeout() { +fn connect_timeout_index() { let context = TestContext::new("3.12"); - // Create a server that just times out. - let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + // Create a server that never responds, causing a timeout for our requests. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); let server = listener.local_addr().unwrap().to_string(); let start = Instant::now(); @@ -961,3 +1026,90 @@ fn connect_timeout() { "Test with 1s connect timeout took too long" ); } + +#[test] +fn connect_timeout_stream() { + let context = TestContext::new("3.12"); + + // Create a server that never responds, causing a timeout for our requests. + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let server = listener.local_addr().unwrap().to_string(); + + let start = Instant::now(); + uv_snapshot!(context.filters(), context + .pip_install() + .arg(format!("https://{server}/tqdm-0.1-py3-none-any.whl")) + .env(EnvVars::UV_HTTP_CONNECT_TIMEOUT, "1") + .env(EnvVars::UV_HTTP_RETRIES, "0"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download `tqdm @ https://[LOCALHOST]/tqdm-0.1-py3-none-any.whl` + ├─▶ Failed to fetch: `https://[LOCALHOST]/tqdm-0.1-py3-none-any.whl` + ├─▶ error sending request for url (https://[LOCALHOST]/tqdm-0.1-py3-none-any.whl) + ├─▶ client error (Connect) + ╰─▶ operation timed out + "); + + // Assumption: There's less than 2s overhead for this test and startup. + let elapsed = start.elapsed(); + assert!( + elapsed < Duration::from_secs(3), + "Test with 1s connect timeout took too long" + ); +} + +#[tokio::test] +async fn retry_read_timeout_index() { + let context = TestContext::new("3.12"); + + let (server, _guard) = read_timeout_server(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg("tqdm") + .arg("--index-url") + .arg(server) + // Speed the test up with the minimum testable values + .env(EnvVars::UV_HTTP_TIMEOUT, "1") + .env(EnvVars::UV_HTTP_RETRIES, "1"), @" + success: false + exit_code: 2 + ----- stdout ----- + + ----- stderr ----- + error: Request failed after 1 retry + Caused by: Failed to fetch: `http://[LOCALHOST]/tqdm/` + Caused by: error decoding response body + Caused by: request or response body error + Caused by: operation timed out + "); +} + +#[tokio::test] +async fn retry_read_timeout_stream() { + let context = TestContext::new("3.12"); + + let (server, _guard) = read_timeout_server(); + + uv_snapshot!(context.filters(), context + .pip_install() + .arg(format!("{server}/tqdm-0.1-py3-none-any.whl")) + // Speed the test up with the minimum testable values + .env(EnvVars::UV_HTTP_TIMEOUT, "1") + .env(EnvVars::UV_HTTP_RETRIES, "1"), @" + success: false + exit_code: 1 + ----- stdout ----- + + ----- stderr ----- + × Failed to download `tqdm @ http://[LOCALHOST]/tqdm-0.1-py3-none-any.whl` + ├─▶ Request failed after 1 retry + ├─▶ Failed to read metadata: `http://[LOCALHOST]/tqdm-0.1-py3-none-any.whl` + ├─▶ Failed to read from zip file + ├─▶ an upstream reader returned an error: Failed to download distribution due to network timeout. Try increasing UV_HTTP_TIMEOUT (current value: [TIME]). + ╰─▶ Failed to download distribution due to network timeout. Try increasing UV_HTTP_TIMEOUT (current value: [TIME]). + "); +}