fix(fetch): remove abort listener when request settles#5318
Conversation
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
There was a problem hiding this comment.
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
Requestand expose it forfetch()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
AbortSignalacross manyfetch()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.
| @@ -228,6 +228,15 @@ function fetch (input, init = undefined) { | |||
| } | |||
| ) | |||
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #5318 +/- ##
=======================================
Coverage 93.22% 93.23%
=======================================
Files 110 110
Lines 36599 36642 +43
=======================================
+ Hits 34121 34162 +41
- Misses 2478 2480 +2 ☔ View full report in Codecov by Sentry. 🚀 New features to boost your workflow:
|
| // deserializedError. | ||
|
|
||
| abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller) | ||
| cleanupAbortListeners() |
There was a problem hiding this comment.
Please move this function call to abortFetch.
|
|
||
| // 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)) |
There was a problem hiding this comment.
const { setImmediate } = require('node:timers/promises')| await new Promise((resolve) => setTimeout(resolve, 100)) | |
| await setImmediate() |
| // otherwise a MaxListenersExceededWarning is emitted and the listeners leak. | ||
| for (let i = 0; i < 100; i++) { | ||
| const res = await fetch(url, { signal }) | ||
| await res.text() |
There was a problem hiding this comment.
| await res.text() | |
| await res.arrayBuffer() |
There was a problem hiding this comment.
This is just a personal preference. It avoids string allocation, which makes the tests faster.
mcollina
left a comment
There was a problem hiding this comment.
I'm surprised this does not cause regressions, but in hindsight it should be ok
lgtm but we need to wait @KhafraDev opinion too.
I agree. A possible issue is that If this has no consequences, I think this should be upstreamed to the fetch spec. At the bare minimum, a note stating "the abort listener added can be removed here" could be added to the call sites. |
|
At first, I didn’t think this would work, but after thinking it through more carefully, it actually seems to work quite well. That said, since this behavior is outside the specification, we should proceed with caution. I found one edge case that is worth considering. In this scenario, determining when it is safe to detach the signal is very similar to how garbage collection works. Specifically, the link can only be removed when:
As far as I can tell, only browsers handle this edge case correctly today; it has not yet been implemented in server-side fetch. This is not an immediate problem since it is currently unimplemented, but it will likely become an issue if we decide to implement it later. const c = new AbortController();
const r = await fetch(
new URL("/", globalThis.location?.href ?? "https://example.com/"),
{ signal: c.signal },
);
const r2 = r.clone(); // Clone it.
await r.arrayBuffer(); // Consume the entire body first to confirm the request has ended.
c.abort(); // Abort.
console.log((await r2.text()).slice(0, 100)); // This should throw an error because they are shared.If this is a known limitation and we are comfortable with it for now, I’m fine with moving forward. However, it is something we will need to take into account if and when we implement this in the future. |
I can't find anything in the spec that says this case should throw. Browsers seem to be mishandling it. |
Problem
fetch()attaches anabortlistener to the passedAbortSignalon every call — in theRequestconstructor and in the fetch algorithm — but only removes them via aFinalizationRegistry(on GC). Reusing one signal across many requests accumulates listeners and Node emitsMaxListenersExceededWarning.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
Requestconstructor exposes its cleanup through an internal static accessor following the existing pattern inrequest.js, so no new public symbol is introduced.Test
Added
test/fetch/issue-5285.js: issues 100fetchcalls sharing oneAbortControllersignal and asserts noabortlisteners remain and noMaxListenersExceededWarningis emitted. Fails onmain, passes here. Fulltest/fetchsuite (471 tests) and node-fetch suite pass.Closes #5285