Repository navigation
Response body returned after client disconnect is never cancelled #411
Description
Summary
If the client disconnects while an async handler is still running, the Response body the handler then returns is never cancelled. The adapter reads one or two chunks and then waits on a closed socket forever. The stream's cancel() never runs, so whatever is producing the body (an upstream fetch, an SSE generator, a DB cursor) is held until the process exits.
Reproduces on @hono/node-server 1.19.14 and 2.1.5 (latest), with hono 4.13.13 on Node v22.23.2.
Reproduction
package.json
{ "type": "module", "dependencies": { "@hono/node-server": "2.1.5", "hono": "4.13.13" } }repro.mjs
import { createServer, request } from "node:http";
import { once } from "node:events";
import { getRequestListener } from "@hono/node-server";
import { readFileSync } from "node:fs";
const pkg = JSON.parse(readFileSync("node_modules/@hono/node-server/package.json", "utf8"));
let cancelled = false;
let closed;
const socketClosed = new Promise((resolve) => (closed = resolve));
const server = createServer(
getRequestListener(async () => {
await socketClosed;
return new Response(
new ReadableStream({
start(controller) {
controller.enqueue(new TextEncoder().encode("data: first\n\n"));
},
cancel() {
cancelled = true;
},
}),
{ headers: { "content-type": "text/event-stream" } },
);
}),
);
server.on("request", (_req, res) => res.once("close", closed));
server.listen(0, "127.0.0.1");
await once(server, "listening");
const client = request(`http://127.0.0.1:${server.address().port}/`);
client.on("error", () => {});
client.end();
setTimeout(() => client.destroy(), 50);
await socketClosed;
await new Promise((r) => setTimeout(r, 1000));
console.log(`@hono/node-server ${pkg.version} on Node ${process.version}: body cancelled = ${cancelled}`);
server.closeAllConnections();
server.close();$ npm i && node repro.mjs
@hono/node-server 2.1.5 on Node v22.23.2: body cancelled = false
The same script on 1.19.14 prints body cancelled = false too.
Expected: body cancelled = true. The socket is already closed, so the body should be cancelled, as it is when the disconnect happens after streaming starts.
Cause
writeFromReadableStreamDefaultReader attaches its close/error listeners to cancel the reader. If outgoing was already destroyed before the response arrived, close has already fired and never fires again. After that, either the pending reader.read() never settles (the producer is waiting for a consumer), or writable.write() returns false and once('drain') waits forever.
The same gap exists in a narrow window after the response arrives on a live socket: responseViaResponseObject may await new Promise((resolve) => setTimeout(resolve)) between its first reads and attaching those listeners. A disconnect inside that tick is missed in the same way.
writeFromReadableStream already returns early for writable.destroyed, but it doesn't cancel the stream, and the responseViaResponseObject path doesn't go through that check.
Suggested fix
In writeFromReadableStreamDefaultReader, after attaching the listeners, cancel immediately if writable.destroyed (or writable.closed) is already true. Optionally, responseViaResponseObject could skip the body reads and cancel res.body when outgoing.destroyed is true on entry.
Related, but separate
- cancel() on 'close' passes undefined, so Node synthesizes an AbortError for a plain client disconnect #388 covers the reason passed to
cancel()onclose. Herecancel()is never called at all. - In 1.19.x, a request
signalfirst accessed after the disconnect also never aborts, because the controller is created lazily. 2.x fixes that half, but not the body.
Workaround
A first middleware that reads c.req.raw.signal on entry (so 1.x creates the controller), and after await next() calls c.res.body?.cancel(signal.reason) if signal.aborted. This covers a disconnect before the handler returns, but not the setTimeout window above.
AGENT GENERATED
Activity
mdickinson commentedon Oct 29, 2026
We also tripped over this issue recently, leading to a steady socket leak that resulted in a hang in our process. An agent report (Claude Fable 5.1) follows. The fix in #412 works for us.
We hit this in production on
@hono/node-server2.1.0 and can confirm the report on 2.1.5, withhono4.13.13 on Node 24.19.0.Our handlers stream S3
GetObjectbodies from the AWS SDK v3. When a browser aborts a range or media request while the handler is still waiting on S3, the body the handler then returns is never cancelled, and the SDK socket behind it stays open in CLOSE-WAIT for the life of the process. The SDK's pool defaults to 50 sockets, so under steady traffic it filled every 20 to 40 minutes, after which every request that needed an S3 socket hung until the process was restarted.Both paths described above reproduce for us. A handler that returns
new Response(stream)takes the cached-response path and reaches the early return inwriteFromReadableStream. A handler that returns a nativeResponsegoes throughresponseViaResponseObjectand reacheswriteFromReadableStreamDefaultReaderafter the outgoing response has already closed. On stock 2.1.5 neither path cancels the stream. Cancelling the stream in the early return ofwriteFromReadableStreamalone fixes the first path and not the second; with the change in #412 applied todist/index.mjs, both paths cancel.Prints
stream cancelled: falseeither way.