From 656fa7fbb88c584d3d49faf83b8568b2dc49bdb0 Mon Sep 17 00:00:00 2001 From: Ciro Spaciari MacBook Date: Fri, 17 Jul 2026 16:46:49 -0700 Subject: [PATCH 1/5] perf_hooks: export the real performance object and fix entry prototypes perf_hooks.performance was a hand-written object literal that forwarded to the global instead of being it, so globalThis.performance !== require('perf_hooks') .performance and the Node-only surface (timerify, nodeTiming, eventLoopUtilization, the EventTarget methods) was missing or duplicated. Export the real global and install the Node-only members on Performance.prototype as non-enumerable properties, matching lib/internal/perf/performance.js. PerformanceNodeTiming used $toClass, which installs a brand new empty prototype and so dropped every accessor and method declared on the class; lookups then fell through to PerformanceEntry's brand-checking getters and threw. Link the prototype chain directly instead. PerformanceObserver's node-types subclass had the same problem and only needs its public name fixed. PerformanceResourceTiming was exported as a throwNotImplemented stub that shadowed the working native class. Entries now carry util.inspect.custom so they print as ` { ... }` rather than `{}`, and mark/measure include detail in toJSON. The inspector uses the same circular check util.inspect uses to skip custom inspectors on prototype objects, since the accessors it reads are brand-checked. measure(name, options, endMark) dropped endMark whenever the options dictionary carried no start or end, measuring to now() instead of to the mark. Node ignores such a dictionary for timing but still honours the trailing endMark and keeps detail, so route that case through the endMark while clearing duration. getEntriesByType/getEntriesByName now report Node's ERR_MISSING_ARGS wording, and mark/clearMarks match Node's message for a Symbol argument. Adds 5 upstream Node v26.3.0 tests, copied verbatim, plus common.sleepSync (also verbatim from upstream) which one of them imports. --- src/js/node/perf_hooks.ts | 146 +++++++++++------- src/jsc/bindings/webcore/JSPerformance.cpp | 10 +- .../webcore/PerformanceUserTiming.cpp | 19 ++- test/js/node/test/common/index.js | 7 + test/js/node/test/common/index.mjs | 2 + .../test-perf-hooks-timerify-basic.js | 25 +++ ...est-perf-hooks-timerify-histogram-sync.mjs | 19 +++ .../test/parallel/test-performance-global.js | 16 ++ .../test-performance-measure-detail.js | 20 +++ .../parallel/test-performance-timeline.mjs | 56 +++++++ 10 files changed, 256 insertions(+), 64 deletions(-) create mode 100644 test/js/node/test/parallel/test-perf-hooks-timerify-basic.js create mode 100644 test/js/node/test/parallel/test-perf-hooks-timerify-histogram-sync.mjs create mode 100644 test/js/node/test/parallel/test-performance-global.js create mode 100644 test/js/node/test/parallel/test-performance-measure-detail.js create mode 100644 test/js/node/test/parallel/test-performance-timeline.mjs diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index 16206fe4a3b2..e25c95506b84 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,58 @@ 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 performance entries as ` { ...toJSON() }`. WebCore +// exposes name/entryType/startTime/duration as prototype accessors, so the +// entries have no own properties and 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 +257,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 +321,42 @@ 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 rather than exporting a +// forwarding shim, so `globalThis.performance === require('perf_hooks').performance` +// and `performance.timerify` / `.eventLoopUtilization` / `.nodeTiming` exist. +// They go on Performance.prototype, non-enumerable, exactly as node does in +// lib/internal/perf/performance.js, so Object.keys(performance) is unchanged. +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 8f0fa04800e4..fd2d2e4c1e2d 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)); }))); diff --git a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp index 32e0ede03abc..aafcf1310b1b 100644 --- a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp +++ b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp @@ -275,9 +275,14 @@ ExceptionOr> PerformanceUserTiming::measure(JSC::JSGloba } } +// Node derives validity from start/end only (lib/internal/perf/usertiming.js +// calculateStartDuration), so `measure(name, { detail })` and +// `measure(name, { duration })` fall through to start = 0, end = now() instead +// of throwing. User Timing L3 counts `detail` toward a non-empty dictionary; +// node-compat wins here. static bool isNonEmptyDictionary(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) @@ -289,10 +294,18 @@ ExceptionOr> PerformanceUserTiming::measure(JSC::JSGloba if (isNonEmptyDictionary(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/test/common/index.js b/test/js/node/test/common/index.js index bd895f25b471..bc19ebf8b74d 100644 --- a/test/js/node/test/common/index.js +++ b/test/js/node/test/common/index.js @@ -1106,6 +1106,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, @@ -1164,6 +1170,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 a9eaa6749a6e..590a804c9615 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 000000000000..c33d83b1cf21 --- /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 000000000000..b26a40ee3cda --- /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 000000000000..82d339eb9489 --- /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 000000000000..1bfcda661f43 --- /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 000000000000..e3af49447c86 --- /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' +}); From 684dd812e046bc8dd0bdf3d71f2493ac07a63b8f Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 00:58:46 +0000 Subject: [PATCH 2/5] perf_hooks: match Node's Symbol error message in clearMeasures too clearMarks and clearMeasures are exact siblings in Node (both coerce the name via template literal), so clearMeasures(Symbol()) should throw the same V8 wording that clearMarks(Symbol()) does. The guard was added to mark/clearMarks but missed clearMeasures. measure() is deliberately excluded: Node validates its name arg via validateString and throws ERR_INVALID_ARG_TYPE instead. --- src/jsc/bindings/webcore/JSPerformance.cpp | 2 ++ test/js/node/perf_hooks/perf_hooks.test.ts | 9 +++++++++ 2 files changed, 11 insertions(+) diff --git a/src/jsc/bindings/webcore/JSPerformance.cpp b/src/jsc/bindings/webcore/JSPerformance.cpp index fd2d2e4c1e2d..e85dee315343 100644 --- a/src/jsc/bindings/webcore/JSPerformance.cpp +++ b/src/jsc/bindings/webcore/JSPerformance.cpp @@ -599,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/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index 3ad9379d4960..9ed7396c0a39 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -24,6 +24,15 @@ 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)); +}); + test("timerify entry shape", async () => { const { promise, resolve } = Promise.withResolvers(); const observer = new PerformanceObserver(list => resolve(list.getEntries()[0])); From ef0fd8ed22aa2de8a0d660c7669b91b13d1a356d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 01:44:26 +0000 Subject: [PATCH 3/5] test: cover measure(name, {detail|duration}, endMark) trailing-endMark path Pins the two load-bearing lines in PerformanceUserTiming::measure's options-dict visitor: the !endMark.isNull() block (e3 empty dict case) and the duration = std::nullopt clear (e2 {duration: 999} case). Verified byte-identical against Node v26.3.0. --- test/js/node/perf_hooks/perf_hooks.test.ts | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/test/js/node/perf_hooks/perf_hooks.test.ts b/test/js/node/perf_hooks/perf_hooks.test.ts index 9ed7396c0a39..bdf207de7fc5 100644 --- a/test/js/node/perf_hooks/perf_hooks.test.ts +++ b/test/js/node/perf_hooks/perf_hooks.test.ts @@ -33,6 +33,27 @@ test("Symbol name argument throws V8 wording", () => { 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])); From 8131d3cf276a41cd9d4882c317956b8364c2a4d7 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:28:08 +0000 Subject: [PATCH 4/5] perf_hooks: rename isNonEmptyDictionary -> hasStartOrEnd The body now checks only start||end, so the old name no longer described the invariant. File-local static with one caller. --- src/jsc/bindings/webcore/PerformanceUserTiming.cpp | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp index aafcf1310b1b..90939c5d24af 100644 --- a/src/jsc/bindings/webcore/PerformanceUserTiming.cpp +++ b/src/jsc/bindings/webcore/PerformanceUserTiming.cpp @@ -275,12 +275,10 @@ ExceptionOr> PerformanceUserTiming::measure(JSC::JSGloba } } -// Node derives validity from start/end only (lib/internal/perf/usertiming.js -// calculateStartDuration), so `measure(name, { detail })` and -// `measure(name, { duration })` fall through to start = 0, end = now() instead -// of throwing. User Timing L3 counts `detail` toward a non-empty dictionary; -// node-compat wins here. -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.start || measureOptions.end; } @@ -291,7 +289,7 @@ 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.duration && measureOptions.end) From 842adbaf5809114b64ac1c59bb109738f1e608cc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 18 Jul 2026 03:44:59 +0000 Subject: [PATCH 5/5] perf_hooks: trim two comment blocks to the 3-line cap --- src/js/node/perf_hooks.ts | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) diff --git a/src/js/node/perf_hooks.ts b/src/js/node/perf_hooks.ts index e25c95506b84..858b4df289f2 100644 --- a/src/js/node/perf_hooks.ts +++ b/src/js/node/perf_hooks.ts @@ -133,10 +133,9 @@ function lazyInspect() { return (_lazyInspect ??= require("internal/util/inspect").inspect); } -// Node prints performance entries as ` { ...toJSON() }`. WebCore -// exposes name/entryType/startTime/duration as prototype accessors, so the -// entries have no own properties and default inspection prints `{}`. -// Ported from node's lib/internal/perf/performance_entry.js. +// 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, { @@ -323,11 +322,9 @@ function processTimerifyComplete(name, start, args, histogram) { const nodeTiming = createPerformanceNodeTiming(); -// Node augments the real `performance` object rather than exporting a -// forwarding shim, so `globalThis.performance === require('perf_hooks').performance` -// and `performance.timerify` / `.eventLoopUtilization` / `.nodeTiming` exist. -// They go on Performance.prototype, non-enumerable, exactly as node does in -// lib/internal/perf/performance.js, so Object.keys(performance) is unchanged. +// 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: {