Skip to content

fix(fetch): remove abort listener when request settles#5318

Open
ATOM00blue wants to merge 1 commit into
nodejs:mainfrom
ATOM00blue:fix/fetch-abort-listener-leak
Open

fix(fetch): remove abort listener when request settles#5318
ATOM00blue wants to merge 1 commit into
nodejs:mainfrom
ATOM00blue:fix/fetch-abort-listener-leak

Conversation

@ATOM00blue
Copy link
Copy Markdown

Problem

fetch() attaches an abort listener to the passed AbortSignal on every call — in the Request constructor and in the fetch algorithm — but only removes them via a FinalizationRegistry (on GC). Reusing one signal across many requests accumulates listeners and Node emits MaxListenersExceededWarning.

Fix

Capture the listener-removal callbacks and invoke them deterministically once the fetch settles, covering the end-of-body, network-error and abort paths. The Request constructor exposes its cleanup through an internal static accessor following the existing pattern in request.js, so no new public symbol is introduced.

Test

Added test/fetch/issue-5285.js: issues 100 fetch calls sharing one AbortController signal and asserts no abort listeners remain and no MaxListenersExceededWarning is emitted. Fails on main, passes here. Full test/fetch suite (471 tests) and node-fetch suite pass.

Closes #5285

fetch() registers an `abort` listener on the passed AbortSignal (in both
the Request constructor and the fetch algorithm) but only removed it via
the FinalizationRegistry, i.e. on garbage collection. Reusing a single
signal across many requests therefore accumulated listeners and Node.js
emitted a MaxListenersExceededWarning.

Capture the listener-removal callbacks and invoke them deterministically
once the fetch settles (end-of-body, network error and abort paths) so
that no listeners are leaked when a signal is reused.

Closes nodejs#5285
Copilot AI review requested due to automatic review settings May 21, 2026 02:35
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Note

Copilot was unable to run its full agentic suite in this review.

Adds deterministic cleanup for AbortSignal listeners used by fetch()/Request to prevent listener leaks and MaxListenersExceededWarning when reusing a single signal across many requests.

Changes:

  • Add request-level abort-listener cleanup plumbing in Request and expose it for fetch() to call.
  • Ensure fetch() removes abort listeners on error/abort/end-of-body settlement paths.
  • Add a regression test covering listener leakage when reusing one AbortSignal across many fetch() calls.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 4 comments.

File Description
test/fetch/issue-5285.js Adds regression test asserting no leaked abort listeners / warnings when reusing a signal.
lib/web/fetch/request.js Tracks and exposes deterministic removal of the listener that ties request signal to an external signal.
lib/web/fetch/index.js Calls cleanup hooks so request/fetch abort listeners are removed when the fetch settles.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread test/fetch/issue-5285.js

// Allow the trailing end-of-body cleanup of the final request, which is
// scheduled in a microtask, to run before asserting.
await new Promise((resolve) => setTimeout(resolve, 100))
Comment thread test/fetch/issue-5285.js

let warning = null
function onWarning (value) {
warning = value
Comment thread lib/web/fetch/request.js
Comment on lines +446 to +462
const removeAbortListener = util.addAbortListener(signal, abort)
// The third argument must be a registry key to be unregistered.
// Without it, you cannot unregister.
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
// abort is used as the unregister key. (because it is unique)
requestFinalizer.register(ac, { signal, abort }, abort)

// Allow the listener to be removed deterministically once the fetch
// that owns this request has settled, instead of relying solely on the
// FinalizationRegistry (i.e. garbage collection). Reusing a single
// signal across many requests would otherwise leak listeners.
// See https://github.com/nodejs/undici/issues/5285
this.#abortCleanup = () => {
requestFinalizer.unregister(abort)
removeAbortListener()
this.#abortCleanup = null
}
Comment thread lib/web/fetch/index.js
Comment on lines 211 to 229
@@ -228,6 +228,15 @@ function fetch (input, init = undefined) {
}
)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

MaxListenersExceededWarning when using signal

2 participants