Skip to content
143 changes: 84 additions & 59 deletions src/js/node/perf_hooks.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
// Hardcoded module "node:perf_hooks"
const {
throwNotImplemented,
kNodeEntryTypes,
NodeEntryObserver,
enqueueNodeEntry,
Expand Down Expand Up @@ -103,7 +102,10 @@ class PerformanceNodeTiming {
};
}
}
$toClass(PerformanceNodeTiming, "PerformanceNodeTiming", PerformanceEntry);
if (PerformanceEntry) {
Comment thread
robobun marked this conversation as resolved.
Object.setPrototypeOf(PerformanceNodeTiming.prototype, PerformanceEntry.prototype);
Object.setPrototypeOf(PerformanceNodeTiming, PerformanceEntry);
}

function createPerformanceNodeTiming() {
const object = Object.create(PerformanceNodeTiming.prototype);
Expand All @@ -122,13 +124,57 @@ function eventLoopUtilization(_utilization1, _utilization2) {
};
}

// PerformanceEntry is not a valid constructor, so we have to fake it.
class PerformanceResourceTiming {
constructor() {
throwNotImplemented("PerformanceResourceTiming");
const { PerformanceResourceTiming } = globalThis;

// Resolved on first inspection so requiring perf_hooks does not pull in the
// inspect module, and so repeated inspection does not re-resolve it.
var _lazyInspect;
function lazyInspect() {
return (_lazyInspect ??= require("internal/util/inspect").inspect);
}

// Node prints entries as `<ClassName> { ...toJSON() }`; WebCore's fields are
// prototype accessors so default inspection prints `{}`. Ported from node's
// lib/internal/perf/performance_entry.js.
if (PerformanceEntry) {
const kInspect = Symbol.for("nodejs.util.inspect.custom");
Object.defineProperty(PerformanceEntry.prototype, kInspect, {
__proto__: null,
configurable: true,
writable: true,
value: function inspect(depth, options) {
if (depth < 0) return this;
// A prototype object is not an entry, and toJSON below is brand-checked.
// Same circular check util.inspect uses to skip custom inspectors there.
if (Object.getOwnPropertyDescriptor(this, "constructor")?.value?.prototype === this) return this;
const opts = {
...options,
depth: options?.depth == null ? null : options.depth - 1,
};
return this.constructor.name + " " + lazyInspect()(this.toJSON(), opts);
},
});

// PerformanceEntry.prototype.toJSON has no notion of `detail`; node's mark
// and measure entries include it.
for (const Ctor of [PerformanceMark, PerformanceMeasure]) {
if (!Ctor) continue;
Object.defineProperty(Ctor.prototype, "toJSON", {
__proto__: null,
configurable: true,
writable: true,
value: function toJSON() {
return {
name: this.name,
entryType: this.entryType,
startTime: this.startTime,
duration: this.duration,
detail: this.detail,
};
},
});
}
}
$toClass(PerformanceResourceTiming, "PerformanceResourceTiming", PerformanceEntry);

const kNodeObserver = Symbol("kNodeObserver");
const kObserverCallback = Symbol("kObserverCallback");
Expand Down Expand Up @@ -210,8 +256,6 @@ class PerformanceObserverForNodeTypes extends NodePerformanceObserver {
return super.disconnect();
}
}
// Not $toClass: that resets the prototype object and would drop the
// observe/disconnect overrides above. Only the public name needs fixing.
Object.defineProperty(PerformanceObserverForNodeTypes, "name", {
value: "PerformanceObserver",
configurable: true,
Expand Down Expand Up @@ -276,59 +320,40 @@ function processTimerifyComplete(name, start, args, histogram) {
}
}

export default {
timerify,
performance: {
mark(_) {
return performance.mark(...arguments);
},
measure(_) {
return performance.measure(...arguments);
},
clearMarks(_) {
return performance.clearMarks(...arguments);
},
clearMeasures(_) {
return performance.clearMeasures(...arguments);
},
getEntries(_) {
return performance.getEntries(...arguments);
},
getEntriesByName(_) {
return performance.getEntriesByName(...arguments);
},
getEntriesByType(_) {
return performance.getEntriesByType(...arguments);
const nodeTiming = createPerformanceNodeTiming();

// Node augments the real `performance` object (not a forwarding shim), so
// timerify/eventLoopUtilization/nodeTiming go on Performance.prototype,
// non-enumerable, per lib/internal/perf/performance.js.
if (Performance) {
Object.defineProperties(Performance.prototype, {
nodeTiming: {
__proto__: null,
configurable: true,
enumerable: false,
writable: true,
value: nodeTiming,
},
setResourceTimingBufferSize(_) {
return performance.setResourceTimingBufferSize(...arguments);
timerify: {
__proto__: null,
configurable: true,
enumerable: false,
writable: true,
value: timerify,
},
timeOrigin: performance.timeOrigin,
toJSON(_) {
return performance.toJSON(...arguments);
eventLoopUtilization: {
__proto__: null,
configurable: true,
enumerable: false,
writable: true,
value: eventLoopUtilization,
},
onresourcetimingbufferfull: performance.onresourcetimingbufferfull,
nodeTiming: createPerformanceNodeTiming(),
now: () => performance.now(),
timerify,
eventLoopUtilization: eventLoopUtilization,
clearResourceTimings: function () {},
},
// performance: {
// clearMarks: [Function: clearMarks],
// clearMeasures: [Function: clearMeasures],
// clearResourceTimings: [Function: clearResourceTimings],
// getEntries: [Function: getEntries],
// getEntriesByName: [Function: getEntriesByName],
// getEntriesByType: [Function: getEntriesByType],
// mark: [Function: mark],
// measure: [Function: measure],
// now: performance.now,
// setResourceTimingBufferSize: [Function: setResourceTimingBufferSize],
// timeOrigin: performance.timeOrigin,
// toJSON: [Function: toJSON],
// onresourcetimingbufferfull: [Getter/Setter]
// },
});
}

export default {
timerify,
performance,
constants,
Performance,
PerformanceEntry,
Expand Down
12 changes: 10 additions & 2 deletions src/jsc/bindings/webcore/JSPerformance.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
#include "JSPerformance.h"

#include "ActiveDOMObject.h"
#include "ErrorCode.h"
#include "EventNames.h"
#include "ExtendedDOMClientIsoSubspaces.h"
#include "ExtendedDOMIsoSubspaces.h"
Expand Down Expand Up @@ -448,7 +449,7 @@ static inline JSC::EncodedJSValue jsPerformancePrototypeFunction_getEntriesByTyp
UNUSED_PARAM(callFrame);
auto& impl = castedThis->wrapped();
if (callFrame->argumentCount() < 1) [[unlikely]]
return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject));
return Bun::ERR::MISSING_ARGS(throwScope, lexicalGlobalObject, "The \"type\" argument must be specified"_s);
EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0);
auto type = convert<IDLDOMString>(*lexicalGlobalObject, argument0.value());
RETURN_IF_EXCEPTION(throwScope, {});
Expand All @@ -468,7 +469,7 @@ static inline JSC::EncodedJSValue jsPerformancePrototypeFunction_getEntriesByNam
UNUSED_PARAM(callFrame);
auto& impl = castedThis->wrapped();
if (callFrame->argumentCount() < 1) [[unlikely]]
return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject));
return Bun::ERR::MISSING_ARGS(throwScope, lexicalGlobalObject, "The \"name\" argument must be specified"_s);
EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0);
auto name = convert<IDLDOMString>(*lexicalGlobalObject, argument0.value());
RETURN_IF_EXCEPTION(throwScope, {});
Expand Down Expand Up @@ -528,6 +529,9 @@ static inline JSC::EncodedJSValue jsPerformancePrototypeFunction_markBody(JSC::J
if (callFrame->argumentCount() < 1) [[unlikely]]
return throwVMError(lexicalGlobalObject, throwScope, createNotEnoughArgumentsError(lexicalGlobalObject));
EnsureStillAliveScope argument0 = callFrame->uncheckedArgument(0);
// Node reports the V8 wording here; JSC's own message differs.
if (argument0.value().isSymbol()) [[unlikely]]
return throwVMTypeError(lexicalGlobalObject, throwScope, "Cannot convert a Symbol value to a string"_s);
auto markName = convert<IDLDOMString>(*lexicalGlobalObject, argument0.value());
RETURN_IF_EXCEPTION(throwScope, {});
EnsureStillAliveScope argument1 = callFrame->argument(1);
Expand All @@ -549,6 +553,8 @@ static inline JSC::EncodedJSValue jsPerformancePrototypeFunction_clearMarksBody(
UNUSED_PARAM(callFrame);
auto& impl = castedThis->wrapped();
EnsureStillAliveScope argument0 = callFrame->argument(0);
if (argument0.value().isSymbol()) [[unlikely]]
return throwVMTypeError(lexicalGlobalObject, throwScope, "Cannot convert a Symbol value to a string"_s);
Comment thread
robobun marked this conversation as resolved.
auto markName = argument0.value().isUndefined() ? String() : convert<IDLDOMString>(*lexicalGlobalObject, argument0.value());
RETURN_IF_EXCEPTION(throwScope, {});
RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.clearMarks(WTF::move(markName)); })));
Expand Down Expand Up @@ -593,6 +599,8 @@ static inline JSC::EncodedJSValue jsPerformancePrototypeFunction_clearMeasuresBo
UNUSED_PARAM(callFrame);
auto& impl = castedThis->wrapped();
EnsureStillAliveScope argument0 = callFrame->argument(0);
if (argument0.value().isSymbol()) [[unlikely]]
return throwVMTypeError(lexicalGlobalObject, throwScope, "Cannot convert a Symbol value to a string"_s);
auto measureName = argument0.value().isUndefined() ? String() : convert<IDLDOMString>(*lexicalGlobalObject, argument0.value());
RETURN_IF_EXCEPTION(throwScope, {});
RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS<IDLUndefined>(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.clearMeasures(WTF::move(measureName)); })));
Expand Down
21 changes: 16 additions & 5 deletions src/jsc/bindings/webcore/PerformanceUserTiming.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -275,9 +275,12 @@ ExceptionOr<Ref<PerformanceMeasure>> PerformanceUserTiming::measure(JSC::JSGloba
}
}

static bool isNonEmptyDictionary(const PerformanceMeasureOptions& measureOptions)
// Node (lib/internal/perf/usertiming.js calculateStartDuration) only treats
// start/end as supplying timing; User Timing L3 also counts detail/duration,
// but node-compat wins here.
static bool hasStartOrEnd(const PerformanceMeasureOptions& measureOptions)
{
return !measureOptions.detail.isUndefined() || measureOptions.start || measureOptions.duration || measureOptions.end;
return measureOptions.start || measureOptions.end;
}

ExceptionOr<Ref<PerformanceMeasure>> PerformanceUserTiming::measure(JSC::JSGlobalObject& globalObject, const String& measureName, std::optional<StartOrMeasureOptions>&& startOrMeasureOptions, const String& endMark)
Expand All @@ -286,13 +289,21 @@ ExceptionOr<Ref<PerformanceMeasure>> PerformanceUserTiming::measure(JSC::JSGloba
return std::visit(
WTF::makeVisitor(
[&](const PerformanceMeasureOptions& measureOptions) -> ExceptionOr<Ref<PerformanceMeasure>> {
if (isNonEmptyDictionary(measureOptions)) {
if (hasStartOrEnd(measureOptions)) {
if (!endMark.isNull())
return Exception { TypeError };
if (!measureOptions.start && !measureOptions.end)
return Exception { TypeError };
if (measureOptions.start && measureOptions.duration && measureOptions.end)
return Exception { TypeError };
return measure(globalObject, measureName, measureOptions);
}

// A dictionary without start/end does not supply timing, but node
// still measures to endMark while keeping detail.
if (!endMark.isNull()) {
PerformanceMeasureOptions optionsWithEndMark = measureOptions;
optionsWithEndMark.end = endMark;
optionsWithEndMark.duration = std::nullopt;
return measure(globalObject, measureName, optionsWithEndMark);
Comment thread
robobun marked this conversation as resolved.
}

return measure(globalObject, measureName, measureOptions);
Expand Down
30 changes: 30 additions & 0 deletions test/js/node/perf_hooks/perf_hooks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,36 @@ test("doesn't throw", () => {
expect(() => performance.markResourceTiming()).not.toThrow();
});

// Node coerces the name via `${name}` for mark/clearMarks/clearMeasures, so a
// Symbol hits V8's ToString message. Verified against Node v26.3.0.
test("Symbol name argument throws V8 wording", () => {
const msg = "Cannot convert a Symbol value to a string";
expect(() => performance.mark(Symbol())).toThrow(new TypeError(msg));
expect(() => performance.clearMarks(Symbol())).toThrow(new TypeError(msg));
expect(() => performance.clearMeasures(Symbol())).toThrow(new TypeError(msg));
});

// Node only looks at start/end to decide whether the options dict supplies
// timing; a {detail}/{duration}-only dict falls through and the trailing
// endMark is honoured. Verified against Node v26.3.0.
test("measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark", () => {
performance.mark("end100", { startTime: 100 });
const e = performance.measure("x", { detail: "d" }, "end100");
expect({ detail: e.detail, startTime: e.startTime, duration: e.duration }).toEqual({
detail: "d",
startTime: 0,
duration: 100,
});
// duration in the dict is discarded when endMark is supplied.
const e2 = performance.measure("x2", { duration: 999 }, "end100");
expect({ startTime: e2.startTime, duration: e2.duration }).toEqual({ startTime: 0, duration: 100 });
// An empty dict + endMark still measures to the mark, not to now().
const e3 = performance.measure("x3", {}, "end100");
expect({ startTime: e3.startTime, duration: e3.duration }).toEqual({ startTime: 0, duration: 100 });
performance.clearMarks("end100");
performance.clearMeasures();
});

test("timerify entry shape", async () => {
const { promise, resolve } = Promise.withResolvers();
const observer = new PerformanceObserver(list => resolve(list.getEntries()[0]));
Expand Down
7 changes: 7 additions & 0 deletions test/js/node/test/common/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -1112,6 +1112,12 @@ function expectRequiredModule(mod, expectation, checkESModule = true) {
assert.deepStrictEqual(clone, { ...expectation });
}

function sleepSync(ms) {
const sab = new SharedArrayBuffer(4);
const i32 = new Int32Array(sab);
Atomics.wait(i32, 0, 0, ms);
}

const common = {
allowGlobals,
buildType,
Expand Down Expand Up @@ -1170,6 +1176,7 @@ const common = {
skipIfInspectorDisabled,
skipIfSQLiteMissing,
skipIfWorker,
sleepSync,
spawnPromisified,

get enoughTestMem() {
Expand Down
2 changes: 2 additions & 0 deletions test/js/node/test/common/index.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ const {
skipIfEslintMissing,
skipIfInspectorDisabled,
skipIfSQLiteMissing,
sleepSync,
spawnPromisified,
} = common;

Expand Down Expand Up @@ -114,5 +115,6 @@ export {
skipIfEslintMissing,
skipIfInspectorDisabled,
skipIfSQLiteMissing,
sleepSync,
spawnPromisified,
};
25 changes: 25 additions & 0 deletions test/js/node/test/parallel/test-perf-hooks-timerify-basic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Test basic functionality of timerify and PerformanceObserver.
'use strict';

const common = require('../common');
const assert = require('assert');
const { timerify, PerformanceObserver } = require('perf_hooks');

// Verifies that `performance.timerify` is an alias of `perf_hooks.timerify`.
assert.strictEqual(performance.timerify, timerify);

// Intentional non-op. Do not wrap in common.mustCall();
const n = timerify(function noop() {});

const obs = new PerformanceObserver(common.mustCall((list) => {
const entries = list.getEntries();
const entry = entries[0];
assert(entry);
assert.strictEqual(entry.name, 'noop');
assert.strictEqual(entry.entryType, 'function');
assert.strictEqual(typeof entry.duration, 'number');
assert.strictEqual(typeof entry.startTime, 'number');
obs.disconnect();
}));
obs.observe({ entryTypes: ['function'] });
n();
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
// Test that timerify works with histogram option for synchronous functions.

import { sleepSync } from '../common/index.mjs';
import assert from 'assert';
import { createHistogram, timerify } from 'perf_hooks';

const histogram = createHistogram();

const m = () => {
// Deterministic blocking delay (~1 millisecond). The histogram operates on
// nanosecond precision, so this should be sufficient to prevent zero timings.
sleepSync(1);
};
const n = timerify(m, { histogram });
assert.strictEqual(histogram.max, 0);
for (let i = 0; i < 10; i++) {
n();
}
assert.notStrictEqual(histogram.max, 0);
Loading
Loading