Skip to content

cancel() on 'close' passes undefined, so Node synthesizes an AbortError for a plain client disconnect #388

Description

@raulfdm

Summary

writeFromReadableStreamDefaultReader registers the same cancel handler on both 'error' and 'close':

https://github.com/honojs/node-server/blob/main/src/utils/stream.ts (dist/index.mjs:616-620 in 2.0.12)

const cancel = (error) => {
  reader.cancel(error).catch(() => {});
};
writable.on("close", cancel);
writable.on("error", cancel);

'error' passes the error along, but 'close' emits no arguments, so on a normal client disconnect this calls reader.cancel(undefined).

Cancelling with no reason is legal, but it is not free: when the body is a web stream backed by a Node stream — anything that went through CompressionStream, i.e. every response behind compress() — the cancel reaches Node's webstream adapter, which calls destroy(stream, undefined), and destroy.js:328 synthesizes an AbortError whenever the reason is falsy and the stream is not yet finished. So a silent disconnect is converted into a real error object that then propagates upstream through pipeThrough to whatever produced the body.

Passing an explicit reason on 'close' would let that cancel stay silent.

Why I'm reporting it here rather than only upstream

To be straight about the scope: the crash we actually hit is not primarily yours, and I've filed it where I think it belongs — remix-run/react-router#15364. React Router's StreamPump.cancel destroys its PassThrough and detaches its own 'error' listener in the same tick, so the synthesized AbortError lands on a stream with no listener and becomes an uncaughtException. Under Hono + compress() + @react-router/node, one browser closing a tab mid-stream restarted our production pod.

That is their bug to fix, and the fix there is sufficient. But the undefined on 'close' is what manufactures the error object in the first place, and it seemed worth your call whether that is intended.

This is not the same as #248 / #258. That one was an unhandled promise rejection from reader.cancel(), and the .catch(() => {}) added in #258 does fix it — it is present in the code above and our crash still happens, because ours is an unhandled 'error' event on a Node stream further upstream, which no .catch here can reach.

Reproduction

Not Hono-specific, so this is the smallest form — no server, no framework. It uses @react-router/node only because it is a convenient producer of a Node-backed web stream; the relevant part is the cancel() with no reason, which is what the 'close' listener above does.

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);
  process.exit(1);
});

const body = new PassThrough();
const stream = createReadableStreamFromReadable(body);
const compressed = stream.pipeThrough(new CompressionStream("gzip"));
const reader = compressed.getReader();

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

await reader.cancel();          // what the 'close' listener does: no reason

await new Promise((resolve) => setTimeout(resolve, 100));
console.log("no crash");
npm install && node repro.mjs

UNCAUGHT EXCEPTION: AbortError - The operation was aborted. Dropping the pipeThrough(new CompressionStream("gzip")) prints no crash — compression is what puts the Node adapter in the path.

Environment

  • @hono/node-server 2.0.12, hono 4.12.33
  • Node 26.5.1, macOS arm64
  • Also present in 1.19.14 (same code)

Question

Is reader.cancel(undefined) on 'close' deliberate? If a distinct reason (or anything non-falsy) is acceptable there, it would keep Node from fabricating an AbortError for what is just a client going away, and would make this class of failure harder to trip regardless of what the body producer does. Glad to send a PR if you want it.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions