Skip to content

http: aborting a keep-alive response during async iteration emits an unhandled Socket error #65938

Description

@DavidOsipov

Version

Reproduced with the standalone script below on:

Node version Independent runs Unhandled Socket error / exit 1
v24.19.0 20 20
v24.21.0 20 20
v26.3.0 20 20
v26.8.1 20 20

As of the preparation date, v24.21.0 is the latest LTS release and v26.8.1 is
the latest Current release in the official Node release index.
The v26.8.1 Linux x64 executable was downloaded from nodejs.org and its archive
was checked against the official SHASUMS256.txt.

Platform

Linux x64 under WSL2. Output of uname -srvmo (hostname omitted):

Linux 6.18.33.2-microsoft-standard-WSL2 #1 SMP PREEMPT_DYNAMIC Thu Jun 18 21:54:43 UTC 2026 x86_64 GNU/Linux

Other operating systems have not been tested. The first affected Node release
has not been bisected.

Subsystem

node:http, ClientRequest, IncomingMessage, keep-alive socket lifecycle,
and cancellation through AbortSignal.

What steps will reproduce the bug?

Save the following as repro.mjs and run:

node repro.mjs

The script uses only Node built-ins, binds a local server to 127.0.0.1 on an
ephemeral port, and returns a one-byte HTTP response. It requires no packages,
external services, TLS certificates, DNS customization or application code.
Both the request and the response have persistent error handlers. Async
iteration is also inside a try/catch. The script does not remove any error
handlers or install a process-wide exception handler.

// Standalone upstream reproducer: only Node built-ins and a loopback HTTP server.
import http from "node:http";
import { once } from "node:events";
import { setTimeout as delay } from "node:timers/promises";

const agent = new http.Agent({ keepAlive: true });
const server = http.createServer((_request, response) => response.end("x"));
server.listen(0, "127.0.0.1");
await once(server, "listening");

try {
  const controller = new AbortController();
  const response = await new Promise((resolve, reject) => {
    const request = http.get(
      `http://127.0.0.1:${server.address().port}/`,
      { agent, signal: controller.signal },
      (incoming) => {
        incoming.on("error", (error) => console.log("response error:", error.code));
        resolve(incoming);
      }
    );
    request.on("error", (error) => {
      console.log("request error:", error.code);
      reject(error);
    });
  });

  try {
    for await (const chunk of response) {
      console.log("received bytes:", chunk.length);
      controller.abort(new Error("stop reading"));
      break;
    }
  } catch (error) {
    console.log("iteration error:", error.code ?? error.message);
  }

  // Leave time for deferred events; a caught rejection alone proves too little.
  await delay(50);
  console.log("survived");
} finally {
  agent.destroy();
  server.closeAllConnections();
  await new Promise((resolve) => server.close(resolve));
}

How often does it reproduce? Is there a required condition?

The exact script above failed in all 80 fresh-process runs across the four
versions in the table. Each run had a five-second external timeout; none of
these failures was a timeout. The process exited with code 1 and an unhandled
ABORT_ERR on a Socket.

The trigger is cancellation from inside the response's async iteration while
using an agent with keepAlive: true. A one-byte response is sufficient. No
socket reuse by a second request, large response, concurrency or machine load
is required by this reproducer.

Control experiments changed one aspect of the script at a time. Each variant
was run ten times on each of v24.21.0 and v26.8.1:

Single change Result on v24.21.0 Result on v26.8.1
Set the agent's keepAlive to false 10/10 exit 0, reaches survived 10/10 exit 0, reaches survived
Omit signal from http.get options 10/10 exit 0, reaches survived 10/10 exit 0, reaches survived
Remove the controller.abort(...) call 10/10 exit 0, reaches survived 10/10 exit 0, reaches survived
Remove break, retaining cancellation 10/10 unhandled Socket error 10/10 unhandled Socket error

The last control shows that explicit early termination of the iterator is not
required for this particular response. These observations describe this local
reproducer; they do not establish all affected network timings or environments.

What is the expected behavior? Why is that the expected behavior?

Cancellation should be delivered through the HTTP request/response error paths
or an iterator rejection that the application can handle. With those paths
handled, this script should reach survived and exit 0 instead of terminating
through an unhandled error on the underlying socket.

The HTTP request documentation
describes request cancellation with an AbortSignal as request destruction and
documents an error event carrying ABORT_ERR and the supplied cause. Here,
handlers are registered on both public HTTP objects, but the error escapes
through their underlying socket.

What do you see instead?

Representative output from v26.8.1; the local script path has been normalized
to .../repro.mjs:

received bytes: 1
node:events:505
    throw er; // Unhandled 'error' event
    ^

AbortError: The operation was aborted
    at stream.<computed>.AbortError.cause (node:internal/streams/add-abort-signal:47:22)
    at [nodejs.internal.kHybridDispatch] (node:internal/event_target:848:20)
    at AbortSignal.dispatchEvent (node:internal/event_target:789:26)
    at runAbort (node:internal/abort_controller:554:10)
    at abortSignal (node:internal/abort_controller:518:3)
    at AbortController.abort (node:internal/abort_controller:573:5)
    at file:///.../repro.mjs:31:18
    at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
Emitted 'error' event on Socket instance at:
    at emitErrorNT (node:internal/streams/destroy:170:8)
    at emitErrorCloseNT (node:internal/streams/destroy:129:3)
    at process.processTicksAndRejections (node:internal/process/task_queues:90:21) {
  code: 'ABORT_ERR',
  [cause]: Error: stop reading
      at file:///.../repro.mjs:31:24
      at process.processTicksAndRejections (node:internal/process/task_queues:104:5)
}

Node.js v26.8.1

Neither the request handler, the response handler nor the iterator catch handles
this socket error. The survived line is absent.

Additional information

Observed lifecycle and suspected cause

Additional diagnostic instrumentation on v24.21.0 showed:

  1. Immediately before cancellation inside the loop, request.destroyed was
    false, response.complete was true, and response.destroyed was false.
    The socket still had socketErrorListener registered.
  2. After cancellation, Node removed socketErrorListener. A stack captured
    from the socket's removeListener event identified responseKeepAlive()
    called by IncomingMessage.responseOnEnd().
  3. An observer using the public events.errorMonitor symbol then observed
    ABORT_ERR with socket.listenerCount("error") === 0. The observer did not
    consume the error, and the process still exited 1.

Relevant v24.21.0 source
removes the socket's error listener during responseKeepAlive() and explicitly
notes a gap before the deferred free notification. The request destruction path
passes its error to the socket. These observations suggest a race between
response completion/keep-alive cleanup and deferred error emission after abort.
This is a diagnosis hypothesis, not a bisected root cause or proposed core patch.

Application impact

This was originally found in an HTTP(S) client that aborts downloads when its
response-size limit is exceeded. The application handled its ordinary request
failure, but a later unhandled socket error terminated the whole process.
The larger application reproduction also failed over HTTPS with a TLSSocket.
The standalone HTTP example above removes that application's body limit and
shows that a large response or TLS is not necessary.

The one-line keepAlive: false control avoids the failure in this example, but
changes connection reuse. In the application, a local workaround owns abort
handling, rejects with the original reason, and closes the request/response
without supplying an error to their destroy() calls. Neither observation is
presented as the required Node.js fix.

Related issues and pull requests checked

Search and review date: 2026-09-09. Both open and closed entries were included.
I did not find an exact current report containing this AbortSignal/async-iteration
reproducer. The following entries appear related, but equivalence has not been
established:

Reference State when checked Relationship
#33434 Closed Historical unhandled socket error after explicit socket.destroy(error) on Node 12. The closing comment cites the end of Node 12 releases, not a verified fix. This may share the socket-listener lifecycle gap; the new reproduction uses a request AbortSignal on supported releases.
#32851 Closed Legacy request.abort() destroying a socket after a successful request. Different observed outcome from this unhandled error during response iteration.
#57360 Closed after inactivity Uses fetch, Readable.fromWeb() and .pipe(), with an error on a Readable. The present example uses core HTTP objects directly and handles errors on both of them.
#64120 Closed A Duplex.toWeb() adapter TypeError involving an undefined controller. The present example has no Web Streams adapter.
PR #61710 Closed Addresses two calls to responseKeepAlive() through request-finish ordering. Related lifecycle area; this report has a pending abort error on a destroyed socket.
PR #65674 Open Addresses server-side request iteration leaving an incomplete connection reusable. The present report concerns client response cancellation and process termination.

Searches included combinations of AbortError, AbortController, keepAlive,
keep-alive, http.request, IncomingMessage, unhandled,
socketErrorListener and responseKeepAlive, scoped to repo:nodejs/node.
This search is not proof that no duplicate exists. Please link or redirect this
report if an existing issue covers the same behavior.

Activity

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