You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Process does not exit after aborting a request to an unresponsive host #4405
When issuing an HTTP request to an unresponsive host with undici and aborting it via AbortController before the library’s timeout elapses, the request rejects as expected, but the Node.js process remains alive until the original timeout completes. With no other work pending, the process should exit promptly after the abort.
Reproducible By
Use the following minimal script as test.mjs:
import{fetch}from"undici";consturl="http://1.2.4.5:6789/";// no server expectedconstcontroller=newAbortController();setTimeout(()=>{console.log(newDate().toISOString(),"aborting");controller.abort();},1000).unref();fetch(url,{signal: controller.signal}).catch(()=>{}).then(()=>{console.log(newDate().toISOString(),"fetch finished");});
Observed result: the request is aborted quickly, but the process does not exit until around the default connect/overall timeout.
Expected Behavior
After AbortController.abort() causes the fetch to reject, and with no other referenced timers/handles, the process should exit immediately (or very shortly thereafter).
Logs & Screenshots
2025-08-12T10:56:55+00:00 before
2025-08-12T10:56:56.412Z aborting
2025-08-12T10:56:56.416Z fetch finished
2025-08-12T10:57:05+00:00 after
Note: “after” prints ~10s after the request was aborted/settled, suggesting an internal referenced timer/handle remains until timeout expiry.
Environment
OS: Linux 6.12.36
Node.js: v22.13.1
undici: 7.13.0
Additional context
It appears an internal connect/overall timeout or connection attempt remains referenced after abort, keeping the event loop alive until timeout expiry.
I was debugging it like an hour ago. Added a custom property uzlopak true to the signal. Seems like the signal gets replaced with a new signal, because the property uzlopak was gone?
agent.dispatch has no onRequestStart listener. It starts with onConnect. but if we never dont connect to the server, how can we trigger the connect event?
Still reproduces on main. To put a name on what is not being destroyed:
1.0s aborting
1.0s rejected: AbortError
10.6s process exited
The fetch settles immediately. What keeps the loop alive is the connect that was already in
flight. Reading the handles shortly after the abort:
active handles: ["Socket","Socket"]active requests: ["TCPConnectWrap"]
The process then exits when that connect finally gives up, not when the request was aborted.
Where it comes from, as far as I can follow it in lib/dispatcher/client.js:
_resume returns early whenever client[kConnecting] is set, so emptying the queue through
aborts never reaches anything that could tear the connection attempt down.
The socket does not exist on the client while it is connecting. client[kConnector](…, (err, socket) => …)
only hands it over once net.connect fires, so there is no reference to destroy in the meantime.
The one guard that would destroy it, if (client.destroyed) in that callback, runs after the
connect completes, which is exactly the ~10s being waited on.
So it is less that something is not destroyed on abort and more that nothing can be: the
connection attempt is only reachable after it finishes. Fixing it seems to need one of
the connector accepting a signal or returning a handle so an attempt can be cancelled,
the client holding the connecting socket so _resume can destroy it once the queue drains, or
leaving the socket unref'd until a request is attached, which fixes the process not exiting
without cancelling anything.
They differ in how much of the connection lifecycle they touch, so I did not want to pick one
and send a PR. Happy to implement whichever you prefer.
Activity
mcollina commentedon Aug 12, 2025
Looks like a bug. We are missing to unref/destroy something on abort.
Uzlopak commentedon Aug 12, 2025
I was debugging it like an hour ago. Added a custom property uzlopak true to the signal. Seems like the signal gets replaced with a new signal, because the property uzlopak was gone?
Uzlopak commentedon Aug 12, 2025
Nevermind, that is desired behavior.
Is it maybe because of
undici/lib/web/fetch/index.js
Line 2058 in 6ca7da0
agent.dispatch has no onRequestStart listener. It starts with onConnect. but if we never dont connect to the server, how can we trigger the connect event?
test: add reproduction test for issue #4405
fix: handle close event instead of monkey-patching destroy for issue #…
revert: remove ineffective connect.js fix for issue #4405
fix: ensure connection timeouts are cancelled when fetch is aborted
Kjubikstronk commentedon Aug 31, 2026
Still reproduces on
main. To put a name on what is not being destroyed:The fetch settles immediately. What keeps the loop alive is the connect that was already in
flight. Reading the handles shortly after the abort:
The process then exits when that connect finally gives up, not when the request was aborted.
Where it comes from, as far as I can follow it in
lib/dispatcher/client.js:_resumereturns early wheneverclient[kConnecting]is set, so emptying the queue throughaborts never reaches anything that could tear the connection attempt down.
client[kConnector](…, (err, socket) => …)only hands it over once
net.connectfires, so there is no reference to destroy in the meantime.if (client.destroyed)in that callback, runs after theconnect completes, which is exactly the ~10s being waited on.
So it is less that something is not destroyed on abort and more that nothing can be: the
connection attempt is only reachable after it finishes. Fixing it seems to need one of
_resumecan destroy it once the queue drains, orwithout cancelling anything.
They differ in how much of the connection lifecycle they touch, so I did not want to pick one
and send a PR. Happy to implement whichever you prefer.