Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 18 additions & 1 deletion lib/web/fetch/request.js
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ const assert = require('node:assert')
const { getMaxListeners, setMaxListeners, defaultMaxListeners } = require('node:events')

const kAbortController = Symbol('abortController')
const kClonedFrom = Symbol('clonedFrom')

const requestFinalizer = new FinalizationRegistry(({ signal, abort }) => {
signal.removeEventListener('abort', abort)
Expand Down Expand Up @@ -809,7 +810,23 @@ class Request {
}

// 4. Return clonedRequestObject.
return fromInnerRequest(clonedRequest, this.#dispatcher, ac.signal, getHeadersGuard(this.#headers))
const clonedRequestObject = fromInnerRequest(
clonedRequest,
this.#dispatcher,
ac.signal,
getHeadersGuard(this.#headers)
)

// The abort travels source signal -> this request's controller -> the clone's
// controller, and each hop is only held weakly. Keep both alive while the clone is:
// its own controller, because only ac.signal is handed on and a signal does not keep
// its controller alive; and the request it was cloned from, which transitively keeps
// the rest of the chain alive when clones are cloned. Same reasoning as the
// constructor, see https://github.com/nodejs/undici/issues/1926.
clonedRequestObject[kAbortController] = ac
clonedRequestObject[kClonedFrom] = this

return clonedRequestObject
}

[nodeUtil.inspect.custom] (depth, options) {
Expand Down
67 changes: 67 additions & 0 deletions test/fetch/clone-abort-after-gc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
'use strict'

const { test } = require('node:test')
const assert = require('node:assert')
const { createServer } = require('node:http')
const { once } = require('node:events')
const { fetch, Request } = require('../..')
const { closeServerAsPromise } = require('../utils/node-http')

const hasGC = typeof global.gc !== 'undefined'

test('a cloned request still aborts after garbage collection', async (t) => {
if (!hasGC) {
throw new Error('gc is not available. Run with \'--expose-gc\'.')
}

// A server that never responds, so only the abort can settle the fetch.
const server = createServer(() => {})
t.after(closeServerAsPromise(server))
server.listen(0)
await once(server, 'listening')

const controller = new AbortController()
let request = new Request(`http://localhost:${server.address().port}`, {
signal: controller.signal
})

// The request the clone came from is now unreachable. Its controller is only held
// weakly by the abort machinery, so collecting it used to break the chain that
// carries the abort through to the clone.
request = request.clone()

setTimeout(() => {
global.gc()
controller.abort()
}, 100)

await assert.rejects(fetch(request), { name: 'AbortError' })
})

test('a chain of clones still aborts after garbage collection', async (t) => {
if (!hasGC) {
throw new Error('gc is not available. Run with \'--expose-gc\'.')
}

const server = createServer(() => {})
t.after(closeServerAsPromise(server))
server.listen(0)
await once(server, 'listening')

const controller = new AbortController()
let request = new Request(`http://localhost:${server.address().port}`, {
signal: controller.signal
})

// Every intermediate is dropped, so the whole chain has to stay reachable.
request = request.clone()
request = request.clone()
request = request.clone()

setTimeout(() => {
global.gc()
controller.abort()
}, 100)

await assert.rejects(fetch(request), { name: 'AbortError' })
})