rvolosatovs opened PR #14743 from rvolosatovs:wasi-http-io-task-ownership to bytecodealliance:main:
The task driving an outbound request's connection was kept alive by three references: the transmission future, the response body and the request body. The request body reference is circular in the default
send_requestimplementation, since the request body is owned by the hyper connection that the task itself drives. A guest could therefore leave connections running after the store was dropped, with no way for the embedder to reclaim them, e.g. by feeding an endless upstream response body into another outbound request.Drop the request and transmission future references and instead have the store hold a reference to the task until it finishes. Dropping the store now cancels every outbound connection not referenced by something the embedder holds, while a response body handed to the host unchanged keeps its connection alive for as long as the host reads it.
As a consequence, the guest dropping all of its handles no longer cancels the task; it runs until it finishes or the store is dropped.
Refs https://github.com/bytecodealliance/wasmtime/pull/14633
rvolosatovs requested alexcrichton for a review on PR #14743.
rvolosatovs requested wasmtime-wasi-reviewers for a review on PR #14743.
rvolosatovs requested adamrk for a review on PR #14743.
rvolosatovs requested wasmtime-wasi-reviewers for a review on PR #14743.
:memo: adamrk submitted PR review:
Much nicer than what I had before!
:speech_balloon: adamrk created PR review comment:
So I guess one difference here is that the guest can't cancel the io by dropping the response body anymore, right? (The drop just decrements the Arc ref count). Would it make sense to wrap this io in a type that cancels on drop?
alexcrichton unassigned alexcrichton from PR #14743 wasi-http(p3): tie outbound I/O task lifetime to the store.
alexcrichton requested adamrk for a review on PR #14743.
:memo: rvolosatovs submitted PR review.
:speech_balloon: rvolosatovs created PR review comment:
So I guess one difference here is that the guest can't cancel the io by dropping the response body anymore, right?
Sort of, and I think that's the expected behavior: if a guest calls
send/handleand drops the response, IMO it'd still expect the request body to be transmitted, not aborted. The default implementation uses HTTP/1 with hyper and the hyper future will not resolve until all buffered bytes have been flushed. Ultimately, dropping the response body would, in fact, cancel I/O once the flush completes. A server, which does not read request body would keep the I/O pending. I tested with this example:use http_body_util::channel::Channel; use hyper::body::Bytes; use hyper::client::conn::http1; use std::time::Duration; use tokio::io::{AsyncWriteExt, copy, sink}; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::oneshot; #[tokio::main] async fn main() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let addr = listener.local_addr().unwrap(); let (dropped_tx, dropped_rx) = oneshot::channel(); let server = tokio::spawn(async move { let (mut conn, _) = listener.accept().await.unwrap(); conn.readable().await.unwrap(); conn.write_all(b"HTTP/1.1 200 OK\r\n").await.unwrap(); conn.write_all(b"transfer-encoding: chunked\r\n\r\n") .await .unwrap(); dropped_rx.await.unwrap(); println!("server: response dropped, reading request"); let n = copy(&mut conn, &mut sink()).await; println!("server: connection closed after receiving {n:?} bytes"); }); let stream = TcpStream::connect(addr).await.unwrap(); let io = hyper_util::rt::TokioIo::new(stream); let (mut sender, conn) = http1::handshake(io).await.unwrap(); let conn = tokio::spawn(conn); let (mut body_tx, body) = Channel::<Bytes>::new(1); let req = hyper::Request::post("/").body(body).unwrap(); let res = sender.send_request(req).await.unwrap(); let upload = tokio::spawn(async move { for i in 0.. { if body_tx.send_data(vec![0xff; 0xffff].into()).await.is_err() { println!("client: upload aborted after {i} buffers"); return; } } }); tokio::time::sleep(Duration::from_secs(1)).await; println!("client: upload stalled: {}", !upload.is_finished()); drop(res); dropped_tx.send(()).unwrap(); let res = tokio::time::timeout(Duration::from_secs(5), conn).await; println!("client: connection resolved: {res:?}"); upload.await.unwrap(); server.await.unwrap(); }Dropping the store would abort the transmit immediately.
IMO embedders that want to tighten this, e.g. ensure that the I/O is done within a timeout would implement a custom
send_request, e.g. by potentially wrapping thedefault_send_requestand wrapping the returned future (https://github.com/bytecodealliance/wasmtime/blob/b140c545f5e5523fed8ee42a6166b1fa04d39b31/crates/wasi-http/src/ctx.rs#L259) with a timeout.
In fact, how about we provide such wrapper forsend_request? (like #14633) Perhaps we could even provide a trait andwith_flush_timeoutmethod, such that embedders could do something like:fn send_request(req) ... { default_send_request(req).with_flush_timeout(Duration::from_secs(10)) }
:memo: adamrk submitted PR review.
:speech_balloon: adamrk created PR review comment:
Regarding cancellation by dropping the body: I guess the WASI spec doesn't say anything about dropping cancelling the operation, so we should probably keep it as you have here.
Regarding the wrapper: I think it could make sense, but not sure anyone really needs it for now. The main concern was the DOS potential so I'd say we put this in as-is and then do something like the wrapper if it comes up again.
:thumbs_up: adamrk submitted PR review.
rvolosatovs added PR #14743 wasi-http(p3): tie outbound I/O task lifetime to the store to the merge queue.
:check: rvolosatovs merged PR #14743.
rvolosatovs removed PR #14743 wasi-http(p3): tie outbound I/O task lifetime to the store from the merge queue.
Last updated: Oct 31 2026 at 23:03 UTC