denoland/deno · error

stream reader has shut down

Error message

stream reader has shut down

What it means

ExternallyAbortableReaderStream (ext/http/reader_stream.rs) adapts a tokio AsyncRead into the hyper response body — used for auto-compressed bodies, where the handler writes into a duplex pipe and this stream feeds hyper. A clean end requires ShutdownHandle::shutdown(), set by op_http_shutdown when the response finishes; if the reader hits EOF without that flag, the stream emits io::ErrorKind::UnexpectedEof 'stream reader has shut down' so the truncated body aborts the transfer instead of appearing successful.

Source

Thrown at ext/http/reader_stream.rs:58

      inner: ReaderStream::new(reader),
      done: done.clone(),
    };
    (this, ShutdownHandle(done))
  }
}

impl<R: AsyncRead> Stream for ExternallyAbortableReaderStream<R> {
  type Item = std::io::Result<Bytes>;

  fn poll_next(
    self: Pin<&mut Self>,
    cx: &mut Context<'_>,
  ) -> Poll<Option<Self::Item>> {
    let this = self.project();
    let val = std::task::ready!(this.inner.poll_next(cx));
    match val {
      None if this.done.load(Ordering::SeqCst) => Poll::Ready(None),
      None => Poll::Ready(Some(Err(std::io::Error::new(
        std::io::ErrorKind::UnexpectedEof,
        "stream reader has shut down",
      )))),
      Some(val) => Poll::Ready(Some(val)),
    }
  }
}

#[cfg(test)]
mod tests {
  use bytes::Bytes;
  use deno_core::futures::StreamExt;
  use tokio::io::AsyncWriteExt;

  use super::*;

  #[tokio::test]
  async fn success() {

View on GitHub (pinned to 9ad36f7a2c)

Solutions

  1. Wrap the streaming body generator in try/finally and finish or explicitly close/abort the writer on error
  2. Do not write any body bytes before you are certain the response can complete
  3. Handle the upstream error before any bytes reach the compressed writer
  4. On the client side, treat UnexpectedEof on a content-length'd response as partial data and retry idempotent requests

Example fix

// before
new Response(
  (async function* () {
    yield part1;
    throw new Error('boom'); // body abandoned mid-stream
  })(),
);

// after
new Response(
  (async function* () {
    try {
      yield part1;
      throw new Error('boom');
    } catch (e) {
      console.error('body failed', e);
      yield encoder.encode('partial content'); // or close cleanly
    }
  })(),
);
Defensive patterns

Strategy: try-catch

Try / catch

// server side: never abandon a partially written body
const body = new ReadableStream({
  async start(controller) {
    try {
      for await (const chunk of source()) controller.enqueue(chunk);
      controller.close();
    } catch (e) {
      controller.error(e); // client sees a clean stream error, not a truncated body
    }
  },
});

// client side: treat EOF-without-end as partial content
const res = await fetch(url);
const text = await res.text().catch((e) => {
  if (/unexpected EOF|stream reader has shut down/i.test(String(e))) {
    throw new Error('Partial response body; safe to retry');
  }
  throw e;
});

Prevention

When it happens

Trigger: A Deno.serve handler writes part of a compressible (gzip/brotli auto-negotiated) body and then abandons it: the handler throws mid-write, the response body writer is dropped without a proper close/abort, or the shutdown path is never invoked because the handler errored while the compressed body was partially streamed.

Common situations: Streaming generators that throw after emitting some chunks; handlers returning early after partial writes; clients observing dropped connections with truncated content-length'd bodies behind proxies.

Related errors


AI-assisted analysis of denoland/deno@9ad36f7a2c (2026-08-20). Data as JSON: /api/errors/8b6843b3b449546e. Report an issue: GitHub.