diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 6b6c01d2264..68c9b977f1a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -159,6 +159,7 @@ an unbundled version instead of bundling one in `libnode.so`. To enable this, pass `EXTERNAL_PATH=/path/to/global/node_modules/undici` to `build/wasm.js`. Pass this path with `loader.js` appended to `--shared-builtin-undici/undici-path` in Node.js's `configure.py`. If building on a non-Alpine Linux distribution, you may need to also set the `WASM_CC`, `WASM_CFLAGS`, `WASM_LDFLAGS` and `WASM_LDLIBS` environment variables before running `build/wasm.js`. +Similarly, you can set the `WASM_OPT` environment variable to utilize your own `wasm-opt` optimizer. ### Benchmarks diff --git a/build/wasm.js b/build/wasm.js index 2f65c0e1d74..1880ce3dfe4 100644 --- a/build/wasm.js +++ b/build/wasm.js @@ -14,6 +14,7 @@ const WASM_CC = process.env.WASM_CC || 'clang' let WASM_CFLAGS = process.env.WASM_CFLAGS || '--sysroot=/usr/share/wasi-sysroot -target wasm32-unknown-wasi' let WASM_LDFLAGS = process.env.WASM_LDFLAGS || '' const WASM_LDLIBS = process.env.WASM_LDLIBS || '' +const WASM_OPT = process.env.WASM_OPT || './wasm-opt' // For compatibility with Node.js' `configure --shared-builtin-undici/undici-path ...` const EXTERNAL_PATH = process.env.EXTERNAL_PATH @@ -77,7 +78,7 @@ const hasApk = (function () { try { execSync('command -v apk'); return true } catch (error) { return false } })() const hasOptimizer = (function () { - try { execSync('./wasm-opt --version'); return true } catch (error) { return false } + try { execSync(`${WASM_OPT} --version`); return true } catch (error) { return false } })() if (hasApk) { // Gather information about the tools used for the build @@ -97,7 +98,7 @@ ${join(WASM_SRC, 'src')}/*.c \ ${WASM_LDLIBS}`, { stdio: 'inherit' }) if (hasOptimizer) { - execSync(`./wasm-opt ${WASM_OPT_FLAGS} -o ${join(WASM_OUT, 'llhttp.wasm')} ${join(WASM_OUT, 'llhttp.wasm')}`, { stdio: 'inherit' }) + execSync(`${WASM_OPT} ${WASM_OPT_FLAGS} -o ${join(WASM_OUT, 'llhttp.wasm')} ${join(WASM_OUT, 'llhttp.wasm')}`, { stdio: 'inherit' }) } writeWasmChunk('llhttp.wasm', 'llhttp-wasm.js') @@ -109,7 +110,7 @@ ${join(WASM_SRC, 'src')}/*.c \ ${WASM_LDLIBS}`, { stdio: 'inherit' }) if (hasOptimizer) { - execSync(`./wasm-opt ${WASM_OPT_FLAGS} --enable-simd -o ${join(WASM_OUT, 'llhttp_simd.wasm')} ${join(WASM_OUT, 'llhttp_simd.wasm')}`, { stdio: 'inherit' }) + execSync(`${WASM_OPT} ${WASM_OPT_FLAGS} --enable-simd -o ${join(WASM_OUT, 'llhttp_simd.wasm')} ${join(WASM_OUT, 'llhttp_simd.wasm')}`, { stdio: 'inherit' }) } writeWasmChunk('llhttp_simd.wasm', 'llhttp_simd-wasm.js') diff --git a/lib/dispatcher/balanced-pool.js b/lib/dispatcher/balanced-pool.js index 15a7e7b5879..1e2de289cb7 100644 --- a/lib/dispatcher/balanced-pool.js +++ b/lib/dispatcher/balanced-pool.js @@ -25,9 +25,23 @@ const kWeight = Symbol('kWeight') const kMaxWeightPerServer = Symbol('kMaxWeightPerServer') const kErrorPenalty = Symbol('kErrorPenalty') +/** + * Calculate the greatest common divisor of two numbers by + * using the Euclidean algorithm. + * + * @param {number} a + * @param {number} b + * @returns {number} + */ function getGreatestCommonDivisor (a, b) { - if (b === 0) return a - return getGreatestCommonDivisor(b, a % b) + if (a === 0) return b + + while (b !== 0) { + const t = b + b = a % b + a = t + } + return a } function defaultFactory (origin, opts) { @@ -105,7 +119,12 @@ class BalancedPool extends PoolBase { } _updateBalancedPoolStats () { - this[kGreatestCommonDivisor] = this[kClients].map(p => p[kWeight]).reduce(getGreatestCommonDivisor, 0) + let result = 0 + for (let i = 0; i < this[kClients].length; i++) { + result = getGreatestCommonDivisor(this[kClients][i][kWeight], result) + } + + this[kGreatestCommonDivisor] = result } removeUpstream (upstream) { diff --git a/lib/web/fetch/body.js b/lib/web/fetch/body.js index 55718ac7c81..464e7b50e5c 100644 --- a/lib/web/fetch/body.js +++ b/lib/web/fetch/body.js @@ -16,12 +16,25 @@ const { kState } = require('./symbols') const { webidl } = require('./webidl') const { Blob } = require('node:buffer') const assert = require('node:assert') -const { isErrored } = require('../../core/util') +const { isErrored, isDisturbed } = require('node:stream') const { isArrayBuffer } = require('node:util/types') const { serializeAMimeType } = require('./data-url') const { multipartFormDataParser } = require('./formdata-parser') const textEncoder = new TextEncoder() +function noop () {} + +const hasFinalizationRegistry = globalThis.FinalizationRegistry && process.version.indexOf('v18') !== 0 +let streamRegistry + +if (hasFinalizationRegistry) { + streamRegistry = new FinalizationRegistry((weakRef) => { + const stream = weakRef.deref() + if (stream && !stream.locked && !isDisturbed(stream) && !isErrored(stream)) { + stream.cancel('Response object has been garbage collected').catch(noop) + } + }) +} // https://fetch.spec.whatwg.org/#concept-bodyinit-extract function extractBody (object, keepalive = false) { @@ -264,7 +277,7 @@ function safelyExtractBody (object, keepalive = false) { return extractBody(object, keepalive) } -function cloneBody (body) { +function cloneBody (instance, body) { // To clone a body body, run these steps: // https://fetch.spec.whatwg.org/#concept-body-clone @@ -272,6 +285,10 @@ function cloneBody (body) { // 1. Let « out1, out2 » be the result of teeing body’s stream. const [out1, out2] = body.stream.tee() + if (hasFinalizationRegistry) { + streamRegistry.register(instance, new WeakRef(out1)) + } + // 2. Set body’s stream to out1. body.stream = out1 @@ -414,7 +431,7 @@ async function consumeBody (object, convertBytesToJSValue, instance) { // 1. If object is unusable, then return a promise rejected // with a TypeError. - if (bodyUnusable(object[kState].body)) { + if (bodyUnusable(object)) { throw new TypeError('Body is unusable: Body has already been read') } @@ -454,7 +471,9 @@ async function consumeBody (object, convertBytesToJSValue, instance) { } // https://fetch.spec.whatwg.org/#body-unusable -function bodyUnusable (body) { +function bodyUnusable (object) { + const body = object[kState].body + // An object including the Body interface mixin is // said to be unusable if its body is non-null and // its body’s stream is disturbed or locked. @@ -496,5 +515,8 @@ module.exports = { extractBody, safelyExtractBody, cloneBody, - mixinBody + mixinBody, + streamRegistry, + hasFinalizationRegistry, + bodyUnusable } diff --git a/lib/web/fetch/request.js b/lib/web/fetch/request.js index bc436aa9705..542ea7fb28a 100644 --- a/lib/web/fetch/request.js +++ b/lib/web/fetch/request.js @@ -2,7 +2,7 @@ 'use strict' -const { extractBody, mixinBody, cloneBody } = require('./body') +const { extractBody, mixinBody, cloneBody, bodyUnusable } = require('./body') const { Headers, fill: fillHeaders, HeadersList, setHeadersGuard, getHeadersGuard, setHeadersList, getHeadersList } = require('./headers') const { FinalizationRegistry } = require('./dispatcher-weakref')() const util = require('../../core/util') @@ -557,7 +557,7 @@ class Request { // 40. If initBody is null and inputBody is non-null, then: if (initBody == null && inputBody != null) { // 1. If input is unusable, then throw a TypeError. - if (util.isDisturbed(inputBody.stream) || inputBody.stream.locked) { + if (bodyUnusable(input)) { throw new TypeError( 'Cannot construct a Request with a Request object that has already been used.' ) @@ -759,7 +759,7 @@ class Request { webidl.brandCheck(this, Request) // 1. If this is unusable, then throw a TypeError. - if (this.bodyUsed || this.body?.locked) { + if (bodyUnusable(this)) { throw new TypeError('unusable') } @@ -877,7 +877,7 @@ function cloneRequest (request) { // 2. If request’s body is non-null, set newRequest’s body to the // result of cloning request’s body. if (request.body != null) { - newRequest.body = cloneBody(request.body) + newRequest.body = cloneBody(newRequest, request.body) } // 3. Return newRequest. diff --git a/lib/web/fetch/response.js b/lib/web/fetch/response.js index 603410a4a63..155dbadd1ad 100644 --- a/lib/web/fetch/response.js +++ b/lib/web/fetch/response.js @@ -1,7 +1,7 @@ 'use strict' const { Headers, HeadersList, fill, getHeadersGuard, setHeadersGuard, setHeadersList } = require('./headers') -const { extractBody, cloneBody, mixinBody } = require('./body') +const { extractBody, cloneBody, mixinBody, hasFinalizationRegistry, streamRegistry, bodyUnusable } = require('./body') const util = require('../../core/util') const nodeUtil = require('node:util') const { kEnumerableProperty } = util @@ -26,24 +26,9 @@ const { URLSerializer } = require('./data-url') const { 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((weakRef) => { - const stream = weakRef.deref() - if (stream && !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. @@ -244,7 +229,7 @@ class Response { webidl.brandCheck(this, Response) // 1. If this is unusable, then throw a TypeError. - if (this.bodyUsed || this.body?.locked) { + if (bodyUnusable(this)) { throw webidl.errors.exception({ header: 'Response.clone', message: 'Body has already been consumed.' @@ -327,7 +312,7 @@ function cloneResponse (response) { // 3. If response’s body is non-null, then set newResponse’s body to the // result of cloning response’s body. if (response.body != null) { - newResponse.body = cloneBody(response.body) + newResponse.body = cloneBody(newResponse, response.body) } // 4. Return newResponse. @@ -532,7 +517,7 @@ function fromInnerResponse (innerResponse, guard) { // a primitive or an object, even undefined. If the held value is an object, the registry keeps // a strong reference to it (so it can pass it to the cleanup callback later). Reworded from // https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/FinalizationRegistry - registry.register(response, new WeakRef(innerResponse.body.stream)) + streamRegistry.register(response, new WeakRef(innerResponse.body.stream)) } return response diff --git a/package.json b/package.json index 86fbc7e26b3..bf777b24a2b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "undici", - "version": "6.19.7", + "version": "6.19.8", "description": "An HTTP/1.1 client, written from scratch for Node.js", "homepage": "https://undici.nodejs.org", "bugs": { diff --git a/test/fetch/fire-and-forget.js b/test/fetch/fire-and-forget.js index d8090885eb8..d356729b14d 100644 --- a/test/fetch/fire-and-forget.js +++ b/test/fetch/fire-and-forget.js @@ -37,8 +37,9 @@ test('does not need the body to be consumed to continue', { timeout: 180_000, sk // eslint-disable-next-line no-undef gc(true) const array = new Array(batch) - for (let i = 0; i < batch; i++) { + for (let i = 0; i < batch; i += 2) { array[i] = fetch(url).catch(() => {}) + array[i + 1] = fetch(url).then(r => r.clone()).catch(() => {}) } await Promise.all(array) await sleep(delay) diff --git a/test/fetch/response.js b/test/fetch/response.js index 912c24a40e3..86c57b13658 100644 --- a/test/fetch/response.js +++ b/test/fetch/response.js @@ -2,6 +2,8 @@ const { test } = require('node:test') const assert = require('node:assert') +const { setImmediate } = require('node:timers/promises') +const { AsyncLocalStorage } = require('node:async_hooks') const { tspl } = require('@matteo.collina/tspl') const { Response, @@ -285,3 +287,29 @@ test('fromInnerResponse', () => { assert.strictEqual(getHeadersList(response[kHeaders]), innerResponse.headersList) assert.strictEqual(getHeadersGuard(response[kHeaders]), 'immutable') }) + +test('clone body garbage collection', async () => { + const asyncLocalStorage = new AsyncLocalStorage() + let ref + + await new Promise(resolve => { + asyncLocalStorage.run(new Map(), async () => { + const res = new Response('hello world') + const clone = res.clone() + + asyncLocalStorage.getStore().set('key', clone) + ref = new WeakRef(clone.body) + + await res.text() + await clone.text() // consume body + + resolve() + }) + }) + + await setImmediate() + global.gc() + + const cloneBody = ref.deref() + assert.equal(cloneBody, undefined, 'clone body was not garbage collected') +})