perf_hooks: fix truncation of monitorEventLoopDelay() resolution

`IntervalHistogram` stored the interval as `int32_t`, so a resolution
above 2^31 - 1 ms wrapped: `resolution: 2 ** 32 + 1` sampled every
millisecond.

Signed-off-by: James M Snell <jasnell@gmail.com>
Assisted-by: OpenCode
PR-URL: https://github.com/nodejs/node/pull/66115
Reviewed-By: Matteo Collina <matteo.collina@gmail.com>
Reviewed-By: Filip Skokan <panva.ip@gmail.com>
This commit is contained in:
James M Snell authored and GitHub committed 2026-09-27 08:10:15 +00:00
1 parent 0519f500b4
commit 2ce7a73fec
10 files changed
+353 -32

No files matched your search

+27 -4
View File
@@ -1926,6 +1926,9 @@ are not guaranteed to reflect any correct state of the event loop.
<!-- YAML
added: v11.10.0
changes:
- version: REPLACEME
pr-url: https://github.com/nodejs/node/pull/66115
description: Added the `lowest`, `highest`, and `figures` options.
- version:
- v26.5.0
- v24.19.0
@@ -1939,6 +1942,14 @@ changes:
* `resolution` {number} The sampling rate in milliseconds for interval-based
sampling. Must be greater than zero. This option is ignored when
`samplePerIteration` is `true`. **Default:** `10`.
* `lowest` {number|bigint} The lowest discernible delay, in nanoseconds. Must
be an integer value greater than `0`. **Default:** `1` when
`samplePerIteration` is `true`, otherwise `1000`.
* `highest` {number|bigint} The highest recordable delay, in nanoseconds.
Must be an integer value that is equal to or greater than two times
`lowest`. **Default:** `2n ** 63n - 1n`.
* `figures` {number} The number of accuracy digits. Must be an integer
between `1` and `5`. **Default:** `3`.
* Returns: {ELDHistogram}
_This property is an extension by Node.js. It is not available in Web browsers._
@@ -1954,6 +1965,16 @@ the application is idle.
The two sampling modes produce significantly different results and should not
be compared directly.
The `lowest`, `highest`, and `figures` options configure the histogram as they
do for [`perf_hooks.createHistogram()`][]. `lowest` must be greater than `0`
because an event loop delay of zero is not possible: the event loop has a
minimal overhead, and the measurement itself depends on the event loop turning.
Delays greater than `highest` are not recorded, and are counted by
[`histogram.exceeds`][] instead. With interval-based sampling, every sample
includes the `resolution`, so `highest` should be well above
`resolution * 1e6`. The histogram's memory use depends on these options, not
on the number of samples.
```mjs
import { monitorEventLoopDelay } from 'node:perf_hooks';
@@ -2253,8 +2274,8 @@ added: v11.10.0
* Type: {number}
The number of times the event loop delay exceeded the maximum 1 hour event
loop delay threshold.
The number of values that were not recorded because they exceeded the
histogram's highest recordable value.
### `histogram.exceedsBigInt`
@@ -2266,8 +2287,8 @@ added:
* Type: {bigint}
The number of times the event loop delay exceeded the maximum 1 hour event
loop delay threshold.
The number of values that were not recorded because they exceeded the
histogram's highest recordable value.
### `histogram.export()`
@@ -3432,7 +3453,9 @@ dns.promises.resolve('localhost');
[`'exit'`]: process.md#event-exit
[`child_process.spawnSync()`]: child_process.md#child_processspawnsynccommand-args-options
[`histogram.diff()`]: #histogramdiffother
[`histogram.exceeds`]: #histogramexceeds
[`histogram.export()`]: #histogramexport
[`perf_hooks.createHistogram()`]: #perf_hookscreatehistogramoptions
[`perf_hooks.createSlidingWindowHistogram()`]: #perf_hookscreateslidingwindowhistogramoptions
[`perf_hooks.eventLoopUtilization()`]: #perf_hookseventlooputilizationutilization1-utilization2
[`perf_hooks.importHistogram()`]: #perf_hooksimporthistogramdata
+2
View File
@@ -1041,8 +1041,10 @@ module.exports = {
isHistogram,
kDestroy,
kHandle,
kMaxInt64,
kSkipThrow,
createHistogram,
createSlidingWindowHistogram,
importHistogram,
validateHistogramOptions,
};
+27 -3
View File
@@ -1,5 +1,6 @@
'use strict';
const {
BigInt,
Symbol,
SymbolDispose,
} = primordials;
@@ -24,7 +25,9 @@ const {
const {
Histogram,
kHandle,
kMaxInt64,
kSkipThrow,
validateHistogramOptions,
} = require('internal/histogram');
const {
@@ -37,6 +40,12 @@ const {
const kEnabled = Symbol('kEnabled');
// Default histogram options. The lowest discernible delay is in nanoseconds,
// and its default depends on the sampling mode.
const kDefaultIntervalLowest = 1000;
const kDefaultIterationLowest = 1;
const kDefaultFigures = 3;
class ELDHistogram extends Histogram {
constructor(skipThrowSymbol = undefined) {
if (skipThrowSymbol !== kSkipThrow) {
@@ -76,8 +85,11 @@ class ELDHistogram extends Histogram {
/**
* @param {{
* samplePerIteration : boolean,
* resolution : number
* samplePerIteration? : boolean,
* resolution? : number,
* lowest? : number|bigint,
* highest? : number|bigint,
* figures? : number,
* }} [options]
* @returns {ELDHistogram}
*/
@@ -88,10 +100,22 @@ function monitorEventLoopDelay(options = kEmptyObject) {
validateBoolean(samplePerIteration, 'options.samplePerIteration');
validateInteger(resolution, 'options.resolution', 1);
const {
lowest = samplePerIteration ?
kDefaultIterationLowest : kDefaultIntervalLowest,
highest = kMaxInt64,
figures = kDefaultFigures,
} = options;
validateHistogramOptions(lowest, highest, figures);
// Throws if the native histogram cannot be created with these options.
const handle = createELDHistogram(
resolution, samplePerIteration, BigInt(lowest), BigInt(highest), figures);
const histogram = new ELDHistogram(kSkipThrow);
markTransferMode(histogram, true, false);
histogram[kEnabled] = false;
histogram[kHandle] = createELDHistogram(resolution, samplePerIteration);
histogram[kHandle] = handle;
return histogram;
}
+11 -10
View File
@@ -2616,11 +2616,11 @@ void IntervalHistogram::RegisterExternalReferences(
IntervalHistogram::IntervalHistogram(Environment* env,
Local<Object> wrap,
AsyncWrap::ProviderType type,
int32_t interval,
uint64_t interval,
OnInterval on_interval,
const Histogram::Options& options)
std::shared_ptr<Histogram> histogram)
: HandleWrap(env, wrap, reinterpret_cast<uv_handle_t*>(&timer_), type),
HistogramImpl(options),
HistogramImpl(std::move(histogram)),
interval_(interval),
on_interval_(on_interval) {
MakeWeak();
@@ -2633,9 +2633,9 @@ IntervalHistogram::IntervalHistogram(Environment* env,
BaseObjectPtr<IntervalHistogram> IntervalHistogram::Create(
Environment* env,
int32_t interval,
uint64_t interval,
OnInterval on_interval,
const Histogram::Options& options,
std::shared_ptr<Histogram> histogram,
AsyncWrap::ProviderType type) {
Local<Object> obj;
if (!GetConstructorTemplate(env)
@@ -2646,7 +2646,7 @@ BaseObjectPtr<IntervalHistogram> IntervalHistogram::Create(
}
return MakeBaseObject<IntervalHistogram>(
env, obj, type, interval, on_interval, options);
env, obj, type, interval, on_interval, std::move(histogram));
}
void IntervalHistogram::TimerCB(uv_timer_t* handle) {
@@ -2712,10 +2712,10 @@ void IterationHistogram::RegisterExternalReferences(
IterationHistogram::IterationHistogram(Environment* env,
Local<Object> wrap,
AsyncWrap::ProviderType type,
const Histogram::Options& options)
std::shared_ptr<Histogram> histogram)
: HandleWrap(
env, wrap, reinterpret_cast<uv_handle_t*>(&check_handle_), type),
HistogramImpl(options) {
HistogramImpl(std::move(histogram)) {
MakeWeak();
wrap->SetAlignedPointerInInternalField(
HistogramImpl::InternalFields::kImplField,
@@ -2729,7 +2729,7 @@ IterationHistogram::IterationHistogram(Environment* env,
BaseObjectPtr<IterationHistogram> IterationHistogram::Create(
Environment* env,
const Histogram::Options& options,
std::shared_ptr<Histogram> histogram,
AsyncWrap::ProviderType type) {
Local<Object> obj;
if (!GetConstructorTemplate(env)
@@ -2739,7 +2739,8 @@ BaseObjectPtr<IterationHistogram> IterationHistogram::Create(
return nullptr;
}
return MakeBaseObject<IterationHistogram>(env, obj, type, options);
return MakeBaseObject<IterationHistogram>(
env, obj, type, std::move(histogram));
}
void IterationHistogram::PrepareCB(uv_prepare_t* handle) {
+7 -7
View File
@@ -499,17 +499,17 @@ class IntervalHistogram final : public HandleWrap,
static BaseObjectPtr<IntervalHistogram> Create(
Environment* env,
int32_t interval,
uint64_t interval,
OnInterval on_interval,
const Histogram::Options& options,
std::shared_ptr<Histogram> histogram,
AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM);
IntervalHistogram(Environment* env,
v8::Local<v8::Object> wrap,
AsyncWrap::ProviderType type,
int32_t interval,
uint64_t interval,
OnInterval on_interval,
const Histogram::Options& options = Histogram::Options{});
std::shared_ptr<Histogram> histogram);
static void FastStart(v8::Local<v8::Value> receiver, bool reset);
static void FastStop(v8::Local<v8::Value> receiver);
@@ -534,7 +534,7 @@ class IntervalHistogram final : public HandleWrap,
template <typename T>
friend void StopHandleHistogram(v8::Local<v8::Value>);
int32_t interval_ = 0;
uint64_t interval_ = 0;
OnInterval on_interval_ = nullptr;
uv_timer_t timer_;
@@ -559,13 +559,13 @@ class IterationHistogram final
static BaseObjectPtr<IterationHistogram> Create(
Environment* env,
const Histogram::Options& options,
std::shared_ptr<Histogram> histogram,
AsyncWrap::ProviderType type = AsyncWrap::PROVIDER_ELDHISTOGRAM);
IterationHistogram(Environment* env,
v8::Local<v8::Object> wrap,
AsyncWrap::ProviderType type,
const Histogram::Options& options = Histogram::Options{});
std::shared_ptr<Histogram> histogram);
static void FastStart(v8::Local<v8::Value> receiver, bool reset);
static void FastStop(v8::Local<v8::Value> receiver);
+31 -7
View File
@@ -4,6 +4,7 @@
#include "histogram-inl.h"
#include "memory_tracker-inl.h"
#include "node_buffer.h"
#include "node_errors.h"
#include "node_external_reference.h"
#include "node_internals.h"
#include "node_process-inl.h"
@@ -14,6 +15,7 @@
namespace node {
namespace performance {
using v8::BigInt;
using v8::Context;
using v8::DontDelete;
using v8::Function;
@@ -28,6 +30,7 @@ using v8::Object;
using v8::ObjectTemplate;
using v8::PropertyAttribute;
using v8::ReadOnly;
using v8::Uint32;
using v8::Value;
// Microseconds in a millisecond, as a float.
@@ -318,14 +321,34 @@ void CreateELDHistogram(const FunctionCallbackInfo<Value>& args) {
Environment* env = Environment::GetCurrent(args);
int64_t interval = args[0].As<Integer>()->Value();
CHECK_GT(interval, 0);
CHECK(args[2]->IsBigInt());
CHECK(args[3]->IsBigInt());
CHECK(args[4]->IsUint32());
bool lossless = true;
const int64_t lowest = args[2].As<BigInt>()->Int64Value(&lossless);
CHECK(lossless);
const int64_t highest = args[3].As<BigInt>()->Int64Value(&lossless);
CHECK(lossless);
const int figures = static_cast<int>(args[4].As<Uint32>()->Value());
// The options are validated in JS, but hdr_init() still rejects some
// combinations, such as a very large lowest value.
std::shared_ptr<Histogram> histogram =
Histogram::Create(Histogram::Options{lowest, highest, figures});
if (!histogram) {
return THROW_ERR_INVALID_ARG_VALUE(env, "Invalid histogram options");
}
if (args[1]->IsTrue()) {
BaseObjectPtr<IterationHistogram> histogram =
IterationHistogram::Create(env, Histogram::Options{1});
args.GetReturnValue().Set(histogram->object());
BaseObjectPtr<IterationHistogram> eld =
IterationHistogram::Create(env, std::move(histogram));
if (eld) args.GetReturnValue().Set(eld->object());
return;
}
BaseObjectPtr<IntervalHistogram> histogram =
IntervalHistogram::Create(env, interval, [](Histogram& histogram) {
BaseObjectPtr<IntervalHistogram> eld = IntervalHistogram::Create(
env,
interval,
[](Histogram& histogram) {
uint64_t delta = histogram.RecordDelta();
TRACE_COUNTER1(TRACING_CATEGORY_NODE2(perf, event_loop),
"delay", delta);
@@ -337,8 +360,9 @@ void CreateELDHistogram(const FunctionCallbackInfo<Value>& args) {
"mean", histogram.Mean());
TRACE_COUNTER1(TRACING_CATEGORY_NODE2(perf, event_loop),
"stddev", histogram.Stddev());
}, Histogram::Options { 1000 });
args.GetReturnValue().Set(histogram->object());
},
std::move(histogram));
if (eld) args.GetReturnValue().Set(eld->object());
}
void MarkBootstrapComplete(const FunctionCallbackInfo<Value>& args) {
@@ -7,7 +7,7 @@ const assert = require('assert');
const { internalBinding } = require('internal/test/binding');
const { createELDHistogram } = internalBinding('performance');
const histogram = createELDHistogram(1, true);
const histogram = createELDHistogram(1, true, 1n, 2n ** 63n - 1n, 3);
function testFastMethods() {
histogram.start(true);
@@ -0,0 +1,213 @@
'use strict';
// Tests the lowest, highest, and figures options of monitorEventLoopDelay().
const common = require('../common');
const assert = require('assert');
const { monitorEventLoopDelay } = require('perf_hooks');
const kMaxInt64 = 2n ** 63n - 1n;
function readHead(data, offset) {
const view = new DataView(data.buffer, data.byteOffset, data.byteLength);
const info = data[offset] & 0x1f;
const major = data[offset] >> 5;
switch (info) {
case 24: return { major, argument: BigInt(view.getUint8(offset + 1)), next: offset + 2 };
case 25: return { major, argument: BigInt(view.getUint16(offset + 1)), next: offset + 3 };
case 26: return { major, argument: BigInt(view.getUint32(offset + 1)), next: offset + 5 };
case 27: return { major, argument: view.getBigUint64(offset + 1), next: offset + 9 };
default:
assert.ok(info < 24);
return { major, argument: BigInt(info), next: offset + 1 };
}
}
// Returns the histogram configuration from the documented export() format:
// a CBOR map in which key 1 is the lowest discernible value, key 2 the highest
// trackable value, key 3 the number of significant figures, and key 9 the
// length of the counts array.
function getLayout(histogram) {
const data = histogram.export();
const map = readHead(data, 0);
assert.strictEqual(map.major, 5);
const fields = new Map();
let offset = map.next;
for (let n = 0n; n < map.argument; n++) {
const key = readHead(data, offset);
const value = readHead(data, key.next);
offset = value.next;
if (value.major === 4) {
// The counts array contains unsigned integers.
for (let i = 0n; i < value.argument; i++)
offset = readHead(data, offset).next;
}
fields.set(key.argument, value.argument);
}
return {
lowest: fields.get(1n),
highest: fields.get(2n),
figures: fields.get(3n),
countsLength: fields.get(9n),
};
}
{
// The defaults are unchanged.
assert.deepStrictEqual(getLayout(monitorEventLoopDelay()), {
lowest: 1000n,
highest: kMaxInt64,
figures: 3n,
countsLength: 46080n,
});
assert.deepStrictEqual(
getLayout(monitorEventLoopDelay({ samplePerIteration: true })), {
lowest: 1n,
highest: kMaxInt64,
figures: 3n,
countsLength: 55296n,
});
}
for (const samplePerIteration of [false, true]) {
assert.deepStrictEqual(getLayout(monitorEventLoopDelay({
samplePerIteration,
lowest: 1000,
highest: 3_600_000_000_000,
figures: 2,
})), {
lowest: 1000n,
highest: 3_600_000_000_000n,
figures: 2n,
countsLength: 3456n,
});
assert.deepStrictEqual(getLayout(monitorEventLoopDelay({
samplePerIteration,
lowest: 1_000_000n,
highest: 60_000_000_000n,
figures: 1,
})), {
lowest: 1_000_000n,
highest: 60_000_000_000n,
figures: 1n,
countsLength: 224n,
});
assert.deepStrictEqual(getLayout(monitorEventLoopDelay({
samplePerIteration,
lowest: 1_000_000,
})), {
lowest: 1_000_000n,
highest: kMaxInt64,
figures: 3n,
countsLength: 35840n,
});
for (const name of ['lowest', 'highest', 'figures']) {
for (const value of ['a', null, false, {}, []]) {
assert.throws(() => monitorEventLoopDelay({
samplePerIteration,
[name]: value,
}), { code: 'ERR_INVALID_ARG_TYPE' });
}
}
assert.throws(() => monitorEventLoopDelay({ samplePerIteration, figures: 3n }),
{ code: 'ERR_INVALID_ARG_TYPE' });
for (const options of [
{ lowest: 0 },
{ lowest: 1.5 },
{ lowest: 2 ** 53 },
{ lowest: 0n },
{ lowest: 2n ** 63n },
{ highest: 0 },
{ highest: 1.5 },
{ highest: 2 ** 53 },
{ highest: 2n ** 63n },
{ lowest: 10, highest: 19 },
{ lowest: 10n, highest: 19n },
{ figures: 0 },
{ figures: 6 },
{ figures: 1.5 },
]) {
assert.throws(() => monitorEventLoopDelay({
samplePerIteration,
...options,
}), { code: 'ERR_OUT_OF_RANGE' });
}
// These options pass validation, but the histogram cannot be created.
assert.throws(() => monitorEventLoopDelay({
samplePerIteration,
lowest: 2 ** 45,
highest: 2 ** 46,
figures: 5,
}), { code: 'ERR_INVALID_ARG_VALUE' });
}
{
// The default lowest depends on the sampling mode, and highest is validated
// against it.
assert.throws(() => monitorEventLoopDelay({ highest: 1999 }),
{ code: 'ERR_OUT_OF_RANGE' });
monitorEventLoopDelay({ highest: 2000 });
monitorEventLoopDelay({ samplePerIteration: true, highest: 1999 });
assert.throws(() => monitorEventLoopDelay({
samplePerIteration: true,
highest: 1,
}), { code: 'ERR_OUT_OF_RANGE' });
}
{
// A right-sized histogram records one sample per event loop iteration.
const iterations = 10;
const histogram = monitorEventLoopDelay({
samplePerIteration: true,
lowest: 1000,
highest: 3_600_000_000_000,
figures: 2,
});
histogram.enable();
const done = common.mustCall(() => {
histogram.disable();
assert.ok(histogram.count >= iterations - 1,
`Expected at least ${iterations - 1} samples, got ${histogram.count}`);
assert.strictEqual(histogram.exceeds, 0);
});
let remaining = iterations;
function tick() {
if (--remaining > 0) {
setImmediate(tick);
} else {
done();
}
}
setImmediate(tick);
}
{
// Check that delays greater than highest are counted by exceeds.
// Delayed timer callbacks can run close together, so some samples may
// still be recorded despite resolution being much larger than highest.
const histogram = monitorEventLoopDelay({
resolution: 20,
highest: 2_000_000,
});
histogram.enable();
const done = common.mustCall(() => {
histogram.disable();
assert.ok(histogram.exceeds >= 2);
});
(function wait() {
if (histogram.exceeds >= 2) {
done();
} else {
setTimeout(wait, 5);
}
})();
}
@@ -0,0 +1,31 @@
'use strict';
// Tests that monitorEventLoopDelay() does not truncate a resolution greater
// than 2 ** 31 - 1 milliseconds to 32 bits.
const common = require('../common');
const assert = require('assert');
const { monitorEventLoopDelay } = require('perf_hooks');
// Truncated to 32 bits, this resolution would be 1 ms.
const histogram = monitorEventLoopDelay({ resolution: 2 ** 32 + 1 });
const control = monitorEventLoopDelay({ resolution: 1 });
histogram.enable();
control.enable();
const done = common.mustCall(() => {
histogram.disable();
control.disable();
// The first sample is recorded after two timer callbacks, which for this
// resolution is roughly 99 days after enable().
assert.strictEqual(histogram.count, 0);
assert.strictEqual(histogram.exceeds, 0);
});
(function wait() {
if (control.count >= 10) {
done();
} else {
setTimeout(wait, 2);
}
})();
+3
View File
@@ -145,6 +145,9 @@ export interface PerformanceBinding {
createELDHistogram(
interval: number,
samplePerIteration: boolean,
lowest: bigint,
highest: bigint,
figures: number,
): InternalPerformanceBinding.ELDHistogram;
markBootstrapComplete(): void;
uvMetricsInfo(): void;