From b3445e405a9c4374f196bf0599953e726b36bc8e Mon Sep 17 00:00:00 2001 From: Guillaume Pannatier Date: Thu, 27 Nov 2025 16:06:32 +0100 Subject: [PATCH] fix: ac lost on cloned request (#4068) --- lib/web/fetch/request.js | 22 +++++++++++++-- test/fetch/issue-4068.js | 60 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 80 insertions(+), 2 deletions(-) create mode 100644 test/fetch/issue-4068.js diff --git a/lib/web/fetch/request.js b/lib/web/fetch/request.js index e3c11bc01ff..5249a358359 100644 --- a/lib/web/fetch/request.js +++ b/lib/web/fetch/request.js @@ -29,6 +29,7 @@ const assert = require('node:assert') const { getMaxListeners, setMaxListeners, defaultMaxListeners } = require('node:events') const kAbortController = Symbol('abortController') +const kDependantAbortController = Symbol('dependantAbortController') const requestFinalizer = new FinalizationRegistry(({ signal, abort }) => { signal.removeEventListener('abort', abort) @@ -428,6 +429,12 @@ class Request { // See, https://github.com/nodejs/undici/issues/1926. this[kAbortController] = ac + // Keep ref to original ac while cloned request is alive. + // See, https://github.com/nodejs/undici/issues/4068. + if (webidl.is.Request(input) && input[kAbortController]) { + this[kDependantAbortController] = input[kAbortController] + } + const acRef = new WeakRef(ac) const abort = buildAbort(acRef) @@ -790,8 +797,19 @@ class Request { ) } - // 4. Return clonedRequestObject. - return fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers)) + // 4. Construct clonedRequestObject. + const req = fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers)) + req[kAbortController] = ac + + // 5. Keep reference of original controller to avoid GC + // Keep ref to original ac while cloned request is alive. + // See, https://github.com/nodejs/undici/issues/4068. + if (this[kAbortController]) { + req[kDependantAbortController] = this[kAbortController] + } + + // 6. Return cloned request + return req } [nodeUtil.inspect.custom] (depth, options) { diff --git a/test/fetch/issue-4068.js b/test/fetch/issue-4068.js new file mode 100644 index 00000000000..1e617fea143 --- /dev/null +++ b/test/fetch/issue-4068.js @@ -0,0 +1,60 @@ +'use strict' + +const { once } = require('node:events') +const { test, describe } = require('node:test') +const { fetch, Request } = require('../..') +const { createServer } = require('node:http') + +describe('issue 4068', async () => { + test('abort signal for new request', async (t) => { + t.plan(2) + const server = createServer(() => {}).listen(0) + let aborted = false + + t.after(server.close.bind(server)) + + const ac = new AbortController() + let req = new Request(`http://localhost:${server.address().port}`, { + signal: ac.signal + }) + + req.signal.addEventListener('abort', () => { + aborted = true + }) + + req = new Request(req) + setTimeout(() => { + global.gc() + ac.abort() + }) + await once(server, 'listening') + await t.assert.rejects(fetch(req)) + t.assert.ok(aborted) + }) + + test('abort signal for cloned request', async (t) => { + t.plan(2) + const server = createServer(() => {}).listen(0) + let aborted = false + + t.after(server.close.bind(server)) + + const ac = new AbortController() + let req = new Request(`http://localhost:${server.address().port}`, { + signal: ac.signal + }) + + req.signal.addEventListener('abort', () => { + aborted = true + }) + + req = req.clone() + setTimeout(() => { + global.gc() + ac.abort() + }) + await once(server, 'listening') + await t.assert.rejects(fetch(req)) + t.assert.ok(aborted) + }) +})