Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

use FinalizationRegistry to cancel the body if response is collected #3199

Merged
merged 26 commits into from
May 7, 2024
Merged
Show file tree
Hide file tree
Changes from 14 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
9 changes: 5 additions & 4 deletions .github/workflows/nodejs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,13 +37,13 @@ jobs:
- name: Checkout
uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
with:
persist-credentials: false
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: lts/*

- name: Install dependencies
run: npm install

Expand All @@ -59,6 +59,7 @@ jobs:
- 18
- 20
- 21
- 22
runs-on:
- ubuntu-latest
- windows-latest
Expand Down Expand Up @@ -168,13 +169,13 @@ jobs:
- name: Checkout
uses: actions/checkout@9bb56186c3b09b4f86b1c65136769dd318469633 # v4.1.2
with:
persist-credentials: false
persist-credentials: false

- name: Setup Node.js
uses: actions/setup-node@60edb5dd545a775178f52524783378180af0d1f8 # v4.0.2
with:
node-version: lts/*

- name: Install dependencies
run: npm install

Expand Down
34 changes: 25 additions & 9 deletions lib/web/fetch/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -120,12 +120,16 @@ class Fetch extends EE {
}
}

function handleFetchDone (response) {
finalizeAndReportTiming(response, 'fetch')
}

// https://fetch.spec.whatwg.org/#fetch-method
function fetch (input, init = undefined) {
webidl.argumentLengthCheck(arguments, 1, 'globalThis.fetch')

// 1. Let p be a new promise.
const p = createDeferredPromise()
let p = createDeferredPromise()

// 2. Let requestObject be the result of invoking the initial value of
// Request as constructor with input and init as arguments. If this throws
Expand Down Expand Up @@ -185,16 +189,17 @@ function fetch (input, init = undefined) {
// 3. Abort controller with requestObject’s signal’s abort reason.
controller.abort(requestObject.signal.reason)

const realResponse = responseObject?.deref()

// 4. Abort the fetch() call with p, request, responseObject,
// and requestObject’s signal’s abort reason.
abortFetch(p, request, responseObject, requestObject.signal.reason)
abortFetch(p, request, realResponse, requestObject.signal.reason)
}
)

// 12. Let handleFetchDone given response response be to finalize and
// report timing with response, globalObject, and "fetch".
const handleFetchDone = (response) =>
finalizeAndReportTiming(response, 'fetch')
// see function handleFetchDone

// 13. Set controller to the result of calling fetch given request,
// with processResponseEndOfBody set to handleFetchDone, and processResponse
Expand Down Expand Up @@ -228,10 +233,11 @@ function fetch (input, init = undefined) {

// 4. Set responseObject to the result of creating a Response object,
// given response, "immutable", and relevantRealm.
responseObject = fromInnerResponse(response, 'immutable')
responseObject = new WeakRef(fromInnerResponse(response, 'immutable'))

// 5. Resolve p with responseObject.
p.resolve(responseObject)
p.resolve(responseObject.deref())
p = null
}

controller = fetching({
Expand Down Expand Up @@ -314,7 +320,10 @@ const markResourceTiming = performance.markResourceTiming
// https://fetch.spec.whatwg.org/#abort-fetch
function abortFetch (p, request, responseObject, error) {
// 1. Reject promise with error.
p.reject(error)
if (p) {
// We might have already resolved the promise at this stage
p.reject(error)
}

// 2. If request’s body is not null and is readable, then cancel request’s
// body with error.
Expand Down Expand Up @@ -1066,7 +1075,10 @@ function fetchFinale (fetchParams, response) {
// 4. If fetchParams’s process response is non-null, then queue a fetch task to run fetchParams’s
// process response given response, with fetchParams’s task destination.
if (fetchParams.processResponse != null) {
queueMicrotask(() => fetchParams.processResponse(response))
queueMicrotask(() => {
fetchParams.processResponse(response)
fetchParams.processResponse = null
})
}

// 5. Let internalResponse be response, if response is a network error; otherwise response’s internal response.
Expand Down Expand Up @@ -1884,7 +1896,11 @@ async function httpNetworkFetch (
// 12. Let cancelAlgorithm be an algorithm that aborts fetchParams’s
// controller with reason, given reason.
const cancelAlgorithm = (reason) => {
fetchParams.controller.abort(reason)
// If the aborted fetch was already terminated, then we do not
// need to do anything.
if (!isCancelled(fetchParams)) {
fetchParams.controller.abort(reason)
}
}

// 13. Let highWaterMark be a non-negative, non-NaN number, chosen by
Expand Down
19 changes: 19 additions & 0 deletions lib/web/fetch/response.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,9 +26,23 @@ const { URLSerializer } = require('./data-url')
const { kHeadersList, kConstruct } = require('../../core/symbols')
const assert = require('node:assert')
const { types } = require('node:util')
const { isDisturbed, isErrored } = require('node:stream')

const textEncoder = new TextEncoder('utf-8')

const hasFinalizationRegistry = globalThis.FinalizationRegistry && process.version.indexOf('v18') !== 0
let registry

if (hasFinalizationRegistry) {
registry = new FinalizationRegistry((stream) => {
if (!stream.locked && !isDisturbed(stream) && !isErrored(stream)) {
stream.cancel('Response object has been garbage collected').catch(noop)
}
})
}

function noop () {}

// https://fetch.spec.whatwg.org/#response-class
class Response {
// Creates network error Response.
Expand Down Expand Up @@ -510,6 +524,11 @@ function fromInnerResponse (innerResponse, guard) {
response[kHeaders] = new Headers(kConstruct)
response[kHeaders][kHeadersList] = innerResponse.headersList
response[kHeaders][kGuard] = guard

if (hasFinalizationRegistry && innerResponse.body?.stream) {
registry.register(response, innerResponse.body.stream)
}

return response
}

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@
"test:eventsource:nobuild": "borp --expose-gc -p \"test/eventsource/*.js\"",
"test:fuzzing": "node test/fuzzing/fuzzing.test.js",
"test:fetch": "npm run build:node && npm run test:fetch:nobuild",
"test:fetch:nobuild": "borp --expose-gc -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy",
"test:fetch:nobuild": "borp --timeout 180000 --expose-gc -p \"test/fetch/*.js\" && npm run test:webidl && npm run test:busboy",
"test:interceptors": "borp -p \"test/interceptors/*.js\"",
"test:jest": "cross-env NODE_V8_COVERAGE= jest",
"test:unit": "borp --expose-gc -p \"test/*.js\"",
Expand Down
11 changes: 9 additions & 2 deletions test/fetch/cookies.js
Original file line number Diff line number Diff line change
@@ -1,16 +1,23 @@
'use strict'

Check failure on line 1 in test/fetch/cookies.js

View workflow job for this annotation

GitHub Actions / test (20, macos-latest) / Test with Node.js 20 on macos-latest

test/fetch/cookies.js

[Error [ERR_TEST_FAILURE]: test timed out after 180000ms] { code: 'ERR_TEST_FAILURE', failureType: 'testTimeoutFailure', cause: 'test timed out after 180000ms' }

Check failure on line 1 in test/fetch/cookies.js

View workflow job for this annotation

GitHub Actions / test (21, macos-latest) / Test with Node.js 21 on macos-latest

test/fetch/cookies.js

[Error [ERR_TEST_FAILURE]: test timed out after 180000ms] { code: 'ERR_TEST_FAILURE', failureType: 'testTimeoutFailure', cause: 'test timed out after 180000ms' }

const { once } = require('node:events')
const { createServer } = require('node:http')
const { test } = require('node:test')
const { test, beforeEach } = require('node:test')
const assert = require('node:assert')
const { tspl } = require('@matteo.collina/tspl')
const { Client, fetch, Headers } = require('../..')
const { Client, fetch, Headers, Agent, setGlobalDispatcher } = require('../..')
const { closeServerAsPromise } = require('../utils/node-http')
const pem = require('https-pem')
const { createSecureServer } = require('node:http2')
const { closeClientAndServerAsPromise } = require('../utils/node-http')

beforeEach(() => {
mcollina marked this conversation as resolved.
Show resolved Hide resolved
setGlobalDispatcher(new Agent({
keepAliveMaxTimeout: 10,
keepAliveTimeoutThreshold: 10
}))
})

test('Can receive set-cookie headers from a server using fetch - issue #1262', async (t) => {
const server = createServer((req, res) => {
res.setHeader('set-cookie', 'name=value; Domain=example.com')
Expand Down Expand Up @@ -62,7 +69,7 @@
t.after(closeServerAsPromise(server))
await once(server, 'listening')

await fetch(`http://localhost:${server.address().port}`, {

Check failure on line 72 in test/fetch/cookies.js

View workflow job for this annotation

GitHub Actions / test (20, macos-latest) / Test with Node.js 20 on macos-latest

Cookie header is delimited with a semicolon rather than a comma - issue #1905

[Error [ERR_TEST_FAILURE]: fetch failed] { code: 'ERR_TEST_FAILURE', failureType: 'testCodeFailure', cause: TypeError [Error]: fetch failed at fetch (/Users/runner/work/undici/undici/index.js:111:13) at async TestContext.<anonymous> (/Users/runner/work/undici/undici/test/fetch/cookies.js:72:3) at async Test.run (node:internal/test_runner/test:640:9) at async Test.processPendingSubtests (node:internal/test_runner/test:382:7) { [cause]: Error: read ETIMEDOUT at TCP.onStreamRead (node:internal/stream_base_commons:217:20) { errno: -60, code: 'ETIMEDOUT', syscall: 'read' } } }

Check failure on line 72 in test/fetch/cookies.js

View workflow job for this annotation

GitHub Actions / test (21, macos-latest) / Test with Node.js 21 on macos-latest

Cookie header is delimited with a semicolon rather than a comma - issue #1905

[Error [ERR_TEST_FAILURE]: fetch failed] { code: 'ERR_TEST_FAILURE', failureType: 'testCodeFailure', cause: TypeError [Error]: fetch failed at fetch (/Users/runner/work/undici/undici/index.js:111:13) at async TestContext.<anonymous> (/Users/runner/work/undici/undici/test/fetch/cookies.js:72:3) at async Test.run (node:internal/test_runner/test:640:9) at async Test.processPendingSubtests (node:internal/test_runner/test:382:7) { [cause]: Error: read ETIMEDOUT at TCP.onStreamRead (node:internal/stream_base_commons:217:20) { errno: -60, code: 'ETIMEDOUT', syscall: 'read' } } }
headers: [
['cookie', 'FOO=lorem-ipsum-dolor-sit-amet'],
['cookie', 'BAR=the-quick-brown-fox']
Expand Down Expand Up @@ -93,7 +100,7 @@
allowH2: true
})

const response = await fetch(

Check failure on line 103 in test/fetch/cookies.js

View workflow job for this annotation

GitHub Actions / test (20, macos-latest) / Test with Node.js 20 on macos-latest

Can receive set-cookie headers from a http2 server using fetch - issue #2885

[Error [ERR_TEST_FAILURE]: fetch failed] { code: 'ERR_TEST_FAILURE', failureType: 'testCodeFailure', cause: TypeError [Error]: fetch failed at fetch (/Users/runner/work/undici/undici/index.js:111:13) at process.processTicksAndRejections (node:internal/process/task_queues:95:5) at async TestContext.<anonymous> (/Users/runner/work/undici/undici/test/fetch/cookies.js:103:20) at async Test.run (node:internal/test_runner/test:640:9) at async Test.processPendingSubtests (node:internal/test_runner/test:382:7) { [cause]: Error [ConnectTimeoutError]: Connect Timeout Error (attempted addresses: ::1:61138) at onConnectTimeout (/Users/runner/work/undici/undici/lib/core/connect.js:190:24) at /Users/runner/work/undici/undici/lib/core/connect.js:133:46 at Immediate._onImmediate (/Users/runner/work/undici/undici/lib/core/connect.js:174:9) at process.processImmediate (node:internal/timers:478:21) { code: 'UND_ERR_CONNECT_TIMEOUT' } } }
`https://localhost:${server.address().port}/`,
// Needs to be passed to disable the reject unauthorized
{
Expand Down
53 changes: 53 additions & 0 deletions test/fetch/fire-and-forget.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
'use strict'

const { randomFillSync } = require('node:crypto')
const { setTimeout: sleep } = require('timers/promises')
const { test } = require('node:test')
const { fetch, Agent, setGlobalDispatcher } = require('../..')
const { createServer } = require('node:http')
const { closeServerAsPromise } = require('../utils/node-http')

const blob = randomFillSync(new Uint8Array(1024 * 512))

// Enable when/if FinalizationRegistry in Node.js 18 becomes stable again
const isNode18 = process.version.startsWith('v18')

test('does not need the body to be consumed to continue', { timeout: 180_000, skip: isNode18 }, async (t) => {
const agent = new Agent({
keepAliveMaxTimeout: 10,
keepAliveTimeoutThreshold: 10
})
setGlobalDispatcher(agent)
const server = createServer((req, res) => {
res.writeHead(200)
res.end(blob)
})
t.after(closeServerAsPromise(server))

await new Promise((resolve) => {
server.listen(0, resolve)
})

const url = new URL(`http://127.0.0.1:${server.address().port}`)

const batch = 50
const delay = 0
let total = 0
while (total < 10000) {
// eslint-disable-next-line no-undef
gc(true)
const array = new Array(batch)
for (let i = 0; i < batch; i++) {
array[i] = fetch(url).catch(() => {})
}
await Promise.all(array)
await sleep(delay)

console.log(
'RSS',
(process.memoryUsage.rss() / 1024 / 1024) | 0,
'MB after',
(total += batch) + ' fetch() requests'
)
}
})
Loading