diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 16206fe4a3b..858b4df289f 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -1,6 +1,5 @@ // Hardcoded module "node:perf_hooks" const { - throwNotImplemented, kNodeEntryTypes, NodeEntryObserver, enqueueNodeEntry, @@ -103,7 +102,10 @@ class PerformanceNodeTiming { }; } } -$toClass(PerformanceNodeTiming, "PerformanceNodeTiming", PerformanceEntry); +if (PerformanceEntry) { + Object.setPrototypeOf(PerformanceNodeTiming.prototype, PerformanceEntry.prototype); + Object.setPrototypeOf(PerformanceNodeTiming, PerformanceEntry); +} function createPerformanceNodeTiming() { const object = Object.create(PerformanceNodeTiming.prototype); @@ -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 ` { ...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"); @@ -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, @@ -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, diff --git a/src/jsc/bindings/webcore/JSPerformance.cpp b/src/jsc/bindings/webcore/JSPerformance.cpp index 8f0fa04800e..e85dee31534 100644 --- a/src/jsc/bindings/webcore/JSPerformance.cpp +++ b/src/jsc/bindings/webcore/JSPerformance.cpp @@ -22,6 +22,7 @@ #include "JSPerformance.h" #include "ActiveDOMObject.h" +#include "ErrorCode.h" #include "EventNames.h" #include "ExtendedDOMClientIsoSubspaces.h" #include "ExtendedDOMIsoSubspaces.h" @@ -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(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); @@ -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(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); @@ -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(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); EnsureStillAliveScope argument1 = callFrame->argument(1); @@ -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); auto markName = argument0.value().isUndefined() ? String() : convert(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.clearMarks(WTF::move(markName)); }))); @@ -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(*lexicalGlobalObject, argument0.value()); RETURN_IF_EXCEPTION(throwScope, {}); RELEASE_AND_RETURN(throwScope, JSValue::encode(toJS(*lexicalGlobalObject, throwScope, [&]() -> decltype(auto) { return impl.clearMeasures(WTF::move(measureName)); }))); diff --git a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp index 32e0ede03ab..90939c5d24a 100644 --- a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp +++ b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp @@ -275,9 +275,12 @@ ExceptionOr> 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> PerformanceUserTiming::measure(JSC::JSGlobalObject& globalObject, const String& measureName, std::optional&& startOrMeasureOptions, const String& endMark) @@ -286,13 +289,21 @@ ExceptionOr> PerformanceUserTiming::measure(JSC::JSGloba return std::visit( WTF::makeVisitor( [&](const PerformanceMeasureOptions& measureOptions) -> ExceptionOr> { - 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); } return measure(globalObject, measureName, measureOptions); diff --git a/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index 3ad9379d496..bdf207de7fc 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -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])); diff --git a/test/js/node/test/common/index.js b/test/js/node/test/common/index.js index f5f9599004a..158d6eb7e46 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -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, @@ -1170,6 +1176,7 @@ const common = { skipIfInspectorDisabled, skipIfSQLiteMissing, skipIfWorker, + sleepSync, spawnPromisified, get enoughTestMem() { diff --git a/test/js/node/test/common/index.mjs b/test/js/node/test/common/index.mjs index a9eaa6749a6..590a804c961 100644 --- a/test/js/node/test/common/index.mjs +++ b/test/js/node/test/common/index.mjs @@ -55,6 +55,7 @@ const { skipIfEslintMissing, skipIfInspectorDisabled, skipIfSQLiteMissing, + sleepSync, spawnPromisified, } = common; @@ -114,5 +115,6 @@ export { skipIfEslintMissing, skipIfInspectorDisabled, skipIfSQLiteMissing, + sleepSync, spawnPromisified, }; diff --git a/test/js/node/test/parallel/test-perf-hooks-timerify-basic.js b/test/js/node/test/parallel/test-perf-hooks-timerify-basic.js new file mode 100644 index 00000000000..c33d83b1cf2 --- /dev/null +++ b/test/js/node/test/parallel/test-perf-hooks-timerify-basic.js @@ -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(); diff --git a/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-sync.mjs b/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-sync.mjs new file mode 100644 index 00000000000..b26a40ee3cd --- /dev/null +++ b/test/js/node/test/parallel/test-perf-hooks-timerify-histogram-sync.mjs @@ -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); diff --git a/test/js/node/test/parallel/test-performance-global.js b/test/js/node/test/parallel/test-performance-global.js new file mode 100644 index 00000000000..82d339eb948 --- /dev/null +++ b/test/js/node/test/parallel/test-performance-global.js @@ -0,0 +1,16 @@ +'use strict'; +/* eslint-disable no-global-assign */ + +require('../common'); + +const perf_hooks = require('perf_hooks'); +const assert = require('assert'); + +const perf = performance; +assert.strictEqual(globalThis.performance, perf_hooks.performance); +performance = undefined; +assert.strictEqual(globalThis.performance, undefined); +assert.strictEqual(typeof perf_hooks.performance.now, 'function'); + +// Restore the value of performance for the known globals check +performance = perf; diff --git a/test/js/node/test/parallel/test-performance-measure-detail.js b/test/js/node/test/parallel/test-performance-measure-detail.js new file mode 100644 index 00000000000..1bfcda661f4 --- /dev/null +++ b/test/js/node/test/parallel/test-performance-measure-detail.js @@ -0,0 +1,20 @@ +'use strict'; + +const common = require('../common'); +const assert = require('assert'); +const util = require('util'); +const { performance, PerformanceObserver } = require('perf_hooks'); + +const perfObserver = new PerformanceObserver(common.mustCall((items) => { + const entries = items.getEntries(); + assert.ok(entries.length === 1); + for (const entry of entries) { + assert.ok(util.inspect(entry).includes('this is detail')); + } +})); + +perfObserver.observe({ entryTypes: ['measure'] }); + +performance.measure('sample', { + detail: 'this is detail', +}); diff --git a/test/js/node/test/parallel/test-performance-timeline.mjs b/test/js/node/test/parallel/test-performance-timeline.mjs new file mode 100644 index 00000000000..e3af49447c8 --- /dev/null +++ b/test/js/node/test/parallel/test-performance-timeline.mjs @@ -0,0 +1,56 @@ +// This file may needs to be updated to wpt: +// https://github.com/web-platform-tests/wpt + +import '../common/index.mjs'; +import assert from 'assert'; + +import { performance } from 'perf_hooks'; +import { setTimeout } from 'timers/promises'; + +// Order by startTime +performance.mark('one'); +await setTimeout(50); +performance.mark('two'); +await setTimeout(50); +performance.mark('three'); +await setTimeout(50); +performance.measure('three', 'three'); +await setTimeout(50); +performance.measure('two', 'two'); +await setTimeout(50); +performance.measure('one', 'one'); +const entries = performance.getEntriesByType('measure'); +assert.deepStrictEqual(entries.map((x) => x.name), ['one', 'two', 'three']); +const allEntries = performance.getEntries(); +assert.deepStrictEqual(allEntries.map((x) => x.name), ['one', 'one', 'two', 'two', 'three', 'three']); + +performance.mark('a'); +await setTimeout(50); +performance.measure('a', 'a'); +await setTimeout(50); +performance.mark('a'); +await setTimeout(50); +performance.measure('a', 'one'); +const entriesByName = performance.getEntriesByName('a'); +assert.deepStrictEqual(entriesByName.map((x) => x.entryType), ['measure', 'mark', 'measure', 'mark']); +const marksByName = performance.getEntriesByName('a', 'mark'); +assert.deepStrictEqual(marksByName.map((x) => x.entryType), ['mark', 'mark']); +const measuresByName = performance.getEntriesByName('a', 'measure'); +assert.deepStrictEqual(measuresByName.map((x) => x.entryType), ['measure', 'measure']); +const invalidTypeEntriesByName = performance.getEntriesByName('a', null); +assert.strictEqual(invalidTypeEntriesByName.length, 0); + +// getEntriesBy[Name|Type](undefined) +performance.mark(undefined); +assert.strictEqual(performance.getEntriesByName(undefined).length, 1); +assert.strictEqual(performance.getEntriesByType(undefined).length, 0); +assert.throws(() => performance.getEntriesByName(), { + name: 'TypeError', + message: 'The "name" argument must be specified', + code: 'ERR_MISSING_ARGS' +}); +assert.throws(() => performance.getEntriesByType(), { + name: 'TypeError', + message: 'The "type" argument must be specified', + code: 'ERR_MISSING_ARGS' +});