Skip to content

StreamPump.cancel detaches its 'error' listener before the destroy() it requested emits, crashing the process #15364

Description

@raulfdm

Reproduction

The bug is in createReadableStreamFromReadable itself, so it reproduces with no app, no router and no HTTP server — two files, two commands:

package.json

{ "name": "repro", "type": "module", "dependencies": { "@react-router/node": "8.3.0" } }

repro.mjs

import { PassThrough } from "node:stream";
import { createReadableStreamFromReadable } from "@react-router/node";

process.on("uncaughtException", (error) => {
  console.log("UNCAUGHT EXCEPTION:", error.name, "-", error.message);
  console.log(error.stack.split("\n").slice(0, 6).join("\n"));
  process.exit(1);
});

const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);

// The consumer of the response body. Compression is what makes Node synthesize
// an AbortError on cancel; without it the same cancel is silent.
const compressed = stream.pipeThrough(new CompressionStream("gzip"));
const reader = compressed.getReader();

body.write("<!DOCTYPE html><html>");
await reader.read();

// What a response writer does when the client goes away mid-stream.
await reader.cancel();

await new Promise((resolve) => setTimeout(resolve, 100));

console.log("no crash");
npm install && node repro.mjs

Prints UNCAUGHT EXCEPTION: AbortError - The operation was aborted and exits 1. Without the uncaughtException handler the process dies outright.

I did not use a StackBlitz or a fork of the integration suite because neither adds anything here — there is no app to scaffold, and the failing path is 20 lines of library code. Happy to publish it as a repo if you'd prefer a link.

System Info

  System:
    OS: macOS 26.5.2
    CPU: (10) arm64 Apple M1 Pro
    Memory: 1.99 GB / 32.00 GB
  Binaries:
    Node: 26.5.1
    npm: 11.4.2
    pnpm: 11.15.1
  npmPackages:
    @hono/node-server: 2.0.12 => 2.0.12
    @react-router/dev: 8.3.0 => 8.3.0
    @react-router/node: 8.3.0 => 8.3.0
    react-router: 8.3.0 => 8.3.0
    vite: 8.2.0

Used Package Manager

pnpm

Expected Behavior

Cancelling a ReadableStream produced by createReadableStreamFromReadable should tear the underlying Node stream down quietly. A client that disconnects mid-response is routine, not a defect, and it should not be able to raise an uncaughtException.

Actual Behavior

It raises one, and in a default SSR setup that kills the server process.

StreamPump.cancel destroys the stream and then immediately detaches its own error listener:

https://github.com/remix-run/react-router/blob/main/packages/react-router-node/stream.ts (dist/index.js:255-261 in 8.3.0)

cancel(reason) {
  if (this.stream.destroy) this.stream.destroy(reason);   // <- requests the error
  this.stream.off("data", this.enqueue);
  this.stream.off("error", this.error);                    // <- removes the listener for it
  this.stream.off("end", this.close);
  this.stream.off("close", this.close);
}

destroy(reason) emits 'error' asynchronously, so by the time it fires the listener registered in start() is gone. A Node stream that emits 'error' with no listener rethrows, which surfaces as an uncaughtException.

The reason matters for whether there is an error to emit at all. When the consumer is a CompressionStream — i.e. any SSR response behind compression middleware — the cancel goes through Node's webstream adapter, which calls destroy(stream, undefined), and destroy.js:328 synthesizes an AbortError when the reason is falsy and the stream is unfinished. That error then propagates back through pipeThrough into StreamPump.cancel.

Full stack:

AbortError: The operation was aborted
    at destroyer (node:internal/streams/destroy:328:11)
    at Object.cancel (node:internal/webstreams/adapters:498:7)
    at Object.cancelAlgorithm (node:internal/webstreams/util:190:25)
    at readableStreamDefaultControllerCancelSteps (node:internal/webstreams/readablestream:2612:37)
    at [kCancel] (node:internal/webstreams/readablestream:1174:12)

Two counterfactuals in the same repro, both confirming the diagnosis:

  • add body.on("error", () => {}) before createReadableStreamFromReadableno crash
  • drop the pipeThrough(new CompressionStream("gzip"))no crash (nothing synthesizes an error object, so the cancel is silent)

Why this matters in a framework-mode app

entry.server.tsx as generated by the template hands its PassThrough to createReadableStreamFromReadable and attaches no error listener of its own, so the stream's only listener is the one StreamPump removes. Any compressed streaming SSR response is therefore one client-disconnect away from taking the process down. We hit it in production behind Hono's compress(): GET /mentor/dashboard answered 200, and ten seconds later the pod had restarted. With Sentry's onUncaughtExceptionIntegration installed the exit is explicit (logAndExitProcess), but plain Node dies just the same.

We've worked around it by keeping our own error listener on the PassThrough, which is easy enough once you know — but it is not obvious from the template, and the failure mode (a whole server restart from a closed tab) is severe.

Possible fix

Reorder cancel so the listener outlives the destroy it requested, e.g. detach the other three immediately and let error/close clean themselves up, or swallow the error explicitly:

cancel(reason) {
  this.stream.off("data", this.enqueue);
  this.stream.off("end", this.close);
  this.stream.off("close", this.close);
  if (this.stream.destroy) this.stream.destroy(reason);
}

I patched exactly that into node_modules and re-ran the repro: it prints no crash, and reverting the patch brings the AbortError straight back. Happy to open a PR in that shape, or another if you'd rather solve it differently.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions