How to Fix: HostBodyStreamProducer::poll_produce returns StreamResult::Completed without producing items, causing a trap on WASI HTTP response body reads

8 min read

When HostBodyStreamProducer::poll_produce reports StreamResult::Completed before yielding bytes, the consumer side treats the body as finished and a later read can trap instead of returning a clean end-of-stream.

This bug shows up in WASI HTTP response body handling when the host stream implementation violates an important contract: completion must only be reported after all readable items have been produced or the stream has been explicitly finalized in a way the reader can safely observe. If completion is returned too early, guest code may attempt another body read against an invalid or inconsistent state and hit a trap.

Understanding the Root Cause

The failure is caused by a mismatch between producer semantics and consumer expectations in the body streaming pipeline.

At a high level, a WASI HTTP response body reader expects one of these states during polling:

  • Produced data: one or more chunks are available.
  • Pending: no bytes are ready yet, but more may arrive later.
  • Completed: the stream is truly finished and no more items will ever be produced.

The problem occurs when HostBodyStreamProducer::poll_produce returns StreamResult::Completed even though it has not actually produced any item for the current read path and has not cleanly transitioned the reader into a final consumable EOF state.

That creates a dangerous gap:

  1. The host tells the runtime the stream is complete.
  2. The guest-side body handling logic may still expect a final chunk, an EOF marker, or a valid readable state transition.
  3. A subsequent read, poll, or state access reaches an internal path that assumes the stream bookkeeping is consistent.
  4. Because no item was produced before completion, the runtime can hit an unexpected invariant violation, which surfaces as a trap.

In practice, this usually means one of the following implementation bugs exists:

  • The producer returns Completed when it should return Pending.
  • The producer marks the stream done before flushing a buffered chunk.
  • The host closes internal state too early, so the next read touches released or invalid stream state.
  • The adapter assumes that Completed is only emitted after at least one valid state transition that the reader can observe safely.

The key rule is simple: never signal completion early. In a streaming contract, completion is not just “I have nothing right now.” It means “the stream is definitively over, and the reader can stop without entering an invalid state.”

Step-by-Step Solution

The fix is to make the host body producer obey a strict completion contract and ensure the response body reader sees one of two safe outcomes: either actual bytes are produced, or the stream stays pending until it can be completed cleanly.

1. Audit the producer contract

Review the implementation of HostBodyStreamProducer::poll_produce and identify every branch that returns StreamResult::Completed.

You are looking for logic like this:

match body_source.poll_next(cx) {
    Poll::Ready(None) => StreamResult::Completed,
    Poll::Ready(Some(chunk)) => StreamResult::Produced(chunk),
    Poll::Pending => StreamResult::Pending,
}

This looks reasonable, but it can still be wrong if:

  • None is observed before buffered data is surfaced.
  • The stream adapter requires a final state handoff before completion.
  • The body source can temporarily produce no chunk while not being semantically complete.

2. Ensure buffered bytes are emitted before completion

If your producer wraps another stream, socket, or channel, flush any buffered data first.

fn poll_produce(&mut self, cx: &mut Context<'_>) -> StreamResult<Bytes> {
    if let Some(buf) = self.pending_chunk.take() {
        return StreamResult::Produced(buf);
    }

    match Pin::new(&mut self.inner).poll_next(cx) {
        Poll::Ready(Some(chunk)) => StreamResult::Produced(chunk),
        Poll::Ready(None) => {
            self.finished = true;
            StreamResult::Completed
        }
        Poll::Pending => StreamResult::Pending,
    }
}

This guarantees completion is emitted only after all queued chunks are drained.

3. Return Pending instead of Completed when the end is not yet safely observable

If the adapter or runtime expects another poll cycle before finalization, do not report completion immediately.

fn poll_produce(&mut self, cx: &mut Context<'_>) -> StreamResult<Bytes> {
    if self.finishing && !self.eof_committed {
        self.eof_committed = true;
        cx.waker().wake_by_ref();
        return StreamResult::Pending;
    }

    match Pin::new(&mut self.inner).poll_next(cx) {
        Poll::Ready(Some(chunk)) => StreamResult::Produced(chunk),
        Poll::Ready(None) => {
            if self.can_complete_safely() {
                StreamResult::Completed
            } else {
                self.finishing = true;
                StreamResult::Pending
            }
        }
        Poll::Pending => StreamResult::Pending,
    }
}

The exact shape depends on your runtime, but the principle stays the same: do not collapse “not ready” and “finished” into the same signal.

4. Guard against double-completion and post-completion reads

Once completion has been returned, the producer should move into a stable terminal state.

fn poll_produce(&mut self, cx: &mut Context<'_>) -> StreamResult<Bytes> {
    if self.finished {
        return StreamResult::Completed;
    }

    match Pin::new(&mut self.inner).poll_next(cx) {
        Poll::Ready(Some(chunk)) => StreamResult::Produced(chunk),
        Poll::Ready(None) => {
            self.finished = true;
            StreamResult::Completed
        }
        Poll::Pending => StreamResult::Pending,
    }
}

This avoids re-entering invalid internal logic after stream termination.

5. Add a regression test for empty and near-empty response bodies

The most important test is the one that reproduces the bug: a response body that completes without a produced item on the final poll path.

#[test]
fn response_body_does_not_trap_when_stream_completes_empty() {
    let mut producer = TestProducer::empty();

    let first = producer.poll_produce_for_test();
    assert!(matches!(first, StreamResult::Completed | StreamResult::Pending));

    let read_result = read_response_body_from_guest_side(&mut producer);
    assert!(read_result.is_ok());
    assert_eq!(read_result.unwrap(), b"");
}

Also add tests for one-byte, multi-chunk, and delayed-pending cases:

#[test]
fn response_body_returns_chunk_then_completes() {
    let mut producer = TestProducer::from_chunks(vec![b"a".to_vec()]);

    assert!(matches!(producer.poll_produce_for_test(), StreamResult::Produced(_)));
    assert!(matches!(producer.poll_produce_for_test(), StreamResult::Completed));
}

#[test]
fn response_body_pending_then_chunk_then_complete() {
    let mut producer = TestProducer::pending_then_chunks(vec![b"hello".to_vec()]);

    assert!(matches!(producer.poll_produce_for_test(), StreamResult::Pending));
    assert!(matches!(producer.poll_produce_for_test(), StreamResult::Produced(_)));
    assert!(matches!(producer.poll_produce_for_test(), StreamResult::Completed));
}

6. Validate behavior through the full WASI HTTP stack

Unit tests on the producer are not enough. Run an integration test where a guest performs an HTTP request and reads the response body until EOF. The expected behavior is:

  • No trap occurs.
  • An empty body returns an empty byte sequence.
  • A non-empty body returns all chunks in order.
  • EOF is reported cleanly after the last chunk.
// Pseudocode for integration validation
let response = wasi_http_client.get("/empty-body");
let bytes = response.into_body().read_to_end()?;
assert_eq!(bytes.len(), 0);

let response = wasi_http_client.get("/single-chunk");
let bytes = response.into_body().read_to_end()?;
assert_eq!(bytes, b"ok");

7. If you maintain the adapter layer, enforce the invariant explicitly

If the bug is difficult to catch at the producer level, add validation where the stream result is consumed. For example, reject illegal state transitions in debug builds:

debug_assert!(
    !(matches!(result, StreamResult::Completed) && state.expecting_chunk_without_eof_handoff()),
    "producer returned Completed before reader observed a valid terminal transition"
);

This will surface violations earlier during development instead of letting them become runtime traps.

Common Edge Cases

Empty HTTP response bodies

This is the most obvious trigger. A 204 No Content, 304 Not Modified, or a response with a legitimate zero-length body must terminate cleanly without producing phantom chunks and without trapping.

Chunked bodies with delayed readiness

If the underlying transport is asynchronous, a temporary lack of bytes is Pending, not Completed. Returning completion too early will truncate the response or create a trap on the next read.

Buffered adapters

If your host implementation wraps a decoder, decompressor, or framed transport, the inner source may be complete while the adapter still holds unread bytes. The adapter must emit those bytes before signaling completion.

Double polling after EOF

Some runtimes poll streams again after completion to verify stable EOF behavior. If your implementation frees internal state and then assumes it will never be called again, a second poll can fail unexpectedly. Keep a durable finished flag.

Cancellation or abrupt connection close

An aborted response should usually become an error state, not a normal Completed state. Mixing error termination with successful completion makes debugging much harder and can mask data loss.

Trailers or protocol-specific finalization

If your stack supports HTTP trailers or another final metadata phase, do not mark the body fully complete until that protocol step is handled consistently by both producer and consumer.

FAQ

Why does this bug cause a trap instead of just returning an empty body?

Because the issue is not simply “the body is empty.” It is a state machine violation. The consumer believes the producer completed in a valid way, but the internal stream bookkeeping was not transitioned safely, so a later read reaches an impossible state and traps.

Should an empty response body ever produce a chunk?

No. An empty body does not need to produce a data chunk. But it does need to complete in a way the runtime expects. If the surrounding adapter requires an observable pending/finalization step or stable EOF state, that contract must still be honored.

What is the safest rule for implementers of poll_produce?

Treat StreamResult::Completed as a terminal promise: no buffered bytes remain, no additional protocol work is pending, post-completion polls are safe, and the reader can stop without touching invalid state.

To resolve this issue, update HostBodyStreamProducer::poll_produce so it returns Completed only after all available bytes and final stream state transitions have been fully handled. Use Pending when data is not ready yet or when completion cannot be observed safely on the current poll. Add regression tests for empty bodies, delayed bodies, and repeated reads after EOF. That combination removes the trap and restores correct WASI HTTP response body behavior.

Leave a Reply

Your email address will not be published. Required fields are marked *