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:
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:
- 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.
- After cancellation, Node removed
socketErrorListener. A stack captured
from the socket's removeListener event identified responseKeepAlive()
called by IncomingMessage.responseOnEnd().
- 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.
Version
Reproduced with the standalone script below on:
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):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.mjsand run:The script uses only Node built-ins, binds a local server to
127.0.0.1on anephemeral 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
errorhandlers. Asynciteration is also inside a
try/catch. The script does not remove any errorhandlers or install a process-wide exception handler.
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_ERRon aSocket.The trigger is cancellation from inside the response's async iteration while
using an agent with
keepAlive: true. A one-byte response is sufficient. Nosocket 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:
keepAlivetofalsesurvivedsurvivedsignalfromhttp.getoptionssurvivedsurvivedcontroller.abort(...)callsurvivedsurvivedbreak, retaining cancellationThe 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
survivedand exit 0 instead of terminatingthrough 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_ERRand 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:Neither the request handler, the response handler nor the iterator catch handles
this socket error. The
survivedline is absent.Additional information
Observed lifecycle and suspected cause
Additional diagnostic instrumentation on v24.21.0 showed:
request.destroyedwasfalse,response.completewastrue, andresponse.destroyedwasfalse.The socket still had
socketErrorListenerregistered.socketErrorListener. A stack capturedfrom the socket's
removeListenerevent identifiedresponseKeepAlive()called by
IncomingMessage.responseOnEnd().events.errorMonitorsymbol then observedABORT_ERRwithsocket.listenerCount("error") === 0. The observer did notconsume the error, and the process still exited 1.
Relevant v24.21.0 source
removes the socket's error listener during
responseKeepAlive()and explicitlynotes 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: falsecontrol avoids the failure in this example, butchanges 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 ispresented 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:
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.request.abort()destroying a socket after a successful request. Different observed outcome from this unhandled error during response iteration.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.Duplex.toWeb()adapter TypeError involving an undefined controller. The present example has no Web Streams adapter.responseKeepAlive()through request-finish ordering. Related lifecycle area; this report has a pending abort error on a destroyed socket.Searches included combinations of
AbortError,AbortController,keepAlive,keep-alive,http.request,IncomingMessage,unhandled,socketErrorListenerandresponseKeepAlive, scoped torepo: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.