From dcaded991e270554b65e9ef71c65cdb5d4d092fd Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Wed, 9 Sep 2026 22:11:24 -0700 Subject: [PATCH 001/119] src: seed V8 from the OS CSPRNG instead of OpenSSL's DRBG InitializeOncePerProcessInternal() calls CSPRNG(nullptr, 0) to confirm OpenSSL's random source is seeded and installs a V8 entropy source that goes through CSPRNG() as well. The first RAND_status() of the process therefore runs before V8 starts, instantiates the DRBG, and with it constructs the default provider's algorithm and name tables (ossl_method_construct, ossl_namemap_stored): 3.7% of the samples of `node -e 0` on Linux x64, all of it before v8Start. V8 uses the entropy for hash seeds, address space layout randomization and Math.random(), none of which are cryptographic, so read the OS CSPRNG directly through uv_random(). AIX is the exception: uv_random() reads the blocking /dev/random there, so it stays on OpenSSL's DRBG, which seeds from /dev/urandom. Keep activating the default provider at startup, which the eager check did as a side effect and --openssl-legacy-provider depends on. Its explicit OSSL_PROVIDER_load() disables OpenSSL's provider fallback, so without a prior activation the default provider never loads. Run the seeding check itself only when that provider is unavailable or FIPS is in effect, the cases where an OpenSSL configuration from any source can leave the process without a DRBG and an early abort beats a hang at the first crypto call. Every crypto consumer stays on OpenSSL, and a system without a usable CSPRNG still aborts at startup, now from uv_random() failing. Two other behaviors change. A configuration whose [random] section names a DRBG that cannot be fetched used to abort at startup; it now starts and the first crypto call fails on the fetch. With --secure-heap the process DRBGs are instantiated after the secure heap exists, so they are allocated from it, and a Worker's isolate setup no longer aborts the process from the entropy callback when the heap cannot hold another per-thread DRBG. Tests cover both, and the default provider staying active under --openssl-legacy-provider. Measured on Linux x64 against an unpatched build of the same tree, both binaries interleaved, min of 300 runs: `node -e 0` 29.18 -> 27.82 ms, nodeStart to v8Start 2.91 -> 2.11 ms. RAND_status and the provider's table construction leave the startup profile (2.8% of samples before); the provider activation that remains is 0.05%. The first crypto.randomBytes() instantiates the DRBG in 0.19 ms. The `parallel`, `sequential`, `message`, `es-module` and `addons` suites show no failure the unpatched build does not have. Refs: https://github.com/nodejs/node/commit/5cc36c39d2 Refs: https://github.com/nodejs/node/pull/44493 Refs: https://github.com/nodejs/node/pull/46237 Signed-off-by: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65796 Reviewed-By: Filip Skokan Reviewed-By: James M Snell --- src/node.cc | 36 +++++++++++++++---- .../test-legacy-provider-option.js | 3 ++ .../openssl3-conf/random_unavailable.cnf | 7 ++++ test/parallel/test-crypto-no-algorithm.js | 18 ++++++++++ test/parallel/test-crypto-secure-heap.js | 35 ++++++++++++++++++ 5 files changed, 93 insertions(+), 6 deletions(-) create mode 100644 test/fixtures/openssl3-conf/random_unavailable.cnf diff --git a/src/node.cc b/src/node.cc index 49cac0538ce..5c513d90d09 100644 --- a/src/node.cc +++ b/src/node.cc @@ -49,6 +49,9 @@ #if HAVE_OPENSSL #include "ncrypto.h" +#if OPENSSL_VERSION_MAJOR >= 3 +#include +#endif #include "node_crypto.h" #if OPENSSL_VERSION_MAJOR >= 3 && !defined(CONF_MFLAGS_IGNORE_MISSING_FILE) // OpenSSL hides this deprecated macro under OPENSSL_NO_DEPRECATED, but the @@ -1264,15 +1267,36 @@ InitializeOncePerProcessInternal(const std::vector& args, } crypto::InstallFipsIndicatorCallback(); - // Ensure CSPRNG is properly seeded. - CHECK(ncrypto::CSPRNG(nullptr, 0)); + // Activating the default provider here keeps --openssl-legacy-provider + // working. Its explicit load disables OpenSSL's fallback, and the eager + // CSPRNG check used to activate the provider as a side effect. Only + // check the seeding when that provider is missing or FIPS is on, so a + // configuration without a DRBG still aborts at startup instead of + // hanging at the first crypto call. Otherwise the DRBG is instantiated + // on first use. +#if OPENSSL_VERSION_MAJOR >= 3 + const bool check_csprng = ncrypto::isFipsEnabled() || + !OSSL_PROVIDER_available(nullptr, "default"); +#else + const bool check_csprng = true; +#endif + if (check_csprng) { + CHECK(ncrypto::CSPRNG(nullptr, 0)); + } + // V8 uses the entropy for hash seeds, ASLR and Math.random(), none of + // it cryptographic. Going through OpenSSL would instantiate the DRBG + // and build the default provider's algorithm tables on every startup. + // V8 falls back to very weak entropy when the source fails, so abort + // instead. V8::SetEntropySource([](unsigned char* buffer, size_t length) { - // V8 falls back to very weak entropy when this function fails - // and /dev/urandom isn't available. That wouldn't be so bad if - // the entropy was only used for Math.random() but it's also used for - // hash table and address space layout randomization. Better to abort. +#ifdef _AIX + // uv_random() reads /dev/random on AIX, which blocks. OpenSSL seeds + // from /dev/urandom there. CHECK(ncrypto::CSPRNG(buffer, length)); +#else + CHECK_EQ(uv_random(nullptr, nullptr, buffer, length, 0, nullptr), 0); +#endif return true; }); #endif // !defined(OPENSSL_IS_BORINGSSL) diff --git a/test/addons/openssl-providers/test-legacy-provider-option.js b/test/addons/openssl-providers/test-legacy-provider-option.js index 5ad60dac9b8..1f01ce55a8f 100644 --- a/test/addons/openssl-providers/test-legacy-provider-option.js +++ b/test/addons/openssl-providers/test-legacy-provider-option.js @@ -22,3 +22,6 @@ if (getFips()) { common.skip('this test cannot be run in FIPS mode'); } providers.testProviderPresent('legacy'); +// The explicit legacy load disables OpenSSL's provider fallback, so the +// default provider has to be active before it runs. +providers.testProviderPresent('default'); diff --git a/test/fixtures/openssl3-conf/random_unavailable.cnf b/test/fixtures/openssl3-conf/random_unavailable.cnf new file mode 100644 index 00000000000..a2dc8d2c9ff --- /dev/null +++ b/test/fixtures/openssl3-conf/random_unavailable.cnf @@ -0,0 +1,7 @@ +nodejs_conf = nodejs_init + +[nodejs_init] +random = random_sect + +[random_sect] +random = NO-SUCH-DRBG diff --git a/test/parallel/test-crypto-no-algorithm.js b/test/parallel/test-crypto-no-algorithm.js index db781c66a6d..b18b0825b81 100644 --- a/test/parallel/test-crypto-no-algorithm.js +++ b/test/parallel/test-crypto-no-algorithm.js @@ -56,3 +56,21 @@ if (isMainThread) { assert(common.nodeProcessAborted(cp.status, cp.signal), `process did not abort, code:${cp.status} signal:${cp.signal}`); } + +// AIX keeps OpenSSL as V8's entropy source, so a DRBG that cannot be +// fetched still aborts at startup there. +if (!common.isAIX) { + // A configuration whose random section names a DRBG that cannot be + // fetched starts normally; the first crypto call fails, without a hang. + const fixtures = require('../common/fixtures'); + const { spawnSync } = require('node:child_process'); + const randomConf = fixtures.path('openssl3-conf', 'random_unavailable.cnf'); + const cp = spawnSync(process.execPath, + [ `--openssl-config=${randomConf}`, '-e', + 'require("node:crypto").randomBytes(8)' ], + { encoding: 'utf8' }); + assert(!common.nodeProcessAborted(cp.status, cp.signal), + `process aborted, code:${cp.status} signal:${cp.signal}`); + assert.strictEqual(cp.status, 1); + assert.match(cp.stderr, /unable to fetch drbg/); +} diff --git a/test/parallel/test-crypto-secure-heap.js b/test/parallel/test-crypto-secure-heap.js index 3845f49a474..ef686293b90 100644 --- a/test/parallel/test-crypto-secure-heap.js +++ b/test/parallel/test-crypto-secure-heap.js @@ -60,6 +60,28 @@ if (process.argv[2] === 'child') { return; } +if (process.argv[2] === 'workers') { + // Eight Workers held alive at once. A 1 KiB secure heap has room for a + // few DRBGs only, so an isolate setup that drew its entropy through + // OpenSSL would fail for the later Workers and abort the process. + const { Worker } = require('worker_threads'); + const i32 = new Int32Array(new SharedArrayBuffer(4)); + let online = 0; + for (let i = 0; i < 8; i++) { + const worker = new Worker( + 'const { workerData } = require("worker_threads");' + + 'Atomics.wait(workerData.i32, 0, 0);', + { eval: true, workerData: { i32 } }); + worker.on('online', () => { + if (++online === 8) { + Atomics.store(i32, 0, 1); + Atomics.notify(i32, 0); + } + }); + } + return; +} + const child = fork( process.argv[1], ['child'], @@ -69,6 +91,19 @@ child.on('exit', common.mustCall((code) => { assert.strictEqual(code, 0); })); +// AIX keeps OpenSSL as V8's entropy source, so a Worker's isolate setup +// still draws on the secure heap there. +if (!common.isAIX) { + const child = fork( + process.argv[1], + ['workers'], + { execArgv: ['--secure-heap=1024', '--secure-heap-min=4'] }); + child.on('exit', common.mustCall((code, signal) => { + assert.strictEqual(signal, null); + assert.strictEqual(code, 0); + })); +} + { const child = fork(fixtures.path('a.js'), { execArgv: ['--secure-heap=3', '--secure-heap-min=3'], From a0ea1d9a7d5b34cf433b4ab221ffaf381b132807 Mon Sep 17 00:00:00 2001 From: Matteo Collina Date: Thu, 10 Sep 2026 09:21:44 +0200 Subject: [PATCH 002/119] buffer: fix unaligned UTF-16LE decoding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit For odd-length input, the destination only has room for complete code units. Copy those units and ignore the trailing byte. This matches the aligned and big-endian paths. Assisted-by: pi Signed-off-by: Matteo Collina PR-URL: https://github.com/nodejs/node/pull/65905 Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Filip Skokan Reviewed-By: Robert Nagy --- src/string_bytes.cc | 4 ++-- test/parallel/test-buffer-tostring.js | 7 +++++++ 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/src/string_bytes.cc b/src/string_bytes.cc index b39d6fcc2ec..656b8245800 100644 --- a/src/string_bytes.cc +++ b/src/string_bytes.cc @@ -722,8 +722,8 @@ MaybeLocal StringBytes::Encode(Isolate* isolate, } if (reinterpret_cast(buf) % 2 != 0) { return EncodeTwoByteString( - isolate, str_len, [buf, buflen](uint16_t* dst) { - memcpy(dst, buf, buflen); + isolate, str_len, [buf, str_len](uint16_t* dst) { + memcpy(dst, buf, str_len * sizeof(*dst)); }); } return ExternTwoByteString::NewFromCopy( diff --git a/test/parallel/test-buffer-tostring.js b/test/parallel/test-buffer-tostring.js index a3dad0146d7..676d2f85f56 100644 --- a/test/parallel/test-buffer-tostring.js +++ b/test/parallel/test-buffer-tostring.js @@ -9,6 +9,13 @@ for (const encoding of ['utf8', 'utf-8', 'ucs2', 'ucs-2', 'ascii', 'latin1', assert.strictEqual(Buffer.from('foo', encoding).toString(encoding), 'foo'); } +// Ignore an incomplete trailing code unit when decoding unaligned UTF-16LE. +for (const size of [514, 516]) { + const buffer = Buffer.alloc(size, 0x61); + assert.strictEqual(buffer.toString('utf16le', 1), + '\u6161'.repeat((size - 1) >>> 1)); +} + // base64 ['base64', 'BASE64'].forEach((encoding) => { assert.strictEqual(Buffer.from('Zm9v', encoding).toString(encoding), 'Zm9v'); From efdc04d81bbbc12bb00d1546b48a94b6e417cd39 Mon Sep 17 00:00:00 2001 From: Yuya Inoue <65857152+inoway46@users.noreply.github.com> Date: Thu, 10 Sep 2026 22:34:57 +0900 Subject: [PATCH 003/119] doc: add inoway46 as triager Signed-off-by: inoway46 PR-URL: https://github.com/nodejs/node/pull/65565 Reviewed-By: Luigi Pinca Reviewed-By: Daeyeon Jeong Reviewed-By: Trivikram Kamat Reviewed-By: Filip Skokan Reviewed-By: Darshan Sen --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 0376c0e9814..24a76d872f2 100644 --- a/README.md +++ b/README.md @@ -761,6 +761,8 @@ maintaining the Node.js project. **Wiyeong Seo** <> * [iam-frankqiu](https://github.com/iam-frankqiu) - **Frank Qiu** <> (he/him) +* [inoway46](https://github.com/inoway46) - + **Yuya Inoue** <> (he/him) * [milesguicent](https://github.com/milesguicent) - **Miles Guicent** <> (he/him) * [preveen-stack](https://github.com/preveen-stack) - From 6ab4e1e0795afae8e86032209f001093f14d8215 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Thu, 10 Sep 2026 08:19:13 -0700 Subject: [PATCH 004/119] test: try fixing windows build replacing WMIC Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65949 Reviewed-By: Filip Skokan Reviewed-By: Stefan Stojanovic --- test/common/child_process.js | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/test/common/child_process.js b/test/common/child_process.js index c74154bb084..59505e5824d 100644 --- a/test/common/child_process.js +++ b/test/common/child_process.js @@ -15,12 +15,12 @@ function cleanupStaleProcess(filename) { process.once('beforeExit', () => { const basename = filename.replace(/.*[/\\]/g, ''); try { - execFileSync(`${process.env.SystemRoot}\\System32\\wbem\\WMIC.exe`, [ - 'process', - 'where', - `commandline like '%${basename}%child'`, - 'delete', - '/nointeractive', + execFileSync(`${process.env.SystemRoot}\\System32\\WindowsPowerShell\\v1.0\\powershell.exe`, [ + '-NoProfile', + '-NonInteractive', + '-Command', + `Get-CimInstance Win32_Process -Filter "CommandLine LIKE '%${basename}%child'" | ` + + 'ForEach-Object { Stop-Process -Id $_.ProcessId -Force }', ]); } catch { // Ignore failures, there might not be any stale process to clean up. From bf534f6eceb7ca0f33c5a2d507e1695726b772bc Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Thu, 10 Sep 2026 17:19:25 +0200 Subject: [PATCH 005/119] tools: fix commit queue error summary matching Recognize the error symbol emitted by core-validate-commit so validation failures appear outside the collapsed output. Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65913 Reviewed-By: James M Snell Reviewed-By: Luigi Pinca Reviewed-By: Xuguang Mei Reviewed-By: Trivikram Kamat --- tools/actions/commit-queue.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tools/actions/commit-queue.sh b/tools/actions/commit-queue.sh index e260773d14a..0c3c70da89f 100755 --- a/tools/actions/commit-queue.sh +++ b/tools/actions/commit-queue.sh @@ -72,7 +72,7 @@ commit_queue_failed() { Add https://github.com/nodejs/node/labels/commit-queue-squash to land it as one commit, or https://github.com/nodejs/node/labels/commit-queue-rebase to land the commits separately.' else if [ -z "$reported_failure" ]; then - reported_failure=$(grep -e '✘' -e '⚠' output | tail -n 10) + reported_failure=$(grep -e '✘' -e '✖' -e '⚠' output | tail -n 10) fi if [ -z "$reported_failure" ]; then reported_failure=$(tail -n 10 output) From 9187d589a53529251e7570bbf91fef288b529028 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 8 Sep 2026 00:20:36 +0200 Subject: [PATCH 006/119] crypto: read EC curve metadata directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Avoid reconstructing EC keys for key details and TLS ephemeral-key curve reporting. Assisted-by: GitHub Copilot Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65908 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Tobias Nießen --- benchmark/crypto/ec-key-details.js | 23 +++++++++++++++++++++++ deps/ncrypto/ncrypto.cc | 18 ++++++++++++++++++ deps/ncrypto/ncrypto.h | 1 + src/crypto/crypto_common.cc | 5 +---- src/crypto/crypto_ec.cc | 6 +----- 5 files changed, 44 insertions(+), 9 deletions(-) create mode 100644 benchmark/crypto/ec-key-details.js diff --git a/benchmark/crypto/ec-key-details.js b/benchmark/crypto/ec-key-details.js new file mode 100644 index 00000000000..e7266e9ad19 --- /dev/null +++ b/benchmark/crypto/ec-key-details.js @@ -0,0 +1,23 @@ +'use strict'; + +const common = require('../common.js'); +const { KeyObject } = require('crypto'); + +const bench = common.createBenchmark(main, { + namedCurve: ['P-256', 'P-384', 'P-521'], + type: ['public', 'private'], + n: [10000], +}); + +async function main({ namedCurve, type, n }) { + const pair = await crypto.subtle.generateKey({ + name: 'ECDSA', namedCurve, + }, true, ['sign', 'verify']); + const cryptoKey = pair[`${type}Key`]; + bench.start(); + for (let index = 0; index < n; index++) { + if (!KeyObject.from(cryptoKey).asymmetricKeyDetails.namedCurve) + throw new Error('Missing named curve'); + } + bench.end(n); +} diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index b727bb06cdb..27dc2344bb5 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -6916,6 +6916,24 @@ int Ec::getCurve() const { return EC_GROUP_get_curve_name(getGroup()); } +int Ec::GetCurveId(const EVPKeyPointer& key) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + char name[80]; + size_t length = 0; + if (EVP_PKEY_get_utf8_string_param( + key.get(), OSSL_PKEY_PARAM_GROUP_NAME, name, sizeof(name), &length) != + 1) { + return NID_undef; + } + return GetCurveIdFromName(name); +#else + const EC_KEY* ec = key; + if (ec == nullptr) return NID_undef; + const EC_GROUP* group = EC_KEY_get0_group(ec); + return group == nullptr ? NID_undef : EC_GROUP_get_curve_name(group); +#endif +} + int Ec::GetCurveIdFromName(const char* name) { int nid = EC_curve_nist2nid(name); if (nid == NID_undef) { diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 6b1edceed06..d548910931b 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -812,6 +812,7 @@ class Ec final { #endif static int GetCurveIdFromName(const char* name); + static int GetCurveId(const EVPKeyPointer& key); using GetCurveCallback = std::function; static bool GetCurves(GetCurveCallback callback); diff --git a/src/crypto/crypto_common.cc b/src/crypto/crypto_common.cc index fde12953860..b1b1c48e4cc 100644 --- a/src/crypto/crypto_common.cc +++ b/src/crypto/crypto_common.cc @@ -28,7 +28,6 @@ namespace node { using ncrypto::ClearErrorOnReturn; -using ncrypto::ECKeyPointer; using ncrypto::EVPKeyPointer; using ncrypto::SSLPointer; using ncrypto::SSLSessionPointer; @@ -231,9 +230,7 @@ MaybeLocal GetEphemeralKey(Environment* env, const SSLPointer& ssl) { case EVP_PKEY_X448: { const char* curve_name; if (kid == EVP_PKEY_EC) { - ECKeyPointer ec(key); - if (!ec) break; - int nid = EC_GROUP_get_curve_name(ec.getGroup()); + int nid = ncrypto::Ec::GetCurveId(key); if (nid == NID_undef) break; curve_name = OBJ_nid2sn(nid); } else { diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index 125f6f3b818..8fa6c5d0491 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -896,11 +896,7 @@ bool GetEcKeyDetail(Environment* env, const auto& m_pkey = key.GetAsymmetricKey(); CHECK_EQ(m_pkey.id(), EVP_PKEY_EC); - ECKeyPointer ec(m_pkey); - if (!ec) return true; - - const auto group = ec.getGroup(); - int nid = EC_GROUP_get_curve_name(group); + int nid = Ec::GetCurveId(m_pkey); if (nid == NID_undef) return true; return target From 2f3d3428c21416aca7df6f6390a4482bee893a41 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 8 Sep 2026 00:27:41 +0200 Subject: [PATCH 007/119] crypto: export EC JWK coordinates directly MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Query provider coordinates together instead of serializing and decoding the public point. Assisted-by: GitHub Copilot Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65908 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Tobias Nießen --- benchmark/crypto/ec-jwk-export.js | 19 ++++++++++ deps/ncrypto/ncrypto.cc | 59 +++++++++++++++++++++++++++++++ deps/ncrypto/ncrypto.h | 5 +++ src/crypto/crypto_ec.cc | 36 +++++++------------ 4 files changed, 96 insertions(+), 23 deletions(-) create mode 100644 benchmark/crypto/ec-jwk-export.js diff --git a/benchmark/crypto/ec-jwk-export.js b/benchmark/crypto/ec-jwk-export.js new file mode 100644 index 00000000000..0539fc6468a --- /dev/null +++ b/benchmark/crypto/ec-jwk-export.js @@ -0,0 +1,19 @@ +'use strict'; + +const common = require('../common.js'); +const { generateKeyPairSync } = require('crypto'); + +const bench = common.createBenchmark(main, { + namedCurve: ['prime256v1', 'secp384r1', 'secp521r1', 'secp256k1'], + type: ['public', 'private'], + n: [10000], +}); + +function main({ namedCurve, type, n }) { + const key = generateKeyPairSync('ec', { namedCurve })[`${type}Key`]; + const options = { format: 'jwk' }; + bench.start(); + for (let index = 0; index < n; index++) + key.export(options); + bench.end(n); +} diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 27dc2344bb5..ffc10cf68c1 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -6916,6 +6916,65 @@ int Ec::getCurve() const { return EC_GROUP_get_curve_name(getGroup()); } +bool Ec::GetKeyComponents(const EVPKeyPointer& key, + BignumPointer* x, + BignumPointer* y, + BignumPointer* priv, + int* degree) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + const int nid = GetCurveId(key); + switch (nid) { + case NID_X9_62_prime256v1: + case NID_secp256k1: + *degree = 256; + break; + case NID_secp384r1: + *degree = 384; + break; + case NID_secp521r1: + *degree = 521; + break; + default: + *degree = 0; + } + if (*degree != 0) { + MarkPopErrorOnReturn pop_errors; + unsigned char x_bytes[66]{}; + unsigned char y_bytes[66]{}; + const size_t width = (*degree + 7) / 8; + OSSL_PARAM params[] = { + OSSL_PARAM_construct_BN(OSSL_PKEY_PARAM_EC_PUB_X, x_bytes, width), + OSSL_PARAM_construct_BN(OSSL_PKEY_PARAM_EC_PUB_Y, y_bytes, width), + OSSL_PARAM_construct_end(), + }; + if (EVP_PKEY_get_params(key.get(), params) == 1 && + OSSL_PARAM_modified(¶ms[0]) && OSSL_PARAM_modified(¶ms[1])) { + x->reset(BN_native2bn(x_bytes, width, nullptr)); + y->reset(BN_native2bn(y_bytes, width, nullptr)); + return *x && *y && + (priv == nullptr || + GetPKeyBnParam(key.get(), OSSL_PKEY_PARAM_PRIV_KEY, priv)); + } + } +#endif + ECKeyPointer ec(key); + if (!ec || ec.getPublicKey() == nullptr) return false; + *degree = EC_GROUP_get_degree(ec.getGroup()); + x->reset(BN_new()); + y->reset(BN_new()); + if (!*x || !*y || + EC_POINT_get_affine_coordinates( + ec.getGroup(), ec.getPublicKey(), x->get(), y->get(), nullptr) != 1) { + return false; + } + if (priv != nullptr) { + if (ec.getPrivateKey() == nullptr) return false; + priv->reset(BN_dup(ec.getPrivateKey())); + if (!*priv) return false; + } + return true; +} + int Ec::GetCurveId(const EVPKeyPointer& key) { #if NCRYPTO_USE_OPENSSL3_PROVIDER char name[80]; diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index d548910931b..048308f2827 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -813,6 +813,11 @@ class Ec final { static int GetCurveIdFromName(const char* name); static int GetCurveId(const EVPKeyPointer& key); + static bool GetKeyComponents(const EVPKeyPointer& key, + BignumPointer* x, + BignumPointer* y, + BignumPointer* priv, + int* degree); using GetCurveCallback = std::function; static bool GetCurves(GetCurveCallback callback); diff --git a/src/crypto/crypto_ec.cc b/src/crypto/crypto_ec.cc index 8fa6c5d0491..4cfadfe67ce 100644 --- a/src/crypto/crypto_ec.cc +++ b/src/crypto/crypto_ec.cc @@ -625,30 +625,21 @@ bool ExportJWKEcKey(Environment* env, const auto& m_pkey = key.GetAsymmetricKey(); CHECK_EQ(m_pkey.id(), EVP_PKEY_EC); - ECKeyPointer ec(m_pkey); - if (!ec) { - THROW_ERR_CRYPTO_INVALID_JWK(env, "Invalid JWK EC key"); + BignumPointer x; + BignumPointer y; + BignumPointer priv; + int degree_bits; + if (!Ec::GetKeyComponents( + m_pkey, + &x, + &y, + key.GetKeyType() == kKeyTypePrivate ? &priv : nullptr, + °ree_bits)) { return false; } - // A provider-backed key need not expose its public point. - if (ec.getPublicKey() == nullptr) return false; - - const auto pub = ec.getPublicKey(); - const auto group = ec.getGroup(); - - int degree_bits = EC_GROUP_get_degree(group); int degree_bytes = (degree_bits / CHAR_BIT) + (7 + (degree_bits % CHAR_BIT)) / 8; - auto x = BignumPointer::New(); - auto y = BignumPointer::New(); - - if (!EC_POINT_get_affine_coordinates(group, pub, x.get(), y.get(), nullptr)) { - ThrowCryptoError(env, ERR_get_error(), - "Failed to get elliptic-curve point coordinates"); - return false; - } - if (!target ->DefineOwnProperty( env->context(), env->jwk_kty_string(), env->jwk_ec_string()) @@ -672,7 +663,7 @@ bool ExportJWKEcKey(Environment* env, } Local crv_name; - const int nid = EC_GROUP_get_curve_name(group); + const int nid = Ec::GetCurveId(m_pkey); switch (nid) { case NID_X9_62_prime256v1: crv_name = env->p256_string(); @@ -699,9 +690,8 @@ bool ExportJWKEcKey(Environment* env, } if (key.GetKeyType() == kKeyTypePrivate) { - auto pvt = ec.getPrivateKey(); - if (pvt == nullptr) return false; - return SetEncodedValue(env, target, env->jwk_d_string(), pvt, degree_bytes) + return SetEncodedValue( + env, target, env->jwk_d_string(), priv.get(), degree_bytes) .IsJust(); } From fead604eba79c5f65b9be0cfa485247a855c1b08 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 8 Sep 2026 00:34:22 +0200 Subject: [PATCH 008/119] crypto: avoid EC raw export reconstruction MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read private scalars and matching uncompressed provider encodings directly. Assisted-by: GitHub Copilot Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/65908 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Tobias Nießen --- benchmark/crypto/ec-raw-export.js | 23 ++++++++++ deps/ncrypto/ncrypto.cc | 45 ++++++++++++++++++ deps/ncrypto/ncrypto.h | 3 ++ src/crypto/crypto_keys.cc | 76 ++++++++++--------------------- 4 files changed, 95 insertions(+), 52 deletions(-) create mode 100644 benchmark/crypto/ec-raw-export.js diff --git a/benchmark/crypto/ec-raw-export.js b/benchmark/crypto/ec-raw-export.js new file mode 100644 index 00000000000..15c1d9333bb --- /dev/null +++ b/benchmark/crypto/ec-raw-export.js @@ -0,0 +1,23 @@ +'use strict'; + +const common = require('../common.js'); +const { generateKeyPairSync } = require('crypto'); + +const bench = common.createBenchmark(main, { + namedCurve: ['prime256v1', 'secp384r1', 'secp521r1'], + format: ['raw-private', 'raw-public'], + type: ['uncompressed', 'compressed'], + n: [10000], +}, { + combinationFilter: ({ format, type }) => format === 'raw-public' || type === 'uncompressed', +}); + +function main({ namedCurve, format, type, n }) { + const pair = generateKeyPairSync('ec', { namedCurve }); + const key = format === 'raw-private' ? pair.privateKey : pair.publicKey; + const options = format === 'raw-public' ? { format, type } : { format }; + bench.start(); + for (let index = 0; index < n; index++) + key.export(options); + bench.end(n); +} diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index ffc10cf68c1..04338f489b9 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -6916,6 +6916,51 @@ int Ec::getCurve() const { return EC_GROUP_get_curve_name(getGroup()); } +DataPointer Ec::TryExportPublic(const EVPKeyPointer& key, + point_conversion_form_t form) { + if (form != POINT_CONVERSION_UNCOMPRESSED) return {}; +#if NCRYPTO_USE_OPENSSL3_PROVIDER + { + MarkPopErrorOnReturn pop_errors; + size_t length = 0; + if (EVP_PKEY_get_octet_string_param( + key.get(), OSSL_PKEY_PARAM_PUB_KEY, nullptr, 0, &length) == 1) { + auto bytes = DataPointer::Alloc(length); + if (bytes && length != 0 && + EVP_PKEY_get_octet_string_param(key.get(), + OSSL_PKEY_PARAM_PUB_KEY, + bytes.get(), + length, + &length) == 1 && + (bytes.get()[0] & ~1) == form) { + return bytes.resize(length); + } + } + } +#endif + return {}; +} + +DataPointer Ec::ExportPrivate(const EVPKeyPointer& key) { +#if NCRYPTO_USE_OPENSSL3_PROVIDER + { + MarkPopErrorOnReturn pop_errors; + BignumPointer priv; + BignumPointer order; + if (GetPKeyBnParam(key.get(), OSSL_PKEY_PARAM_PRIV_KEY, &priv) && + GetPKeyBnParam(key.get(), OSSL_PKEY_PARAM_EC_ORDER, &order)) { + return priv.encodePadded(order.byteLength()); + } + } +#endif + ECKeyPointer ec(key); + if (!ec || ec.getPrivateKey() == nullptr) return {}; + auto order = BignumPointer::New(); + if (!order || !EC_GROUP_get_order(ec.getGroup(), order.get(), nullptr)) + return {}; + return BignumPointer::EncodePadded(ec.getPrivateKey(), order.byteLength()); +} + bool Ec::GetKeyComponents(const EVPKeyPointer& key, BignumPointer* x, BignumPointer* y, diff --git a/deps/ncrypto/ncrypto.h b/deps/ncrypto/ncrypto.h index 048308f2827..a8a8c7fc66d 100644 --- a/deps/ncrypto/ncrypto.h +++ b/deps/ncrypto/ncrypto.h @@ -813,6 +813,9 @@ class Ec final { static int GetCurveIdFromName(const char* name); static int GetCurveId(const EVPKeyPointer& key); + static DataPointer TryExportPublic(const EVPKeyPointer& key, + point_conversion_form_t form); + static DataPointer ExportPrivate(const EVPKeyPointer& key); static bool GetKeyComponents(const EVPKeyPointer& key, BignumPointer* x, BignumPointer* y, diff --git a/src/crypto/crypto_keys.cc b/src/crypto/crypto_keys.cc index f0e6b8ad62f..04982195d24 100644 --- a/src/crypto/crypto_keys.cc +++ b/src/crypto/crypto_keys.cc @@ -387,21 +387,24 @@ bool KeyObjectData::ToEncodedPublicKey( Mutex::ScopedLock lock(mutex()); const auto& pkey = GetAsymmetricKey(); if (pkey.id() == EVP_PKEY_EC) { + auto form = static_cast(config.ec_point_form); + auto bytes = ncrypto::Ec::TryExportPublic(pkey, form); + if (bytes) + return Buffer::Copy(env, bytes.get(), bytes.size()) + .ToLocal(out); ECKeyPointer ec_key(pkey); if (!ec_key) { THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); return false; } - // A provider-backed key need not expose its public point. if (ec_key.getPublicKey() == nullptr) { THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to export EC public key"); return false; } - auto form = static_cast(config.ec_point_form); - const auto group = ec_key.getGroup(); - const auto point = ec_key.getPublicKey(); - return ECPointToBuffer(env, group, point, form).ToLocal(out); + return ECPointToBuffer( + env, ec_key.getGroup(), ec_key.getPublicKey(), form) + .ToLocal(out); } const int id = pkey.id(); bool is_raw_supported = id == EVP_PKEY_ED25519 || id == EVP_PKEY_ED448 || @@ -442,25 +445,7 @@ bool KeyObjectData::ToEncodedPrivateKey( Mutex::ScopedLock lock(mutex()); const auto& pkey = GetAsymmetricKey(); if (pkey.id() == EVP_PKEY_EC) { - ECKeyPointer ec_key(pkey); - if (!ec_key) { - THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); - return false; - } - const BIGNUM* private_key = ec_key.getPrivateKey(); - if (private_key == nullptr) { - THROW_ERR_CRYPTO_OPERATION_FAILED(env, - "Failed to export EC private key"); - return false; - } - const auto group = ec_key.getGroup(); - auto order = BignumPointer::New(); - if (!order || !EC_GROUP_get_order(group, order.get(), nullptr)) { - THROW_ERR_CRYPTO_OPERATION_FAILED(env, - "Failed to export EC private key"); - return false; - } - auto buf = BignumPointer::EncodePadded(private_key, order.byteLength()); + auto buf = ncrypto::Ec::ExportPrivate(pkey); if (!buf) { THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to export EC private key"); @@ -1582,24 +1567,27 @@ void KeyObjectHandle::ExportECPublicRaw( return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); } + CHECK(args[0]->IsInt32()); + auto form = + static_cast(args[0].As()->Value()); + + auto bytes = ncrypto::Ec::TryExportPublic(m_pkey, form); + if (bytes) { + args.GetReturnValue().Set( + Buffer::Copy(env, bytes.get(), bytes.size()) + .FromMaybe(Local())); + return; + } ECKeyPointer ec_key(m_pkey); if (!ec_key) return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); - // A provider-backed key need not expose its public point. if (ec_key.getPublicKey() == nullptr) { return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to export EC public key"); } - - CHECK(args[0]->IsInt32()); - auto form = - static_cast(args[0].As()->Value()); - - const auto group = ec_key.getGroup(); - const auto point = ec_key.getPublicKey(); - Local buf; - if (!ECPointToBuffer(env, group, point, form).ToLocal(&buf)) return; - + if (!ECPointToBuffer(env, ec_key.getGroup(), ec_key.getPublicKey(), form) + .ToLocal(&buf)) + return; args.GetReturnValue().Set(buf); } @@ -1618,23 +1606,7 @@ void KeyObjectHandle::ExportECPrivateRaw( return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); } - ECKeyPointer ec_key(m_pkey); - if (!ec_key) return THROW_ERR_CRYPTO_INCOMPATIBLE_KEY_OPTIONS(env); - - const BIGNUM* private_key = ec_key.getPrivateKey(); - if (private_key == nullptr) { - return THROW_ERR_CRYPTO_OPERATION_FAILED(env, - "Failed to export EC private key"); - } - - const auto group = ec_key.getGroup(); - auto order = BignumPointer::New(); - if (!order || !EC_GROUP_get_order(group, order.get(), nullptr)) { - return THROW_ERR_CRYPTO_OPERATION_FAILED(env, - "Failed to export EC private key"); - } - - auto buf = BignumPointer::EncodePadded(private_key, order.byteLength()); + auto buf = ncrypto::Ec::ExportPrivate(m_pkey); if (!buf) { return THROW_ERR_CRYPTO_OPERATION_FAILED(env, "Failed to export EC private key"); From 7a33e61390bd7f0bed1a653d77c946ae3b69b0cf Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Mon, 7 Sep 2026 22:36:38 +0200 Subject: [PATCH 009/119] crypto: avoid EC reconstruction for signature sizing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use EVP_PKEY_bits() to determine the width of ECDSA signature components on OpenSSL 3. This avoids reconstructing the EC group and public point just to read the group order size. Signed-off-by: Filip Skokan Assisted-by: GitHub Copilot PR-URL: https://github.com/nodejs/node/pull/65908 Reviewed-By: Yagiz Nizipli Reviewed-By: James M Snell Reviewed-By: Tobias Nießen --- deps/ncrypto/ncrypto.cc | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/deps/ncrypto/ncrypto.cc b/deps/ncrypto/ncrypto.cc index 04338f489b9..32e96ad3a9f 100644 --- a/deps/ncrypto/ncrypto.cc +++ b/deps/ncrypto/ncrypto.cc @@ -4130,11 +4130,7 @@ std::optional EVPKeyPointer::getBytesOfRS() const { #endif } else if (id == EVP_PKEY_EC) { #if NCRYPTO_USE_OPENSSL3_PROVIDER - Ec ec(get()); - if (!ec) return std::nullopt; - const EC_GROUP* group = ec.getGroup(); - if (group == nullptr) return std::nullopt; - bits = EC_GROUP_order_bits(group); + bits = EVP_PKEY_bits(get()); #else const EC_KEY* ec_key = EVP_PKEY_get0_EC_KEY(get()); if (ec_key == nullptr) return std::nullopt; From 942263832f48d9b0e101d02387a01fd0e1a4e922 Mon Sep 17 00:00:00 2001 From: Philipp Dunkel Date: Thu, 10 Sep 2026 23:21:29 +0200 Subject: [PATCH 010/119] test: skip C++ symbols in tick-processor-arguments The test only checks that a CLI flag is passed through to the V8 tick processor, but processing a --prof log makes the tick processor resolve the C++ symbols of every shared library listed in it by shelling out to nm (plus c++filt on macOS) once per library. On a --shared build that links around a hundred dylibs this takes longer than the test timeout on the macOS x86_64 GitHub Actions runner, and the outcome depends on the host toolchain rather than on node. Drop the shared-library entries from the log before processing it so the test exercises argument handling only. C++ symbol resolution is covered by test/tick-processor. Signed-off-by: Philipp Dunkel PR-URL: https://github.com/nodejs/node/pull/65906 Refs: https://github.com/nodejs/node/issues/50050 Reviewed-By: James M Snell Reviewed-By: Matteo Collina --- test/parallel/test-tick-processor-arguments.js | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/test/parallel/test-tick-processor-arguments.js b/test/parallel/test-tick-processor-arguments.js index 406b13b676d..a2d99192f97 100644 --- a/test/parallel/test-tick-processor-arguments.js +++ b/test/parallel/test-tick-processor-arguments.js @@ -19,6 +19,17 @@ const files = fs.readdirSync(tmpdir.path); const logfile = files.find((name) => /\.log$/.test(name)); assert(logfile); +// Drop the shared-library entries: the tick processor resolves the C++ +// symbols of every listed library through nm (and c++filt on macOS), which is +// slow on builds that link many shared libraries and depends on the host +// toolchain. This test only checks that CLI arguments reach the tick +// processor; C++ symbol resolution is covered by test/tick-processor. +const logpath = tmpdir.resolve(logfile); +fs.writeFileSync(logpath, fs.readFileSync(logpath, 'utf8') + .split('\n') + .filter((line) => !line.startsWith('shared-library,')) + .join('\n')); + // Make sure that the --preprocess argument is passed through correctly, // as an example flag listed in deps/v8/tools/tickprocessor.js. // Any of the other flags there should work for this test too, if --preprocess From bedf7e7001fd93bdd5e787e22587fd1f37467b45 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 2 Sep 2026 21:57:09 +0000 Subject: [PATCH 011/119] src: fix Stop() terminating the next Environment on the isolate After `Stop(env)`, freeing the Environment and creating another one on the same isolate failed whenever no JavaScript ran in between: the new Environment's first script was terminated before it started. That is the normal case when `Stop()` is called from the process exit handler for an uncaught exception, or by an embedder while the loop is idle. `Stop()` calls `isolate->TerminateExecution()` unless `kDoNotTerminateIsolate` is set, and V8 only clears that request the next time JavaScript runs, so it outlived the Environment it was meant for. Cancel a pending termination when the Environment it was meant for is freed. `Worker::Run()` already did this by hand before freeing its Environment, with a TODO asking why V8 hit a DCHECK without it; this is why, and that call now happens in `FreeEnvironment()`. Refs: https://github.com/nodejs/node/pull/33347 Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/65819 Reviewed-By: Yagiz Nizipli Reviewed-By: Chemi Atlow --- src/api/environment.cc | 3 +++ src/node_worker.cc | 5 ----- test/cctest/test_environment.cc | 21 +++++++++++++++++++++ 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/src/api/environment.cc b/src/api/environment.cc index 56ade1e1623..7c4bf4cda70 100644 --- a/src/api/environment.cc +++ b/src/api/environment.cc @@ -514,6 +514,9 @@ void FreeEnvironment(Environment* env) { Isolate* isolate = env->isolate(); Isolate::DisallowJavascriptExecutionScope disallow_js(isolate, Isolate::DisallowJavascriptExecutionScope::THROW_ON_FAILURE); + // A termination requested by Stop() targets this Environment; if no JS ran + // since, it is still pending and must not hit the isolate's next user. + isolate->CancelTerminateExecution(); { HandleScope handle_scope(isolate); // For env->context(). Context::Scope context_scope(env->context()); diff --git a/src/node_worker.cc b/src/node_worker.cc index 825c7d03bb0..fd6a0d90afa 100644 --- a/src/node_worker.cc +++ b/src/node_worker.cc @@ -314,11 +314,6 @@ void Worker::Run() { DeleteFnPtr env_; auto cleanup_env = OnScopeLeave([&]() { - // TODO(addaleax): This call is harmless but should not be necessary. - // Figure out why V8 is raising a DCHECK() here without it - // (in test/parallel/test-async-hooks-worker-asyncfn-terminate-4.js). - isolate_->CancelTerminateExecution(); - if (!env_) return; env_->set_can_call_into_js(false); diff --git a/test/cctest/test_environment.cc b/test/cctest/test_environment.cc index 30f92f97eec..ff451259908 100644 --- a/test/cctest/test_environment.cc +++ b/test/cctest/test_environment.cc @@ -365,6 +365,27 @@ TEST_F(EnvironmentTest, WorkerInEnvironmentWithoutSnapshot) { EXPECT_EQ(node::SpinEventLoop(*env).FromJust(), 0); } +TEST_F(EnvironmentTest, StopFromExitHandlerDoesNotLeakIntoNextEnvironment) { + const v8::HandleScope handle_scope(isolate_); + const Argv argv; + { + Env env{handle_scope, argv}; + node::SetProcessExitHandler( + *env, [](node::Environment* env_, int) { node::Stop(env_); }); + // The uncaught exception runs the exit handler from C++ and does not + // re-enter JS afterwards, so nothing consumes the termination request. + EXPECT_TRUE( + node::LoadEnvironment(*env, "throw new Error('uncaught')").IsEmpty()); + EXPECT_TRUE(node::SpinEventLoop(*env).IsNothing()); + } + { + Env env{handle_scope, argv, node::EnvironmentFlags::kNoCreateInspector}; + v8::Local result = + node::LoadEnvironment(*env, "return 42;").ToLocalChecked(); + EXPECT_EQ(result->Int32Value(env.context()).FromJust(), 42); + } +} + TEST_F(EnvironmentTest, NoEnvironmentSanity) { const v8::HandleScope handle_scope(isolate_); v8::Local context = v8::Context::New(isolate_); From 480710e00fd63f7ffd5f01b16f12ed75c279de30 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:44:09 +0000 Subject: [PATCH 012/119] tools: bump js-yaml from 4.3.1 to 4.3.2 in /tools/lint-md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] PR-URL: https://github.com/nodejs/node/pull/65932 Reviewed-By: Marco Ippolito Reviewed-By: Filip Skokan Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca Reviewed-By: Gürgün Dayıoğlu --- tools/lint-md/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/lint-md/package-lock.json b/tools/lint-md/package-lock.json index 1ab4268328b..ea6663dc369 100644 --- a/tools/lint-md/package-lock.json +++ b/tools/lint-md/package-lock.json @@ -326,9 +326,9 @@ } }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", From 82feeab4f7da3675ecec37cf6cfb0d760aa7d28c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Fri, 11 Sep 2026 07:44:22 +0000 Subject: [PATCH 013/119] tools: bump js-yaml from 4.3.1 to 4.3.2 in /tools/eslint Bumps [js-yaml](https://github.com/nodeca/js-yaml) from 4.3.1 to 4.3.2. - [Changelog](https://github.com/nodeca/js-yaml/blob/4.3.2/CHANGELOG.md) - [Commits](https://github.com/nodeca/js-yaml/compare/4.3.1...4.3.2) --- updated-dependencies: - dependency-name: js-yaml dependency-version: 4.3.2 dependency-type: indirect ... Signed-off-by: dependabot[bot] PR-URL: https://github.com/nodejs/node/pull/65931 Reviewed-By: Filip Skokan Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca --- tools/eslint/package-lock.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tools/eslint/package-lock.json b/tools/eslint/package-lock.json index d9f1b6c5e68..ea1716a3247 100644 --- a/tools/eslint/package-lock.json +++ b/tools/eslint/package-lock.json @@ -1454,9 +1454,9 @@ "license": "MIT" }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "funding": [ { "type": "github", From c0cbf01ffb2aab0273e6d3970c6f87f36464fa96 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 6 Sep 2026 15:27:21 -0700 Subject: [PATCH 014/119] zlib: fix zstd reset Preserve dictionaries and params when zstd is reset. Update missing documentation. Signed-off-by: James M Snell Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65867 Reviewed-By: Trivikram Kamat Reviewed-By: Filip Skokan Reviewed-By: Robert Nagy --- doc/api/zlib.md | 10 +++- src/node_zlib.cc | 44 +++++++++++++++-- test/parallel/test-zlib-zstd-reset.js | 68 +++++++++++++++++++++++++++ 3 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 test/parallel/test-zlib-zstd-reset.js diff --git a/doc/api/zlib.md b/doc/api/zlib.md index d8825ffd315..e1d94fd12c6 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -1088,8 +1088,14 @@ Only applicable to deflate algorithm. added: v0.7.0 --> -Reset the compressor/decompressor to factory defaults. Only applicable to -the inflate and deflate algorithms. +For inflate and deflate streams, reset the compressor/decompressor to factory +defaults. + +For Zstd streams, cancel the current frame and start a new session while +preserving the configured parameters and dictionary. If `pledgedSrcSize` was +configured for a Zstd compressor, it applies again to the next frame. + +Calling `reset()` while a write is in progress throws an `Error`. ## Class: `ZstdOptions` diff --git a/src/node_zlib.cc b/src/node_zlib.cc index c74e98c9cd1..b1c83f3e7a2 100644 --- a/src/node_zlib.cc +++ b/src/node_zlib.cc @@ -1718,7 +1718,29 @@ CompressionError ZstdCompressContext::Init(uint64_t pledged_src_size, } CompressionError ZstdCompressContext::ResetStream() { - return Init(pledged_src_size_); + size_t result = ZSTD_CCtx_reset(cctx_.get(), ZSTD_reset_session_only); + if (ZSTD_isError(result)) { + const ZSTD_ErrorCode error = ZSTD_getErrorCode(result); + return CompressionError( + ZSTD_getErrorString(error), ZstdStrerror(error), error); + } + + result = ZSTD_CCtx_setPledgedSrcSize(cctx_.get(), pledged_src_size_); + if (ZSTD_isError(result)) { + const ZSTD_ErrorCode error = ZSTD_getErrorCode(result); + return CompressionError( + ZSTD_getErrorString(error), ZstdStrerror(error), error); + } + + if (pledged_src_size_ == ZSTD_CONTENTSIZE_UNKNOWN) { + consumed_src_size_.reset(); + } else { + consumed_src_size_ = 0; + } + error_ = ZSTD_error_no_error; + error_string_.clear(); + error_code_string_.clear(); + return {}; } void ZstdCompressContext::DoThreadPoolWork() { @@ -1798,9 +1820,23 @@ CompressionError ZstdDecompressContext::Init(uint64_t pledged_src_size, } CompressionError ZstdDecompressContext::ResetStream() { - // We pass ZSTD_CONTENTSIZE_UNKNOWN because the argument is ignored for - // decompression. - return Init(ZSTD_CONTENTSIZE_UNKNOWN, {}, reject_garbage_after_end_); + const size_t result = + ZSTD_DCtx_reset(dctx_.get(), ZSTD_reset_session_only); + if (ZSTD_isError(result)) { + const ZSTD_ErrorCode error = ZSTD_getErrorCode(result); + return CompressionError( + ZSTD_getErrorString(error), ZstdStrerror(error), error); + } + + frame_complete_ = false; + decoding_frame_after_complete_ = false; + ignoring_trailing_input_ = false; + frame_prefix_size_ = 0; + possible_frame_types_ = 0; + error_ = ZSTD_error_no_error; + error_string_.clear(); + error_code_string_.clear(); + return {}; } void ZstdDecompressContext::DoThreadPoolWork() { diff --git a/test/parallel/test-zlib-zstd-reset.js b/test/parallel/test-zlib-zstd-reset.js new file mode 100644 index 00000000000..839669bc630 --- /dev/null +++ b/test/parallel/test-zlib-zstd-reset.js @@ -0,0 +1,68 @@ +'use strict'; + +require('../common'); +const assert = require('assert'); +const { finished } = require('stream/promises'); +const test = require('node:test'); +const zlib = require('zlib'); + +const dictionary = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. ' + + 'Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.', +); +const input = Buffer.from( + 'Lorem ipsum dolor sit amet, consectetur adipiscing elit. '.repeat(100), +); + +async function collect(stream, ...data) { + const chunks = []; + stream.on('data', (chunk) => chunks.push(chunk)); + for (let i = 0; i < data.length - 1; i++) { + stream.write(data[i]); + } + stream.end(data[data.length - 1]); + await finished(stream); + return Buffer.concat(chunks); +} + +test('ZstdCompress reset preserves its initial options', async () => { + const options = { + dictionary, + pledgedSrcSize: input.length, + params: { + [zlib.constants.ZSTD_c_compressionLevel]: 19, + [zlib.constants.ZSTD_c_checksumFlag]: 1, + }, + }; + const expected = await collect(zlib.createZstdCompress(options), input); + const reset = zlib.createZstdCompress(options); + reset.reset(); + + assert.deepStrictEqual(await collect(reset, input), expected); +}); + +test('ZstdDecompress reset preserves its dictionary', async () => { + const compressed = zlib.zstdCompressSync(input, { dictionary }); + const decompress = zlib.createZstdDecompress({ dictionary }); + decompress.reset(); + + assert.deepStrictEqual(await collect(decompress, compressed), input); +}); + +test('ZstdDecompress reset preserves its parameters', async () => { + const compressed = await collect(zlib.createZstdCompress({ + params: { + [zlib.constants.ZSTD_c_windowLog]: 11, + }, + }), Buffer.alloc(2048), Buffer.alloc(2048)); + const decompress = zlib.createZstdDecompress({ + params: { + [zlib.constants.ZSTD_d_windowLogMax]: 10, + }, + }); + decompress.reset(); + + await assert.rejects(collect(decompress, compressed), { + code: 'ZSTD_error_frameParameter_windowTooLarge', + }); +}); From 6f9c1a7b90a735cc3ee5f43ede1faf6ee7f6f5b4 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sun, 6 Sep 2026 15:34:06 -0700 Subject: [PATCH 015/119] doc: fill in missing zstd docs Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65867 Reviewed-By: Trivikram Kamat Reviewed-By: Filip Skokan Reviewed-By: Robert Nagy --- doc/api/zlib.md | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/doc/api/zlib.md b/doc/api/zlib.md index e1d94fd12c6..db37b7b4a85 100644 --- a/doc/api/zlib.md +++ b/doc/api/zlib.md @@ -1056,7 +1056,8 @@ added: v0.5.8 --> * `kind` **Default:** `zlib.constants.Z_FULL_FLUSH` for zlib-based streams, - `zlib.constants.BROTLI_OPERATION_FLUSH` for Brotli-based streams. + `zlib.constants.BROTLI_OPERATION_FLUSH` for Brotli-based streams, and + `zlib.constants.ZSTD_e_flush` for Zstd-based streams. * `callback` {Function} Flush pending data. Don't call this frivolously, premature flushes negatively @@ -1123,6 +1124,9 @@ Each Zstd-based class takes an `options` object. All options are optional. * `finishFlush` {integer} **Default:** `zlib.constants.ZSTD_e_end` * `chunkSize` {integer} **Default:** `16 * 1024` * `params` {Object} Key-value object containing indexed [Zstd parameters][]. +* `pledgedSrcSize` {number} Expected total size of the uncompressed input. It + must be a non-negative safe integer and must match the input size when + compression finishes. Only applicable to Zstd compressors. * `maxOutputLength` {integer} Limits output size when using [convenience methods][]. **Default:** [`buffer.kMaxLength`][] * `info` {boolean} If `true`, returns an object with `buffer` and `engine`. **Default:** `false` @@ -1747,6 +1751,8 @@ Compress a chunk of data with [`ZstdCompress`][]. ### `zlib.zstdDecompress(buffer[, options], callback)` +> Stability: 1 - Experimental + @@ -68,7 +69,8 @@ See: * Merging pull requests The TSC can remove inactive collaborators or provide them with _emeritus_ -status. Emeriti may request that the TSC restore them to active status. +status. Emeriti may request that the TSC restore them to active status. See +[Restoring emeritus Collaborators](#restoring-emeritus-collaborators). A collaborator is automatically made emeritus (and removed from active collaborator status) if it has been more than 12 months since the collaborator @@ -335,6 +337,29 @@ After the nomination passes, a TSC member onboards the new collaborator. See [the onboarding guide](./onboarding.md) for details of the onboarding process. +### Restoring emeritus Collaborators + +An emeritus collaborator who has resumed contributing may request restoration to +active status by opening an issue in [the TSC issue tracker][]. The request +describes their recent contributions and their intent to take on collaborator +responsibilities again. There is no new nomination and no vote. The request +stays open for one week, matching the window for a collaborator nomination. If +no TSC member objects, the request passes. + +Before restoring access, a TSC member confirms that the account making the +request is still under the control of the same person. See +[The Authenticity of Contributors](#the-authenticity-of-contributors). + +After the request passes, a TSC member re-onboards the returning collaborator, +reversing the applicable +[offboarding tasks](./doc/contributing/offboarding.md). As in +[the onboarding guide][], the returning collaborator authors the pull request +moving themselves from the emeriti list back to the active list in the README. +That restarts the activity clock the [inactive collaborator workflow][] measures. + +An emeritus TSC member returning as a collaborator rejoins the TSC through a TSC +motion under [Section 3 of the TSC Charter][TSC Charter]. + ## Consensus seeking process The TSC follows a [Consensus Seeking][] decision-making model per the @@ -343,5 +368,8 @@ The TSC follows a [Consensus Seeking][] decision-making model per the [Consensus Seeking]: https://en.wikipedia.org/wiki/Consensus-seeking_decision-making [TSC Charter]: https://github.com/nodejs/TSC/blob/HEAD/TSC-Charter.md [discussion in the nodejs/collaborators]: https://github.com/nodejs/collaborators/discussions/categories/collaborator-nominations +[inactive collaborator workflow]: https://github.com/nodejs/node/blob/HEAD/.github/workflows/find-inactive-collaborators.yml [nodejs/help]: https://github.com/nodejs/help [nodejs/node]: https://github.com/nodejs/node +[the TSC issue tracker]: https://github.com/nodejs/TSC/issues +[the onboarding guide]: ./onboarding.md#exercise-make-a-pull-request-adding-yourself-to-the-readme diff --git a/doc/contributing/offboarding.md b/doc/contributing/offboarding.md index f9d8140b54b..8f431b5e9a6 100644 --- a/doc/contributing/offboarding.md +++ b/doc/contributing/offboarding.md @@ -22,4 +22,8 @@ emeritus or leaves the project. the collaborator be removed from the Node.js coverity project if they had access. +An emeritus collaborator may later ask the TSC to restore them to active status. +See [Restoring emeritus Collaborators][]. + +[Restoring emeritus Collaborators]: https://github.com/nodejs/node/blob/HEAD/GOVERNANCE.md#restoring-emeritus-collaborators [`@nodejs/collaborators`]: https://github.com/orgs/nodejs/teams/collaborators/members From 8de8ed797800b8c7be95b3cfd7e9e5f4f5259b3e Mon Sep 17 00:00:00 2001 From: Joyee Cheung Date: Sat, 12 Sep 2026 21:20:47 +0200 Subject: [PATCH 030/119] meta: add joyeecheung as v8 currency strategic initiative champion Signed-off-by: Joyee Cheung PR-URL: https://github.com/nodejs/node/pull/65965 Reviewed-By: Filip Skokan Reviewed-By: Yagiz Nizipli Reviewed-By: Richard Lau Reviewed-By: Robert Nagy Reviewed-By: Chengzhong Wu Reviewed-By: Antoine du Hamel Reviewed-By: Luigi Pinca Reviewed-By: Xuguang Mei Reviewed-By: Marco Ippolito Reviewed-By: Matteo Collina --- doc/contributing/strategic-initiatives.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/doc/contributing/strategic-initiatives.md b/doc/contributing/strategic-initiatives.md index 1b275178f9e..ce68d17d890 100644 --- a/doc/contributing/strategic-initiatives.md +++ b/doc/contributing/strategic-initiatives.md @@ -11,7 +11,7 @@ agenda to ensure they are active and have the support they need. | QUIC / HTTP3 | [James M Snell][jasnell] | | | Unified HTTP API | [James M Snell][jasnell] | | | Shadow Realm | [Chengzhong Wu][legendecas] | | -| V8 Currency | | | +| V8 Currency | [Joyee Cheung][joyeecheung] | | | Next-10 | [Jacob Smith][JakobJingleheimer] | | | Single executable apps | [Darshan Sen][RaisinTen] | | | Performance | [Rafael Gonzaga][RafaelGSS] | | From 7721d01f04b2c5ba43f084b39b0c96e4e8aaa929 Mon Sep 17 00:00:00 2001 From: Sergey Sannikov Date: Sat, 12 Sep 2026 23:30:04 +0400 Subject: [PATCH 031/119] assert: fix TypeError on deepStrictEqual with null Map key or Set member deepStrictEqual() and util.isDeepStrictEqual() threw "Cannot read properties of null (reading 'constructor')" instead of comparing when a Map key or Set member was null/undefined (or another primitive) and lined up against object-only keys/members in the other collection with an equal count. The primitive/null handling was gated behind an optimization that is skipped when the counts match, letting such keys reach objectComparisonStart, which dereferences `.constructor`. Resolve primitive and null keys/members directly in every case. Signed-off-by: semx <7532921+semx@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/64449 Reviewed-By: Jordan Harband Reviewed-By: James M Snell --- lib/internal/util/comparisons.js | 16 +++++++++------- test/parallel/test-assert-deep.js | 15 +++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/lib/internal/util/comparisons.js b/lib/internal/util/comparisons.js index 1233fa78dc9..55f5f678c9e 100644 --- a/lib/internal/util/comparisons.js +++ b/lib/internal/util/comparisons.js @@ -686,16 +686,18 @@ function setObjectEquiv(array, a, b, mode, memo) { const comparator = mode !== kLoose ? objectComparisonStart : innerDeepEqual; const extraChecks = mode === kLoose || array.length !== a.size; for (const val1 of a) { - if (extraChecks) { - if (typeof val1 === 'object') { - if (b.has(val1)) { - continue; - } - } else if (b.has(val1)) { + // Primitive and null members can only match by identity, and must never + // reach objectComparisonStart (which throws on `val.constructor` for + // null/undefined). Resolve them directly for every such member. + if (typeof val1 !== 'object' || val1 === null) { + if (b.has(val1)) { continue; - } else if (mode !== kLoose) { + } + if (mode !== kLoose) { return false; } + } else if (extraChecks && b.has(val1)) { + continue; } let innerStart = start; diff --git a/test/parallel/test-assert-deep.js b/test/parallel/test-assert-deep.js index 80d8bf1b728..3350b67821c 100644 --- a/test/parallel/test-assert-deep.js +++ b/test/parallel/test-assert-deep.js @@ -278,6 +278,10 @@ test('es6 Maps and Sets', () => { assertDeepAndStrictEqual(new Set([[1, 2], [3, 4]]), new Set([[3, 4], [1, 2]])); assertNotDeepOrStrict(new Set([{ a: 0 }]), new Set([{ a: 1 }])); assertNotDeepOrStrict(new Set([Symbol()]), new Set([Symbol()])); + // A null/primitive member lined up against object-only members in the other + // set must report inequality, not throw on `member.constructor`. + assertNotDeepOrStrict(new Set([null, {}, {}]), new Set([{}, {}, {}])); + assertNotDeepOrStrict(new Set([undefined, {}, {}]), new Set([{}, {}, {}])); { const a = [ 1, 2 ]; @@ -298,6 +302,17 @@ test('es6 Maps and Sets', () => { new Map([[[1], 1], [{}, 2]]), new Map([[[1], 2], [{}, 1]]) ); + // A null/primitive key that lines up with object-only keys in the other map + // must report inequality, not throw on `key.constructor`. Refs: object keys + // of `b` equal in count to `a.size` used to skip the primitive-key handling. + assertNotDeepOrStrict( + new Map([[null, 1], [{}, 2]]), + new Map([[{}, 9], [{}, 9]]) + ); + assertNotDeepOrStrict( + new Map([[undefined, 1], [{}, 2]]), + new Map([[{}, 9], [{}, 9]]) + ); assertNotDeepOrStrict(new Set([1]), [1]); assertNotDeepOrStrict(new Set(), []); From d32b496d102e4b9923e127d0f358492a166beb74 Mon Sep 17 00:00:00 2001 From: Jihwan Date: Sun, 13 Sep 2026 08:42:59 +0900 Subject: [PATCH 032/119] test_runner: fix quote escaping in JUnit Escape XML content before replacing double quotes and line feeds. Add a snapshot test for repeated quotes, literal quote references, and quotes mixed with ampersands, less-than signs, or a newline. Signed-off-by: hanityx PR-URL: https://github.com/nodejs/node/pull/65971 Refs: https://github.com/nodejs/node/pull/60274 Reviewed-By: Xuguang Mei Reviewed-By: Luigi Pinca --- lib/internal/test_runner/reporter/junit.js | 2 +- test/fixtures/test-runner/output/junit_quote.js | 9 +++++++++ .../test-runner/output/junit_quote.snapshot | 16 ++++++++++++++++ test/test-runner/test-output-junit-quote.mjs | 11 +++++++++++ 4 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/test-runner/output/junit_quote.js create mode 100644 test/fixtures/test-runner/output/junit_quote.snapshot create mode 100644 test/test-runner/test-output-junit-quote.mjs diff --git a/lib/internal/test_runner/reporter/junit.js b/lib/internal/test_runner/reporter/junit.js index 5052f5444c0..e5a3fa961ea 100644 --- a/lib/internal/test_runner/reporter/junit.js +++ b/lib/internal/test_runner/reporter/junit.js @@ -22,7 +22,7 @@ const inspectOptions = { __proto__: null, colors: false, breakLength: Infinity } const HOSTNAME = hostname(); function escapeAttribute(s = '') { - return escapeContent(RegExpPrototypeSymbolReplace(/"/g, RegExpPrototypeSymbolReplace(/\n/g, s, ' '), '"')); + return RegExpPrototypeSymbolReplace(/"/g, RegExpPrototypeSymbolReplace(/\n/g, escapeContent(s), ' '), '"'); } function escapeContent(s = '') { diff --git a/test/fixtures/test-runner/output/junit_quote.js b/test/fixtures/test-runner/output/junit_quote.js new file mode 100644 index 00000000000..4f77d9ffbb0 --- /dev/null +++ b/test/fixtures/test-runner/output/junit_quote.js @@ -0,0 +1,9 @@ +// Flags: --test --test-reporter=junit +'use strict'; +const test = require('node:test'); + +test('quote"only', () => {}); +test('quote"only', () => {}); +test('amp&and"quote"', () => {}); +test('lt {}); +test('line\n"break', () => {}); diff --git a/test/fixtures/test-runner/output/junit_quote.snapshot b/test/fixtures/test-runner/output/junit_quote.snapshot new file mode 100644 index 00000000000..48d3ac2c53c --- /dev/null +++ b/test/fixtures/test-runner/output/junit_quote.snapshot @@ -0,0 +1,16 @@ + + + + + + + + + + + + + + + + diff --git a/test/test-runner/test-output-junit-quote.mjs b/test/test-runner/test-output-junit-quote.mjs new file mode 100644 index 00000000000..6d1769eea83 --- /dev/null +++ b/test/test-runner/test-output-junit-quote.mjs @@ -0,0 +1,11 @@ +// Test that the output of test-runner/output/junit_quote.js matches +// test-runner/output/junit_quote.snapshot +import '../common/index.mjs'; +import * as fixtures from '../common/fixtures.mjs'; +import { spawnAndAssert, junitTransform, ensureCwdIsProjectRoot } from '../common/assertSnapshot.js'; + +ensureCwdIsProjectRoot(); +await spawnAndAssert( + fixtures.path('test-runner/output/junit_quote.js'), + junitTransform, +); From 0bdf4b348e66c3e6581b1e3f9b5e02179a2b6cf1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:15:01 +0200 Subject: [PATCH 033/119] test: close WebAssembly test HTTP servers Unreferencing listeners leaves accepted connections alive until the keep-alive timeout. Close each single-use connection and its server when the response closes, including intentionally destroyed responses. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- test/es-module/test-wasm-web-api.js | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/test/es-module/test-wasm-web-api.js b/test/es-module/test-wasm-web-api.js index ee1971133be..6177c909e56 100644 --- a/test/es-module/test-wasm-web-api.js +++ b/test/es-module/test-wasm-web-api.js @@ -16,7 +16,13 @@ const simpleWasmBytes = fixtures.readSync('simple.wasm'); // Sets up an HTTP server with the given response handler and calls fetch() to // obtain a Response from the newly created server. async function testRequest(handler) { - const server = createServer((_, res) => handler(res)).unref().listen(0); + const server = createServer(common.mustCall((_, res) => { + res.setHeader('Connection', 'close'); + res.once('close', common.mustCall(() => { + server.close(common.mustCall()); + })); + handler(res); + })).listen(0); await events.once(server, 'listening'); const { port } = server.address(); return fetch(`http://127.0.0.1:${port}/foo.wasm`); From 6fb3fb9a8a5d81a3e24a85882a61f8e8d4b80eb1 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:15:01 +0200 Subject: [PATCH 034/119] tools: reduce test runner timing overhead RunProcess sleeps after polling even when the child has already exited. Skip that sleep, saving up to 100 ms per test. Sort --time results in descending order to display the 20 slowest tests. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- tools/test.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/test.py b/tools/test.py index 0bdfa5de799..1495182fcc0 100755 --- a/tools/test.py +++ b/tools/test.py @@ -741,10 +741,11 @@ def RunProcess(context, timeout, args, **rest): timed_out = True else: exit_code = process.poll() - time.sleep(sleep_time) - sleep_time = sleep_time * SLEEP_TIME_FACTOR - if sleep_time > MAX_SLEEP_TIME: - sleep_time = MAX_SLEEP_TIME + if exit_code is None: + time.sleep(sleep_time) + sleep_time = sleep_time * SLEEP_TIME_FACTOR + if sleep_time > MAX_SLEEP_TIME: + sleep_time = MAX_SLEEP_TIME return (process, exit_code, timed_out) @@ -1846,7 +1847,7 @@ def should_keep(case): print() sys.stderr.write("--- Total time: %s ---\n" % FormatTime(duration)) timed_tests = [ t for t in cases_to_run if not t.duration is None ] - timed_tests.sort(key=lambda x: x.duration) + timed_tests.sort(key=lambda x: x.duration, reverse=True) for i, entry in enumerate(timed_tests[:20], start=1): t = FormatTimedelta(entry.duration) sys.stderr.write("%4i (%s) %s\n" % (i, t, entry.GetLabel())) From a421a74240ccbb5ac9f9058f8bc03977f6c2d525 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:32:54 +0200 Subject: [PATCH 035/119] test: avoid idle HTTP/HTTPS connections Single-use requests otherwise wait for the keep-alive timeout. Use nonpersistent agents where agent selection is unrelated to coverage. For default-agent tests, close the server after consuming the response. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- test/parallel/test-http-buffer-sanity.js | 1 + test/parallel/test-http-byteswritten.js | 2 +- test/parallel/test-http-client-check-http-token.js | 2 +- test/parallel/test-http-client-encoding.js | 1 + test/parallel/test-http-client-response-domain.js | 1 + test/parallel/test-http-decoded-auth.js | 2 +- test/parallel/test-http-default-port.js | 2 +- .../test-http-dont-set-default-headers-with-setHost.js | 1 + test/parallel/test-http-dont-set-default-headers.js | 1 + test/parallel/test-http-early-hints-invalid-argument.js | 4 ++-- test/parallel/test-http-head-request.js | 1 + test/parallel/test-http-hex-write.js | 2 +- test/parallel/test-http-outgoing-end-types.js | 2 +- test/parallel/test-http-outgoing-finish-writable.js | 1 + test/parallel/test-http-outgoing-finish.js | 1 + test/parallel/test-http-outgoing-properties.js | 2 ++ test/parallel/test-http-outgoing-write-types.js | 2 +- test/parallel/test-http-request-arguments.js | 2 +- test/parallel/test-http-request-large-payload.js | 1 + test/parallel/test-http-server-connection-list-when-close.js | 1 + test/parallel/test-http-server-delete-parser.js | 1 + test/parallel/test-http-server-multiheaders.js | 1 + test/parallel/test-http-server-multiheaders2.js | 1 + .../test-http-url.parse-auth-with-header-in-request.js | 1 + test/parallel/test-http-url.parse-auth.js | 1 + test/parallel/test-http-url.parse-basic.js | 5 ++++- test/parallel/test-http-url.parse-https.request.js | 5 ++++- test/parallel/test-http-url.parse-path.js | 1 + test/parallel/test-http-url.parse-post.js | 1 + test/parallel/test-http-url.parse-search.js | 1 + test/parallel/test-http-write-callbacks.js | 1 + test/parallel/test-http-write-empty-string.js | 2 +- test/parallel/test-http-zero-length-write.js | 2 +- test/parallel/test-https-drain.js | 1 + test/parallel/test-https-request-arguments.js | 1 + test/parallel/test-https-truncate.js | 2 +- test/parallel/test-https-unix-socket-self-signed.js | 1 + 37 files changed, 45 insertions(+), 15 deletions(-) diff --git a/test/parallel/test-http-buffer-sanity.js b/test/parallel/test-http-buffer-sanity.js index a235f3793a4..e122976f0e3 100644 --- a/test/parallel/test-http-buffer-sanity.js +++ b/test/parallel/test-http-buffer-sanity.js @@ -55,6 +55,7 @@ const server = http.Server(common.mustCallAtLeast(function(req, res) { server.listen(0, common.mustCall(() => { const req = http.request({ + agent: false, port: server.address().port, method: 'POST', path: '/', diff --git a/test/parallel/test-http-byteswritten.js b/test/parallel/test-http-byteswritten.js index 003b7dfbd04..475176e6c97 100644 --- a/test/parallel/test-http-byteswritten.js +++ b/test/parallel/test-http-byteswritten.js @@ -51,5 +51,5 @@ const httpServer = http.createServer(common.mustCall(function(req, res) { })); httpServer.listen(0, function() { - http.get({ port: this.address().port }); + http.get({ port: this.address().port, agent: false }); }); diff --git a/test/parallel/test-http-client-check-http-token.js b/test/parallel/test-http-client-check-http-token.js index ef2445ec66e..7ab9aa83d76 100644 --- a/test/parallel/test-http-client-check-http-token.js +++ b/test/parallel/test-http-client-check-http-token.js @@ -29,6 +29,6 @@ server.listen(0, common.mustCall(() => { }); expectedSuccesses.forEach((method) => { - http.request({ method, port: server.address().port }).end(); + http.request({ method, port: server.address().port, agent: false }).end(); }); })); diff --git a/test/parallel/test-http-client-encoding.js b/test/parallel/test-http-client-encoding.js index a4701cdbd0a..25349630728 100644 --- a/test/parallel/test-http-client-encoding.js +++ b/test/parallel/test-http-client-encoding.js @@ -29,6 +29,7 @@ const server = http.createServer((req, res) => { server.close(); }).listen(0, common.mustCall(() => { http.request({ + agent: false, port: server.address().port, encoding: 'utf8' }, common.mustCall((res) => { diff --git a/test/parallel/test-http-client-response-domain.js b/test/parallel/test-http-client-response-domain.js index 9975ca3f949..da3d3a09ff0 100644 --- a/test/parallel/test-http-client-response-domain.js +++ b/test/parallel/test-http-client-response-domain.js @@ -49,6 +49,7 @@ function test() { })); const req = http.get({ + agent: false, socketPath: common.PIPE, headers: { 'Content-Length': '1' }, method: 'POST', diff --git a/test/parallel/test-http-decoded-auth.js b/test/parallel/test-http-decoded-auth.js index 076c056253b..4f7847133f5 100644 --- a/test/parallel/test-http-decoded-auth.js +++ b/test/parallel/test-http-decoded-auth.js @@ -43,6 +43,6 @@ for (const testCase of testCases) { server.listen(0, function() { // make the request const url = new URL(`http://${testCase.username}:${testCase.password}@localhost:${this.address().port}`); - http.request(url).end(); + http.request(url, { agent: false }).end(); }); } diff --git a/test/parallel/test-http-default-port.js b/test/parallel/test-http-default-port.js index 2005487502f..874affcdf23 100644 --- a/test/parallel/test-http-default-port.js +++ b/test/parallel/test-http-default-port.js @@ -44,7 +44,6 @@ for (const { mod, createServer } of [ assert.strictEqual(req.headers['x-port'], `${server.address().port}`); res.writeHead(200); res.end('ok'); - server.close(); })).listen(0, common.mustCall(() => { mod.globalAgent.defaultPort = server.address().port; mod.get({ @@ -54,6 +53,7 @@ for (const { mod, createServer } of [ 'x-port': server.address().port } }, common.mustCall((res) => { + res.on('end', common.mustCall(() => server.close())); res.resume(); })); })); diff --git a/test/parallel/test-http-dont-set-default-headers-with-setHost.js b/test/parallel/test-http-dont-set-default-headers-with-setHost.js index e2a4e39c24b..41805112785 100644 --- a/test/parallel/test-http-dont-set-default-headers-with-setHost.js +++ b/test/parallel/test-http-dont-set-default-headers-with-setHost.js @@ -14,6 +14,7 @@ const server = http.createServer(common.mustCall(function(req, res) { })); server.listen(0, common.localhostIPv4, function() { http.request({ + agent: false, method: 'POST', host: common.localhostIPv4, port: this.address().port, diff --git a/test/parallel/test-http-dont-set-default-headers.js b/test/parallel/test-http-dont-set-default-headers.js index 3f73c11e511..0b8e4c58f56 100644 --- a/test/parallel/test-http-dont-set-default-headers.js +++ b/test/parallel/test-http-dont-set-default-headers.js @@ -17,6 +17,7 @@ const server = http.createServer(common.mustCall(function(req, res) { })); server.listen(0, common.localhostIPv4, function() { http.request({ + agent: false, method: 'POST', host: common.localhostIPv4, port: this.address().port, diff --git a/test/parallel/test-http-early-hints-invalid-argument.js b/test/parallel/test-http-early-hints-invalid-argument.js index edf613614bc..b426ca3e840 100644 --- a/test/parallel/test-http-early-hints-invalid-argument.js +++ b/test/parallel/test-http-early-hints-invalid-argument.js @@ -38,7 +38,7 @@ const testResBody = 'response content\n'; server.listen(0, common.mustCall(() => { const req = http.request({ - port: server.address().port, path: '/' + port: server.address().port, path: '/', agent: false }); req.end(); @@ -79,7 +79,7 @@ const testResBody = 'response content\n'; server.listen(0, common.mustCall(() => { const req = http.request({ - port: server.address().port, path: '/' + port: server.address().port, path: '/', agent: false }); req.end(); diff --git a/test/parallel/test-http-head-request.js b/test/parallel/test-http-head-request.js index 26d490d357d..a9fcb2c166b 100644 --- a/test/parallel/test-http-head-request.js +++ b/test/parallel/test-http-head-request.js @@ -35,6 +35,7 @@ function test(headers) { server.listen(0, common.mustCall(function() { const request = http.request({ + agent: false, port: this.address().port, method: 'HEAD', path: '/' diff --git a/test/parallel/test-http-hex-write.js b/test/parallel/test-http-hex-write.js index a3cbec6b36c..4162811276d 100644 --- a/test/parallel/test-http-hex-write.js +++ b/test/parallel/test-http-hex-write.js @@ -34,7 +34,7 @@ http.createServer(function(q, s) { s.end(); this.close(); }).listen(0, common.mustCall(function() { - http.request({ port: this.address().port }) + http.request({ port: this.address().port, agent: false }) .on('response', common.mustCall(function(res) { let data = ''; diff --git a/test/parallel/test-http-outgoing-end-types.js b/test/parallel/test-http-outgoing-end-types.js index 20b443bff2c..48372a98e81 100644 --- a/test/parallel/test-http-outgoing-end-types.js +++ b/test/parallel/test-http-outgoing-end-types.js @@ -14,5 +14,5 @@ const httpServer = http.createServer(common.mustCall(function(req, res) { })); httpServer.listen(0, common.mustCall(function() { - http.get({ port: this.address().port }); + http.get({ port: this.address().port, agent: false }); })); diff --git a/test/parallel/test-http-outgoing-finish-writable.js b/test/parallel/test-http-outgoing-finish-writable.js index e3c870164ba..e0d9b73702c 100644 --- a/test/parallel/test-http-outgoing-finish-writable.js +++ b/test/parallel/test-http-outgoing-finish-writable.js @@ -25,6 +25,7 @@ server.listen(0); server.on('listening', common.mustCall(function() { const clientRequest = http.request({ + agent: false, port: server.address().port, method: 'GET', path: '/' diff --git a/test/parallel/test-http-outgoing-finish.js b/test/parallel/test-http-outgoing-finish.js index 0f71cccdf81..f2378d9e05b 100644 --- a/test/parallel/test-http-outgoing-finish.js +++ b/test/parallel/test-http-outgoing-finish.js @@ -33,6 +33,7 @@ http.createServer(function(req, res) { this.close(); }).listen(0, function() { const req = http.request({ + agent: false, port: this.address().port, method: 'PUT' }); diff --git a/test/parallel/test-http-outgoing-properties.js b/test/parallel/test-http-outgoing-properties.js index 85c5b659a36..a831765322b 100644 --- a/test/parallel/test-http-outgoing-properties.js +++ b/test/parallel/test-http-outgoing-properties.js @@ -36,6 +36,7 @@ const OutgoingMessage = http.OutgoingMessage; server.on('listening', common.mustCall(function() { const clientRequest = http.request({ + agent: false, port: server.address().port, method: 'GET', path: '/' @@ -62,6 +63,7 @@ const OutgoingMessage = http.OutgoingMessage; server.on('listening', common.mustCall(() => { const req = http.request({ + agent: false, port: server.address().port, method: 'GET', path: '/' diff --git a/test/parallel/test-http-outgoing-write-types.js b/test/parallel/test-http-outgoing-write-types.js index 6257b87eea8..0f2c686d5a7 100644 --- a/test/parallel/test-http-outgoing-write-types.js +++ b/test/parallel/test-http-outgoing-write-types.js @@ -20,5 +20,5 @@ const httpServer = http.createServer(common.mustCall(function(req, res) { })); httpServer.listen(0, common.mustCall(function() { - http.get({ port: this.address().port }); + http.get({ port: this.address().port, agent: false }); })); diff --git a/test/parallel/test-http-request-arguments.js b/test/parallel/test-http-request-arguments.js index 5cdd514fd50..b08da9bc525 100644 --- a/test/parallel/test-http-request-arguments.js +++ b/test/parallel/test-http-request-arguments.js @@ -18,7 +18,7 @@ const http = require('http'); common.mustCall(() => { http.get( 'http://example.com/testpath', - { hostname: 'localhost', port: server.address().port }, + { hostname: 'localhost', port: server.address().port, agent: false }, common.mustCall((res) => { res.resume(); }) diff --git a/test/parallel/test-http-request-large-payload.js b/test/parallel/test-http-request-large-payload.js index 3be100b7404..08fada1381f 100644 --- a/test/parallel/test-http-request-large-payload.js +++ b/test/parallel/test-http-request-large-payload.js @@ -16,6 +16,7 @@ const server = http.createServer(function(req, res) { server.listen(0, function() { const req = http.request({ + agent: false, method: 'POST', port: this.address().port }); diff --git a/test/parallel/test-http-server-connection-list-when-close.js b/test/parallel/test-http-server-connection-list-when-close.js index a530b710c49..0c8308b63c5 100644 --- a/test/parallel/test-http-server-connection-list-when-close.js +++ b/test/parallel/test-http-server-connection-list-when-close.js @@ -5,6 +5,7 @@ const http = require('http'); function request(server) { http.get({ + agent: false, port: server.address().port, path: '/', }, (res) => { diff --git a/test/parallel/test-http-server-delete-parser.js b/test/parallel/test-http-server-delete-parser.js index 4215ee2f9df..6b5a3e13f50 100644 --- a/test/parallel/test-http-server-delete-parser.js +++ b/test/parallel/test-http-server-delete-parser.js @@ -14,6 +14,7 @@ const server = http.createServer(common.mustCall((req, res) => { server.listen(0, '127.0.0.1', common.mustCall(() => { const req = http.request({ + agent: false, port: server.address().port, host: '127.0.0.1', method: 'GET', diff --git a/test/parallel/test-http-server-multiheaders.js b/test/parallel/test-http-server-multiheaders.js index fea84a8d4a7..e15dbd0fcae 100644 --- a/test/parallel/test-http-server-multiheaders.js +++ b/test/parallel/test-http-server-multiheaders.js @@ -48,6 +48,7 @@ const server = http.createServer(common.mustCall((req, res) => { server.listen(0, function() { http.get({ + agent: false, host: 'localhost', port: this.address().port, path: '/', diff --git a/test/parallel/test-http-server-multiheaders2.js b/test/parallel/test-http-server-multiheaders2.js index 0408afa1b13..85f2fb09f93 100644 --- a/test/parallel/test-http-server-multiheaders2.js +++ b/test/parallel/test-http-server-multiheaders2.js @@ -100,6 +100,7 @@ const headers = [] server.listen(0, function() { http.get({ + agent: false, host: 'localhost', port: this.address().port, path: '/', diff --git a/test/parallel/test-http-url.parse-auth-with-header-in-request.js b/test/parallel/test-http-url.parse-auth-with-header-in-request.js index ea5793ee18a..e4834c32a64 100644 --- a/test/parallel/test-http-url.parse-auth-with-header-in-request.js +++ b/test/parallel/test-http-url.parse-auth-with-header-in-request.js @@ -41,6 +41,7 @@ const server = http.createServer(function(request, response) { server.listen(0, function() { const testURL = url.parse(`http://asdf:qwer@localhost:${this.address().port}`); + testURL.agent = false; // The test here is if you set a specific authorization header in the // request we should not override that with basic auth testURL.headers = { diff --git a/test/parallel/test-http-url.parse-auth.js b/test/parallel/test-http-url.parse-auth.js index 2bb53115864..287c27b9eb9 100644 --- a/test/parallel/test-http-url.parse-auth.js +++ b/test/parallel/test-http-url.parse-auth.js @@ -42,6 +42,7 @@ server.listen(0, function() { const port = this.address().port; // username = "user", password = "pass:" const testURL = url.parse(`http://user:pass%3A@localhost:${port}`); + testURL.agent = false; // make the request http.request(testURL).end(); diff --git a/test/parallel/test-http-url.parse-basic.js b/test/parallel/test-http-url.parse-basic.js index d0c23097717..223d1d7af25 100644 --- a/test/parallel/test-http-url.parse-basic.js +++ b/test/parallel/test-http-url.parse-basic.js @@ -43,7 +43,6 @@ const server = http.createServer(function(request, response) { check(request); response.writeHead(200, {}); response.end('ok'); - server.close(); }); server.listen(0, common.mustCall(function() { @@ -54,5 +53,9 @@ server.listen(0, common.mustCall(function() { // Since there is a little magic with the agent // make sure that an http request uses the http.Agent assert.ok(clientRequest.agent instanceof http.Agent); + clientRequest.on('response', common.mustCall((response) => { + response.on('end', common.mustCall(() => server.close())); + response.resume(); + })); clientRequest.end(); })); diff --git a/test/parallel/test-http-url.parse-https.request.js b/test/parallel/test-http-url.parse-https.request.js index ff819adc2b8..e20c3a0ec7b 100644 --- a/test/parallel/test-http-url.parse-https.request.js +++ b/test/parallel/test-http-url.parse-https.request.js @@ -45,7 +45,6 @@ const server = https.createServer(httpsOptions, function(request, response) { check(request); response.writeHead(200, {}); response.end('ok'); - server.close(); }); server.listen(0, common.mustCall(function() { @@ -57,5 +56,9 @@ server.listen(0, common.mustCall(function() { // Since there is a little magic with the agent // make sure that the request uses the https.Agent assert.ok(clientRequest.agent instanceof https.Agent); + clientRequest.on('response', common.mustCall((response) => { + response.on('end', common.mustCall(() => server.close())); + response.resume(); + })); clientRequest.end(); })); diff --git a/test/parallel/test-http-url.parse-path.js b/test/parallel/test-http-url.parse-path.js index 25e4838c4af..04fe12a4ff1 100644 --- a/test/parallel/test-http-url.parse-path.js +++ b/test/parallel/test-http-url.parse-path.js @@ -40,6 +40,7 @@ const server = http.createServer(function(request, response) { server.listen(0, function() { const testURL = url.parse(`http://localhost:${this.address().port}/asdf`); + testURL.agent = false; // make the request http.request(testURL).end(); diff --git a/test/parallel/test-http-url.parse-post.js b/test/parallel/test-http-url.parse-post.js index db5ee78fe6e..447a1b6a3fd 100644 --- a/test/parallel/test-http-url.parse-post.js +++ b/test/parallel/test-http-url.parse-post.js @@ -47,6 +47,7 @@ const server = http.createServer(function(request, response) { server.listen(0, function() { testURL = url.parse(`http://localhost:${this.address().port}/asdf?qwer=zxcv`); + testURL.agent = false; testURL.method = 'POST'; // make the request diff --git a/test/parallel/test-http-url.parse-search.js b/test/parallel/test-http-url.parse-search.js index 0759c779d3f..80f435a6789 100644 --- a/test/parallel/test-http-url.parse-search.js +++ b/test/parallel/test-http-url.parse-search.js @@ -41,6 +41,7 @@ const server = http.createServer(function(request, response) { server.listen(0, function() { const port = this.address().port; const testURL = url.parse(`http://localhost:${port}/asdf?qwer=zxcv`); + testURL.agent = false; // make the request http.request(testURL).end(); diff --git a/test/parallel/test-http-write-callbacks.js b/test/parallel/test-http-write-callbacks.js index 1f90e5135be..3b29f7c2f5d 100644 --- a/test/parallel/test-http-write-callbacks.js +++ b/test/parallel/test-http-write-callbacks.js @@ -71,6 +71,7 @@ server.on('checkContinue', common.mustCall((req, res) => { server.listen(0, common.mustCall(function() { const req = http.request({ + agent: false, port: this.address().port, method: 'PUT', headers: { 'expect': '100-continue' } diff --git a/test/parallel/test-http-write-empty-string.js b/test/parallel/test-http-write-empty-string.js index 88eff08f766..05e97a4865c 100644 --- a/test/parallel/test-http-write-empty-string.js +++ b/test/parallel/test-http-write-empty-string.js @@ -39,7 +39,7 @@ const server = http.createServer(function(request, response) { }); server.listen(0, common.mustCall(() => { - http.get({ port: server.address().port }, common.mustCall((res) => { + http.get({ port: server.address().port, agent: false }, common.mustCall((res) => { let response = ''; assert.strictEqual(res.statusCode, 200); diff --git a/test/parallel/test-http-zero-length-write.js b/test/parallel/test-http-zero-length-write.js index dfaa7b92fb7..92905fd9755 100644 --- a/test/parallel/test-http-zero-length-write.js +++ b/test/parallel/test-http-zero-length-write.js @@ -75,7 +75,7 @@ const server = http.createServer(common.mustCall((req, res) => { })); server.listen(0, common.mustCall(function() { - const req = http.request({ port: this.address().port, method: 'POST' }); + const req = http.request({ port: this.address().port, method: 'POST', agent: false }); let actual = ''; req.on('response', common.mustCall((res) => { res.setEncoding('utf8'); diff --git a/test/parallel/test-https-drain.js b/test/parallel/test-https-drain.js index 5d7bf973645..b9a5c3d3bda 100644 --- a/test/parallel/test-https-drain.js +++ b/test/parallel/test-https-drain.js @@ -45,6 +45,7 @@ const server = https.createServer(options, function(req, res) { server.listen(0, common.mustCall(function() { let resumed = false; const req = https.request({ + agent: false, method: 'POST', port: this.address().port, rejectUnauthorized: false diff --git a/test/parallel/test-https-request-arguments.js b/test/parallel/test-https-request-arguments.js index 9dc80094be0..e68f757be81 100644 --- a/test/parallel/test-https-request-arguments.js +++ b/test/parallel/test-https-request-arguments.js @@ -32,6 +32,7 @@ const options = { 'https://example.com/testpath', { + agent: false, hostname: 'localhost', port: server.address().port, rejectUnauthorized: false diff --git a/test/parallel/test-https-truncate.js b/test/parallel/test-https-truncate.js index beed36cd7c0..eaaedea1afc 100644 --- a/test/parallel/test-https-truncate.js +++ b/test/parallel/test-https-truncate.js @@ -47,7 +47,7 @@ function httpsTest() { }); server.listen(0, function() { - const opts = { port: this.address().port, rejectUnauthorized: false }; + const opts = { port: this.address().port, rejectUnauthorized: false, agent: false }; https.get(opts).on('response', function(res) { test(res); }); diff --git a/test/parallel/test-https-unix-socket-self-signed.js b/test/parallel/test-https-unix-socket-self-signed.js index 9db92ac2aed..5a3b76e1179 100644 --- a/test/parallel/test-https-unix-socket-self-signed.js +++ b/test/parallel/test-https-unix-socket-self-signed.js @@ -21,6 +21,7 @@ const server = https.createServer(options, common.mustCall((req, res) => { server.listen(common.PIPE, common.mustCall(() => { https.get({ + agent: false, socketPath: common.PIPE, rejectUnauthorized: false }); From 67312ab25c6163af542201379d24af6078173fec Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:32:54 +0200 Subject: [PATCH 036/119] test: synchronize ordered runner events Release the slow fixture over a local socket after the fast fixture emits its bypassed completion event. This removes the fixed 30-second delay while preserving event-order assertions and a bounded failure timeout. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- .../execution-ordered-bypass/slow.mjs | 12 +++++---- .../test-runner-execution-ordered-bypass.mjs | 25 ++++++++++++++++--- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs b/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs index 4ee60ffe853..21f6f2b6867 100644 --- a/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs +++ b/test/fixtures/test-runner/execution-ordered-bypass/slow.mjs @@ -1,9 +1,11 @@ import { test } from 'node:test'; -import { setTimeout as sleep } from 'node:timers/promises'; +import { once } from 'node:events'; +import { connect } from 'node:net'; test('slow', async () => { - // Long enough that fast-fail's process can spawn, run, and round-trip its - // bypassed test:complete to the host on slow CI, but short enough that the - // test does not waste much time when the bypass is working. - await sleep(30_000); + // The host closes this connection after receiving fast-fail's bypassed + // test:complete event, so this test cannot finish before that event arrives. + const socket = connect(Number(process.argv[2]), '127.0.0.1'); + socket.resume(); + await once(socket, 'end'); }); diff --git a/test/parallel/test-runner-execution-ordered-bypass.mjs b/test/parallel/test-runner-execution-ordered-bypass.mjs index ac1c97ee007..75e9eb51d7d 100644 --- a/test/parallel/test-runner-execution-ordered-bypass.mjs +++ b/test/parallel/test-runner-execution-ordered-bypass.mjs @@ -1,8 +1,10 @@ // Flags: --no-warnings -import '../common/index.mjs'; +import { mustCall, platformTimeout } from '../common/index.mjs'; import * as fixtures from '../common/fixtures.mjs'; import assert from 'node:assert'; +import { once } from 'node:events'; +import { createServer } from 'node:net'; import { test, run } from 'node:test'; const files = [ @@ -10,15 +12,29 @@ const files = [ fixtures.path('test-runner', 'execution-ordered-bypass', 'fast-fail.mjs'), ]; -test('execution-ordered events bypass FileTest declaration-order buffer', async () => { +test('execution-ordered events bypass FileTest declaration-order buffer', { + timeout: platformTimeout(30_000), +}, async (t) => { + const { promise: fastCompleted, resolve: releaseSlow } = Promise.withResolvers(); + const server = createServer(mustCall((socket) => { + t.after(() => socket.destroy()); + fastCompleted.then(mustCall(() => { + socket.end(); + })); + })); + t.after(() => server.close()); + await once(server.listen(0, '127.0.0.1'), 'listening'); + // Concurrency must be a number so the runner does not collapse it to 1 on // single-core CI runners (where `concurrency: true` resolves to // `availableParallelism() - 1`). Without two slots the runner spawns the - // files sequentially and fast-fail never starts while slow is sleeping. + // files sequentially and fast-fail never starts while slow is waiting. const stream = run({ files, isolation: 'process', concurrency: 2, + argv: [String(server.address().port)], + signal: t.signal, }); const events = []; @@ -27,6 +43,9 @@ test('execution-ordered events bypass FileTest declaration-order buffer', async if (data.name === 'slow' || data.name === 'fast-fail') { events.push(`complete:${data.name}`); } + if (data.name === 'fast-fail') { + releaseSlow(); + } }); stream.on('test:fail', (data) => { From f6e6ec44584d61683990b4052dcd9c611d598eeb Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:32:55 +0200 Subject: [PATCH 037/119] test: skip retries in DNS timeout coverage A single query attempt exercises the configured timeout without the default retry backoff. Retry behavior has separate coverage. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- test/parallel/test-dns-channel-timeout.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-dns-channel-timeout.js b/test/parallel/test-dns-channel-timeout.js index 1e4dac54897..0c9c7c31cae 100644 --- a/test/parallel/test-dns-channel-timeout.js +++ b/test/parallel/test-dns-channel-timeout.js @@ -22,10 +22,11 @@ for (const ctor of [dns.Resolver, dns.promises.Resolver]) { for (const timeout of [-1, 0, 1]) new ctor({ timeout }); // OK } +// One attempt is enough to exercise the timeout without retry backoff. for (const timeout of [0, 1, 2]) { const server = dgram.createSocket('udp4'); server.bind(0, '127.0.0.1', common.mustCall(() => { - const resolver = new dns.Resolver({ timeout }); + const resolver = new dns.Resolver({ timeout, tries: 1 }); resolver.setServers([`127.0.0.1:${server.address().port}`]); resolver.resolve4('nodejs.org', common.mustCall((err) => { assert.throws(() => { throw err; }, { @@ -40,7 +41,7 @@ for (const timeout of [0, 1, 2]) { for (const timeout of [0, 1, 2]) { const server = dgram.createSocket('udp4'); server.bind(0, '127.0.0.1', common.mustCall(() => { - const resolver = new dns.promises.Resolver({ timeout }); + const resolver = new dns.promises.Resolver({ timeout, tries: 1 }); resolver.setServers([`127.0.0.1:${server.address().port}`]); resolver.resolve4('nodejs.org').catch(common.mustCall((err) => { assert.throws(() => { throw err; }, { From 02ce44d88f44636fff67944d743c43844fc264d4 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:32:55 +0200 Subject: [PATCH 038/119] test: collect timeout signals explicitly Force collection on a later turn while the timeout sources are only retained by AbortSignal.any(). Shorten the first timeout and clear the watchdog after the assertion. This preserves the source-retention regression check without waiting ten seconds on successful runs. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- .../test-abort-controller-any-timeout.js | 36 +++++++++++++------ 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/test/parallel/test-abort-controller-any-timeout.js b/test/parallel/test-abort-controller-any-timeout.js index 2d94afaa63d..675be3af703 100644 --- a/test/parallel/test-abort-controller-any-timeout.js +++ b/test/parallel/test-abort-controller-any-timeout.js @@ -1,28 +1,42 @@ +// Flags: --expose-gc 'use strict'; -require('../common'); +const common = require('../common'); const assert = require('assert'); const { once } = require('node:events'); const { describe, it } = require('node:test'); describe('AbortSignal.any() with timeout signals', () => { it('should abort when the first timeout signal fires', async () => { - const signal = AbortSignal.any([AbortSignal.timeout(9000), AbortSignal.timeout(110000)]); + const signal = AbortSignal.any([ + AbortSignal.timeout(common.platformTimeout(1000)), + AbortSignal.timeout(110000), + ]); + let timeout; const abortPromise = Promise.race([ once(signal, 'abort').then(() => { throw signal.reason; }), - new Promise((resolve) => setTimeout(resolve, 10000)), + new Promise((resolve) => { + timeout = setTimeout(resolve, common.platformTimeout(10000)); + }), ]); - // The promise should be aborted by the 9000ms timeout - await assert.rejects( - () => abortPromise, - { - name: 'TimeoutError', - message: 'The operation was aborted due to timeout' - } - ); + // Collect after this turn so the WeakRefs no longer keep the timeout + // signals alive by themselves. + setImmediate(common.mustCall(() => globalThis.gc())); + + try { + await assert.rejects( + () => abortPromise, + { + name: 'TimeoutError', + message: 'The operation was aborted due to timeout' + } + ); + } finally { + clearTimeout(timeout); + } }); }); From e7d21862f9a8f341f5018c6a56dc5671c874a5cd Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:32:55 +0200 Subject: [PATCH 039/119] test: unref cancelled broadcast source timer The source delay should not keep the process alive after cancellation. Keep the blocked-source cancellation assertions and unref its timer. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- test/parallel/test-stream-iter-broadcast-from.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/test/parallel/test-stream-iter-broadcast-from.js b/test/parallel/test-stream-iter-broadcast-from.js index 39d92c2aef4..928d4d472f0 100644 --- a/test/parallel/test-stream-iter-broadcast-from.js +++ b/test/parallel/test-stream-iter-broadcast-from.js @@ -122,8 +122,8 @@ async function testBroadcastFromCancelWhileBlocked() { async function* slowSource() { const enc = new TextEncoder(); yield [enc.encode('chunk1')]; - // Simulate a long delay - the cancel should unblock this - await new Promise((resolve) => setTimeout(resolve, 10000)); + // Simulate a long delay without keeping the cancelled source alive. + await new Promise((resolve) => setTimeout(resolve, 10000).unref()); yield [enc.encode('chunk2')]; sourceFinished = true; } From 9b91c2e75e3340bea8bbacc9c3559e3e072fdbad Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Fri, 11 Sep 2026 13:40:19 +0200 Subject: [PATCH 040/119] test: overlap SLH-DSA signature checks Start each asynchronous signature before the synchronous checks for the same algorithm. Keep every sign, verify, and invalid-digest assertion, with only one asynchronous signature outstanding. This reduces elapsed time when CPU capacity is available without reducing coverage. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65980 Reviewed-By: James M Snell Reviewed-By: LiviaMedeiros --- test/pummel/test-crypto-pqc-sign-verify-slh-dsa.mjs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/test/pummel/test-crypto-pqc-sign-verify-slh-dsa.mjs b/test/pummel/test-crypto-pqc-sign-verify-slh-dsa.mjs index 772d9bab6f6..52b018e7028 100644 --- a/test/pummel/test-crypto-pqc-sign-verify-slh-dsa.mjs +++ b/test/pummel/test-crypto-pqc-sign-verify-slh-dsa.mjs @@ -13,6 +13,9 @@ import { promisify } from 'node:util'; import { randomBytes, sign, verify } from 'node:crypto'; import fixtures from '../common/fixtures.js'; +const pSign = promisify(sign); +const pVerify = promisify(verify); + function getKeyFileName(type, suffix) { return `${type.replaceAll('-', '_')}_${suffix}.pem`; } @@ -37,6 +40,8 @@ for (const [asymmetricKeyType, sigLen] of [ }; const data = randomBytes(32); + // Start the async signature before the sync work to overlap the two. + const signaturePromise = pSign(undefined, data, keys.private); // sync { @@ -48,9 +53,7 @@ for (const [asymmetricKeyType, sigLen] of [ // async { - const pSign = promisify(sign); - const pVerify = promisify(verify); - const signature = await pSign(undefined, data, keys.private); + const signature = await signaturePromise; assert.strictEqual(signature.byteLength, sigLen); assert.strictEqual(await pVerify(undefined, randomBytes(32), keys.public, signature), false); assert.strictEqual(await pVerify(undefined, data, keys.public, signature), true); From 346a82e18d50871a31fab1e890c4affcfb686cbe Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 9 Sep 2026 00:13:04 +0000 Subject: [PATCH 041/119] benchmark: add --csv option to compare.js with --analyze Add a `--csv {filename}` option to benchmark/compare.js to capture the CSV when the `--analyze` option is used Signed-off-by: James M Snell Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65922 Reviewed-By: Antoine du Hamel Reviewed-By: Robert Nagy Reviewed-By: Filip Skokan Reviewed-By: Matteo Collina --- benchmark/compare.js | 102 ++++++++++-------- .../writing-and-running-benchmarks.md | 17 +++ test/parallel/test-benchmark-compare.js | 46 ++++++++ 3 files changed, 123 insertions(+), 42 deletions(-) create mode 100644 test/parallel/test-benchmark-compare.js diff --git a/benchmark/compare.js b/benchmark/compare.js index 77874e8af6c..38fba5ba267 100644 --- a/benchmark/compare.js +++ b/benchmark/compare.js @@ -1,6 +1,7 @@ 'use strict'; const { spawn, fork } = require('node:child_process'); +const { closeSync, openSync, writeSync } = require('node:fs'); const { inspect } = require('util'); const path = require('path'); const CLI = require('./_cli.js'); @@ -27,7 +28,9 @@ const cli = new CLI(`usage: ./node compare.js [options] [--] ... --no-progress don't show benchmark progress indicator --analyze perform statistical analysis after benchmarks complete (Welch's t-test, effect size) instead - of printing csv output + of printing csv output to stdout + --csv filename write csv output to filename (can be combined + with --analyze). Use - to write to stdout. --scale 1000 rate-to-integer multiplier for histogram precision when using --analyze (default: 1000) --max-regression N exit with code 1 if any statistically @@ -60,6 +63,16 @@ if (benchmarks.length === 0) { return; } +const cvsToStdout = cli.optional.csv === '-'; +const csvFd = cli.optional.csv === undefined || cvsToStdout ? + null : + openSync(cli.optional.csv, 'w'); +const outputCsv = !analyze || csvFd !== null || cvsToStdout; + +function writeCsv(line) { + writeSync(csvFd || process.stdout.fd, `${line}\n`); +} + // When --analyze is set, collect results for statistical analysis. const results = analyze ? new Map() : null; @@ -78,17 +91,19 @@ for (const filename of benchmarks) { } // queue.length = binary.length * runs * benchmarks.length -// Print csv header (unless analyzing inline). -if (!analyze) { - console.log('"binary","filename","configuration","rate","time"'); +// Print csv header unless only analyzing inline. +if (outputCsv) { + writeCsv('"binary","filename","configuration","rate","time"'); } const kStartOfQueue = 0; -const showProgress = !cli.optional['no-progress']; +const showProgress = !cli.optional['no-progress'] && !cvsToStdout; let progress; if (showProgress) { - progress = new BenchmarkProgress(queue, benchmarks, { analyze }); + progress = new BenchmarkProgress(queue, benchmarks, { + analyze: analyze || csvFd !== null, + }); progress.startQueue(kStartOfQueue); } @@ -126,11 +141,13 @@ if (showProgress) { results.set(name, { old: [], new: [] }); } results.get(name)[job.binary].push(data.rate); - } else { + } + + if (outputCsv) { // Escape quotes (") for correct csv formatting - conf = conf.replace(/"/g, '""'); - console.log(`"${job.binary}","${job.filename}","${conf}",` + - `${data.rate},${data.time}`); + const csvConf = conf.replace(/"/g, '""'); + writeCsv(`"${job.binary}","${job.filename}","${csvConf}",` + + `${data.rate},${data.time}`); } if (showProgress) { // One item in the subqueue has been completed. @@ -153,8 +170,9 @@ if (showProgress) { // If there are more benchmarks execute the next if (i + 1 < queue.length) { recursive(i + 1); - } else if (analyze) { - printAnalysis(results, scale, maxRegression); + } else { + if (csvFd !== null) closeSync(csvFd); + if (analyze) printAnalysis(results, scale, maxRegression); } }); })(kStartOfQueue); @@ -261,41 +279,41 @@ function printAnalysis(results, scale, maxRegression) { const pad = (s, n) => s + ' '.repeat(Math.max(0, n - s.length)); const rpad = (s, n) => ' '.repeat(Math.max(0, n - s.length)) + s; - console.log(`${pad('', maxNameLen)} confidence` + - ` improvement accuracy (*) (**) (***)`); + writeSync(process.stdout.fd, `${pad('', maxNameLen)} confidence` + + ` improvement accuracy (*) (**) (***)\n`); for (const row of rows) { const imp = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)} %`; - console.log( - `${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` + + writeSync(process.stdout.fd, + `${pad(row.name, maxNameLen)} ${pad(row.stars, 10)}` + ` ${rpad(imp, 11)}` + ` ±${row.ci95.toFixed(2)}%` + ` ±${row.ci99.toFixed(2)}%` + ` ±${row.ci999.toFixed(2)}%` + - `${row.inconclusive ? ' (inconclusive)' : ''}`, + `${row.inconclusive ? ' (inconclusive)' : ''}\n`, ); } if (skipped > 0) { - console.log(''); - console.log( - `Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` + + writeSync(process.stdout.fd, '\n'); + writeSync(process.stdout.fd, + `Note: ${skipped} configuration${skipped === 1 ? ' was' : 's were'}` + ` skipped because Welch's t-test requires at least 2 samples per` + - ` binary. Use --runs 2 or higher.`, + ` binary. Use --runs 2 or higher.\n`, ); } // --- Bar chart visualization --- printChart(rows, maxNameLen); - console.log(''); - console.log( - `Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` + - `Use --scale to adjust precision if needed.\n`, + writeSync(process.stdout.fd, '\n'); + writeSync(process.stdout.fd, + `Rates were scaled by ${scale}x into HdrHistogram (3 significant figures).\n` + + `Use --scale to adjust precision if needed.\n\n`, ); const anyFamilyWise = rows.filter((r) => r.pAdjusted < 0.05).length; - console.log( - `Be aware that when doing many comparisons the risk of a false-positive\n` + + writeSync(process.stdout.fd, + `Be aware that when doing many comparisons the risk of a false-positive\n` + `result increases. In this case, there are ${rows.length} comparisons, ` + `you can thus\nexpect the following amount of false-positive results:\n` + ` ${(rows.length * 0.05).toFixed(2)} false positives, when considering ` + @@ -307,19 +325,19 @@ function printAnalysis(results, scale, maxRegression) { `\nThe stars above are per-benchmark and uncorrected. Adjusting for the ` + `size of\nthis comparison set (Holm-Bonferroni), ${anyFamilyWise} ` + `comparison${anyFamilyWise === 1 ? '' : 's'} remain${anyFamilyWise === 1 ? 's' : ''} ` + - `significant at 5%.\n--max-regression uses the corrected values.`, + `significant at 5%.\n--max-regression uses the corrected values.\n`, ); // Gate: exit with error if any regression is shown to exceed the limit. if (maxRegression > 0) { if (underpowered > 0) { - console.log(''); - console.log( - `Note: ${underpowered} of ${rows.length} comparison` + + writeSync(process.stdout.fd, '\n'); + writeSync(process.stdout.fd, + `Note: ${underpowered} of ${rows.length} comparison` + `${rows.length === 1 ? '' : 's'} could not resolve an effect as ` + `small as ${maxRegression}%, and are marked (inconclusive). They are ` + `not\nevidence of no regression -- the samples are too noisy to tell. ` + - `Raise --runs,\nor pin cores with --set CPUSET, to narrow them.`, + `Raise --runs,\nor pin cores with --set CPUSET, to narrow them.\n`, ); } @@ -340,18 +358,18 @@ function printAnalysis(results, scale, maxRegression) { ); if (failures.length > 0) { - console.log(''); - console.log( - `FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` + + writeSync(process.stdout.fd, '\n'); + writeSync(process.stdout.fd, + `FAIL: ${failures.length} benchmark${failures.length === 1 ? '' : 's'}` + ` regressed by more than ${maxRegression}%` + ` (interval excludes the threshold,\n` + - `family-wise corrected across ${rows.length} comparisons):`, + `family-wise corrected across ${rows.length} comparisons):\n`, ); for (const f of failures) { - console.log( - ` ${f.name} ${f.improvement.toFixed(2)}% ` + + writeSync(process.stdout.fd, + ` ${f.name} ${f.improvement.toFixed(2)}% ` + `(95% CI up to ${(f.improvement + f.ci95).toFixed(2)}%, ` + - `adjusted p=${f.pAdjusted.toExponential(2)})`, + `adjusted p=${f.pAdjusted.toExponential(2)})\n`, ); } process.exitCode = 1; @@ -388,8 +406,8 @@ function printChart(rows, maxNameLen) { axisCenter + ' '.repeat(Math.max(0, halfWidth - Math.ceil(axisCenter.length / 2) - axisRight.length)) + axisRight; - console.log(''); - console.log(leftLabel); + writeSync(process.stdout.fd, '\n'); + writeSync(process.stdout.fd, `${leftLabel}\n`); for (const row of rows) { const imp = row.improvement; @@ -421,6 +439,6 @@ function printChart(rows, maxNameLen) { const label = `${row.improvement >= 0 ? '+' : ''}${row.improvement.toFixed(2)}%`; const sig = row.stars.trim(); - console.log(`${pad(row.name, maxNameLen)} ${chars.join('')} ${label} ${sig}`); + writeSync(process.stdout.fd, `${pad(row.name, maxNameLen)} ${chars.join('')} ${label} ${sig}\n`); } } diff --git a/doc/contributing/writing-and-running-benchmarks.md b/doc/contributing/writing-and-running-benchmarks.md index c1bc2a27484..9de63bb46ad 100644 --- a/doc/contributing/writing-and-running-benchmarks.md +++ b/doc/contributing/writing-and-running-benchmarks.md @@ -411,6 +411,8 @@ module, you can use the `--filter` option:_ --set variable=value set benchmark variable (can be repeated) --no-progress don't show benchmark progress indicator --analyze perform statistical analysis inline (no R needed) + --csv filename write csv output to filename (can be combined + with --analyze) --scale 1000 rate multiplier for --analyze precision --max-regression N exit with code 1 if any significant regression exceeds N% (implies --analyze) @@ -424,6 +426,14 @@ The simplest way to get statistical results is to pass `--analyze`: node benchmark/compare.js --old ./node-main --new ./node-pr-5134 --analyze string_decoder ``` +Use `--csv` to retain the raw benchmark results. If you pass both `--csv` and +`--analyze`, both the raw results and the analysis are printed: + +```bash +node benchmark/compare.js --old ./node-main --new ./node-pr-5134 \ + --analyze --csv compare-pr-5134.csv string_decoder +``` + This runs the benchmarks and prints the analysis directly: ```console @@ -433,6 +443,13 @@ string_decoder/string-decoder.js n=2500000 chunkLen=16 inLen=128 encoding='utf8' ... ``` +Use `-csv -` to output the raw results to stdout with the analysis. + +```bash +node benchmark/compare.js --old ./node-main --new ./node-pr-5134 \ + --analyze --csv - string_decoder +``` + The `--analyze` mode uses the histogram API's `welchTest()` method to perform the same Welch's t-test that the R script uses. Benchmark rates are scaled to integers for the histogram (controlled by `--scale`, default 1000). With the diff --git a/test/parallel/test-benchmark-compare.js b/test/parallel/test-benchmark-compare.js new file mode 100644 index 00000000000..29795d6b743 --- /dev/null +++ b/test/parallel/test-benchmark-compare.js @@ -0,0 +1,46 @@ +'use strict'; + +require('../common'); + +const assert = require('node:assert'); +const { spawnSyncAndExitWithoutError } = require('../common/child_process'); +const { readFileSync } = require('node:fs'); +const path = require('node:path'); +const tmpdir = require('../common/tmpdir'); + +const compare = path.resolve(__dirname, '../../benchmark/compare.js'); + +tmpdir.refresh(); + +const csv = tmpdir.resolve('compare.csv'); +spawnSyncAndExitWithoutError(process.execPath, [ + compare, + '--old', process.execPath, + '--new', process.execPath, + '--runs', '1', + '--filter', 'buffer-compare-offset.js', + '--set', 'method=offset', + '--set', 'size=16', + '--set', 'n=1', + '--no-progress', + '--analyze', + '--csv', csv, + 'buffers', +], { + encoding: 'utf8', + timeout: 30_000, +}, { + stderr: '', + stdout(stdout) { + assert.match(stdout, /confidence\s+improvement\s+accuracy/); + assert.doesNotMatch(stdout, /"binary","filename"/); + }, +}); + +const lines = readFileSync(csv, 'utf8').trim().split('\n'); +const filename = path.join('buffers', 'buffer-compare-offset.js'); +assert.strictEqual(lines[0], + '"binary","filename","configuration","rate","time"'); +assert.strictEqual(lines.length, 3); +assert(lines[1].startsWith(`"old","${filename}",`)); +assert(lines[2].startsWith(`"new","${filename}",`)); From 412d5fa6cdd3b07c6fa791a6898718a14804f17d Mon Sep 17 00:00:00 2001 From: James M Snell Date: Wed, 9 Sep 2026 06:02:35 +0000 Subject: [PATCH 042/119] test: improve sequential test performance Make a couple of sequential tests clean up eagerly to reduce runtime. Signed-off-by: James M Snell Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65928 Reviewed-By: Luigi Pinca Reviewed-By: Filip Skokan --- test/sequential/test-net-connect-econnrefused.js | 11 ++++------- test/sequential/test-pipe.js | 5 ++++- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/test/sequential/test-net-connect-econnrefused.js b/test/sequential/test-net-connect-econnrefused.js index 67f5820221c..c51e99e59b8 100644 --- a/test/sequential/test-net-connect-econnrefused.js +++ b/test/sequential/test-net-connect-econnrefused.js @@ -32,7 +32,7 @@ let rounds = 1; let reqs = 0; let port; -const server = net.createServer().listen(0, common.mustCall(() => { +const server = net.createServer().listen(0, common.localhostIPv4, common.mustCall(() => { port = server.address().port; server.close(common.mustCall(pummel)); })); @@ -40,17 +40,14 @@ const server = net.createServer().listen(0, common.mustCall(() => { function pummel() { let pending; for (pending = 0; pending < ATTEMPTS_PER_ROUND; pending++) { - net.createConnection({ port, autoSelectFamily: false }).on('error', common.mustCallAtLeast((error) => { - // Family autoselection might be skipped if only a single address is returned by DNS. - const actualError = Array.isArray(error.errors) ? error.errors[0] : error; - + net.createConnection({ host: common.localhostIPv4, port }).on('error', common.mustCall((error) => { console.log('pending', pending, 'rounds', rounds); - assert.strictEqual(actualError.code, 'ECONNREFUSED'); + assert.strictEqual(error.code, 'ECONNREFUSED'); if (--pending > 0) return; if (rounds === ROUNDS) return check(); rounds++; pummel(); - }, 0)); + })); reqs++; } } diff --git a/test/sequential/test-pipe.js b/test/sequential/test-pipe.js index 7515e4c705b..39b11e17d51 100644 --- a/test/sequential/test-pipe.js +++ b/test/sequential/test-pipe.js @@ -93,7 +93,10 @@ function startClient() { port: common.PORT, method: 'GET', path: '/', - headers: { 'content-length': buffer.length }, + headers: { + 'connection': 'close', + 'content-length': buffer.length, + }, }, common.mustCall((res) => { res.setEncoding('utf8'); res.on('data', common.mustCall((string) => { From 1d20a947d94648c91908d00637879aa24ed33422 Mon Sep 17 00:00:00 2001 From: Yuya Inoue <65857152+inoway46@users.noreply.github.com> Date: Mon, 14 Sep 2026 16:11:10 +0900 Subject: [PATCH 043/119] test_runner: avoid reusing v8 serializers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A serializer must not be used after releaseBuffer() is called. Use a dedicated instance to calculate the header length and create a new serializer for each test event. Add a regression test that serializes the same object twice and verifies that both frames can be deserialized independently. Signed-off-by: inoway46 Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/65951 Reviewed-By: Stefan Stojanovic Reviewed-By: Moshe Atlow Reviewed-By: Chemi Atlow Reviewed-By: James M Snell Reviewed-By: Ulises Gascón --- lib/internal/test_runner/reporter/v8-serializer.js | 7 ++++--- test/parallel/test-runner-v8-deserializer.mjs | 9 +++++++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/lib/internal/test_runner/reporter/v8-serializer.js b/lib/internal/test_runner/reporter/v8-serializer.js index c75bfcdac47..0f0cf20902b 100644 --- a/lib/internal/test_runner/reporter/v8-serializer.js +++ b/lib/internal/test_runner/reporter/v8-serializer.js @@ -9,11 +9,12 @@ const { serializeError } = require('internal/error_serdes'); module.exports = async function* v8Reporter(source) { - const serializer = new DefaultSerializer(); - serializer.writeHeader(); - const headerLength = TypedArrayPrototypeGetLength(serializer.releaseBuffer()); + const headerSerializer = new DefaultSerializer(); + headerSerializer.writeHeader(); + const headerLength = TypedArrayPrototypeGetLength(headerSerializer.releaseBuffer()); for await (const item of source) { + const serializer = new DefaultSerializer(); const originalError = item.data.details?.error; if (originalError) { // Error is overridden with a serialized version, so that it can be diff --git a/test/parallel/test-runner-v8-deserializer.mjs b/test/parallel/test-runner-v8-deserializer.mjs index 7f2c0155c97..3a4db367ca6 100644 --- a/test/parallel/test-runner-v8-deserializer.mjs +++ b/test/parallel/test-runner-v8-deserializer.mjs @@ -85,6 +85,15 @@ describe('v8 deserializer', common.mustCall(() => { assert.deepStrictEqual(reported, [reportedDiagnosticEvent]); }); + it('should serialize a repeated object as independent messages', async () => { + const repeatedChunks = await toArray(serializer([diagnosticEvent, diagnosticEvent])); + const reported = await collectReported(repeatedChunks); + assert.deepStrictEqual(reported, [ + reportedDiagnosticEvent, + reportedDiagnosticEvent, + ]); + }); + it('should deserialize a serialized chunk after non-serialized chunk', async () => { const reported = await collectReported([Buffer.concat([Buffer.from('unknown'), ...chunks])]); assert.deepStrictEqual(reported, [ From 0a14c255a155ac2867c1ff91b2399bcb2c90f7f0 Mon Sep 17 00:00:00 2001 From: Sankalp Thakur <31366524+sankalpsthakur@users.noreply.github.com> Date: Wed, 2 Sep 2026 22:57:19 +0530 Subject: [PATCH 044/119] http2: fix onread assert when destroying session from stream handler When session.destroy() runs from a 'stream' handler, MakeCallback drains nextTick while nghttp2 is still inside mem_recv. Close is deferred for that window (see #64166), so later HEADERS in the same buffer created C++ streams without a JS wrapper or onread, and DATA delivery aborted with Assertion failed: onread->IsFunction(). - Reject new streams while the session is closing - Destroy the C++ handle if on_headers runs after JS destroy - Drop DATA when onread is not installed (defensive) Fixes: https://github.com/nodejs/node/issues/64850 Signed-off-by: Sankalp Thakur PR-URL: https://github.com/nodejs/node/pull/65116 Reviewed-By: Matteo Collina Reviewed-By: Tim Perry --- lib/internal/http2/core.js | 9 ++- src/node_http2.cc | 10 ++++ ...st-http2-session-destroy-stream-handler.js | 60 +++++++++++++++++++ 3 files changed, 78 insertions(+), 1 deletion(-) create mode 100644 test/parallel/test-http2-session-destroy-stream-handler.js diff --git a/lib/internal/http2/core.js b/lib/internal/http2/core.js index f9ad89caeef..56b31fec1e6 100644 --- a/lib/internal/http2/core.js +++ b/lib/internal/http2/core.js @@ -358,8 +358,15 @@ function emit(self, ...args) { // the block of headers on. function onSessionHeaders(handle, id, cat, flags, headers, sensitiveHeaders) { const session = this[kOwner]; - if (session.destroyed) + // Session may have been destroyed mid-receive (e.g. session.destroy() from a + // 'stream' handler drained via nextTick inside MakeCallback while nghttp2 is + // still walking the receive buffer). Tear down the C++ stream so subsequent + // DATA frames do not call CallJSOnreadMethod with a missing onread. + if (session.destroyed) { + handle.rstStream(NGHTTP2_REFUSED_STREAM); + handle.destroy(); return; + } const type = session[kType]; session[kUpdateTimer](); diff --git a/src/node_http2.cc b/src/node_http2.cc index acc3641f98a..010109791f0 100644 --- a/src/node_http2.cc +++ b/src/node_http2.cc @@ -1054,6 +1054,16 @@ int Http2Session::OnBeginHeadersCallback(nghttp2_session* handle, // The common case is that we're creating a new stream. The less likely // case is that we're receiving a set of trailers if (!stream) [[likely]] { + // Close() may be deferred while mem_recv is in progress (see + // Http2Session::Close). A 'stream' handler that calls session.destroy() + // runs via nextTick from MakeCallback during that window, so later + // HEADERS in the same receive buffer must not create a C++ stream + // whose JS wrapper (and onread) is never installed. + if (session->is_closing()) { + nghttp2_submit_rst_stream( + session->session(), NGHTTP2_FLAG_NONE, id, NGHTTP2_REFUSED_STREAM); + return NGHTTP2_ERR_TEMPORAL_CALLBACK_FAILURE; + } if (!session->CanAddStream() || Http2Stream::New(session, id, frame->headers.cat) == nullptr) [[unlikely]] { diff --git a/test/parallel/test-http2-session-destroy-stream-handler.js b/test/parallel/test-http2-session-destroy-stream-handler.js new file mode 100644 index 00000000000..35c5b16471e --- /dev/null +++ b/test/parallel/test-http2-session-destroy-stream-handler.js @@ -0,0 +1,60 @@ +'use strict'; + +const common = require('../common'); +if (!common.hasCrypto) + common.skip('missing crypto'); + +const http2 = require('http2'); + +// Regression test for https://github.com/nodejs/node/issues/64850 +// +// Destroying the session from a 'stream' handler runs (via nextTick drained +// from MakeCallback) while nghttp2 is still inside mem_recv. Close is deferred +// for that window; later HEADERS/DATA in the same buffer must not abort with +// Assertion failed: onread->IsFunction(). + +const STREAMS = 8; +const BODY = Buffer.alloc(2048, 'a'); +const ROUNDS = 40; + +const server = http2.createServer({ + settings: { maxConcurrentStreams: 4 }, +}); + +server.on('session', (session) => session.on('error', () => {})); + +server.on('stream', (stream) => { + stream.on('error', () => {}); + stream.session.destroy(); +}); + +server.listen(0, '127.0.0.1', common.mustCall(() => { + const port = server.address().port; + const origin = `http://127.0.0.1:${port}`; + let remaining = ROUNDS; + + const round = () => { + if (remaining-- <= 0) { + server.close(); + return; + } + + const session = http2.connect(origin); + session.on('error', () => {}); + session.on('close', () => setImmediate(round)); + + session.on('connect', () => { + for (let i = 0; i < STREAMS; i++) { + const stream = session.request({ + ':path': `/${i}`, + ':method': 'POST', + }); + stream.on('error', () => {}); + stream.resume(); + stream.end(BODY); + } + }); + }; + + round(); +})); From a284bcac883c06197558f23fd7be3f623e96776f Mon Sep 17 00:00:00 2001 From: Rafael Gonzaga Date: Mon, 14 Sep 2026 14:32:13 -0300 Subject: [PATCH 045/119] doc: clarify permission model scope for output paths Flags such --trace-event-file or any other flag that specifies a directory are subject to permission model rules, but a "bypass" isn't considered a vulnerability while it doesn't pose a risk to the user application Signed-off-by: RafaelGSS PR-URL: https://github.com/nodejs/node/pull/66004 Reviewed-By: Filip Skokan Reviewed-By: Luigi Pinca --- SECURITY.md | 10 +++++++++- doc/api/permissions.md | 8 ++++++++ 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/SECURITY.md b/SECURITY.md index e86448191d6..cdf2469343b 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -500,7 +500,15 @@ The following are **not** vulnerabilities in Node.js: * **Operator-controlled flags**: Behavior unlocked by flags the operator explicitly passes (e.g., `--localstorage-file`) is the operator's responsibility. The permission model does not restrict how Node.js behaves - when the operator intentionally configures it. + when the operator intentionally configures it. This includes any file or + resource that Node.js itself creates, writes, or reads at a location the + operator selected through a flag, including every path derived from a + template or pattern in that flag. For example, trace files rotated by + `--trace-event-file-pattern` (`${rotation}`) being written without a + matching `--allow-fs-write` entry is not a permission model bypass. Such + paths are part of the operator's configuration, not application file-system + access. Inconsistent checks on these paths are treated as regular bugs and + should be reported through the public issue tracker. * **`node:sqlite` and the permission model**: `DatabaseSync` operates with the same file-system privileges as the process. Using SQL pragmas or built-in diff --git a/doc/api/permissions.md b/doc/api/permissions.md index 89cb7713327..2887d9624f6 100644 --- a/doc/api/permissions.md +++ b/doc/api/permissions.md @@ -341,6 +341,14 @@ There are constraints you need to know before using this system: to read files before environment initialization. As a result, such flags are not subject to the rules of the Permission Model. The same applies for V8 flags that can be set via runtime through `v8.setFlagsFromString`. +* Files that Node.js itself creates, writes, or reads at a location selected + by an operator flag may not be consistently checked against the Permission + Model, in particular when the flag accepts a template or pattern that + expands to several paths. For example, trace files rotated by + `--trace-event-file-pattern` (`${rotation}`) can be written even when the + expanded path is not covered by `--allow-fs-write`. Because the location is + chosen by the operator, gaps like this are treated as regular bugs rather + than vulnerabilities. Please report them through the regular issue tracker. * OpenSSL engines cannot be requested at runtime when the Permission Model is enabled, affecting the built-in crypto, https, and tls modules. * Run-Time Loadable Extensions cannot be loaded when the Permission Model is From f8756b63b98583a97afdaf92a74cc16d2e1a4443 Mon Sep 17 00:00:00 2001 From: Donghoon Kang Date: Tue, 15 Sep 2026 04:45:00 +0900 Subject: [PATCH 046/119] perf_hooks: reuse buffer for uv metrics Reuse an aliased Int32Array to transfer uv metrics from C++ to JavaScript instead of allocating a new V8 array on every access. Assisted-by: Codex Signed-off-by: HoonDongKang PR-URL: https://github.com/nodejs/node/pull/65985 Reviewed-By: James M Snell Reviewed-By: Daeyeon Jeong Reviewed-By: Chengzhong Wu --- lib/internal/perf/nodetiming.js | 9 ++--- src/node_perf.cc | 35 +++++++++++++------ src/node_perf_common.h | 3 ++ src/node_snapshotable.cc | 3 ++ .../fixtures/test-nodetiming-uvmetricsinfo.js | 10 +++++- typings/internalBinding/performance.d.ts | 3 +- 6 files changed, 46 insertions(+), 17 deletions(-) diff --git a/lib/internal/perf/nodetiming.js b/lib/internal/perf/nodetiming.js index a9e0c3f252c..5de5e3e6644 100644 --- a/lib/internal/perf/nodetiming.js +++ b/lib/internal/perf/nodetiming.js @@ -29,6 +29,7 @@ const { }, loopIdleTime, uvMetricsInfo, + uvMetricsBuffer, } = internalBinding('performance'); class PerformanceNodeTiming { @@ -129,11 +130,11 @@ class PerformanceNodeTiming { enumerable: true, configurable: true, get: () => { - const metrics = uvMetricsInfo(); + uvMetricsInfo(); return { - loopCount: metrics[0], - events: metrics[1], - eventsWaiting: metrics[2], + loopCount: uvMetricsBuffer[0], + events: uvMetricsBuffer[1], + eventsWaiting: uvMetricsBuffer[2], }; }, }, diff --git a/src/node_perf.cc b/src/node_perf.cc index 177c2a78985..75a62b89a53 100644 --- a/src/node_perf.cc +++ b/src/node_perf.cc @@ -14,7 +14,6 @@ namespace node { namespace performance { -using v8::Array; using v8::Context; using v8::DontDelete; using v8::Function; @@ -57,7 +56,12 @@ PerformanceState::PerformanceState(Isolate* isolate, offsetof(performance_state_internal, observers), NODE_PERFORMANCE_ENTRY_TYPE_INVALID, root, - MAYBE_FIELD_PTR(info, observers)) { + MAYBE_FIELD_PTR(info, observers)), + uv_metrics(isolate, + offsetof(performance_state_internal, uv_metrics), + 3, + root, + MAYBE_FIELD_PTR(info, uv_metrics)) { if (info == nullptr) { // For performance states initialized from scratch, reset // all the milestones and initialize the time origin. @@ -81,9 +85,15 @@ PerformanceState::SerializeInfo PerformanceState::Serialize( // We'll re-initialize them after deserialization. ResetMilestones(); + // Do not retain runtime metrics in the snapshot. + for (size_t i = 0; i < uv_metrics.Length(); ++i) { + uv_metrics[i] = 0; + } + SerializeInfo info{root.Serialize(context, creator), milestones.Serialize(context, creator), - observers.Serialize(context, creator)}; + observers.Serialize(context, creator), + uv_metrics.Serialize(context, creator)}; return info; } @@ -105,6 +115,7 @@ void PerformanceState::Deserialize(v8::Local context, root.Deserialize(context); milestones.Deserialize(context); observers.Deserialize(context); + uv_metrics.Deserialize(context); // Re-initialize the time origin and timestamp i.e. the process start time. Initialize(time_origin, time_origin_timestamp); @@ -116,6 +127,7 @@ std::ostream& operator<<(std::ostream& o, << " " << i.root << ", // root\n" << " " << i.milestones << ", // milestones\n" << " " << i.observers << ", // observers\n" + << " " << i.uv_metrics << ", // uv_metrics\n" << "}"; return o; } @@ -265,17 +277,13 @@ void LoopIdleTime(const FunctionCallbackInfo& args) { void UvMetricsInfo(const FunctionCallbackInfo& args) { Environment* env = Environment::GetCurrent(args); - Isolate* isolate = env->isolate(); uv_metrics_t metrics; // uv_metrics_info always return 0 CHECK_EQ(uv_metrics_info(env->event_loop(), &metrics), 0); - Local data[] = { - Integer::New(isolate, metrics.loop_count), - Integer::New(isolate, metrics.events), - Integer::New(isolate, metrics.events_waiting), - }; - Local arr = Array::New(env->isolate(), data, arraysize(data)); - args.GetReturnValue().Set(arr); + AliasedInt32Array& buffer = env->performance_state()->uv_metrics; + buffer[0] = static_cast(metrics.loop_count); + buffer[1] = static_cast(metrics.events); + buffer[2] = static_cast(metrics.events_waiting); } void CreateELDHistogram(const FunctionCallbackInfo& args) { @@ -366,6 +374,11 @@ void CreatePerContextProperties(Local target, target->Set(context, FIXED_ONE_BYTE_STRING(isolate, "milestones"), state->milestones.GetJSArray()).Check(); + target + ->Set(context, + FIXED_ONE_BYTE_STRING(isolate, "uvMetricsBuffer"), + state->uv_metrics.GetJSArray()) + .Check(); Local constants = Object::New(isolate); diff --git a/src/node_perf_common.h b/src/node_perf_common.h index 01e7f35241a..aa84ba55b08 100644 --- a/src/node_perf_common.h +++ b/src/node_perf_common.h @@ -62,6 +62,7 @@ class PerformanceState { AliasedBufferIndex root; AliasedBufferIndex milestones; AliasedBufferIndex observers; + AliasedBufferIndex uv_metrics; }; explicit PerformanceState(v8::Isolate* isolate, @@ -78,6 +79,7 @@ class PerformanceState { AliasedUint8Array root; AliasedFloat64Array milestones; AliasedUint32Array observers; + AliasedInt32Array uv_metrics; uint64_t performance_last_gc_start_mark = 0; uint16_t current_gc_type = 0; @@ -92,6 +94,7 @@ class PerformanceState { // doubles first so that they are always sizeof(double)-aligned double milestones[NODE_PERFORMANCE_MILESTONE_INVALID]; uint32_t observers[NODE_PERFORMANCE_ENTRY_TYPE_INVALID]; + int32_t uv_metrics[3]; }; }; diff --git a/src/node_snapshotable.cc b/src/node_snapshotable.cc index 1f114739444..1d0702ba59b 100644 --- a/src/node_snapshotable.cc +++ b/src/node_snapshotable.cc @@ -394,6 +394,7 @@ size_t SnapshotSerializer::Write(const ImmediateInfo::SerializeInfo& data) { // [ 4/8 bytes ] snapshot index of root // [ 4/8 bytes ] snapshot index of milestones // [ 4/8 bytes ] snapshot index of observers +// [ 4/8 bytes ] snapshot index of uv_metrics template <> performance::PerformanceState::SerializeInfo SnapshotDeserializer::Read() { Debug("Read()\n"); @@ -402,6 +403,7 @@ performance::PerformanceState::SerializeInfo SnapshotDeserializer::Read() { result.root = ReadArithmetic(); result.milestones = ReadArithmetic(); result.observers = ReadArithmetic(); + result.uv_metrics = ReadArithmetic(); if (is_debug) { std::string str = ToStr(result); Debug("Read() %s\n", str); @@ -420,6 +422,7 @@ size_t SnapshotSerializer::Write( size_t written_total = WriteArithmetic(data.root); written_total += WriteArithmetic(data.milestones); written_total += WriteArithmetic(data.observers); + written_total += WriteArithmetic(data.uv_metrics); Debug("Write() wrote %d bytes\n", written_total); diff --git a/test/fixtures/test-nodetiming-uvmetricsinfo.js b/test/fixtures/test-nodetiming-uvmetricsinfo.js index 59b1cc8ebf1..038ca8b7990 100644 --- a/test/fixtures/test-nodetiming-uvmetricsinfo.js +++ b/test/fixtures/test-nodetiming-uvmetricsinfo.js @@ -40,7 +40,15 @@ function safeMetricsInfo(cb) { fs.open(__filename, 'r', (err) => { assert.ifError(err); }); + + const saved = { ...info }; + safeMetricsInfo((nextInfo) => { + assert.notStrictEqual(nextInfo, info); + assert.ok(nextInfo.loopCount > saved.loopCount); + // Updating the shared buffer must not change earlier results. + assert.deepStrictEqual(info, saved); + }); } safeMetricsInfo(openFile); -} \ No newline at end of file +} diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts index fa9a3810fc7..dc4d1e20c6b 100644 --- a/typings/internalBinding/performance.d.ts +++ b/typings/internalBinding/performance.d.ts @@ -129,6 +129,7 @@ export interface PerformanceBinding { samplePerIteration: boolean, ): InternalPerformanceBinding.ELDHistogram; markBootstrapComplete(): void; - uvMetricsInfo(): [number, number, number]; + uvMetricsInfo(): void; + uvMetricsBuffer: Int32Array; now(): number; } From 87bc357db9ec8779bc0f8a627d7b1bfeeb660fa9 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 15 Sep 2026 10:03:38 +0200 Subject: [PATCH 047/119] tools: pass author to commit message validator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Skokan PR-URL: https://github.com/nodejs/node/pull/66012 Reviewed-By: Colin Ihrig Reviewed-By: Antoine du Hamel Reviewed-By: Gürgün Dayıoğlu Reviewed-By: Luigi Pinca Reviewed-By: James M Snell Reviewed-By: Xuguang Mei --- .github/workflows/commit-lint.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/commit-lint.yml b/.github/workflows/commit-lint.yml index 037ea7e810e..6b173d8bc97 100644 --- a/.github/workflows/commit-lint.yml +++ b/.github/workflows/commit-lint.yml @@ -41,7 +41,11 @@ jobs: '--no-validate-metadata', '--tap', '-', ], { cwd: process.env.RUNNER_TEMP, - input: Buffer.from(JSON.stringify([{ id: commit.sha, message: commit.commit.message }])), + input: Buffer.from(JSON.stringify([{ + id: commit.sha, + message: commit.commit.message, + author: commit.commit.author, + }])), silent: true, ignoreReturnCode: true, }); From f09156867210834823c7e30adec72d4ee3adfd47 Mon Sep 17 00:00:00 2001 From: greenhead Date: Sun, 13 Sep 2026 09:05:04 +0900 Subject: [PATCH 048/119] test: fix stderr Buffer assertion in exec encoding test MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: greenhead PR-URL: https://github.com/nodejs/node/pull/66008 Refs: https://github.com/nodejs/node/pull/10919 Reviewed-By: Luigi Pinca Reviewed-By: James M Snell Reviewed-By: Xuguang Mei Reviewed-By: Ulises Gascón --- test/parallel/test-child-process-exec-encoding.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/parallel/test-child-process-exec-encoding.js b/test/parallel/test-child-process-exec-encoding.js index 21ab207fca8..78a9b55b9df 100644 --- a/test/parallel/test-child-process-exec-encoding.js +++ b/test/parallel/test-child-process-exec-encoding.js @@ -41,7 +41,7 @@ if (process.argv[2] === 'child') { [undefined, null, 'buffer', 'invalid'].forEach((encoding) => { run({ encoding }, common.mustCall((stdout, stderr) => { assert(stdout instanceof Buffer); - assert(stdout instanceof Buffer); + assert(stderr instanceof Buffer); assert.strictEqual(stdout.toString(), expectedStdout); assert.strictEqual(stderr.toString(), expectedStderr); })); From 6ea497afbd3fc03c98e2e110a0c82fabc52a5e41 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 13 Sep 2026 11:58:50 +0200 Subject: [PATCH 049/119] tools: make checkout credential use explicit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Disable credential persistence for CodeQL. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66013 Reviewed-By: Antoine du Hamel Reviewed-By: James M Snell Reviewed-By: Xuguang Mei Reviewed-By: Ulises Gascón --- .github/workflows/codeql.yml | 2 ++ .github/workflows/commit-queue.yml | 1 + 2 files changed, 3 insertions(+) diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 0b83c888ecd..10a9e299145 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -25,6 +25,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL diff --git a/.github/workflows/commit-queue.yml b/.github/workflows/commit-queue.yml index 600dadc17bc..8f29a390c67 100644 --- a/.github/workflows/commit-queue.yml +++ b/.github/workflows/commit-queue.yml @@ -188,6 +188,7 @@ jobs: # to be set here because `checkout` configures GitHub authentication # for push as well. token: ${{ secrets.GH_USER_TOKEN }} + persist-credentials: true - name: Start the Commit Queue if: steps.get_mergeable_prs.outputs.numbers != '' From dbc664923558b563e54fceb6e0f8bae8af9394c5 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Sun, 13 Sep 2026 11:58:50 +0200 Subject: [PATCH 050/119] tools: correct Slack action version comments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66013 Reviewed-By: Antoine du Hamel Reviewed-By: James M Snell Reviewed-By: Xuguang Mei Reviewed-By: Ulises Gascón --- .github/workflows/notify-on-push.yml | 4 ++-- .github/workflows/notify-on-review-wanted.yml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/notify-on-push.yml b/.github/workflows/notify-on-push.yml index 16bd91bccd2..25421b8447d 100644 --- a/.github/workflows/notify-on-push.yml +++ b/.github/workflows/notify-on-push.yml @@ -18,7 +18,7 @@ jobs: runs-on: ubuntu-24.04-arm steps: - name: Slack Notification - uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # 2.4.0 + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 env: SLACK_COLOR: '#DE512A' SLACK_ICON: https://github.com/nodejs.png?size=48 @@ -50,7 +50,7 @@ jobs: COMMITS: ${{ toJSON(github.event.commits) }} - name: Slack Notification if: ${{ failure() && steps.commit-check.conclusion == 'failure' && github.repository == 'nodejs/node' }} - uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # 2.4.0 + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 env: SLACK_COLOR: '#DE512A' SLACK_ICON: https://github.com/nodejs.png?size=48 diff --git a/.github/workflows/notify-on-review-wanted.yml b/.github/workflows/notify-on-review-wanted.yml index 2f1f3af8139..effc6c209eb 100644 --- a/.github/workflows/notify-on-review-wanted.yml +++ b/.github/workflows/notify-on-review-wanted.yml @@ -34,7 +34,7 @@ jobs: fi - name: Slack Notification - uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # 2.4.0 + uses: rtCamp/action-slack-notify@33ca3be66c6f378fe1610fd1d5258632dbed5e58 # v2.4.0 env: MSG_MINIMAL: actions url SLACK_COLOR: '#3d85c6' From 395fecebb3a1061a2cc711c095b52818148b186c Mon Sep 17 00:00:00 2001 From: John Finnerty Date: Wed, 16 Sep 2026 00:36:03 +1200 Subject: [PATCH 051/119] doc: clarify QUIC async write backpressure Signed-off-by: John Finnerty <297514060+johnfinnerty-nz@users.noreply.github.com> PR-URL: https://github.com/nodejs/node/pull/65947 Reviewed-By: James M Snell Reviewed-By: Xuguang Mei --- doc/api/quic.md | 40 ++++++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/doc/api/quic.md b/doc/api/quic.md index a6335bc3527..71d1d7215d0 100644 --- a/doc/api/quic.md +++ b/doc/api/quic.md @@ -321,10 +321,17 @@ There are two ways to write data to a stream: up front or can be expressed as an iterable. * **Writer** — access [`stream.writer`][] to push data incrementally. The writer exposes synchronous methods (`writeSync()`, `writevSync()`, - `endSync()`) that return immediately, as well as async equivalents - (`write()`, `writev()`, `end()`) that wait for drain when backpressured. + `endSync()`) that return immediately, as well as asynchronous counterparts + (`write()`, `writev()`, `end()`). The asynchronous `write()` and `writev()` + methods use the stream/iter strict backpressure policy: when the write buffer + is full, they reject with `ERR_INVALID_STATE` instead of waiting for capacity. + If a drain is already pending, `end()` waits for it before closing. Check + `writer.canWrite` before writing. To wait for capacity, use `ondrain()` from + `node:stream/iter`, then retry the write. The stream's `onblocked` callback + reports that transport flow control has blocked progress, but does not + signal that writer capacity is available again. `writeSync()` returns `false` when the write buffer is full; the caller - should wait for drain before retrying. + should wait with `ondrain()` before retrying. These two approaches are mutually exclusive for a given stream. @@ -2322,12 +2329,16 @@ The Writer has the following methods: * `writeSync(chunk)` — Synchronous write. Returns `true` if accepted, `false` if flow-controlled. Data is NOT accepted on `false`. -* `write(chunk[, options])` — Async write with drain wait. `options.signal` - is checked at entry but not observed during the write. +* `write(chunk[, options])` — Async write. Rejects with `ERR_INVALID_STATE` + when the stream is flow-controlled rather than waiting for capacity. + `options.signal` is checked at entry but not observed during the write. * `writevSync(chunks)` — Synchronous vectored write. All-or-nothing. -* `writev(chunks[, options])` — Async vectored write. +* `writev(chunks[, options])` — Async vectored write. Rejects with + `ERR_INVALID_STATE` when the stream is flow-controlled rather than waiting + for capacity. * `endSync()` — Synchronous close. Returns total bytes or `-1`. -* `end([options])` — Async close. +* `end([options])` — Async close. If a drain is already pending, waits for it + before closing. * `fail(reason)` — Errors the stream (sends `RESET_STREAM` to peer). When `reason` is a [`QuicError`][], its [`error.errorCode`][] is used as the wire code on the resulting `RESET_STREAM` frame; otherwise @@ -2337,7 +2348,20 @@ The Writer has the following methods: See [`stream.destroy()`][] for a full-stream abort that also resets the readable side via `STOP_SENDING`. * `canWrite` — `true` if writes will be accepted, `false` if at capacity, - or `null` if closed/errored. + or `null` if closed/errored. When `writeSync()` returns `false`, use + `ondrain()` from `node:stream/iter` to wait before retrying. If `ondrain()` + returns `null`, no drain wait is available and the write should not be + retried. + +```mjs +import { ondrain } from 'node:stream/iter'; + +while (!writer.writeSync(chunk)) { + const drain = ondrain(writer); + if (drain === null) break; + await drain; +} +``` The bytes from each `writeSync()` / `writevSync()` / `write()` / `writev()` input chunk are copied into an internal buffer, so the caller's source From fa572acd6778a72d342ccfe13d4545c78ed5ea66 Mon Sep 17 00:00:00 2001 From: "Node.js GitHub Bot" Date: Tue, 15 Sep 2026 10:00:45 -0400 Subject: [PATCH 052/119] deps: update googletest to 8eff9e336692fc95961e096564f1044c600b881d PR-URL: https://github.com/nodejs/node/pull/66009 Reviewed-By: Filip Skokan Reviewed-By: Colin Ihrig Reviewed-By: Luigi Pinca --- .../include/gtest/gtest-death-test.h | 52 +--- .../include/gtest/internal/gtest-port.h | 32 +-- deps/googletest/src/gtest-internal-inl.h | 21 +- deps/googletest/src/gtest-port.cc | 243 ++---------------- deps/googletest/src/gtest.cc | 2 +- 5 files changed, 43 insertions(+), 307 deletions(-) diff --git a/deps/googletest/include/gtest/gtest-death-test.h b/deps/googletest/include/gtest/gtest-death-test.h index afd7b3a4685..337313ea209 100644 --- a/deps/googletest/include/gtest/gtest-death-test.h +++ b/deps/googletest/include/gtest/gtest-death-test.h @@ -105,54 +105,10 @@ GTEST_API_ bool InDeathTestChild(); // // On the regular expressions used in death tests: // -// On POSIX-compliant systems (*nix), we use the library, -// which uses the POSIX extended regex syntax. -// -// On other platforms (e.g. Windows or Mac), we only support a simple regex -// syntax implemented as part of Google Test. This limited -// implementation should be enough most of the time when writing -// death tests; though it lacks many features you can find in PCRE -// or POSIX extended regex syntax. For example, we don't support -// union ("x|y"), grouping ("(xy)"), brackets ("[xy]"), and -// repetition count ("x{5,7}"), among others. -// -// Below is the syntax that we do support. We chose it to be a -// subset of both PCRE and POSIX extended regex, so it's easy to -// learn wherever you come from. In the following: 'A' denotes a -// literal character, period (.), or a single \\ escape sequence; -// 'x' and 'y' denote regular expressions; 'm' and 'n' are for -// natural numbers. -// -// c matches any literal character c -// \\d matches any decimal digit -// \\D matches any character that's not a decimal digit -// \\f matches \f -// \\n matches \n -// \\r matches \r -// \\s matches any ASCII whitespace, including \n -// \\S matches any character that's not a whitespace -// \\t matches \t -// \\v matches \v -// \\w matches any letter, _, or decimal digit -// \\W matches any character that \\w doesn't match -// \\c matches any literal character c, which must be a punctuation -// . matches any single character except \n -// A? matches 0 or 1 occurrences of A -// A* matches 0 or many occurrences of A -// A+ matches 1 or many occurrences of A -// ^ matches the beginning of a string (not that of each line) -// $ matches the end of a string (not that of each line) -// xy matches x followed by y -// -// If you accidentally use PCRE or POSIX extended regex features -// not implemented by us, you will get a run-time failure. In that -// case, please try to rewrite your regular expression within the -// above syntax. -// -// This implementation is *not* meant to be as highly tuned or robust -// as a compiled regex library, but should perform well enough for a -// death test, which already incurs significant overhead by launching -// a child process. +// Depending on the platform, this may use RE2, the POSIX library, +// the C++11 standard library's engine with ECMAScript syntax, or +// another similar engine. Regular expressions should be simple and portable +// enough to work across the engines of interest. // // Known caveats: // diff --git a/deps/googletest/include/gtest/internal/gtest-port.h b/deps/googletest/include/gtest/internal/gtest-port.h index 154be3c1602..3b2947b852e 100644 --- a/deps/googletest/include/gtest/internal/gtest-port.h +++ b/deps/googletest/include/gtest/internal/gtest-port.h @@ -176,7 +176,7 @@ // GTEST_USES_POSIX_RE - enhanced POSIX regex is used. Do not confuse with // GTEST_HAS_POSIX_RE (see above) which users can // define themselves. -// GTEST_USES_SIMPLE_RE - our own simple regex is used; +// GTEST_USES_STD_RE - std::regex from the C++ standard library is used; // the above RE\b(s) are mutually exclusive. // GTEST_HAS_ABSL - Google Test is compiled with Abseil. @@ -438,8 +438,9 @@ typedef struct _RTL_CRITICAL_SECTION GTEST_CRITICAL_SECTION; #include // NOLINT #define GTEST_USES_POSIX_RE 1 #else -// Use our own simple regex implementation. -#define GTEST_USES_SIMPLE_RE 1 +// Use std::regex from the C++ standard library. +#include // NOLINT +#define GTEST_USES_STD_RE 1 #endif #ifndef GTEST_HAS_EXCEPTIONS @@ -992,12 +993,11 @@ class GTEST_API_ [[nodiscard]] RE { RE2 regex_; }; -#elif defined(GTEST_USES_POSIX_RE) || defined(GTEST_USES_SIMPLE_RE) +#elif defined(GTEST_USES_POSIX_RE) || defined(GTEST_USES_STD_RE) GTEST_DISABLE_MSC_WARNINGS_PUSH_(4251 \ /* class A needs to have dll-interface to be used by clients of class B */) -// A simple C++ wrapper for . It uses the POSIX Extended -// Regular Expression syntax. +// A simple C++ wrapper for or . class GTEST_API_ [[nodiscard]] RE { public: // A copy constructor is required by the Standard to initialize object @@ -1037,9 +1037,9 @@ class GTEST_API_ [[nodiscard]] RE { regex_t full_regex_; // For FullMatch(). regex_t partial_regex_; // For PartialMatch(). -#else // GTEST_USES_SIMPLE_RE +#else // GTEST_USES_STD_RE - std::string full_pattern_; // For FullMatch(); + std::regex regex_; #endif }; @@ -1755,14 +1755,16 @@ class [[nodiscard]] MutexBase { #define GTEST_DECLARE_STATIC_MUTEX_(mutex) \ extern ::testing::internal::MutexBase mutex +#if defined(PTHREAD_NULL) +#define GTEST_INTERNAL_PTHREAD_NULL PTHREAD_NULL +#else +#define GTEST_INTERNAL_PTHREAD_NULL (pthread_t{}) +#endif + // Defines and statically (i.e. at link time) initializes a static mutex. -// The initialization list here does not explicitly initialize each field, -// instead relying on default initialization for the unspecified fields. In -// particular, the owner_ field (a pthread_t) is not explicitly initialized. -// This allows initialization to work whether pthread_t is a scalar or struct. -// The flag -Wmissing-field-initializers must not be specified for this to work. -#define GTEST_DEFINE_STATIC_MUTEX_(mutex) \ - ::testing::internal::MutexBase mutex = {PTHREAD_MUTEX_INITIALIZER, false, 0} +#define GTEST_DEFINE_STATIC_MUTEX_(mutex) \ + ::testing::internal::MutexBase mutex = {PTHREAD_MUTEX_INITIALIZER, false, \ + GTEST_INTERNAL_PTHREAD_NULL} // The Mutex class can only be used for mutexes created at runtime. It // shares its API with MutexBase otherwise. diff --git a/deps/googletest/src/gtest-internal-inl.h b/deps/googletest/src/gtest-internal-inl.h index 4bebca1bc65..5a6332a755a 100644 --- a/deps/googletest/src/gtest-internal-inl.h +++ b/deps/googletest/src/gtest-internal-inl.h @@ -980,26 +980,7 @@ inline UnitTestImpl* GetUnitTestImpl() { return UnitTest::GetInstance()->impl(); } -#ifdef GTEST_USES_SIMPLE_RE - -// Internal helper functions for implementing the simple regular -// expression matcher. -GTEST_API_ bool IsInSet(char ch, const char* str); -GTEST_API_ bool IsAsciiDigit(char ch); -GTEST_API_ bool IsAsciiPunct(char ch); -GTEST_API_ bool IsRepeat(char ch); -GTEST_API_ bool IsAsciiWhiteSpace(char ch); -GTEST_API_ bool IsAsciiWordChar(char ch); -GTEST_API_ bool IsValidEscape(char ch); -GTEST_API_ bool AtomMatchesChar(bool escaped, char pattern, char ch); -GTEST_API_ bool ValidateRegex(const char* regex); -GTEST_API_ bool MatchRegexAtHead(const char* regex, const char* str); -GTEST_API_ bool MatchRepetitionAndRegexAtHead(bool escaped, char ch, - char repeat, const char* regex, - const char* str); -GTEST_API_ bool MatchRegexAnywhere(const char* regex, const char* str); - -#endif // GTEST_USES_SIMPLE_RE + // Parses the command line for Google Test flags, without initializing // other parts of Google Test. diff --git a/deps/googletest/src/gtest-port.cc b/deps/googletest/src/gtest-port.cc index be5b16e76d3..68f77f71247 100644 --- a/deps/googletest/src/gtest-port.cc +++ b/deps/googletest/src/gtest-port.cc @@ -766,249 +766,46 @@ void RE::Init(const char* regex) { delete[] full_pattern; } -#elif defined(GTEST_USES_SIMPLE_RE) - -// Returns true if and only if ch appears anywhere in str (excluding the -// terminating '\0' character). -bool IsInSet(char ch, const char* str) { - return ch != '\0' && strchr(str, ch) != nullptr; -} - -// Returns true if and only if ch belongs to the given classification. -// Unlike similar functions in , these aren't affected by the -// current locale. -bool IsAsciiDigit(char ch) { return '0' <= ch && ch <= '9'; } -bool IsAsciiPunct(char ch) { - return IsInSet(ch, "^-!\"#$%&'()*+,./:;<=>?@[\\]_`{|}~"); -} -bool IsRepeat(char ch) { return IsInSet(ch, "?*+"); } -bool IsAsciiWhiteSpace(char ch) { return IsInSet(ch, " \f\n\r\t\v"); } -bool IsAsciiWordChar(char ch) { - return ('a' <= ch && ch <= 'z') || ('A' <= ch && ch <= 'Z') || - ('0' <= ch && ch <= '9') || ch == '_'; -} - -// Returns true if and only if "\\c" is a supported escape sequence. -bool IsValidEscape(char c) { - return (IsAsciiPunct(c) || IsInSet(c, "dDfnrsStvwW")); -} - -// Returns true if and only if the given atom (specified by escaped and -// pattern) matches ch. The result is undefined if the atom is invalid. -bool AtomMatchesChar(bool escaped, char pattern_char, char ch) { - if (escaped) { // "\\p" where p is pattern_char. - switch (pattern_char) { - case 'd': - return IsAsciiDigit(ch); - case 'D': - return !IsAsciiDigit(ch); - case 'f': - return ch == '\f'; - case 'n': - return ch == '\n'; - case 'r': - return ch == '\r'; - case 's': - return IsAsciiWhiteSpace(ch); - case 'S': - return !IsAsciiWhiteSpace(ch); - case 't': - return ch == '\t'; - case 'v': - return ch == '\v'; - case 'w': - return IsAsciiWordChar(ch); - case 'W': - return !IsAsciiWordChar(ch); - } - return IsAsciiPunct(pattern_char) && pattern_char == ch; - } - - return (pattern_char == '.' && ch != '\n') || pattern_char == ch; -} - -// Helper function used by ValidateRegex() to format error messages. -static std::string FormatRegexSyntaxError(const char* regex, int index) { - return (Message() << "Syntax error at index " << index - << " in simple regular expression \"" << regex << "\": ") - .GetString(); -} - -// Generates non-fatal failures and returns false if regex is invalid; -// otherwise returns true. -bool ValidateRegex(const char* regex) { - if (regex == nullptr) { - ADD_FAILURE() << "NULL is not a valid simple regular expression."; - return false; - } - - bool is_valid = true; - - // True if and only if ?, *, or + can follow the previous atom. - bool prev_repeatable = false; - for (int i = 0; regex[i]; i++) { - if (regex[i] == '\\') { // An escape sequence - i++; - if (regex[i] == '\0') { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i - 1) - << "'\\' cannot appear at the end."; - return false; - } - - if (!IsValidEscape(regex[i])) { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i - 1) - << "invalid escape sequence \"\\" << regex[i] << "\"."; - is_valid = false; - } - prev_repeatable = true; - } else { // Not an escape sequence. - const char ch = regex[i]; - - if (ch == '^' && i > 0) { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i) - << "'^' can only appear at the beginning."; - is_valid = false; - } else if (ch == '$' && regex[i + 1] != '\0') { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i) - << "'$' can only appear at the end."; - is_valid = false; - } else if (IsInSet(ch, "()[]{}|")) { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i) << "'" << ch - << "' is unsupported."; - is_valid = false; - } else if (IsRepeat(ch) && !prev_repeatable) { - ADD_FAILURE() << FormatRegexSyntaxError(regex, i) << "'" << ch - << "' can only follow a repeatable token."; - is_valid = false; - } - - prev_repeatable = !IsInSet(ch, "^$?*+"); - } - } - - return is_valid; -} - -// Matches a repeated regex atom followed by a valid simple regular -// expression. The regex atom is defined as c if escaped is false, -// or \c otherwise. repeat is the repetition meta character (?, *, -// or +). The behavior is undefined if str contains too many -// characters to be indexable by size_t, in which case the test will -// probably time out anyway. We are fine with this limitation as -// std::string has it too. -bool MatchRepetitionAndRegexAtHead(bool escaped, char c, char repeat, - const char* regex, const char* str) { - const size_t min_count = (repeat == '+') ? 1 : 0; - const size_t max_count = (repeat == '?') ? 1 : static_cast(-1) - 1; - // We cannot call numeric_limits::max() as it conflicts with the - // max() macro on Windows. - - for (size_t i = 0; i <= max_count; ++i) { - // We know that the atom matches each of the first i characters in str. - if (i >= min_count && MatchRegexAtHead(regex, str + i)) { - // We have enough matches at the head, and the tail matches too. - // Since we only care about *whether* the pattern matches str - // (as opposed to *how* it matches), there is no need to find a - // greedy match. - return true; - } - if (str[i] == '\0' || !AtomMatchesChar(escaped, c, str[i])) return false; - } - return false; -} - -// Returns true if and only if regex matches a prefix of str. regex must -// be a valid simple regular expression and not start with "^", or the -// result is undefined. -bool MatchRegexAtHead(const char* regex, const char* str) { - if (*regex == '\0') // An empty regex matches a prefix of anything. - return true; - - // "$" only matches the end of a string. Note that regex being - // valid guarantees that there's nothing after "$" in it. - if (*regex == '$') return *str == '\0'; - - // Is the first thing in regex an escape sequence? - const bool escaped = *regex == '\\'; - if (escaped) ++regex; - if (IsRepeat(regex[1])) { - // MatchRepetitionAndRegexAtHead() calls MatchRegexAtHead(), so - // here's an indirect recursion. It terminates as the regex gets - // shorter in each recursion. - return MatchRepetitionAndRegexAtHead(escaped, regex[0], regex[1], regex + 2, - str); - } else { - // regex isn't empty, isn't "$", and doesn't start with a - // repetition. We match the first atom of regex with the first - // character of str and recurse. - return (*str != '\0') && AtomMatchesChar(escaped, *regex, *str) && - MatchRegexAtHead(regex + 1, str + 1); - } -} - -// Returns true if and only if regex matches any substring of str. regex must -// be a valid simple regular expression, or the result is undefined. -// -// The algorithm is recursive, but the recursion depth doesn't exceed -// the regex length, so we won't need to worry about running out of -// stack space normally. In rare cases the time complexity can be -// exponential with respect to the regex length + the string length, -// but usually it's must faster (often close to linear). -bool MatchRegexAnywhere(const char* regex, const char* str) { - if (regex == nullptr || str == nullptr) return false; - - if (*regex == '^') return MatchRegexAtHead(regex + 1, str); - - // A successful match can be anywhere in str. - do { - if (MatchRegexAtHead(regex, str)) return true; - } while (*str++ != '\0'); - return false; -} - -// Implements the RE class. +#elif defined(GTEST_USES_STD_RE) RE::~RE() = default; // Returns true if and only if regular expression re matches the entire str. bool RE::FullMatch(const char* str, const RE& re) { - return re.is_valid_ && MatchRegexAnywhere(re.full_pattern_.c_str(), str); + if (!re.is_valid_ || str == nullptr) return false; + return std::regex_match(str, re.regex_); } // Returns true if and only if regular expression re matches a substring of // str (including str itself). bool RE::PartialMatch(const char* str, const RE& re) { - return re.is_valid_ && MatchRegexAnywhere(re.pattern_.c_str(), str); + if (!re.is_valid_ || str == nullptr) return false; + return std::regex_search(str, re.regex_); } // Initializes an RE from its string representation. void RE::Init(const char* regex) { - full_pattern_.clear(); - pattern_.clear(); + pattern_ = regex == nullptr ? "" : regex; + is_valid_ = false; - if (regex != nullptr) { - pattern_ = regex; - } - - is_valid_ = ValidateRegex(regex); - if (!is_valid_) { - // No need to calculate the full pattern when the regex is invalid. + if (regex == nullptr) { + ADD_FAILURE() << "NULL is not a valid regular expression."; return; } - // Reserves enough bytes to hold the regular expression used for a - // full match: we need space to prepend a '^' and append a '$'. - full_pattern_.reserve(pattern_.size() + 2); - - if (pattern_.empty() || pattern_.front() != '^') { - full_pattern_.push_back('^'); // Makes sure full_pattern_ starts with '^'. +#if GTEST_HAS_EXCEPTIONS + try { + regex_ = std::regex(regex, std::regex_constants::ECMAScript); + } catch (const std::regex_error& e) { + ADD_FAILURE() << "Regular expression \"" << regex + << "\" is not a valid regular expression: " << e.what(); + return; } +#else + regex_ = std::regex(regex, std::regex_constants::ECMAScript); +#endif - full_pattern_.append(pattern_); - - if (pattern_.empty() || pattern_.back() != '$') { - full_pattern_.push_back('$'); // Makes sure full_pattern_ ends with '$'. - } + is_valid_ = true; } #endif // GTEST_USES_POSIX_RE diff --git a/deps/googletest/src/gtest.cc b/deps/googletest/src/gtest.cc index 47c60da2291..3772f18552d 100644 --- a/deps/googletest/src/gtest.cc +++ b/deps/googletest/src/gtest.cc @@ -4648,7 +4648,7 @@ std::string JsonUnitTestResultPrinter::EscapeJson(const std::string& str) { m << "\\r"; break; default: - if (ch < ' ') { + if (static_cast(ch) < ' ' || ch == '\x7F') { m << "\\u00" << String::FormatByte(static_cast(ch)); } else { m << ch; From d6d15168573eb71515c6c64e23cc4f907a9a4eb4 Mon Sep 17 00:00:00 2001 From: Filip Skokan Date: Tue, 15 Sep 2026 21:11:35 +0200 Subject: [PATCH 053/119] test: prevent parser reuse across close scenarios MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both cases replace parser cleanup methods. Faster socket cleanup can return a modified parser to the shared pool and close it before the other request uses it. Run the immediate and deferred close cases in separate test files so each gets its own process and parser pool. Preserve both cleanup paths and all call-count assertions. Signed-off-by: Filip Skokan Assisted-by: Codex PR-URL: https://github.com/nodejs/node/pull/66017 Reviewed-By: James M Snell Reviewed-By: Xuguang Mei Reviewed-By: Ulises Gascón Reviewed-By: Luigi Pinca --- ...ver-connection-list-when-close-deferred.js | 35 ++++++++++++++++ ...-http-server-connection-list-when-close.js | 42 +++++-------------- 2 files changed, 45 insertions(+), 32 deletions(-) create mode 100644 test/parallel/test-http-server-connection-list-when-close-deferred.js diff --git a/test/parallel/test-http-server-connection-list-when-close-deferred.js b/test/parallel/test-http-server-connection-list-when-close-deferred.js new file mode 100644 index 00000000000..af87e356161 --- /dev/null +++ b/test/parallel/test-http-server-connection-list-when-close-deferred.js @@ -0,0 +1,35 @@ +'use strict'; + +const common = require('../common'); +const http = require('http'); + +// Keep this case in a separate process from the immediate-close case so +// their modified parsers cannot be reused across cases. + +function request(server) { + http.get({ + agent: false, + port: server.address().port, + path: '/', + }, (res) => { + res.resume(); + }); +} + +const server = http.createServer(common.mustCallAtLeast((req, res) => { + // See `freeParser` in _http_common.js + const { parser } = req.socket; + parser.free = common.mustCall(() => { + setImmediate(common.mustCall(() => { + parser.close(); + })); + }); + req.socket.on('close', common.mustCall(() => { + setImmediate(common.mustCall(() => { + server.close(); + })); + })); + res.end('ok'); +})).listen(0, common.mustCall(() => { + request(server); +})); diff --git a/test/parallel/test-http-server-connection-list-when-close.js b/test/parallel/test-http-server-connection-list-when-close.js index 0c8308b63c5..305755b14eb 100644 --- a/test/parallel/test-http-server-connection-list-when-close.js +++ b/test/parallel/test-http-server-connection-list-when-close.js @@ -13,36 +13,14 @@ function request(server) { }); } -{ - const server = http.createServer(common.mustCallAtLeast((req, res) => { - // Hack to not remove parser out of server.connectionList - // See `freeParser` in _http_common.js - req.socket.parser.free = common.mustCall(); - req.socket.on('close', common.mustCall(() => { - server.close(); - })); - res.end('ok'); - })).listen(0, common.mustCall(() => { - request(server); +const server = http.createServer(common.mustCallAtLeast((req, res) => { + // Hack to not remove parser out of server.connectionList + // See `freeParser` in _http_common.js + req.socket.parser.free = common.mustCall(); + req.socket.on('close', common.mustCall(() => { + server.close(); })); -} - -{ - const server = http.createServer(common.mustCallAtLeast((req, res) => { - // See `freeParser` in _http_common.js - const { parser } = req.socket; - parser.free = common.mustCall(() => { - setImmediate(common.mustCall(() => { - parser.close(); - })); - }); - req.socket.on('close', common.mustCall(() => { - setImmediate(common.mustCall(() => { - server.close(); - })); - })); - res.end('ok'); - })).listen(0, common.mustCall(() => { - request(server); - })); -} + res.end('ok'); +})).listen(0, common.mustCall(() => { + request(server); +})); From 0af012033e8e5f52c713bc4b65fe64c4dadeb370 Mon Sep 17 00:00:00 2001 From: Yuya Inoue <65857152+inoway46@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:50:14 +0900 Subject: [PATCH 054/119] lib: fix AbortSignal.any() abort propagation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow composite signal sources at construction so abort state is propagated even without listeners, before sources can be collected. Keep the existing weak-reference finalization and timeout cleanup. Cover source collection before each state accessor, including nested composites, and cleanup of unreachable listener-less dependents. Fixes: https://github.com/nodejs/node/issues/65995 Refs: https://github.com/nodejs/node/issues/62363 Assisted-by: Codex Signed-off-by: inoway46 PR-URL: https://github.com/nodejs/node/pull/66014 Reviewed-By: Robert Nagy Reviewed-By: James M Snell Reviewed-By: Xuguang Mei Reviewed-By: Ulises Gascón Reviewed-By: Benjamin Gruenbaum --- lib/internal/abort_controller.js | 4 +++ .../test-abortsignal-any-source-gc.mjs | 31 +++++++++++++++++ .../test-abortsignal-drop-settled-signals.mjs | 34 ++++++++++++++++--- 3 files changed, 64 insertions(+), 5 deletions(-) create mode 100644 test/parallel/test-abortsignal-any-source-gc.mjs diff --git a/lib/internal/abort_controller.js b/lib/internal/abort_controller.js index 09b160e9fe5..3352f6a4cff 100644 --- a/lib/internal/abort_controller.js +++ b/lib/internal/abort_controller.js @@ -377,6 +377,10 @@ class AbortSignal extends EventTarget { resultSignal[kTimeout] = true; } + // Preserve abort state even if sources are collected before this signal is + // observed. Following uses weak references so unused signals can be collected. + followCompositeSignal(resultSignal); + return resultSignal; } diff --git a/test/parallel/test-abortsignal-any-source-gc.mjs b/test/parallel/test-abortsignal-any-source-gc.mjs new file mode 100644 index 00000000000..06fd0c0e29f --- /dev/null +++ b/test/parallel/test-abortsignal-any-source-gc.mjs @@ -0,0 +1,31 @@ +// Flags: --expose-gc + +import '../common/index.mjs'; +import { gcUntil } from '../common/gc.js'; +import assert from 'node:assert/strict'; +import { it } from 'node:test'; + +for (const nested of [false, true]) { + for (const accessor of ['aborted', 'reason', 'throwIfAborted']) { + it(`preserves ${accessor} after source GC (nested: ${nested})`, async () => { + let controller = new AbortController(); + const sourceRef = new WeakRef(controller.signal); + let signal = AbortSignal.any([controller.signal]); + if (nested) signal = AbortSignal.any([signal]); + const reason = { message: 'stop' }; + + controller.abort(reason); + controller = null; + + // Do not observe the composite or attach a listener before source GC. + await gcUntil('source signal is collected', () => sourceRef.deref() === undefined); + + // Exercise each entry point before any other accessor can refresh state. + if (accessor === 'aborted') assert.strictEqual(signal.aborted, true); + if (accessor === 'reason') assert.strictEqual(signal.reason, reason); + assert.throws(() => signal.throwIfAborted(), (err) => err === reason); + assert.strictEqual(signal.aborted, true); + assert.strictEqual(signal.reason, reason); + }); + } +} diff --git a/test/parallel/test-abortsignal-drop-settled-signals.mjs b/test/parallel/test-abortsignal-drop-settled-signals.mjs index 224d65abc70..d4c81a7165d 100644 --- a/test/parallel/test-abortsignal-drop-settled-signals.mjs +++ b/test/parallel/test-abortsignal-drop-settled-signals.mjs @@ -122,16 +122,22 @@ describe('when there is a long-lived signal', () => { }, true); }); - it('does not keep retained dependent signals without listeners', (t, done) => { + it('propagates abort to retained dependent signals without listeners', (t, done) => { const ac = new AbortController(); const retainedSignals = []; - const kDependantSignals = Object.getOwnPropertySymbols(ac.signal).find( - (s) => s.toString() === 'Symbol(kDependantSignals)' - ); function run(iteration) { if (iteration > limit) { - t.assert.strictEqual(ac.signal[kDependantSignals]?.size ?? 0, 0); + const kDependantSignals = Object.getOwnPropertySymbols(ac.signal).find( + (s) => s.toString() === 'Symbol(kDependantSignals)' + ); + t.assert.strictEqual(ac.signal[kDependantSignals].size, limit); + ac.abort('stop'); + for (const signal of retainedSignals) { + t.assert.strictEqual(signal.aborted, true); + t.assert.strictEqual(signal.reason, 'stop'); + t.assert.throws(() => signal.throwIfAborted(), (err) => err === 'stop'); + } done(); return; } @@ -143,6 +149,24 @@ describe('when there is a long-lived signal', () => { run(1); }); + it('drops unreachable dependent signals without listeners', async () => { + const ac = new AbortController(); + const size = () => { + const sym = Object.getOwnPropertySymbols(ac.signal).find( + (s) => s.toString() === 'Symbol(kDependantSignals)' + ); + return ac.signal[sym]?.size ?? 0; + }; + + // Reuse a long-lived source across batches to catch accumulating WeakRefs. + for (let batch = 0; batch < 3; batch++) { + for (let i = 0; i < limit; i++) { + AbortSignal.any([ac.signal]); + } + await gcUntil('unreachable dependents are dropped', () => size() === 0); + } + }); + it('drops observed dependent signals once they are transitively aborted', async () => { const longLived = new AbortController(); const handler = () => {}; From fbe74a49e2257e769565029b5cdf5e0611a9a368 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Wed, 2 Sep 2026 22:41:39 +0000 Subject: [PATCH 055/119] inspector: fix abort when two Environments own the inspector Two Environments with default flags alive at the same time (for example the embedding.md example run on two threads, or two `CommonEnvironmentSetup`s) aborted the process: `Agent::Start()` bound one file-level static `uv_async_t` to the current Environment's loop for every Environment with `kOwnsInspector`, which `kDefaultFlags` implies, and CHECKed that nobody else had. Environments created one after another did not abort, but each ran `StartDebugSignalHandler()` again, which re-initialized the semaphore the watchdog waits on and spawned another detached watchdog thread, leaking one thread per Environment. Give every Agent that asks for the debug signal handler its own async handle, keep those Agents in a mutex-protected list that the watchdog (or the Windows remote thread) walks, and set the watchdog up once per process while still unblocking SIGUSR1 on each Environment's thread. The handle is heap-allocated, closed by the cleanup hook or `~Agent()`, whichever runs first, and freed by its close callback. A SIGUSR1 now reaches every Environment that asked for the handler, and no longer starts the inspector of one that passed `kNoStartDebugSignalHandler`. Refs: https://github.com/nodejs/node/pull/25777 Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/65877 Refs: https://github.com/nodejs/node/pull/44121 Reviewed-By: Matteo Collina --- src/inspector_agent.cc | 129 ++++++++++++++++++-------------- src/inspector_agent.h | 10 ++- test/cctest/test_environment.cc | 3 +- 3 files changed, 81 insertions(+), 61 deletions(-) diff --git a/src/inspector_agent.cc b/src/inspector_agent.cc index 12322f0021b..31277a74d1f 100644 --- a/src/inspector_agent.cc +++ b/src/inspector_agent.cc @@ -70,19 +70,18 @@ using v8_inspector::V8InspectorClient; #ifdef __POSIX__ static uv_sem_t start_io_thread_semaphore; #endif // __POSIX__ -static uv_async_t start_io_thread_async; -// This is just an additional check to make sure start_io_thread_async -// is not accidentally re-used or used when uninitialized. -static std::atomic_bool start_io_thread_async_initialized { false }; -// Protects the Agent* stored in start_io_thread_async.data. -static Mutex start_io_thread_async_mutex; - -// Called on the main thread. -void StartIoThreadAsyncCallback(uv_async_t* handle) { - static_cast(handle->data)->StartIoThread(); +// Agents that asked for the debug signal handler; SIGUSR1 (or the Windows +// remote thread) starts the io thread of each. The mutex also guards the +// once-per-process watchdog setup. +static Mutex start_io_thread_agents_mutex; +static std::vector start_io_thread_agents; +static bool debug_signal_handler_started = false; + +static void RequestIoThreadStartOnAgents() { + Mutex::ScopedLock lock(start_io_thread_agents_mutex); + for (Agent* agent : start_io_thread_agents) agent->RequestIoThreadStart(); } - #ifdef __POSIX__ static void StartIoThreadWakeup(int signo, siginfo_t* info, void* ucontext) { uv_sem_post(&start_io_thread_semaphore); @@ -92,16 +91,11 @@ inline void* StartIoThreadMain(void* unused) { uv_thread_setname("SignalInspector"); for (;;) { uv_sem_wait(&start_io_thread_semaphore); - Mutex::ScopedLock lock(start_io_thread_async_mutex); - - CHECK(start_io_thread_async_initialized); - Agent* agent = static_cast(start_io_thread_async.data); - if (agent != nullptr) - agent->RequestIoThreadStart(); + RequestIoThreadStartOnAgents(); } } -static int StartDebugSignalHandler() { +static int StartWatchdogThread() { // Start a watchdog thread for calling v8::Debug::DebugBreak() because // it's not safe to call directly from the signal handler, it can // deadlock with the thread it interrupts. @@ -136,14 +130,28 @@ static int StartDebugSignalHandler() { fprintf(stderr, "node[%u]: pthread_create: %s\n", uv_os_getpid(), strerror(err)); fflush(stderr); - // Leave SIGUSR1 blocked. We don't install a signal handler, - // receiving the signal would terminate the process. + uv_sem_destroy(&start_io_thread_semaphore); return -err; } RegisterSignalHandler(SIGUSR1, StartIoThreadWakeup); // Restore original mask CHECK_EQ(0, pthread_sigmask(SIG_SETMASK, &sigmask, nullptr)); - // Unblock SIGUSR1. A pending SIGUSR1 signal will now be delivered. + return 0; +} + +static int StartDebugSignalHandler() { + { + Mutex::ScopedLock lock(start_io_thread_agents_mutex); + if (!debug_signal_handler_started) { + // Leave SIGUSR1 blocked on failure. We don't install a signal handler, + // receiving the signal would terminate the process. + if (int err = StartWatchdogThread()) return err; + debug_signal_handler_started = true; + } + } + // Unblock SIGUSR1 on this thread; PlatformInit() left it blocked. A pending + // SIGUSR1 signal will now be delivered. + sigset_t sigmask; sigemptyset(&sigmask); sigaddset(&sigmask, SIGUSR1); CHECK_EQ(0, pthread_sigmask(SIG_UNBLOCK, &sigmask, nullptr)); @@ -154,11 +162,7 @@ static int StartDebugSignalHandler() { #ifdef _WIN32 DWORD WINAPI StartIoThreadProc(void* arg) { - Mutex::ScopedLock lock(start_io_thread_async_mutex); - CHECK(start_io_thread_async_initialized); - Agent* agent = static_cast(start_io_thread_async.data); - if (agent != nullptr) - agent->RequestIoThreadStart(); + RequestIoThreadStartOnAgents(); return 0; } @@ -168,6 +172,9 @@ static int GetDebugSignalHandlerMappingName(DWORD pid, wchar_t* buf, } static int StartDebugSignalHandler() { + Mutex::ScopedLock lock(start_io_thread_agents_mutex); + if (debug_signal_handler_started) return 0; + debug_signal_handler_started = true; wchar_t mapping_name[32]; HANDLE mapping_handle; DWORD pid; @@ -837,7 +844,21 @@ Agent::Agent(Environment* env) debug_options_(env->options()->debug_options()), host_port_(env->inspector_host_port()) {} -Agent::~Agent() = default; +Agent::~Agent() { + StopAcceptingIoThreadStarts(); +} + +void Agent::StopAcceptingIoThreadStarts() { + if (start_io_thread_async_ == nullptr) return; + { + Mutex::ScopedLock lock(start_io_thread_agents_mutex); + std::erase(start_io_thread_agents, this); + } + parent_env_->RemoveCleanupHook(StopAcceptingIoThreadStartsHook, this); + parent_env_->CloseHandle(start_io_thread_async_, + [](uv_async_t* handle) { delete handle; }); + start_io_thread_async_ = nullptr; +} bool Agent::Start(const std::string& path, const DebugOptions& options, @@ -849,33 +870,25 @@ bool Agent::Start(const std::string& path, host_port_ = host_port; client_ = std::make_shared(parent_env_, is_main); - if (parent_env_->owns_inspector()) { - Mutex::ScopedLock lock(start_io_thread_async_mutex); - CHECK_EQ(start_io_thread_async_initialized.exchange(true), false); - CHECK_EQ(0, uv_async_init(parent_env_->event_loop(), - &start_io_thread_async, - StartIoThreadAsyncCallback)); - uv_unref(reinterpret_cast(&start_io_thread_async)); - start_io_thread_async.data = this; - if (parent_env_->should_start_debug_signal_handler()) { - // Ignore failure, SIGUSR1 won't work, but that should not block node - // start. - StartDebugSignalHandler(); + if (parent_env_->owns_inspector() && + parent_env_->should_start_debug_signal_handler()) { + start_io_thread_async_ = new uv_async_t; + start_io_thread_async_->data = this; + CHECK_EQ(0, + uv_async_init(parent_env_->event_loop(), + start_io_thread_async_, + [](uv_async_t* handle) { + static_cast(handle->data)->StartIoThread(); + })); + uv_unref(reinterpret_cast(start_io_thread_async_)); + { + Mutex::ScopedLock lock(start_io_thread_agents_mutex); + start_io_thread_agents.push_back(this); } - - parent_env_->AddCleanupHook([](void* data) { - Environment* env = static_cast(data); - - { - Mutex::ScopedLock lock(start_io_thread_async_mutex); - start_io_thread_async.data = nullptr; - } - - // This is global, will never get freed - env->CloseHandle(&start_io_thread_async, [](uv_async_t*) { - CHECK(start_io_thread_async_initialized.exchange(false)); - }); - }, parent_env_); + parent_env_->AddCleanupHook(StopAcceptingIoThreadStartsHook, this); + // Ignore failure, SIGUSR1 won't work, but that should not block node + // start. + StartDebugSignalHandler(); } AtExit(parent_env_, [](void* env) { @@ -1154,6 +1167,10 @@ void Agent::AllAsyncTasksCanceled() { client_->AllAsyncTasksCanceled(); } +void Agent::StopAcceptingIoThreadStartsHook(void* agent) { + static_cast(agent)->StopAcceptingIoThreadStarts(); +} + void Agent::RequestIoThreadStart() { // We need to attempt to interrupt V8 flow (in case Node is running // continuous JS code) and to wake up libuv thread (in case Node is waiting @@ -1161,14 +1178,10 @@ void Agent::RequestIoThreadStart() { if (!options().allow_attaching_debugger) { return; } - CHECK(start_io_thread_async_initialized); - uv_async_send(&start_io_thread_async); parent_env_->RequestInterrupt([this](Environment*) { StartIoThread(); }); - - CHECK(start_io_thread_async_initialized); - uv_async_send(&start_io_thread_async); + uv_async_send(start_io_thread_async_); } void Agent::ContextCreated(Local context, const ContextInfo& info) { diff --git a/src/inspector_agent.h b/src/inspector_agent.h index 932e4e8dce8..5a1d1de2654 100644 --- a/src/inspector_agent.h +++ b/src/inspector_agent.h @@ -8,6 +8,7 @@ #endif #include "node_options.h" +#include "uv.h" #include "v8.h" #include @@ -117,7 +118,8 @@ class Agent { // Can only be called from the main thread. bool StartIoThread(); - // Calls StartIoThread() from off the main thread. + // Calls StartIoThread() from off the main thread. Only valid while the + // Environment owns the inspector and has not started cleanup. void RequestIoThreadStart(); const DebugOptions& options() { return debug_options_; } @@ -156,6 +158,12 @@ class Agent { bool async_hook_enabled_ = false; bool syncing_async_hook_state_ = false; + // Woken by the SIGUSR1 watchdog; closed by the cleanup hook or ~Agent(), + // whichever runs first, and freed by its close callback. + uv_async_t* start_io_thread_async_ = nullptr; + void StopAcceptingIoThreadStarts(); + static void StopAcceptingIoThreadStartsHook(void* agent); + bool network_tracking_enabled_ = false; bool pending_enable_network_tracking = false; bool pending_disable_network_tracking = false; diff --git a/test/cctest/test_environment.cc b/test/cctest/test_environment.cc index ff451259908..6a48db67baa 100644 --- a/test/cctest/test_environment.cc +++ b/test/cctest/test_environment.cc @@ -339,9 +339,8 @@ TEST_F(EnvironmentTest, RemoveEnvironmentCleanupHookDuringCleanup) { TEST_F(EnvironmentTest, MultipleEnvironmentsPerIsolate) { const v8::HandleScope handle_scope(isolate_); const Argv argv; - // Only one of the Environments can have default flags and own the inspector. Env env1 {handle_scope, argv}; - Env env2 {handle_scope, argv, node::EnvironmentFlags::kNoFlags}; + Env env2{handle_scope, argv}; AtExit(*env1, at_exit_callback1, nullptr); AtExit(*env2, at_exit_callback2, nullptr); From 91953dc45d9ca869533499bc851d9d50ef5fcd95 Mon Sep 17 00:00:00 2001 From: Shelley Vohr Date: Thu, 3 Sep 2026 00:03:53 +0000 Subject: [PATCH 056/119] doc: note that default signal handling resets the signal mask `InitializeOncePerProcess()` without `kNoDefaultSignalHandling` calls `pthread_sigmask(SIG_SETMASK, ...)` with a set containing only SIGUSR1, which unblocks every signal the embedder had blocked on the calling thread. Say so in the flag's documentation. Refs: https://github.com/nodejs/node/pull/44121 Signed-off-by: Shelley Vohr PR-URL: https://github.com/nodejs/node/pull/65877 Refs: https://github.com/nodejs/node/pull/25777 Reviewed-By: Matteo Collina --- src/node.h | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/node.h b/src/node.h index 85e97f42f4c..9f9ba48eb8e 100644 --- a/src/node.h +++ b/src/node.h @@ -239,8 +239,10 @@ enum Flags : uint32_t { kNoICU = 1 << 3, // Do not modify stdio file descriptor or TTY state. kNoStdioInitialization = 1 << 4, - // Do not register Node.js-specific signal handlers - // and reset other signal handlers to default state. + // Do not register Node.js-specific signal handlers, reset other signal + // handlers to default state, or replace the calling thread's signal mask + // (without this flag, POSIX builds with the inspector set it to block + // SIGUSR1 and nothing else). kNoDefaultSignalHandling = 1 << 5, // Do not perform V8 initialization. kNoInitializeV8 = 1 << 6, From 019b71b76b006526d933636eb1e20427d3f68372 Mon Sep 17 00:00:00 2001 From: Barath Raj <129966260+barathraj048@users.noreply.github.com> Date: Wed, 16 Sep 2026 17:17:09 +0530 Subject: [PATCH 057/119] http: don't destroy socket after request completes Aborting a ClientRequest after the request has finished sending and the response has fully arrived has nothing left to cancel. destroy() still called socket.destroy(err), but the resulting 'error' is emitted on a later tick. In that window, responseKeepAlive() has already removed socketErrorListener while handing the socket back to the agent's free pool, so the error lands with no listener and crashes the process. Skip the socket destroy when there is nothing left to cancel. This matches the existing behavior of keepAlive: false requests, which already drop any unread buffered response data in this situation. Fixes: https://github.com/nodejs/node/issues/65938 Signed-off-by: Barath PR-URL: https://github.com/nodejs/node/pull/65952 Reviewed-By: Matteo Collina Reviewed-By: James M Snell --- lib/_http_client.js | 8 +++ ...t-http-client-abort-completed-keepalive.js | 57 +++++++++++++++++++ 2 files changed, 65 insertions(+) create mode 100644 test/parallel/test-http-client-abort-completed-keepalive.js diff --git a/lib/_http_client.js b/lib/_http_client.js index a2326d8d7b0..6283559fcf3 100644 --- a/lib/_http_client.js +++ b/lib/_http_client.js @@ -691,6 +691,14 @@ ClientRequest.prototype.destroy = function destroy(err) { this.res._dump(); } + // Nothing left to cancel: the request was fully sent and the response was + // fully received. Destroying the socket here would emit an error on a + // socket that is already being released to the agent, at which point + // socketErrorListener has been removed and nothing would handle it. + if (this.writableFinished && this.res?.complete) { + return this; + } + this[kError] = err; this.socket?.destroy(err); diff --git a/test/parallel/test-http-client-abort-completed-keepalive.js b/test/parallel/test-http-client-abort-completed-keepalive.js new file mode 100644 index 00000000000..78d2f366bd7 --- /dev/null +++ b/test/parallel/test-http-client-abort-completed-keepalive.js @@ -0,0 +1,57 @@ +'use strict'; +const common = require('../common'); +const assert = require('assert'); +const http = require('http'); + +// Aborting a request whose exchange has already completed must not destroy the +// socket that is being released to the agent. socketErrorListener has been +// removed by responseKeepAlive() at that point, so the error would be emitted +// on a socket with no 'error' listener and crash the process. +// Refs: https://github.com/nodejs/node/issues/65938 + +const agent = new http.Agent({ keepAlive: true }); + +const server = http.createServer((req, res) => { + res.end('x'); +}); + +server.listen(0, '127.0.0.1', common.mustCall(() => { + const controller = new AbortController(); + + const req = http.get({ + port: server.address().port, + host: '127.0.0.1', + agent, + signal: controller.signal, + }, common.mustCall(async (res) => { + res.on('error', common.mustNotCall()); + + for await (const chunk of res) { + assert.strictEqual(chunk.length, 1); + assert.strictEqual(res.complete, true); + assert.strictEqual(req.writableFinished, true); + controller.abort(new Error('stop reading')); + break; + } + + // The socket must survive the abort and go back to the pool, and a + // subsequent request must be able to reuse it. + const res2 = await new Promise((resolve, reject) => { + const req2 = http.get({ + port: server.address().port, + host: '127.0.0.1', + agent, + }, resolve); + req2.on('error', reject); + }); + + let body = ''; + for await (const chunk of res2) body += chunk; + assert.strictEqual(body, 'x'); + + agent.destroy(); + server.close(); + })); + + req.on('error', common.mustNotCall()); +})); From 30d005ee1fcbb377e4a63322fa0b6fd0b722d42e Mon Sep 17 00:00:00 2001 From: James M Snell Date: Mon, 7 Sep 2026 04:42:09 -0700 Subject: [PATCH 058/119] src,lib: add util.markPromiseAsHandled Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65805 Reviewed-By: Matteo Collina Reviewed-By: Moshe Atlow Reviewed-By: LiviaMedeiros --- doc/api/util.md | 11 ++++++++++ lib/util.js | 8 +++++++ src/node_util.cc | 23 ++++++++++++++++++++ test/parallel/test-mark-promise-handled.js | 25 ++++++++++++++++++++++ 4 files changed, 67 insertions(+) create mode 100644 test/parallel/test-mark-promise-handled.js diff --git a/doc/api/util.md b/doc/api/util.md index d6719b88f4f..7893fa0e507 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -1665,6 +1665,17 @@ console.log(util.isDeepStrictEqual(foo, bar, true)); See [`assert.deepStrictEqual()`][] for more information about deep strict equality. +## `util.markPromiseAsHandled(promise)` + + + +* `promise` {Promise} The promise to mark as handled + +Marks a promise as handled so that unhandled rejections are ignored and are not +reported to the `'unhandledrejection'` event. + ## Class: `util.MIMEType` + +* `options` {Object} + * `bins` {number} The number of equal-probability density bins to return. + Must be between 1 and 1000. Cannot be used with `probabilities`. + **Default:** `100`. + * `probabilities` {number\[]} Custom probability boundaries. The array must + contain between 2 and 1001 strictly increasing values, start with `0`, and + end with `1`. Cannot be used with `bins`. + * `dequantize` {string} Controls whether repeated bucket values are spread + deterministically over their equivalent-value ranges. May be `'none'`, + `'hdr'`, or `'all'`. **Default:** `'hdr'`. + * `cache` {boolean} When `true`, retains the expanded histogram snapshot for + reuse by subsequent calls with `cache: true`. The snapshot is invalidated + when the histogram is modified. **Default:** `false`. +* Returns: {Promise} Fulfills with an {Object} containing: + * `probabilities` {Float64Array} The probability boundaries used by the + estimate. + * `quantiles` {Float64Array} The quantiles at the probability boundaries. + * `densities` {Float64Array} The density within each quantile interval. + * `count` {bigint} The number of values in the histogram snapshot. + * `bucketCount` {number} The number of occupied HDR buckets. + * `corrections` {number} The number of non-monotonic floating-point results + that were clamped to the preceding quantile. + * `dequantize` {string} The selected dequantization mode. + +Returns a quantile-respectful density estimate based on the Harrell-Davis +quantile estimator. By default, `bins` generates equal probability boundaries. +The `probabilities` option can instead focus the estimate on regions such as +p90, p99, p99.9, and p99.99. The density for interval `i` contains probability +mass `probabilities[i + 1] - probabilities[i]`. The histogram is snapshotted +when the method is called. Snapshot expansion and the estimate are calculated +in the libuv thread pool. Highly concentrated beta weights use a second-order +asymptotic approximation to avoid numerical convergence loss at large sample +counts. + +Setting `cache` to `true` avoids repeating snapshot capture and expansion when +several estimates are requested from an unchanged histogram. The retained +snapshot uses memory proportional to the number of occupied HDR buckets and is +released when the histogram is next modified. + +QRDE temporarily uses approximately one additional HDR count array plus 32 +bytes per occupied bucket. With `cache: true`, the expanded 32-byte-per-bucket +snapshot remains allocated. The following estimates use `lowest: 1` and +`highest: Number.MAX_SAFE_INTEGER` and exclude allocator and JavaScript object +overhead: + +| `figures` | Histogram | Maximum expanded snapshot | Peak cache-miss QRDE | +| --------- | --------: | ------------------------: | -------------------: | +| 1 | 6.3 KiB | 25 KiB | 31 KiB | +| 2 | 47 KiB | 188 KiB | 235 KiB | +| 3 | 352 KiB | 1.4 MiB | 1.7 MiB | +| 4 | 5.0 MiB | 20 MiB | 25 MiB | +| 5 | 37 MiB | 148 MiB | 185 MiB | + +The maximum snapshot column assumes every representable bucket is occupied. +Lower `highest` values reduce histogram and temporary copy sizes. Concurrent +calls that miss the cache each require their own temporary copy and expanded +snapshot. + +HDR histograms aggregate observations into equivalent-value buckets. The +`'hdr'` dequantization mode models repeated values in buckets wider than one +unit as a continuous uniform distribution over the bucket resolution. This +reduces density artifacts introduced by HDR quantization while preserving +repeated unit-resolution values as point masses. The `'all'` mode also +dequantizes repeated unit-resolution values. Use `'none'` to calculate the +grouped Harrell-Davis estimator using bucket midpoints directly. + +An empty histogram returns the requested `probabilities` but produces empty +`quantiles` and `densities` arrays. A non-dequantized interval whose quantile +boundaries are equal has an infinite density. + ### `histogram.reset()` + +* `options` {Object} + * `chunks` {number} The number of histogram chunks retained. Must be an + integer between `1` and `1024`. + * `chunkDuration` {number} The duration of each chunk in milliseconds. Must + be an integer between `1` and `18_446_744_073_709`. Exactly one of + `chunkDuration` and `recordsPerChunk` must be specified. + * `recordsPerChunk` {number} The number of calls to `record()` assigned to + each chunk. Must be an integer between `1` and `Number.MAX_SAFE_INTEGER`. + Exactly one of `chunkDuration` and `recordsPerChunk` must be specified. + * `lowest` {number|bigint} The lowest discernible value. Must be an integer + value greater than `0`. **Default:** `1`. + * `highest` {number|bigint} The highest recordable value. Must be an integer + value that is equal to or greater than two times `lowest`. + **Default:** `Number.MAX_SAFE_INTEGER`. + * `figures` {number} The number of accuracy digits. Must be an integer between + `1` and `5`. **Default:** `3`. +* Returns: {SlidingWindowHistogram} + +Creates a {SlidingWindowHistogram} that retains the latest `chunks` histogram +chunks. Rotation is lazy and does not create a timer. Time-based rotation is +evaluated when `record()` or `snapshot()` is called. Count-based rotation is +evaluated when `record()` is called. + +One histogram chunk is allocated during construction. Additional chunks are +allocated lazily. The maximum native memory used by the window scales with +`chunks` and with the `lowest`, `highest`, and `figures` histogram options. + +The window boundary has chunk-level precision. With `N` chunks of duration +`D`, a recorded value is retained for between `(N - 1) * D` and `N * D` +milliseconds. Once a count-based window is populated, it retains between +`(N - 1) * C + 1` and `N * C` recording attempts, where `C` is +`recordsPerChunk`. Recording attempts which exceed `highest` are included when +determining count-based rotation. + +```js +const { createSlidingWindowHistogram } = require('node:perf_hooks'); + +const window = createSlidingWindowHistogram({ + chunks: 6, + chunkDuration: 10_000, +}); + +window.record(20_000_000); + +// Materialize the current window as an independent Histogram. +const snapshot = window.snapshot(); +console.log(snapshot.percentile(99)); +``` + ## `perf_hooks.importHistogram(data)` + +Records values into a lazily rotated ring of histogram chunks. Instances are +created using [`perf_hooks.createSlidingWindowHistogram()`][] and cannot be +constructed directly. A `SlidingWindowHistogram` does not extend {Histogram}; +call `snapshot()` to materialize the current window as a {Histogram}. + +`SlidingWindowHistogram` instances cannot be cloned or transferred through a +{MessagePort}. + +### `slidingWindowHistogram.record(val)` + + + +* `val` {number|bigint} The amount to record. + +Records `val` in the current chunk. For a count-based window, every call that +reaches the native histogram counts toward rotation, including values which +exceed the configured `highest` value. + +### `slidingWindowHistogram.reset()` + + + +Invalidates all chunks in the current window. Allocated chunks are reset +lazily when reused. + +### `slidingWindowHistogram.snapshot()` + + + +* Returns: {Histogram} + +Materializes the current window as a new, independent {Histogram}. Values +recorded or expired after this method returns do not change the returned +histogram. Materialization allocates one histogram and merges every retained +chunk. + ## Histogram analysis examples The `Histogram` class provides statistical analysis methods useful for @@ -3107,6 +3210,7 @@ dns.promises.resolve('localhost'); [`'exit'`]: process.md#event-exit [`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options [`histogram.export()`]: #histogramexport +[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions [`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2 [`perf_hooks.importHistogram()`]: #perf_hooksimporthistogramdata [`perf_hooks.monitorEventLoopDelay()`]: #perf_hooksmonitoreventloopdelayoptions diff --git a/lib/internal/histogram.js b/lib/internal/histogram.js index f2e592d9f81..1cc787404fd 100644 --- a/lib/internal/histogram.js +++ b/lib/internal/histogram.js @@ -1,6 +1,7 @@ 'use strict'; const { + BigInt, Float64Array, Map, MapPrototypeEntries, @@ -12,6 +13,7 @@ const { const { Histogram: _Histogram, + SlidingWindowHistogram: _SlidingWindowHistogram, } = internalBinding('performance'); const { @@ -47,7 +49,11 @@ const { const kDestroy = Symbol('kDestroy'); const kHandle = Symbol('kHandle'); const kRecordable = Symbol('kRecordable'); +const kSlidingWindowHandle = Symbol('kSlidingWindowHandle'); const kQrdeDequantizationModes = ['none', 'hdr', 'all']; +const kMaxSlidingWindowHistogramChunks = 1024; +const kMaxChunkDuration = 18_446_744_073_709; +const kMaxInt64 = 9_223_372_036_854_775_807n; const { kClone, @@ -801,6 +807,48 @@ class RecordableHistogram extends Histogram { } } +class SlidingWindowHistogram { + constructor(skipThrowSymbol = undefined) { + if (skipThrowSymbol !== kSkipThrow) { + throw new ERR_ILLEGAL_CONSTRUCTOR(); + } + } + + /** + * @param {number|bigint} val + * @returns {void} + */ + record(val) { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + if (typeof val === 'bigint') { + this[kSlidingWindowHandle].record(val); + return; + } + + validateInteger(val, 'val', 1); + this[kSlidingWindowHandle].record(val); + } + + /** + * @returns {Histogram} + */ + snapshot() { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + return new ClonedHistogram(this[kSlidingWindowHandle].snapshot()); + } + + /** + * @returns {void} + */ + reset() { + if (this[kSlidingWindowHandle] === undefined) + throw new ERR_INVALID_THIS('SlidingWindowHistogram'); + this[kSlidingWindowHandle].reset(); + } +} + function ClonedHistogram(handle) { const histogram = new Histogram(kSkipThrow); markTransferMode(histogram, true, false); @@ -827,6 +875,32 @@ function createRecordableHistogram(handle) { return new ClonedRecordableHistogram(handle); } +function validateHistogramOptions(lowest, highest, figures) { + if (typeof lowest !== 'bigint') { + validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER); + } else if (lowest < 1n || lowest > kMaxInt64) { + throw new ERR_OUT_OF_RANGE( + 'options.lowest', `>= 1n && <= ${kMaxInt64}n`, lowest); + } + + if (typeof highest !== 'bigint') { + validateInteger(highest, 'options.highest', 1, NumberMAX_SAFE_INTEGER); + } else if (highest < 1n || highest > kMaxInt64) { + throw new ERR_OUT_OF_RANGE( + 'options.highest', `>= 1n && <= ${kMaxInt64}n`, highest); + } + + const minimumHighest = 2n * + (typeof lowest === 'bigint' ? lowest : BigInt(lowest)); + const highestBigInt = typeof highest === 'bigint' ? + highest : BigInt(highest); + if (highestBigInt < minimumHighest) { + throw new ERR_OUT_OF_RANGE( + 'options.highest', `>= 2 * options.lowest (${minimumHighest}n)`, highest); + } + validateInteger(figures, 'options.figures', 1, 5); +} + /** * @param {{ * lowest? : number, @@ -846,15 +920,7 @@ function createHistogram(options = kEmptyObject) { halfLife = 0, threshold = 0, } = options; - if (typeof lowest !== 'bigint') - validateInteger(lowest, 'options.lowest', 1, NumberMAX_SAFE_INTEGER); - if (typeof highest !== 'bigint') { - validateInteger(highest, 'options.highest', - 2 * lowest, NumberMAX_SAFE_INTEGER); - } else if (highest < 2n * lowest) { - throw new ERR_INVALID_ARG_VALUE.RangeError('options.highest', highest); - } - validateInteger(figures, 'options.figures', 1, 5); + validateHistogramOptions(lowest, highest, figures); validateNumber(halfLife, 'options.halfLife'); if (halfLife < 0) throw new ERR_OUT_OF_RANGE('options.halfLife', '>= 0', halfLife); @@ -865,6 +931,57 @@ function createHistogram(options = kEmptyObject) { new _Histogram(lowest, highest, figures, halfLife, threshold)); } +/** + * @param {{ + * chunks: number, + * chunkDuration? : number, + * recordsPerChunk? : number, + * lowest? : number|bigint, + * highest? : number|bigint, + * figures? : number, + * }} options + * @returns {SlidingWindowHistogram} + */ +function createSlidingWindowHistogram(options) { + validateObject(options, 'options'); + const { + chunks, + chunkDuration, + recordsPerChunk, + lowest = 1, + highest = NumberMAX_SAFE_INTEGER, + figures = 3, + } = options; + + validateInteger( + chunks, 'options.chunks', 1, kMaxSlidingWindowHistogramChunks); + validateHistogramOptions(lowest, highest, figures); + + const timeBased = chunkDuration !== undefined; + if (timeBased === (recordsPerChunk !== undefined)) { + throw new ERR_INVALID_ARG_VALUE( + 'options', options, + 'must specify exactly one of "chunkDuration" or "recordsPerChunk"'); + } + + let rotateAt; + if (timeBased) { + validateInteger( + chunkDuration, 'options.chunkDuration', 1, kMaxChunkDuration); + rotateAt = BigInt(chunkDuration) * 1_000_000n; + } else { + validateInteger( + recordsPerChunk, 'options.recordsPerChunk', 1, NumberMAX_SAFE_INTEGER); + rotateAt = BigInt(recordsPerChunk); + } + + const histogram = new SlidingWindowHistogram(kSkipThrow); + markTransferMode(histogram, false, false); + histogram[kSlidingWindowHandle] = new _SlidingWindowHistogram( + lowest, highest, figures, chunks, timeBased, rotateAt); + return histogram; +} + /** * Reconstructs a histogram from a CBOR-encoded Uint8Array previously * produced by `histogram.export()`. @@ -880,6 +997,7 @@ function importHistogram(data) { module.exports = { Histogram, RecordableHistogram, + SlidingWindowHistogram, ClonedHistogram, ClonedRecordableHistogram, isHistogram, @@ -887,5 +1005,6 @@ module.exports = { kHandle, kSkipThrow, createHistogram, + createSlidingWindowHistogram, importHistogram, }; diff --git a/lib/perf_hooks.js b/lib/perf_hooks.js index cc158e5c762..5de247442b5 100644 --- a/lib/perf_hooks.js +++ b/lib/perf_hooks.js @@ -25,6 +25,7 @@ const { const { createHistogram, + createSlidingWindowHistogram, importHistogram, } = require('internal/histogram'); @@ -44,6 +45,7 @@ module.exports = { eventLoopUtilization, timerify, createHistogram, + createSlidingWindowHistogram, importHistogram, performance, }; diff --git a/src/histogram.cc b/src/histogram.cc index 872c9f9b11d..30a095b3c43 100644 --- a/src/histogram.cc +++ b/src/histogram.cc @@ -23,6 +23,7 @@ using v8::BigInt; using v8::CFunction; using v8::Context; using v8::Exception; +using v8::FastApiCallbackOptions; using v8::Float64Array; using v8::FunctionCallbackInfo; using v8::FunctionTemplate; @@ -1741,6 +1742,8 @@ CFunction HistogramBase::fast_record_( CFunction::Make(&HistogramBase::FastRecord)); CFunction HistogramBase::fast_record_delta_( CFunction::Make(&HistogramBase::FastRecordDelta)); +CFunction SlidingWindowHistogram::fast_record_( + CFunction::Make(&SlidingWindowHistogram::FastRecord)); CFunction IntervalHistogram::fast_start_( CFunction::Make(&IntervalHistogram::FastStart)); CFunction IntervalHistogram::fast_stop_( @@ -2100,6 +2103,254 @@ void HistogramBase::HistogramTransferData::MemoryInfo( tracker->TrackField("histogram", histogram_); } +SlidingWindowHistogram::SlidingWindowHistogram( + Environment* env, + Local wrap, + const Histogram::Options& options, + size_t chunk_count, + bool time_based, + uint64_t rotate_at, + std::shared_ptr spare) + : BaseObject(env, wrap), + options_(options), + chunks_(chunk_count), + generations_(chunk_count, kNoGeneration), + spare_(std::move(spare)), + time_based_(time_based), + rotate_at_(rotate_at), + origin_(uv_hrtime()) { + MakeWeak(); + external_memory_ = spare_->GetMemorySize(); + env->external_memory_accounter()->Increase(env->isolate(), external_memory_); +} + +SlidingWindowHistogram::~SlidingWindowHistogram() { + env()->external_memory_accounter()->Decrease(env()->isolate(), + external_memory_); +} + +void SlidingWindowHistogram::MemoryInfo(MemoryTracker* tracker) const { + tracker->TrackField("chunks", chunks_); + tracker->TrackField("generations", generations_); + tracker->TrackField("spare", spare_); +} + +uint64_t SlidingWindowHistogram::CurrentTimeGeneration() const { + const uint64_t now = uv_hrtime(); + CHECK_GE(now, origin_); + return (now - origin_) / rotate_at_; +} + +Histogram* SlidingWindowHistogram::GetChunk(uint64_t generation) { + const size_t index = generation % chunks_.size(); + if (generations_[index] == generation) { + CHECK(chunks_[index]); + return chunks_[index].get(); + } + + if (chunks_[index]) { + chunks_[index]->Reset(); + } else if (spare_) { + chunks_[index] = std::move(spare_); + } else { + chunks_[index] = Histogram::Create(options_); + if (!chunks_[index]) return nullptr; + const size_t size = chunks_[index]->GetMemorySize(); + external_memory_ += size; + env()->external_memory_accounter()->Increase(env()->isolate(), size); + } + + generations_[index] = generation; + return chunks_[index].get(); +} + +bool SlidingWindowHistogram::RecordValue(int64_t value) { + uint64_t generation; + if (time_based_) { + generation = CurrentTimeGeneration(); + } else if (records_in_current_chunk_ == rotate_at_) { + CHECK_LT(current_generation_, kNoGeneration - 1); + generation = current_generation_ + 1; + } else { + generation = current_generation_; + } + + Histogram* chunk = GetChunk(generation); + if (chunk == nullptr) return false; + + chunk->Record(value); + if (!time_based_) { + if (generation != current_generation_) { + current_generation_ = generation; + records_in_current_chunk_ = 0; + } + records_in_current_chunk_++; + has_count_records_ = true; + } + return true; +} + +std::shared_ptr SlidingWindowHistogram::CreateSnapshot() const { + std::shared_ptr snapshot = Histogram::Create(options_); + if (!snapshot) return {}; + + uint64_t current_generation; + if (time_based_) { + current_generation = CurrentTimeGeneration(); + } else { + if (!has_count_records_) return snapshot; + current_generation = current_generation_; + } + + for (size_t i = 0; i < chunks_.size(); i++) { + const uint64_t generation = generations_[i]; + if (generation == kNoGeneration || generation > current_generation || + current_generation - generation >= chunks_.size()) { + continue; + } + CHECK(chunks_[i]); + CHECK_EQ(snapshot->Add(*chunks_[i]), 0); + } + return snapshot; +} + +void SlidingWindowHistogram::ResetWindow() { + std::fill(generations_.begin(), generations_.end(), kNoGeneration); + origin_ = uv_hrtime(); + current_generation_ = 0; + records_in_current_chunk_ = 0; + has_count_records_ = false; +} + +void SlidingWindowHistogram::New(const FunctionCallbackInfo& args) { + CHECK(args.IsConstructCall()); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + CHECK_IMPLIES(!args[1]->IsNumber(), args[1]->IsBigInt()); + CHECK(args[2]->IsUint32()); + CHECK(args[3]->IsUint32()); + CHECK(args[4]->IsBoolean()); + CHECK(args[5]->IsBigInt()); + + Environment* env = Environment::GetCurrent(args); + bool lossless = true; + int64_t lowest = 1; + int64_t highest = std::numeric_limits::max(); + + if (args[0]->IsNumber()) { + lowest = args[0].As()->Value(); + } else { + lowest = args[0].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.lowest is out of range"); + } + + if (args[1]->IsNumber()) { + highest = args[1].As()->Value(); + } else { + highest = args[1].As()->Int64Value(&lossless); + if (!lossless) + return THROW_ERR_OUT_OF_RANGE(env, "options.highest is out of range"); + } + + const int figures = args[2].As()->Value(); + const uint32_t chunk_count = args[3].As()->Value(); + if (chunk_count == 0) + return THROW_ERR_OUT_OF_RANGE(env, "options.chunks is out of range"); + + lossless = true; + const uint64_t rotate_at = args[5].As()->Uint64Value(&lossless); + if (!lossless || rotate_at == 0) { + return THROW_ERR_OUT_OF_RANGE(env, "rotation interval is out of range"); + } + + Histogram::Options options{lowest, highest, figures}; + std::shared_ptr spare = Histogram::Create(options); + if (!spare) + return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid histogram options"); + + new SlidingWindowHistogram(env, + args.This(), + options, + chunk_count, + args[4]->IsTrue(), + rotate_at, + std::move(spare)); +} + +void SlidingWindowHistogram::Record(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + CHECK_IMPLIES(!args[0]->IsNumber(), args[0]->IsBigInt()); + bool lossless = true; + const int64_t value = + args[0]->IsBigInt() ? args[0].As()->Int64Value(&lossless) + : static_cast(args[0].As()->Value()); + if (!lossless || value < 1) + return THROW_ERR_OUT_OF_RANGE(env, "value is out of range"); + + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + if (!histogram->RecordValue(value)) THROW_ERR_MEMORY_ALLOCATION_FAILED(env); +} + +void SlidingWindowHistogram::FastRecord(Local receiver, + int64_t value, + // NOLINTNEXTLINE(runtime/references) + FastApiCallbackOptions& options) { + CHECK_GE(value, 1); + TRACK_V8_FAST_API_CALL("histogram.slidingWindow.record"); + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, receiver); + if (!histogram->RecordValue(value)) { + HandleScope scope(options.isolate); + THROW_ERR_MEMORY_ALLOCATION_FAILED(histogram->env()); + } +} + +void SlidingWindowHistogram::Snapshot(const FunctionCallbackInfo& args) { + Environment* env = Environment::GetCurrent(args); + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + + std::shared_ptr snapshot = histogram->CreateSnapshot(); + if (!snapshot) return THROW_ERR_MEMORY_ALLOCATION_FAILED(env); + + BaseObjectPtr result = + HistogramBase::Create(env, std::move(snapshot)); + if (result) args.GetReturnValue().Set(result->object()); +} + +void SlidingWindowHistogram::Reset(const FunctionCallbackInfo& args) { + SlidingWindowHistogram* histogram; + ASSIGN_OR_RETURN_UNWRAP(&histogram, args.This()); + histogram->ResetWindow(); +} + +void SlidingWindowHistogram::Initialize(IsolateData* isolate_data, + Local target) { + Isolate* isolate = isolate_data->isolate(); + Local tmpl = NewFunctionTemplate(isolate, New); + tmpl->SetClassName(FIXED_ONE_BYTE_STRING(isolate, "SlidingWindowHistogram")); + auto instance = tmpl->InstanceTemplate(); + instance->SetInternalFieldCount(BaseObject::kInternalFieldCount); + SetFastMethod(isolate, instance, "record", Record, &fast_record_); + SetProtoMethod(isolate, tmpl, "snapshot", Snapshot); + SetProtoMethod(isolate, tmpl, "reset", Reset); + SetConstructorFunction(isolate, + target, + "SlidingWindowHistogram", + tmpl, + SetConstructorFunctionFlag::NONE); +} + +void SlidingWindowHistogram::RegisterExternalReferences( + ExternalReferenceRegistry* registry) { + registry->Register(New); + registry->Register(Record); + registry->Register(fast_record_); + registry->Register(Snapshot); + registry->Register(Reset); +} + Local IntervalHistogram::GetConstructorTemplate( Environment* env) { Local tmpl = env->intervalhistogram_constructor_template(); diff --git a/src/histogram.h b/src/histogram.h index 623915e47e4..bc72fa36e10 100644 --- a/src/histogram.h +++ b/src/histogram.h @@ -360,6 +360,60 @@ class HistogramBase final : public BaseObject, public HistogramImpl { static v8::CFunction fast_record_delta_; }; +// BaseObject disallows cloning and transfer, so ring state is confined to the +// owning Environment's thread. +class SlidingWindowHistogram final : public BaseObject { + public: + static void Initialize(IsolateData* isolate_data, + v8::Local target); + static void RegisterExternalReferences(ExternalReferenceRegistry* registry); + + void MemoryInfo(MemoryTracker* tracker) const override; + SET_MEMORY_INFO_NAME(SlidingWindowHistogram) + SET_SELF_SIZE(SlidingWindowHistogram) + + private: + static constexpr uint64_t kNoGeneration = + std::numeric_limits::max(); + + static void New(const v8::FunctionCallbackInfo& args); + static void Record(const v8::FunctionCallbackInfo& args); + static void FastRecord(v8::Local receiver, + int64_t value, + v8::FastApiCallbackOptions& options); + static void Snapshot(const v8::FunctionCallbackInfo& args); + static void Reset(const v8::FunctionCallbackInfo& args); + + SlidingWindowHistogram(Environment* env, + v8::Local wrap, + const Histogram::Options& options, + size_t chunk_count, + bool time_based, + uint64_t rotate_at, + std::shared_ptr spare); + ~SlidingWindowHistogram() override; + + Histogram* GetChunk(uint64_t generation); + bool RecordValue(int64_t value); + std::shared_ptr CreateSnapshot() const; + void ResetWindow(); + uint64_t CurrentTimeGeneration() const; + + Histogram::Options options_; + std::vector> chunks_; + std::vector generations_; + std::shared_ptr spare_; + bool time_based_; + uint64_t rotate_at_; + uint64_t origin_; + uint64_t current_generation_ = 0; + uint64_t records_in_current_chunk_ = 0; + size_t external_memory_ = 0; + bool has_count_records_ = false; + + static v8::CFunction fast_record_; +}; + // CRTP mixin for HandleWrap-based histograms with start/stop support. // Provides: StartFlags enum, Start/Stop slow-path handlers, enabled_ flag, // and InitTemplate (shared GetConstructorTemplate body). diff --git a/src/node_perf.cc b/src/node_perf.cc index 75a62b89a53..b4c74e9a09a 100644 --- a/src/node_perf.cc +++ b/src/node_perf.cc @@ -341,6 +341,7 @@ static void CreatePerIsolateProperties(IsolateData* isolate_data, Isolate* isolate = isolate_data->isolate(); HistogramBase::Initialize(isolate_data, target); + SlidingWindowHistogram::Initialize(isolate_data, target); SetMethod(isolate, target, "setupObservers", SetupPerformanceObservers); SetMethod(isolate, @@ -432,6 +433,7 @@ void RegisterExternalReferences(ExternalReferenceRegistry* registry) { registry->Register(SlowPerformanceNow); registry->Register(fast_performance_now); HistogramBase::RegisterExternalReferences(registry); + SlidingWindowHistogram::RegisterExternalReferences(registry); IntervalHistogram::RegisterExternalReferences(registry); IterationHistogram::RegisterExternalReferences(registry); } diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js b/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js new file mode 100644 index 00000000000..1097920f5f7 --- /dev/null +++ b/test/parallel/test-perf-hooks-sliding-window-histogram-fast-calls.js @@ -0,0 +1,31 @@ +// Flags: --expose-internals --no-warnings --allow-natives-syntax +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { internalBinding } = require('internal/test/binding'); +const { + createSlidingWindowHistogram, +} = require('perf_hooks'); + +const histogram = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, +}); + +function record() { + histogram.record(1); +} + +eval('%PrepareFunctionForOptimization(histogram.record)'); +record(); +eval('%OptimizeFunctionOnNextCall(histogram.record)'); +record(); + +assert.strictEqual(histogram.snapshot().count, 2); + +if (common.isDebug) { + const { getV8FastApiCallCount } = internalBinding('debug'); + assert.strictEqual( + getV8FastApiCallCount('histogram.slidingWindow.record'), 1); +} diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram.js b/test/parallel/test-perf-hooks-sliding-window-histogram.js new file mode 100644 index 00000000000..9e28677e5d6 --- /dev/null +++ b/test/parallel/test-perf-hooks-sliding-window-histogram.js @@ -0,0 +1,177 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { setTimeout: delay } = require('timers/promises'); +const { MessageChannel } = require('worker_threads'); +const { + createSlidingWindowHistogram, +} = require('perf_hooks'); + +{ + const histogram = createSlidingWindowHistogram({ + chunks: 3, + recordsPerChunk: 2, + highest: 100, + }); + + assert.strictEqual(histogram.constructor.name, 'SlidingWindowHistogram'); + assert.strictEqual(histogram.recordDelta, undefined); + assert.strictEqual(histogram.snapshot().count, 0); + + for (let value = 1; value <= 6; value++) histogram.record(value); + + const full = histogram.snapshot(); + assert.strictEqual(full.count, 6); + assert.strictEqual(full.min, 1); + assert.strictEqual(full.max, 6); + assert.strictEqual(full.record, undefined); + + histogram.record(7); + let current = histogram.snapshot(); + assert.strictEqual(current.count, 5); + assert.strictEqual(current.min, 3); + assert.strictEqual(current.max, 7); + + histogram.record(8); + histogram.record(9); + current = histogram.snapshot(); + assert.strictEqual(current.count, 5); + assert.strictEqual(current.min, 5); + assert.strictEqual(current.max, 9); + + // Materialized snapshots do not change with the sliding window. + assert.strictEqual(full.count, 6); + assert.strictEqual(full.min, 1); + assert.strictEqual(full.max, 6); + + histogram.reset(); + assert.strictEqual(histogram.snapshot().count, 0); + histogram.record(10n); + assert.strictEqual(histogram.snapshot().maxBigInt, 10n); + + assert.throws(() => new histogram.constructor(), { + code: 'ERR_ILLEGAL_CONSTRUCTOR', + }); + assert.throws(() => histogram.record.call({}, 1), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => histogram.snapshot.call({}), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => histogram.reset.call({}), { + code: 'ERR_INVALID_THIS', + }); + assert.throws(() => structuredClone(histogram), { + name: 'DataCloneError', + }); + + const { port1, port2 } = new MessageChannel(); + assert.throws(() => port1.postMessage(histogram), { + name: 'DataCloneError', + }); + assert.throws(() => port1.postMessage(histogram, [histogram]), { + name: 'DataCloneError', + }); + port1.close(); + port2.close(); +} + +{ + const histogram = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + highest: 10, + }); + + // Out-of-range recording attempts count toward count-based rotation. + histogram.record(11); + histogram.record(1); + let current = histogram.snapshot(); + assert.strictEqual(current.count, 1); + assert.strictEqual(current.exceeds, 1); + + histogram.record(2); + current = histogram.snapshot(); + assert.strictEqual(current.count, 2); + assert.strictEqual(current.exceeds, 0); +} + +{ + for (const options of [ + undefined, + null, + {}, + { chunks: 2 }, + { chunks: 2, chunkDuration: 1, recordsPerChunk: 1 }, + ]) { + assert.throws(() => createSlidingWindowHistogram(options), { + code: options?.chunks === undefined ? + 'ERR_INVALID_ARG_TYPE' : 'ERR_INVALID_ARG_VALUE', + }); + } + + for (const chunks of [0, 1025, 1.5, '2']) { + assert.throws(() => createSlidingWindowHistogram({ + chunks, + recordsPerChunk: 1, + }), { + code: typeof chunks === 'number' ? + 'ERR_OUT_OF_RANGE' : 'ERR_INVALID_ARG_TYPE', + }); + } + + for (const chunkDuration of [0, 1.5, 18_446_744_073_710]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + chunkDuration, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + + for (const recordsPerChunk of [0, 1.5, Number.MAX_SAFE_INTEGER + 1]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + lowest: 10, + highest: 10, + }), { code: 'ERR_OUT_OF_RANGE' }); + + for (const bounds of [ + { lowest: 1n }, + { lowest: 1n, highest: 100 }, + { lowest: 1, highest: 100n }, + ]) { + const histogram = createSlidingWindowHistogram({ + chunks: 1, + recordsPerChunk: 1, + ...bounds, + }); + histogram.record(1); + assert.strictEqual(histogram.snapshot().count, 1); + } +} + +(async () => { + const histogram = createSlidingWindowHistogram({ + chunks: 1, + chunkDuration: 100, + highest: 100, + }); + + histogram.record(1); + assert.strictEqual(histogram.snapshot().count, 1); + + await delay(common.platformTimeout(200)); + assert.strictEqual(histogram.snapshot().count, 0); + + histogram.record(2); + const current = histogram.snapshot(); + assert.strictEqual(current.count, 1); + assert.strictEqual(current.min, 2); +})().then(common.mustCall()); diff --git a/tools/doc/type-parser.mjs b/tools/doc/type-parser.mjs index 33607cbf2bd..b8c9df280b3 100644 --- a/tools/doc/type-parser.mjs +++ b/tools/doc/type-parser.mjs @@ -226,6 +226,8 @@ const customTypesMap = { 'perf_hooks.html#class-performanceobserver', 'PerformanceObserverEntryList': 'perf_hooks.html#class-performanceobserverentrylist', + 'SlidingWindowHistogram': + 'perf_hooks.html#class-slidingwindowhistogram', 'readline.Interface': 'readline.html#class-readlineinterface', diff --git a/typings/internalBinding/performance.d.ts b/typings/internalBinding/performance.d.ts index dc4d1e20c6b..cf3ef0a664f 100644 --- a/typings/internalBinding/performance.d.ts +++ b/typings/internalBinding/performance.d.ts @@ -76,6 +76,20 @@ declare namespace InternalPerformanceBinding { subtract(other: Histogram): number; } + class SlidingWindowHistogram { + constructor( + lowest: number | bigint, + highest: number | bigint, + figures: number, + chunks: number, + timeBased: boolean, + rotateAt: bigint, + ); + record(value: number | bigint): void; + snapshot(): Histogram; + reset(): void; + } + interface Constants { NODE_PERFORMANCE_GC_MAJOR: number; NODE_PERFORMANCE_GC_MINOR: number; @@ -116,6 +130,8 @@ type PerformanceObserverCallback = export interface PerformanceBinding { Histogram: typeof InternalPerformanceBinding.Histogram; + SlidingWindowHistogram: + typeof InternalPerformanceBinding.SlidingWindowHistogram; constants: InternalPerformanceBinding.Constants; observerCounts: Uint32Array; milestones: Float64Array; From a638bd8b3873a91d3882800a6086049a5b79cd29 Mon Sep 17 00:00:00 2001 From: James M Snell Date: Sat, 5 Sep 2026 20:27:04 +0000 Subject: [PATCH 062/119] test: expand histogram test coverage Signed-off-by: James M Snell Assisted-by: Opencode PR-URL: https://github.com/nodejs/node/pull/65825 Reviewed-By: Matteo Collina --- .../test-perf-hooks-histogram-qrde-worker.js | 21 ++++++++ .../test-perf-hooks-histogram-qrde.js | 27 ++++++++++ ...est-perf-hooks-sliding-window-histogram.js | 18 +++++++ .../test-perf-hooks-histogram-heapdump.js | 54 +++++++++++++++++++ 4 files changed, 120 insertions(+) create mode 100644 test/parallel/test-perf-hooks-histogram-qrde-worker.js create mode 100644 test/sequential/test-perf-hooks-histogram-heapdump.js diff --git a/test/parallel/test-perf-hooks-histogram-qrde-worker.js b/test/parallel/test-perf-hooks-histogram-qrde-worker.js new file mode 100644 index 00000000000..ba836651665 --- /dev/null +++ b/test/parallel/test-perf-hooks-histogram-qrde-worker.js @@ -0,0 +1,21 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { once } = require('events'); +const { Worker } = require('worker_threads'); + +const worker = new Worker(` + const { parentPort } = require('worker_threads'); + const { createHistogram } = require('perf_hooks'); + + const histogram = createHistogram({ highest: 200000, figures: 5 }); + for (let i = 1; i <= 100000; i++) histogram.record(i); + histogram.qrde({ bins: 1000, dequantize: 'all' }); + parentPort.postMessage('scheduled'); +`, { eval: true }); + +(async () => { + assert.deepStrictEqual(await once(worker, 'message'), ['scheduled']); + assert.strictEqual(await worker.terminate(), 1); +})().then(common.mustCall()); diff --git a/test/parallel/test-perf-hooks-histogram-qrde.js b/test/parallel/test-perf-hooks-histogram-qrde.js index a417942d916..40eb1e71f1e 100644 --- a/test/parallel/test-perf-hooks-histogram-qrde.js +++ b/test/parallel/test-perf-hooks-histogram-qrde.js @@ -9,6 +9,16 @@ function assertClose(actual, expected, tolerance = 1e-12) { `${actual} != ${expected}`); } +function recordRepeated(histogram, options, value, count) { + const block = createHistogram(options); + block.record(value); + while (count > 0) { + if (count % 2 === 1) histogram.add(block); + count = Math.floor(count / 2); + if (count > 0) block.add(block); + } +} + (async () => { const empty = createHistogram(); const emptyResult = await empty.qrde(); @@ -23,6 +33,9 @@ function assertClose(actual, expected, tolerance = 1e-12) { assert.strictEqual(emptyResult.corrections, 0); assert.strictEqual(emptyResult.dequantize, 'hdr'); + assert.throws(() => empty.qrde.call({}), { + code: 'ERR_INVALID_THIS', + }); assert.throws(() => empty.qrde(null), { code: 'ERR_INVALID_ARG_TYPE', }); @@ -219,4 +232,18 @@ function assertClose(actual, expected, tolerance = 1e-12) { await largeCount.qrde({ bins: 2, dequantize: 'none' }); assert.strictEqual(largeCountResult.count, (1n << 53n) + 1n); assertClose(largeCountResult.quantiles[1], 2); + + // Exercise correction across the exact-to-asymptotic beta CDF threshold. + const correctionOptions = { highest: 131071, figures: 5 }; + const correction = createHistogram(correctionOptions); + recordRepeated(correction, correctionOptions, 1, 26239); + recordRepeated(correction, correctionOptions, 131071, 973761); + const count = 1_000_000; + const threshold = (1 - Math.sqrt(1 - 100_000 / (count + 1))) / 2; + const corrected = await correction.qrde({ + probabilities: [0, threshold - 1e-10, threshold + 1e-10, 1], + dequantize: 'none', + }); + assert.strictEqual(corrected.corrections, 1); + assert.strictEqual(corrected.quantiles[1], corrected.quantiles[2]); })().then(common.mustCall()); diff --git a/test/parallel/test-perf-hooks-sliding-window-histogram.js b/test/parallel/test-perf-hooks-sliding-window-histogram.js index 9e28677e5d6..3ee1ca4ea43 100644 --- a/test/parallel/test-perf-hooks-sliding-window-histogram.js +++ b/test/parallel/test-perf-hooks-sliding-window-histogram.js @@ -49,6 +49,11 @@ const { assert.strictEqual(histogram.snapshot().count, 0); histogram.record(10n); assert.strictEqual(histogram.snapshot().maxBigInt, 10n); + for (const value of [0n, 2n ** 63n]) { + assert.throws(() => histogram.record(value), { + code: 'ERR_OUT_OF_RANGE', + }); + } assert.throws(() => new histogram.constructor(), { code: 'ERR_ILLEGAL_CONSTRUCTOR', @@ -142,6 +147,19 @@ const { highest: 10, }), { code: 'ERR_OUT_OF_RANGE' }); + for (const [name, value] of [ + ['lowest', 0n], + ['lowest', 2n ** 63n], + ['highest', 0n], + ['highest', 2n ** 63n], + ]) { + assert.throws(() => createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + [name]: value, + }), { code: 'ERR_OUT_OF_RANGE' }); + } + for (const bounds of [ { lowest: 1n }, { lowest: 1n, highest: 100 }, diff --git a/test/sequential/test-perf-hooks-histogram-heapdump.js b/test/sequential/test-perf-hooks-histogram-heapdump.js new file mode 100644 index 00000000000..cf310eb973b --- /dev/null +++ b/test/sequential/test-perf-hooks-histogram-heapdump.js @@ -0,0 +1,54 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const { + createJSHeapSnapshot, + validateByRetainingPathFromNodes, +} = require('../common/heap'); +const { + createHistogram, + createSlidingWindowHistogram, +} = require('perf_hooks'); + +(async () => { + const uncached = createHistogram(); + const cached = createHistogram(); + cached.record(1); + cached.record(1000); + await cached.qrde({ cache: true }); + + const sliding = createSlidingWindowHistogram({ + chunks: 2, + recordsPerChunk: 1, + }); + + const nodes = createJSHeapSnapshot(); + const snapshots = validateByRetainingPathFromNodes( + nodes, + 'Node / Histogram', + [{ node_name: 'Node / qrde_snapshot', edge_name: 'qrde_snapshot' }], + ); + assert.strictEqual(snapshots.length, 1); + assert.ok(snapshots[0].self_size > 0); + + const windows = validateByRetainingPathFromNodes( + nodes, + 'Node / SlidingWindowHistogram', + [], + ); + for (const [edgeName, nodeName] of [ + ['chunks', 'Node / chunks'], + ['generations', 'Node / generations'], + ['spare', 'Node / Histogram'], + ]) { + validateByRetainingPathFromNodes(windows, 'Node / SlidingWindowHistogram', [ + { node_name: nodeName, edge_name: edgeName }, + ]); + } + + // Keep all three wrappers live through snapshot generation. + assert.strictEqual(uncached.count, 0); + assert.strictEqual(cached.count, 2); + assert.strictEqual(sliding.snapshot().count, 0); +})().then(common.mustCall()); From 65dcf6575991e9bf534da7114c1edd09bc6ac0bb Mon Sep 17 00:00:00 2001 From: James M Snell Date: Tue, 8 Sep 2026 02:16:50 +0000 Subject: [PATCH 063/119] util: implement debounce I found myself using debounce quite a bit recently while testing some recent other additions (quic and dtls testing, perf_hooks improvements, etc). I was using an npm dependency right up until I realized just how generally useful it is to actually have it Just There. So, since it was a holiday and I just felt like it... util.debounce(...) Signed-off-by: James M Snell PR-URL: https://github.com/nodejs/node/pull/65899 Reviewed-By: Matteo Collina --- doc/api/util.md | 98 +++++++ lib/internal/util/debounce.js | 237 +++++++++++++++++ lib/util.js | 9 + test/parallel/test-util-debounce.js | 381 ++++++++++++++++++++++++++++ 4 files changed, 725 insertions(+) create mode 100644 lib/internal/util/debounce.js create mode 100644 test/parallel/test-util-debounce.js diff --git a/doc/api/util.md b/doc/api/util.md index 7893fa0e507..d9e4aa4c959 100644 --- a/doc/api/util.md +++ b/doc/api/util.md @@ -383,6 +383,104 @@ The `--throw-deprecation` command-line flag and `process.throwDeprecation` property take precedence over `--trace-deprecation` and `process.traceDeprecation`. +## `util.debounce(fn, wait[, options])` + + + +* `fn` {Function} The function to debounce. +* `wait` {integer} The number of milliseconds to delay `fn`. +* `options` {Object} + * `leading` {boolean} When `true`, invokes `fn` immediately when a new + debounce window begins. **Default:** `false`. + * `rejectOnCancel` {boolean} When `true`, a call superseded by a later call + rejects with an `AbortError`. **Default:** `false`. + * `signal` {AbortSignal} An `AbortSignal` that cancels pending calls and + prevents future calls when aborted. +* Returns: {Function} The debounced function. + +Creates a function that delays calling `fn` until `wait` milliseconds have +elapsed since the most recent invocation. The debounced function returns a +{Promise} for the value returned by `fn`. If `fn` throws or returns a rejected +promise, the returned promise is rejected with the same reason. + +When the debounced function is called more than once before the delay expires, +`fn` receives the arguments from the most recent call. By default, the promises +from all calls resolve or reject with the result of that invocation. If +`options.rejectOnCancel` is `true`, the promises from superseded calls reject +with an `AbortError` instead. + +When `options.leading` is `true`, the first call in a debounce window invokes +`fn` immediately. Calls made during that window are delayed until `wait` +milliseconds have elapsed since the most recent call. A trailing invocation +only occurs if the debounced function was called again during the window. +The window begins before `fn` is invoked, so recursive calls and calls made +while an asynchronous `fn` is pending are part of the same window if they occur +before the delay expires. This also applies to calls made after a synchronous +`fn` returns but before the delay expires. + +If `options.signal` is aborted, pending and future calls reject with an +`AbortError`, with the signal's reason set as the error's `cause`, and `fn` is +not invoked by those calls. If the signal is already aborted, `debounce()` +throws an `AbortError`. + +The returned function has the following properties: + +* `cancel([reason])` cancels the current debounce window. Its pending promises + reject with an `AbortError`. If provided, `reason` is set as the error's + `cause`. +* `flush()` cancels the delay and invokes `fn` immediately. It has no effect if + no invocation is pending. +* `pending` {Promise|null} is the promise returned by the most recent call in + the current debounce window, or `null` if no invocation is pending. +* `pendingCount` {integer} is the number of calls awaiting the invocation in + the current debounce window. +* `ref()` makes the pending and future timeout keep the Node.js event loop + active. Returns the debounced function. +* `unref()` allows the event loop to exit while a timeout is pending. This also + applies to future timeouts. Returns the debounced function. + +When invoked, `fn` has the debounced function as its `this` value. After a +trailing invocation, a new debounce window can begin even if a promise returned +by `fn` is still pending. The debounced function preserves the `name` and +`length` of `fn`. + +```mjs +import { setTimeout as wait } from 'node:timers/promises'; +import { debounce } from 'node:util'; + +const fn = debounce(async (value) => { + await wait(100); + return value; +}, 50); + +const first = fn(1); +const second = fn(2); + +console.log(await first); // 2 +console.log(await second); // 2 +``` + +A debounced function can be used to trigger an action after a period of +inactivity. Each call resets the timeout: + +```cjs +const { debounce } = require('node:util'); + +const onInactivity = debounce(() => { + console.log('No activity for 5 seconds'); +}, 5_000).unref(); + +process.stdin.on('data', (data) => { + console.log(`Received ${data.length} bytes`); + onInactivity(); +}); + +// Start the initial inactivity timeout. +onInactivity(); +``` + ## `util.diff(actual, expected)` + +* `fn` {Function} The function to throttle. +* `limit` {integer} The maximum number of times to invoke `fn` during an + interval. Must be greater than `0`. +* `interval` {integer} The length of each interval in milliseconds. +* `options` {Object} + * `concurrency` {number} The maximum number of invocations of `fn` whose + return values may be unsettled at once. Must be a positive integer or + `Infinity`. **Default:** `Infinity`. + * `maxPending` {number} The maximum number of calls that may be queued when + `overflow` is `'queue'`. Must be a non-negative integer or `Infinity`. + **Default:** `Infinity`. + * `overflow` {string} Determines how calls exceeding the limit are handled. + **Default:** `'queue'`. + * `'queue'`: Queue calls in the order received. + * `'drop'`: Reject calls immediately without queueing them. + * `signal` {AbortSignal} An `AbortSignal` that cancels pending calls and + prevents future calls when aborted. + * `strict` {boolean} When `true`, ensures that `limit` is not exceeded during + any rolling interval. **Default:** `false`. +* Returns: {Function} The throttled function. + +Creates a function that limits how often `fn` is invoked. By default, calls that +exceed the limit are queued in the order received rather than discarded. The +throttled function returns a {Promise} for the value returned by `fn`. If `fn` +throws or returns a rejected promise, the returned promise is rejected with the +same reason. + +An invocation starts only when both rate and concurrency capacity are +available. Rate capacity is consumed when `fn` starts, not when a call enters +the queue. Concurrency capacity is released when the value returned by `fn` +settles. Non-promise values settle during the next microtask. + +When `options.overflow` is `'drop'`, calls made without available rate or +concurrency capacity are rejected immediately. When `options.overflow` is +`'queue'` and `options.maxPending` calls are already queued, additional calls +are also rejected immediately. `maxPending` has no effect when `overflow` is +`'drop'`. + +In both cases, rejected calls return a promise rejected with an +`ERR_THROTTLED` error. The rejected promise is marked as handled, so ignoring it +does not emit an `'unhandledRejection'` event. Awaiting or explicitly handling +the promise still observes the rejection. Rejected calls do not consume rate +or concurrency capacity, enter the queue, or schedule a timeout. + +By default, the interval begins when the first call in a new window invokes +`fn`. Up to `limit` calls can invoke `fn` during that window. Queued calls are +processed in groups of up to `limit` as each subsequent window begins. This +windowed behavior can result in calls occurring close together at a window +boundary. + +When `options.strict` is `true`, invocation times are tracked individually. +This ensures that no more than `limit` calls begin during any rolling interval, +at the cost of additional bookkeeping. + +If `options.signal` is aborted, pending and future calls reject with an +`AbortError`, with the signal's reason set as the error's `cause`, and `fn` is +not invoked by those calls. If the signal is already aborted, `throttle()` +throws an `AbortError`. + +The returned function has the following properties: + +* `cancel([reason])` cancels all queued calls and resets the current throttle + window. The queued promises reject with an `AbortError`. If provided, + `reason` is set as the error's `cause`. Does not cancel invocations that have + already started. +* `hasImmediateCapacity()` returns `true` if a call made at that moment could + invoke `fn` without being queued or rejected. The check does not reserve + capacity, and the throttled function always checks again when called. It + returns `false` while calls are queued to preserve their order. Callers can + avoid creating a timeout by only calling the throttled function when this + method returns `true`. +* `pending` {Promise|null} is the promise returned by the most recently queued + call, or `null` if no invocation is queued. +* `pendingCount` {integer} is the number of calls awaiting invocation. +* `activeCount` {integer} is the number of invocations whose return values have + not settled. +* `ref()` makes the pending and future timeout keep the Node.js event loop + active. Returns the throttled function. +* `unref()` allows the event loop to exit while a timeout is pending. This also + applies to future timeouts. Returns the throttled function. + +Calls that have already invoked `fn` are not affected by `cancel()` or by an +aborted signal. When invoked, `fn` has the throttled function as its `this` +value. The throttled function preserves the `name` and `length` of `fn`. + +```mjs +import { throttle } from 'node:util'; + +const request = throttle(async (id) => { + const response = await fetch(`https://example.com/items/${id}`); + return response.json(); +}, 2, 1_000); + +// At most two requests begin during each one-second interval. All other calls +// remain queued and retain their original arguments. +const results = await Promise.all([ + request(1), + request(2), + request(3), + request(4), +]); +``` + ## `util.diff(actual, expected)` + +* `bundle` {ArrayBuffer|Buffer|TypedArray|DataView} A DER-encoded PKCS#12 + (`.p12` or `.pfx`) bundle. +* `options` {Object} + * `passphrase` {string|ArrayBuffer|Buffer|TypedArray|DataView} The passphrase + protecting the bundle. Omitting this option is equivalent to passing `''`. +* Returns: {Object} + * `privateKey` {KeyObject|null} The first private key in the bundle, or + `null` if none is present. + * `certificate` {X509Certificate|null} The certificate matching `privateKey`, + or `null` if no matching certificate is present. + * `additionalCertificates` {X509Certificate\[]} All other certificates in + the bundle. If there is no private key, this contains all certificates. + May be empty. + +Parses a PKCS#12 bundle, commonly stored with a `.p12` or `.pfx` extension, +and returns its private key and certificates. + +```mjs +import { parsePKCS12 } from 'node:crypto'; +import { readFileSync } from 'node:fs'; + +const { privateKey, certificate, additionalCertificates } = parsePKCS12( + readFileSync('bundle.p12'), + { passphrase: 'secret' }, +); +``` + ### `crypto.pbkdf2(password, salt, iterations, keylen, digest, callback)`