cloudflare / cloudflare/workerd
Errored response-body streams terminate cleanly instead of aborting, silently truncating the response
- 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
Assessment
This issue has not been assessed yet.