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.
Summary
writeFromReadableStreamDefaultReaderregisters the samecancelhandler on both'error'and'close':https://github.com/honojs/node-server/blob/main/src/utils/stream.ts (
dist/index.mjs:616-620in 2.0.12)'error'passes the error along, but'close'emits no arguments, so on a normal client disconnect this callsreader.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 behindcompress()— the cancel reaches Node's webstream adapter, which callsdestroy(stream, undefined), anddestroy.js:328synthesizes anAbortErrorwhenever 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 throughpipeThroughto 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.canceldestroys itsPassThroughand detaches its own'error'listener in the same tick, so the synthesizedAbortErrorlands on a stream with no listener and becomes anuncaughtException. 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
undefinedon'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.catchhere can reach.Reproduction
Not Hono-specific, so this is the smallest form — no server, no framework. It uses
@react-router/nodeonly because it is a convenient producer of a Node-backed web stream; the relevant part is thecancel()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.mjsnpm install && node repro.mjsUNCAUGHT EXCEPTION: AbortError - The operation was aborted. Dropping thepipeThrough(new CompressionStream("gzip"))printsno crash— compression is what puts the Node adapter in the path.Environment
@hono/node-server2.0.12,hono4.12.33Question
Is
reader.cancel(undefined)on'close'deliberate? If a distinct reason (or anything non-falsy) is acceptable there, it would keep Node from fabricating anAbortErrorfor 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.