Skip to content

Commit 313f4e0

Browse files
authored
fix(fetch): remove abort listener when request settles (#5318)
1 parent 2f66db7 commit 313f4e0

3 files changed

Lines changed: 100 additions & 6 deletions

File tree

lib/web/fetch/index.js

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ const {
1111
getResponseState
1212
} = require('./response')
1313
const { HeadersList } = require('./headers')
14-
const { Request, cloneRequest, getRequestDispatcher, getRequestState } = require('./request')
14+
const { Request, cloneRequest, getRequestDispatcher, getRequestState, removeRequestAbortListener } = require('./request')
1515
const zlib = require('node:zlib')
1616
const {
1717
makePolicyContainer,
@@ -208,7 +208,7 @@ function fetch (input, init = undefined) {
208208
let controller = null
209209

210210
// 11. Add the following abort steps to requestObject’s signal:
211-
addAbortListener(
211+
const removeAbortListener = addAbortListener(
212212
requestObject.signal,
213213
() => {
214214
// 1. Set locallyAborted to true.
@@ -228,6 +228,15 @@ function fetch (input, init = undefined) {
228228
}
229229
)
230230

231+
// Remove the `abort` listeners registered above and in the Request
232+
// constructor once the fetch has settled. Without this, reusing a single
233+
// signal across many requests leaks listeners and Node.js emits a
234+
// MaxListenersExceededWarning. See https://github.com/nodejs/undici/issues/5285
235+
const cleanupAbortListeners = () => {
236+
removeAbortListener()
237+
removeRequestAbortListener(requestObject)
238+
}
239+
231240
// 12. Let handleFetchDone given response response be to finalize and
232241
// report timing with response, globalObject, and "fetch".
233242
// see function handleFetchDone
@@ -252,13 +261,15 @@ function fetch (input, init = undefined) {
252261
// deserializedError.
253262

254263
abortFetch(p, request, responseObject, controller.serializedAbortReason, controller.controller)
264+
cleanupAbortListeners()
255265
return
256266
}
257267

258268
// 3. If response is a network error, then reject p with a TypeError
259269
// and terminate these substeps.
260270
if (response.type === 'error') {
261271
p.reject(new TypeError('fetch failed', { cause: response.error }))
272+
cleanupAbortListeners()
262273
return
263274
}
264275

@@ -273,7 +284,10 @@ function fetch (input, init = undefined) {
273284

274285
controller = fetching({
275286
request,
276-
processResponseEndOfBody: handleFetchDone,
287+
processResponseEndOfBody: (response) => {
288+
handleFetchDone(response)
289+
cleanupAbortListeners()
290+
},
277291
processResponse,
278292
dispatcher: getRequestDispatcher(requestObject), // undici
279293
// Keep requestObject alive to prevent its AbortController from being GC'd

lib/web/fetch/request.js

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,13 @@ class Request {
9797

9898
#state
9999

100+
/**
101+
* Removes the `abort` listener that makes this request's signal follow the
102+
* passed signal. `null` when no such listener was registered.
103+
* @type {(() => void) | null}
104+
*/
105+
#abortCleanup = null
106+
100107
// https://fetch.spec.whatwg.org/#dom-request
101108
constructor (input, init = undefined) {
102109
webidl.util.markAsUncloneable(this)
@@ -436,12 +443,23 @@ class Request {
436443
setMaxListeners(1500, signal)
437444
}
438445

439-
util.addAbortListener(signal, abort)
446+
const removeAbortListener = util.addAbortListener(signal, abort)
440447
// The third argument must be a registry key to be unregistered.
441448
// Without it, you cannot unregister.
442449
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry
443450
// abort is used as the unregister key. (because it is unique)
444451
requestFinalizer.register(ac, { signal, abort }, abort)
452+
453+
// Allow the listener to be removed deterministically once the fetch
454+
// that owns this request has settled, instead of relying solely on the
455+
// FinalizationRegistry (i.e. garbage collection). Reusing a single
456+
// signal across many requests would otherwise leak listeners.
457+
// See https://github.com/nodejs/undici/issues/5285
458+
this.#abortCleanup = () => {
459+
requestFinalizer.unregister(abort)
460+
removeAbortListener()
461+
this.#abortCleanup = null
462+
}
445463
}
446464
}
447465

@@ -868,15 +886,25 @@ class Request {
868886
static setRequestState (request, newState) {
869887
request.#state = newState
870888
}
889+
890+
/**
891+
* Removes the `abort` listener that makes this request's signal follow the
892+
* signal passed to its constructor, if any. Idempotent.
893+
* @param {Request} request
894+
*/
895+
static removeRequestAbortListener (request) {
896+
request.#abortCleanup?.()
897+
}
871898
}
872899

873-
const { setRequestSignal, getRequestDispatcher, setRequestDispatcher, setRequestHeaders, getRequestState, setRequestState } = Request
900+
const { setRequestSignal, getRequestDispatcher, setRequestDispatcher, setRequestHeaders, getRequestState, setRequestState, removeRequestAbortListener } = Request
874901
Reflect.deleteProperty(Request, 'setRequestSignal')
875902
Reflect.deleteProperty(Request, 'getRequestDispatcher')
876903
Reflect.deleteProperty(Request, 'setRequestDispatcher')
877904
Reflect.deleteProperty(Request, 'setRequestHeaders')
878905
Reflect.deleteProperty(Request, 'getRequestState')
879906
Reflect.deleteProperty(Request, 'setRequestState')
907+
Reflect.deleteProperty(Request, 'removeRequestAbortListener')
880908

881909
mixinBody(Request, getRequestState)
882910

@@ -1111,5 +1139,6 @@ module.exports = {
11111139
fromInnerRequest,
11121140
cloneRequest,
11131141
getRequestDispatcher,
1114-
getRequestState
1142+
getRequestState,
1143+
removeRequestAbortListener
11151144
}

test/fetch/issue-5285.js

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
'use strict'
2+
3+
const { test } = require('node:test')
4+
const assert = require('node:assert')
5+
const http = require('node:http')
6+
const { once, getEventListeners } = require('node:events')
7+
const { fetch } = require('../..')
8+
const { closeServerAsPromise } = require('../utils/node-http')
9+
10+
// https://github.com/nodejs/undici/issues/5285
11+
// Reusing a single AbortSignal across many fetch() calls must not leak
12+
// `abort` listeners on the signal, which previously caused Node.js to emit
13+
// a MaxListenersExceededWarning.
14+
test('fetch removes the abort listener once the request settles', async (t) => {
15+
const server = http.createServer({ joinDuplicateHeaders: true }, (req, res) => {
16+
res.writeHead(200, { 'content-type': 'text/plain' })
17+
res.end('hello')
18+
})
19+
20+
t.after(closeServerAsPromise(server))
21+
await once(server.listen(0), 'listening')
22+
23+
let warning = null
24+
function onWarning (value) {
25+
warning = value
26+
}
27+
process.on('warning', onWarning)
28+
t.after(() => process.off('warning', onWarning))
29+
30+
const controller = new AbortController()
31+
const { signal } = controller
32+
33+
const url = `http://localhost:${server.address().port}`
34+
35+
// Issue many more requests than the default max listeners (10) while
36+
// sharing the same signal. Each settled request must remove its listener,
37+
// otherwise a MaxListenersExceededWarning is emitted and the listeners leak.
38+
for (let i = 0; i < 100; i++) {
39+
const res = await fetch(url, { signal })
40+
await res.text()
41+
}
42+
43+
// Allow the trailing end-of-body cleanup of the final request, which is
44+
// scheduled in a microtask, to run before asserting.
45+
await new Promise((resolve) => setTimeout(resolve, 100))
46+
47+
// No `abort` listeners should remain registered on the signal once every
48+
// request has settled.
49+
assert.strictEqual(getEventListeners(signal, 'abort').length, 0)
50+
assert.strictEqual(warning, null)
51+
})

0 commit comments

Comments
 (0)