Skip to content

Errored response-body streams terminate cleanly instead of aborting, silently truncating the response #6818

Description

@mikepage

Errored response-body streams terminate cleanly instead of aborting, silently truncating the response

Summary

When a Response is constructed from a ReadableStream and that stream errors after the response headers have been sent, workerd finishes the HTTP response with a normal chunked-encoding terminator (0\r\n\r\n) and leaves the connection intact. The downstream HTTP client therefore sees a protocol-complete, successful response containing a truncated body — it has no way to tell that the worker actually failed.

The uncaught exception is logged to stderr in the default logging mode, but it is not surfaced through the HTTP response, the status code, or (critically) structured logging. There is no programmatic signal a consumer can rely on.

This makes any tool that consumes a worker over HTTP — most notably build-time prerendering — unable to detect a failed render. The downstream symptom is a build that writes truncated HTML and exits 0.

Downstream report: withastro/astro#17047 (Astro static prerendering with the Cloudflare adapter)

Expected behavior

When a streaming response body errors after headers are sent, workerd should abort the HTTP response (e.g. reset the connection / signal an incomplete message) rather than emit a clean final chunk. The consumer should observe an incomplete-read / aborted-stream error, matching how Node.js and other runtimes surface a stream that errors mid-flight.

Ideally the uncaught exception should also be emitted via structured logging (--structured-logging) so it remains observable when JSON logging is enabled.

Actual behavior

  • HTTP status is 200.
  • The chunked body is terminated normally (0\r\n\r\n) — the connection is not reset.
  • The consumer reads a truncated (or empty) body with no error.
  • The exception is written to stderr only in the default (non-structured) logging mode. With --structured-logging enabled it is not surfaced anywhere.

Minimal reproduction (standalone workerd)

worker.js:

export default {
  async fetch(request) {
    let n = 0;
    const stream = new ReadableStream({
      pull(controller) {
        if (n === 0) {
          controller.enqueue(new TextEncoder().encode("<html><head>chunk0\n"));
          n++;
          return;
        }
        // Error the body stream after headers have already been sent
        controller.error(new Error("BOOM mid-stream"));
      },
    });
    return new Response(stream, { headers: { "content-type": "text/html" } });
  },
};

config.capnp:

using Workerd = import "/workerd/workerd.capnp";

const config :Workerd.Config = (
  services = [ (name = "main", worker = .mainWorker) ],
  sockets = [ (name = "http", address = "*:8799", http = (), service = "main") ],
);

const mainWorker :Workerd.Worker = (
  modules = [ (name = "worker.js", esModule = embed "worker.js") ],
  compatibilityDate = "2025-06-01",
);

Run and request:

$ workerd serve config.capnp
$ curl -sv --raw http://localhost:8799/

Observed response:

< HTTP/1.1 200 OK
< Transfer-Encoding: chunked
< Content-Type: text/html
<
13
<html><head>chunk0
0

* Connection #0 to host localhost left intact

curl exits 0. The 0\r\n\r\n final chunk indicates a complete message; the client cannot detect the failure. stderr shows:

workerd/server/server.c++: error: Uncaught exception: ... remote.jsg.Error: BOOM mid-stream

A throw inside pull() (instead of controller.error(...)) behaves identically.

Related case: background unhandled rejection

A fire-and-forget rejection after the response is returned is also fully silent (status 200, full body, no error, no structured log):

export default {
  async fetch() {
    Promise.reject(new Error("BOOM unhandled rejection"));
    return new Response("<html><head>ok", { headers: { "content-type": "text/html" } });
  },
};

Additional finding: structured logging suppresses the stderr signal

The exception text above only appears on stderr in the default logging mode. When the runtime is launched with structured logging enabled (structuredLogging = true in the runtime config, which Miniflare/Wrangler enable when a structured-log handler is registered), the uncaught exception is emitted nowhere — not as a structured log entry and not on stderr. This removes the last remaining (already environment-dependent) signal in exactly the configuration that tools like the Cloudflare Vite plugin run under during builds.

Impact

Any consumer of a worker over HTTP cannot reliably detect a failed streaming response:

  • Build-time prerendering (Astro Cloudflare adapter prerenderEnvironment: 'workerd') writes truncated HTML to dist/ and exits 0, so CI reports success on a broken build.
  • Error visibility is OS-dependent (the downstream report notes Linux shows the stderr line, macOS does not) because the only signal is unstructured stderr text.

Proposed fix

  1. When a response body ReadableStream errors after the head has been sent, abort the HTTP response (reset the connection / terminate the chunked stream abnormally) instead of writing a clean 0\r\n\r\n terminator, so the consumer observes an incomplete/aborted read.
  2. Emit the uncaught exception through structured logging so it remains observable when --structured-logging is enabled.

Environment

  • Reproduced standalone with workerd serve (workerd@1.20260423.1) and confirmed at the raw HTTP layer with curl; also reproduced via Miniflare (miniflare@4.x, workers-sdk main).
  • compatibilityDate: 2025-06-01
  • Platforms observed: macOS (stderr suppressed). Downstream report also covers Linux (stderr shown).

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions