cloudflare / cloudflare/workerd

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

Open
#6,818 1 comment 0 reactions 0 assignees View on GitHub
Dominant language
C++
Stars
8.7k
Forks
739
Avg merge
2d 20h
Merged PRs (30d)
174

Description

# 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: https://github.com/withastro/astro/issues/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`:

```js
export default {
async fetch(request) {
let n = 0;
const stream = new ReadableStream({
pull(controller) {
if (n === 0) {
controller.enqueue(new TextEncoder().encode("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`:

```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:

```console
$ 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
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):

```js
export default {
async fetch() {
Promise.reject(new Error("BOOM unhandled rejection"));
return new Response("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).

Contributor guide

Open the contributing guide

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.