Skip to content

Response body returned after client disconnect is never cancelled #411

Open

Description

@ymichael

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

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

  1. mdickinson commented on Oct 29, 2026

    @mdickinson

    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-server 2.1.0 and can confirm the report on 2.1.5, with hono 4.13.13 on Node 24.19.0.

    Our handlers stream S3 GetObject bodies 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 in writeFromReadableStream. A handler that returns a native Response goes through responseViaResponseObject and reaches writeFromReadableStreamDefaultReader after the outgoing response has already closed. On stock 2.1.5 neither path cancels the stream. Cancelling the stream in the early return of writeFromReadableStream alone fixes the first path and not the second; with the change in #412 applied to dist/index.mjs, both paths cancel.

    import { serve } from '@hono/node-server'
    import { Hono } from 'hono'
    import net from 'node:net'
    
    // For the second path, return `new NativeResponse(body)` below instead.
    const NativeResponse = globalThis.Response // captured before serve() replaces it
    
    let cancelled = false
    const app = new Hono()
    app.get('/', async () => {
      await new Promise((r) => setTimeout(r, 300)) // e.g. waiting on a storage GET
      const body = new ReadableStream({
        pull(c) { c.enqueue(new Uint8Array(1024)) }, // never ends, like a large object
        cancel() { cancelled = true },
      })
      return new Response(body)
    })
    
    const server = serve({ fetch: app.fetch, port: 0 }, ({ port }) => {
      const sock = net.connect(port, '127.0.0.1', () => {
        sock.write('GET / HTTP/1.1\r\nHost: x\r\n\r\n')
        setTimeout(() => sock.destroy(), 100) // client leaves before the handler returns
      })
      setTimeout(() => {
        console.log(`stream cancelled: ${cancelled}`)
        server.close(); process.exit(0)
      }, 1500)
    })

    Prints stream cancelled: false either way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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