diff --git a/crates/test-programs/src/bin/p2_http_outbound_request_between_bytes_timeout.rs b/crates/test-programs/src/bin/p2_http_outbound_request_between_bytes_timeout.rs new file mode 100644 index 000000000000..e9df552d5c15 --- /dev/null +++ b/crates/test-programs/src/bin/p2_http_outbound_request_between_bytes_timeout.rs @@ -0,0 +1,79 @@ +//! `between-bytes-timeout` bounds the wait for each subsequent chunk of the body. +//! +//! The server sends the response headers plus one chunk, then stalls well past the +//! timeout, so reading the second chunk has to fail rather than wait for it. +//! `http.wit` documents these timeouts as transport-layer timeouts, so the expected +//! failure is a read timeout — and `io.wit` warns that the debug string of an error +//! "should not be consumed mechanically", so this only asserts the error's kind. +use anyhow::{Result, anyhow}; +use test_programs::wasi::http::{outgoing_handler, types as http_types}; +use test_programs::wasi::io::streams; + +fn main() -> Result<()> { + let addr = std::env::var("HTTP_SERVER").unwrap(); + + let headers = + http_types::Headers::from_list(&[("User-agent".to_string(), b"WASI-HTTP/0.0.1".to_vec())])?; + let request = http_types::OutgoingRequest::new(headers); + request + .set_method(&http_types::Method::Get) + .map_err(|()| anyhow!("set_method"))?; + request + .set_scheme(Some(&http_types::Scheme::Http)) + .map_err(|()| anyhow!("set_scheme"))?; + request + .set_authority(Some(&addr)) + .map_err(|()| anyhow!("set_authority"))?; + request + .set_path_with_query(Some("/between-bytes-timeout")) + .map_err(|()| anyhow!("set_path"))?; + + let outgoing_body = request.body().map_err(|_| anyhow!("request.body"))?; + + let options = http_types::RequestOptions::new(); + options + .set_between_bytes_timeout(Some(500_000_000)) // 500ms + .map_err(|()| anyhow!("set_between_bytes_timeout"))?; + + let future_response = outgoing_handler::handle(request, Some(options))?; + http_types::OutgoingBody::finish(outgoing_body, None)?; + + let incoming_response = match future_response.get() { + Some(r) => r.map_err(|()| anyhow!("taken"))??, + None => { + future_response.subscribe().block(); + future_response + .get() + .expect("available") + .map_err(|()| anyhow!("taken"))?? + } + }; + + let incoming_body = incoming_response + .consume() + .map_err(|()| anyhow!("consume"))?; + let stream = incoming_body.stream().map_err(|()| anyhow!("stream"))?; + let pollable = stream.subscribe(); + + // The first chunk has already been sent by the server, so it must arrive. + pollable.block(); + let first = stream + .read(1024) + .map_err(|e| anyhow!("reading the first chunk failed: {e:?}"))?; + assert!( + !first.is_empty(), + "the first chunk should have arrived before the stall" + ); + + // The second chunk is stalled well past the timeout, so this read must fail. + pollable.block(); + let err = stream + .read(1024) + .expect_err("reading the stalled second chunk should have timed out"); + assert!( + matches!(err, streams::StreamError::LastOperationFailed(_)), + "expected the read to fail with a last-operation failure: {err:?}" + ); + + Ok(()) +} diff --git a/crates/test-programs/src/bin/p2_http_outbound_request_first_byte_timeout.rs b/crates/test-programs/src/bin/p2_http_outbound_request_first_byte_timeout.rs new file mode 100644 index 000000000000..0794743d0c16 --- /dev/null +++ b/crates/test-programs/src/bin/p2_http_outbound_request_first_byte_timeout.rs @@ -0,0 +1,39 @@ +//! `first-byte-timeout` bounds the wait for the first byte of the response. +//! +//! The server accepts the connection but delays its response headers well past +//! the timeout, so the request has to fail rather than wait for the headers. +//! `http.wit` documents these timeouts as transport-layer timeouts, separate +//! from any the user may use to bound a request, so the expected error is a +//! read timeout. +use std::time::Duration; + +use test_programs::wasi::http::types::{ErrorCode, Method, Scheme}; + +fn main() { + let addr = std::env::var("HTTP_SERVER").unwrap(); + let first_byte = Duration::from_millis(500).as_nanos() as u64; + + let res = test_programs::http::request( + Method::Get, + Scheme::Http, + &addr, + "/first-byte-timeout", + None, + None, + None, // connect_timeout + Some(first_byte), // first_byte_timeout + None, // between_bytes_timeout + ); + + let err = match res { + Ok(_) => panic!("the delayed response headers should have timed out"), + Err(err) => err, + }; + assert!( + matches!( + err.downcast_ref::(), + Some(ErrorCode::ConnectionReadTimeout) + ), + "expected a connection read timeout: {err:?}" + ); +} diff --git a/crates/test-programs/src/bin/p3_http_outbound_request_first_byte_timeout.rs b/crates/test-programs/src/bin/p3_http_outbound_request_first_byte_timeout.rs new file mode 100644 index 000000000000..cf4cc371f1a1 --- /dev/null +++ b/crates/test-programs/src/bin/p3_http_outbound_request_first_byte_timeout.rs @@ -0,0 +1,52 @@ +//! `first-byte-timeout` bounds the wait for the first byte of the response. +//! +//! The server accepts the connection but delays its response headers well past +//! the timeout, so the request has to fail rather than wait for the headers. +//! `http.wit` documents these timeouts as transport-layer timeouts, separate +//! from any the user may use to bound a request, so the expected error is a +//! read timeout. +use std::time::Duration; + +use test_programs::p3::wasi::http::types::{ErrorCode, Method, Scheme}; + +struct Component; + +test_programs::p3::export!(Component); + +impl test_programs::p3::exports::wasi::cli::run::Guest for Component { + async fn run() -> Result<(), ()> { + let addr = test_programs::p3::wasi::cli::environment::get_environment() + .into_iter() + .find_map(|(k, v)| k.eq("HTTP_SERVER").then_some(v)) + .unwrap(); + let first_byte = Duration::from_millis(500).as_nanos() as u64; + + let res = test_programs::p3::http::request( + Method::Get, + Scheme::Http, + &addr, + "/first-byte-timeout", + None, + None, + None, // connect_timeout + Some(first_byte), // first_byte_timeout + None, // between_bytes_timeout + ) + .await; + + let err = match res { + Ok(_) => panic!("the delayed response headers should have timed out"), + Err(err) => err, + }; + assert!( + matches!( + err.downcast_ref::(), + Some(ErrorCode::ConnectionReadTimeout) + ), + "expected a connection read timeout: {err:?}" + ); + Ok(()) + } +} + +fn main() {} diff --git a/crates/wasi-http/tests/all/http_server.rs b/crates/wasi-http/tests/all/http_server.rs index f5fb90599ec2..5bc87311107c 100644 --- a/crates/wasi-http/tests/all/http_server.rs +++ b/crates/wasi-http/tests/all/http_server.rs @@ -94,6 +94,54 @@ impl Server { }) } + /// Test-only: delay the response headers, to exercise `first-byte-timeout`. + pub fn http1_delayed(conns: usize, header_delay: std::time::Duration) -> Result { + debug!("initializing delayed http1 server"); + Self::new(conns, move |io| async move { + let service = service_fn(move |_req| async move { + tokio::time::sleep(header_delay).await; + Ok::<_, std::convert::Infallible>(Response::new(crate::body::full( + hyper::body::Bytes::new(), + ))) + }); + let mut builder = hyper::server::conn::http1::Builder::new(); + let http = builder.keep_alive(false).pipeline_flush(true); + let conn = http.serve_connection(io, service).await; + conn?; + Ok(()) + }) + } + + /// Test-only: send the response headers plus one chunk of body, then stall. + /// Exercises `between-bytes-timeout`. + pub fn http1_stalled_body(conns: usize, stall: std::time::Duration) -> Result { + debug!("initializing stalled-body http1 server"); + Self::new(conns, move |io| async move { + let service = service_fn(move |_req| async move { + use futures::StreamExt as _; + let stream = futures::stream::once(async { + Ok::<_, std::io::Error>(hyper::body::Frame::data( + hyper::body::Bytes::from_static(b"first"), + )) + }) + .chain(futures::stream::once(async move { + tokio::time::sleep(stall).await; + Ok(hyper::body::Frame::data(hyper::body::Bytes::from_static( + b"second", + ))) + })); + Ok::<_, std::convert::Infallible>(Response::new(http_body_util::BodyExt::boxed( + http_body_util::StreamBody::new(stream), + ))) + }); + let mut builder = hyper::server::conn::http1::Builder::new(); + let http = builder.keep_alive(false).pipeline_flush(true); + let conn = http.serve_connection(io, service).await; + conn?; + Ok(()) + }) + } + pub fn http2(conns: usize) -> Result { debug!("initializing http2 server"); Self::new(conns, |io| async move { diff --git a/crates/wasi-http/tests/all/p2/async_.rs b/crates/wasi-http/tests/all/p2/async_.rs index 56a4fbcabd2d..90a5ff6dccf2 100644 --- a/crates/wasi-http/tests/all/p2/async_.rs +++ b/crates/wasi-http/tests/all/p2/async_.rs @@ -24,6 +24,26 @@ async fn p2_http_outbound_request_get() -> Result<()> { run(P2_HTTP_OUTBOUND_REQUEST_GET_COMPONENT, &server).await } +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p2_http_outbound_request_between_bytes_timeout() -> Result<()> { + let server = Server::http1_stalled_body(1, std::time::Duration::from_secs(5))?; + run( + P2_HTTP_OUTBOUND_REQUEST_BETWEEN_BYTES_TIMEOUT_COMPONENT, + &server, + ) + .await +} + +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p2_http_outbound_request_first_byte_timeout() -> Result<()> { + let server = Server::http1_delayed(1, std::time::Duration::from_secs(5))?; + run( + P2_HTTP_OUTBOUND_REQUEST_FIRST_BYTE_TIMEOUT_COMPONENT, + &server, + ) + .await +} + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn p2_http_outbound_request_timeout() -> Result<()> { let server = Server::http1(3)?; diff --git a/crates/wasi-http/tests/all/p2/sync.rs b/crates/wasi-http/tests/all/p2/sync.rs index 22f8f523ddb1..edfa44b0dc7b 100644 --- a/crates/wasi-http/tests/all/p2/sync.rs +++ b/crates/wasi-http/tests/all/p2/sync.rs @@ -24,6 +24,24 @@ fn p2_http_outbound_request_get() -> Result<()> { run(P2_HTTP_OUTBOUND_REQUEST_GET_COMPONENT, &server) } +#[test_log::test] +fn p2_http_outbound_request_between_bytes_timeout() -> Result<()> { + let server = Server::http1_stalled_body(1, std::time::Duration::from_secs(5))?; + run( + P2_HTTP_OUTBOUND_REQUEST_BETWEEN_BYTES_TIMEOUT_COMPONENT, + &server, + ) +} + +#[test_log::test] +fn p2_http_outbound_request_first_byte_timeout() -> Result<()> { + let server = Server::http1_delayed(1, std::time::Duration::from_secs(5))?; + run( + P2_HTTP_OUTBOUND_REQUEST_FIRST_BYTE_TIMEOUT_COMPONENT, + &server, + ) +} + #[test_log::test] fn p2_http_outbound_request_timeout() -> Result<()> { let server = Server::http1(3)?; diff --git a/crates/wasi-http/tests/all/p3/mod.rs b/crates/wasi-http/tests/all/p3/mod.rs index 97fc1c6d98d2..fbace8277dec 100644 --- a/crates/wasi-http/tests/all/p3/mod.rs +++ b/crates/wasi-http/tests/all/p3/mod.rs @@ -194,6 +194,16 @@ async fn p3_http_outbound_request_get() -> wasmtime::Result<()> { run_cli(P3_HTTP_OUTBOUND_REQUEST_GET_COMPONENT, &server).await } +#[test_log::test(tokio::test(flavor = "multi_thread"))] +async fn p3_http_outbound_request_first_byte_timeout() -> wasmtime::Result<()> { + let server = Server::http1_delayed(1, std::time::Duration::from_secs(5))?; + run_cli( + P3_HTTP_OUTBOUND_REQUEST_FIRST_BYTE_TIMEOUT_COMPONENT, + &server, + ) + .await +} + #[test_log::test(tokio::test(flavor = "multi_thread"))] async fn p3_http_outbound_request_timeout() -> wasmtime::Result<()> { let server = Server::http1(3)?;