Skip to content

Process does not exit after aborting a request to an unresponsive host #4405

Open

Description

@redneb

Bug Description

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";

const url = "http://1.2.4.5:6789/"; // no server expected

const controller = new AbortController();
setTimeout(() => {
  console.log(new Date().toISOString(), "aborting");
  controller.abort();
}, 1000).unref();

fetch(url, {signal: controller.signal})
  .catch(() => {})
  .then(() => {
    console.log(new Date().toISOString(), "fetch finished");
  });

Then run with a wrapper to show process lifetime:

printf '%s before\n' "$(date -uIs)"; node test.mjs; printf '%s after\n' "$(date -uIs)"

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.

Activity

  1. mcollina commented on Aug 12, 2025

    @mcollina
    SponsorMember

    Looks like a bug. We are missing to unref/destroy something on abort.

  2. Uzlopak commented on Aug 12, 2025

    @Uzlopak
    Contributor

    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?

  3. Uzlopak commented on Aug 12, 2025

    @Uzlopak
    Contributor

    Nevermind, that is desired behavior.

    Is it maybe because of

    return new Promise((resolve, reject) => agent.dispatch(

    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?

  4. Kjubikstronk commented on Aug 31, 2026

    @Kjubikstronk

    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

    1. the connector accepting a signal or returning a handle so an attempt can be cancelled,
    2. the client holding the connecting socket so _resume can destroy it once the queue drains, or
    3. 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.

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

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions