Stream: git-wasmtime

Topic: wasmtime / issue #12903 HostBodyStreamProducer::poll_prod...


view this post on Zulip Wasmtime GitHub notifications bot (Mar 31 2026 at 02:09):

karthik-phl opened issue #12903:

Thanks for filing a bug report! Please fill out the TODOs below.

Note: if you want to report a security issue, please read our security policy!

Test Case

/// Body wrapper that emits a zero-length data frame mid-stream (before end-of-stream).
///
/// This simulates what hyper/reqwest can do when the HTTP response data has been fully
/// delivered but the framing (e.g., chunked transfer encoding) hasn't been finalized yet.
/// In that state, `poll_frame` returns an empty data frame while `is_end_stream()` is
/// still `false`.
struct BodyWithEmptyFrameMidStream {
    inner: http_body_util::StreamBody<
        futures::channel::mpsc::Receiver<Result<http_body::Frame<Bytes>, ErrorCode>>,
    >,
    /// Whether to inject an empty frame before the next real frame
    inject_empty: bool,
}

impl http_body::Body for BodyWithEmptyFrameMidStream {
    type Data = Bytes;
    type Error = ErrorCode;

    fn poll_frame(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
        if self.inject_empty {
            self.inject_empty = false;
            return Poll::Ready(Some(Ok(http_body::Frame::data(Bytes::new()))));
        }
        let this = &mut *self;
        Pin::new(&mut this.inner).poll_frame(cx)
    }

    fn is_end_stream(&self) -> bool {
        // The key behavior: is_end_stream() returns false even when the empty frame
        // is emitted, because the stream hasn't been fully consumed yet.
        false
    }
}

#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn p3_http_empty_frame_mid_stream() -> Result<()> {
    _ = env_logger::try_init();

    // This test verifies the fix for the case where poll_frame returns a zero-length
    // data frame while is_end_stream() is false (i.e., mid-stream). Without the fix,
    // poll_produce would return StreamResult::Completed with 0 items produced,
    // violating the StreamProducer contract.

    let body = b"hello world";
    let raw_body = Bytes::copy_from_slice(body);

    let (mut body_tx, body_rx) = futures::channel::mpsc::channel::<Result<_, ErrorCode>>(2);

    let wrapped_body = BodyWithEmptyFrameMidStream {
        inner: http_body_util::StreamBody::new(body_rx),
        inject_empty: true, // Inject an empty frame before the first real frame
    };

    let request = http::Request::builder()
        .uri("http://localhost/")
        .method(http::Method::GET);

    let response = futures::join!(
        run_http(
            P3_HTTP_ECHO_COMPONENT,
            request.body(wrapped_body)?,
            oneshot::channel().0
        ),
        async {
            body_tx
                .send(Ok(http_body::Frame::data(raw_body)))
                .await
                .unwrap();
            drop(body_tx);
        }
    )
    .0?
    .unwrap();

    assert_eq!(response.status().as_u16(), 200);

    let (_, collected_body) = response.into_parts();
    let collected_body = collected_body.to_bytes();
    assert_eq!(collected_body, body.as_slice());
    Ok(())
}

Steps to Reproduce

Expected Results

HostBodyStreamProducer::poll_produce should handle zero-length data frames and zero-capacity destinations gracefully — either re-polling for real data (returning Poll::Pending) or buffering, without ever returning StreamResult::Completed unless items were actually produced. The response body should be echoed back correctly.

Actual Results

poll_produce returns StreamResult::Completed without writing any data to the destination, violating the contract enforced in futures_and_streams.rs:2536–2544. This causes a trap with the message:
StreamProducer::poll_produce returned StreamResult::Completed without producing any items
This was observed in production with a WASI HTTP guest making concurrent outbound requests using futures::try_join!.

Versions and Environment

Wasmtime version or commit: wasmtime main

Operating system: TODO

Architecture: TODO

Extra Info

Anything else you'd like to add?

view this post on Zulip Wasmtime GitHub notifications bot (Mar 31 2026 at 02:09):

karthik-phl added the bug label to Issue #12903.

view this post on Zulip Wasmtime GitHub notifications bot (Mar 31 2026 at 18:59):

dicej closed issue #12903:

Thanks for filing a bug report! Please fill out the TODOs below.

Note: if you want to report a security issue, please read our security policy!

Test Case

/// Body wrapper that emits a zero-length data frame mid-stream (before end-of-stream).
///
/// This simulates what hyper/reqwest can do when the HTTP response data has been fully
/// delivered but the framing (e.g., chunked transfer encoding) hasn't been finalized yet.
/// In that state, `poll_frame` returns an empty data frame while `is_end_stream()` is
/// still `false`.
struct BodyWithEmptyFrameMidStream {
    inner: http_body_util::StreamBody<
        futures::channel::mpsc::Receiver<Result<http_body::Frame<Bytes>, ErrorCode>>,
    >,
    /// Whether to inject an empty frame before the next real frame
    inject_empty: bool,
}

impl http_body::Body for BodyWithEmptyFrameMidStream {
    type Data = Bytes;
    type Error = ErrorCode;

    fn poll_frame(
        mut self: Pin<&mut Self>,
        cx: &mut Context<'_>,
    ) -> Poll<Option<Result<http_body::Frame<Self::Data>, Self::Error>>> {
        if self.inject_empty {
            self.inject_empty = false;
            return Poll::Ready(Some(Ok(http_body::Frame::data(Bytes::new()))));
        }
        let this = &mut *self;
        Pin::new(&mut this.inner).poll_frame(cx)
    }

    fn is_end_stream(&self) -> bool {
        // The key behavior: is_end_stream() returns false even when the empty frame
        // is emitted, because the stream hasn't been fully consumed yet.
        false
    }
}

#[test_log::test(tokio::test(flavor = "multi_thread"))]
async fn p3_http_empty_frame_mid_stream() -> Result<()> {
    _ = env_logger::try_init();

    // This test verifies the fix for the case where poll_frame returns a zero-length
    // data frame while is_end_stream() is false (i.e., mid-stream). Without the fix,
    // poll_produce would return StreamResult::Completed with 0 items produced,
    // violating the StreamProducer contract.

    let body = b"hello world";
    let raw_body = Bytes::copy_from_slice(body);

    let (mut body_tx, body_rx) = futures::channel::mpsc::channel::<Result<_, ErrorCode>>(2);

    let wrapped_body = BodyWithEmptyFrameMidStream {
        inner: http_body_util::StreamBody::new(body_rx),
        inject_empty: true, // Inject an empty frame before the first real frame
    };

    let request = http::Request::builder()
        .uri("http://localhost/")
        .method(http::Method::GET);

    let response = futures::join!(
        run_http(
            P3_HTTP_ECHO_COMPONENT,
            request.body(wrapped_body)?,
            oneshot::channel().0
        ),
        async {
            body_tx
                .send(Ok(http_body::Frame::data(raw_body)))
                .await
                .unwrap();
            drop(body_tx);
        }
    )
    .0?
    .unwrap();

    assert_eq!(response.status().as_u16(), 200);

    let (_, collected_body) = response.into_parts();
    let collected_body = collected_body.to_bytes();
    assert_eq!(collected_body, body.as_slice());
    Ok(())
}

Steps to Reproduce

Expected Results

HostBodyStreamProducer::poll_produce should handle zero-length data frames and zero-capacity destinations gracefully — either re-polling for real data (returning Poll::Pending) or buffering, without ever returning StreamResult::Completed unless items were actually produced. The response body should be echoed back correctly.

Actual Results

poll_produce returns StreamResult::Completed without writing any data to the destination, violating the contract enforced in futures_and_streams.rs:2536–2544. This causes a trap with the message:
StreamProducer::poll_produce returned StreamResult::Completed without producing any items
This was observed in production with a WASI HTTP guest making concurrent outbound requests using futures::try_join!.

Versions and Environment

Wasmtime version or commit: wasmtime main

Operating system: TODO

Architecture: TODO

Extra Info

Anything else you'd like to add?


Last updated: Apr 12 2026 at 23:10 UTC