Skip to content

perf_hooks: export the real performance object and fix entry prototypes (+5 tests) - #34518

Merged
dylan-conway merged 8 commits into
mainfrom
claude/node-v26-perf-hooks
Jul 21, 2026
Merged

perf_hooks: export the real performance object and fix entry prototypes (+5 tests)#34518
dylan-conway merged 8 commits into
mainfrom
claude/node-v26-perf-hooks

Conversation

@cirospaciari

@cirospaciari cirospaciari commented Jul 17, 2026

Copy link
Copy Markdown
Member

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. This exports the real global and fixes the entry prototypes around it.

Adds 5 upstream Node v26.3.0 tests, copied verbatim: test-perf-hooks-timerify-basic, test-performance-global, test-performance-measure-detail, test-performance-timeline, test-perf-hooks-timerify-histogram-sync.

What changed

  • Export the real performance. Node-only members move onto Performance.prototype as non-enumerable properties, matching lib/internal/perf/performance.js. Object.keys(performance).length goes 15 → 2 (Node is 0).
  • $toClass was silently emptying class prototypes. It installs a brand-new empty prototype, so PerformanceNodeTiming's get name(), get entryType() and toJSON() were dropped and lookups fell through to PerformanceEntry's brand-checking getters and threw. Now the prototype chain is linked directly. PerformanceObserver's node-types subclass had the same bug and only needed its public name fixed.
  • PerformanceResourceTiming was exported as a throwNotImplemented stub shadowing the working native class.
  • Entry inspection and toJSON. Entries print as PerformanceMark { … } instead of {}, and mark/measure include detail.
  • measure(name, options, endMark) dropped endMark whenever the options dictionary carried no start/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.
  • Error messages: getEntriesByType/getEntriesByName use Node's ERR_MISSING_ARGS wording; mark/clearMarks match Node's message for a Symbol argument.

Verification

Driven against the real Node v26.3.0 binary with a script exercising every touched surface; output is identical except for two pre-existing divergences this PR doesn't address (Object.keys(performance).length is 2 vs Node's 0, and a destructured performance.now() doesn't throw the brand-check TypeError Node throws).

  • the 5 new tests: 3/3 green each under the runner's exact invocation
  • no regressions: 33 pre-existing vendored test-dns-* / test-perf-hooks-* / test-performance-* tests pass, and test/js/node/perf_hooks + test/js/bun/perf_hooks + test/js/node/dns + test/js/web/timers is 174 pass / 0 fail

Two bugs found in review and fixed before submitting, both confirmed against Node first: console.log(PerformanceEntry.prototype) threw a TypeError because the new custom inspector ran on prototype objects (prototype inspection is now byte-identical with and without this patch), and the endMark bug above.

Known limitation

The prototype patches install when node:perf_hooks is first required, not at bootstrap, so util.inspect(entry) output and performance.timerify still depend on whether something in the process loaded perf_hooks. Node has them from the start. Installing at global bootstrap is the proper fix and is left as follow-up.

Deliberately not included

test-performance-gc needs a JSC::HeapObserver and a gc entry type that doesn't exist. test-performance-nodetiming and test-perf-hooks-usertiming need real startup milestones — Bun's are fabricated constants set to timeOrigin — so they can only be made to pass by faking the values.

Independent of #34517, which vendors 14 tests needing no source change.


[review] gate passed · iteration 2 · 11 files touched

fails on main (without fix)
ASAN without fix: 2 FAILED
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/perf_hooks/perf_hooks.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (842adbaf5)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [18.23ms]
(pass) doesn't throw [15.93ms]
26 | 
27 | // Node coerces the name via `${name}` for mark/clearMarks/clearMeasures, so a
28 | // Symbol hits V8's ToString message. Verified against Node v26.3.0.
29 | test("Symbol name argument throws V8 wording", () => {
30 |   const msg = "Cannot convert a Symbol value to a string";
31 |   expect(() => performance.mark(Symbol())).toThrow(new TypeError(msg));
                                                ^
error: expect(received).toThrow(expected)

Expected message: "Cannot convert a Symbol value to a string"
Received message: "Cannot convert a symbol to a string"

      at <anonymous> (/workspace/bun/test/js/n
... (truncated)

release without fix: all passed
bun test v1.4.0-canary.1 (8131d3cf2)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [1.30ms]
(pass) doesn't throw [0.22ms]
(pass) Symbol name argument throws V8 wording [0.07ms]
(pass) measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark [0.08ms]
(pass) timerify entry shape [1.26ms]
(pass) timerify is exposed on both performance and as a top-level export (Node v25.2+) [0.03ms]
(pass) export surface matches Node v26.3.0 [0.13ms]
(pass) timerify and createHistogram survive Object.prototype option pollution [14.37ms]
(pass) timerify and AsyncResource.bind survive Object.prototype.get pollution [15.20ms]
(pass) net entries are instanceof PerformanceEntry [8.62ms]

 10 pass
 0 fail
 56 expect() calls
Ran 10 tests across 1 file. [202.00ms]
__F:0:S:0
passes on PR (with fix)
ASAN with fix: all passed
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/perf_hooks/perf_hooks.test.ts
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
bun test v1.4.0 (842adbaf5)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [10.16ms]
(pass) doesn't throw [31.34ms]
(pass) Symbol name argument throws V8 wording [7.60ms]
(pass) measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark [7.04ms]
(pass) timerify entry shape [84.21ms]
(pass) timerify is exposed on both performance and as a top-level export (Node v25.2+) [2.79ms]
(pass) export surface matches Node v26.3.0 [27.17ms]
(pass) timerify and createHistogram survive Object.prototype option pollution [747.03ms]
(pass) timerify and AsyncResource.bind survive Object.prototype.get pollution [753.28ms]
(pass) net entries are instanceof PerformanceEntry [502.41ms]

 10 pass
 0 fail
 56 expect() calls
Ran 10 tes
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: checking for self-update (current version: 1.29.0)
[configured] bun-profile → bun (stripped) in 805ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/137] gen generated_host_exports.rs
generated_host_exports.rs: 91 exports (host=3, lazy=10, generic=78, rust=0); 244 extern-C blocks audited
[2/137] gen cpp.rs (cppbind)
[3/137] gen JS modules (bundle-modules)
Preprocess modules (10546ms)
Bundle modules (55ms)
Postprocesss modules (154ms)
Bundle Functions (975ms)
Generate Code (117ms)

[11.87s] Bundled "src/js" for production
  2037 kb
  165 internal modules
  13 native modules
  90 internal functions across 19 files
[3/137] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)
info: syncing channel updates for nightly-2026-05-06-x86_64-unknown-linux-gnu
info: latest update on 2026-05-06 for version 1.97.0-nightly (e95e73209 2026-05-05)
info: component rust-src is up to date
info: compon
... (truncated)
diff hotspot
src/js/node/perf_hooks.ts                          | 143 ++++++++++++---------
 src/jsc/bindings/webcore/JSPerformance.cpp         |  12 +-
 src/jsc/bindings/webcore/PerformanceUserTiming.cpp |  21 ++-
 test/js/node/perf_hooks/perf_hooks.test.ts         |  30 +++++
 test/js/node/test/common/index.js                  |   7 +
 test/js/node/test/common/index.mjs                 |   2 +
 .../parallel/test-perf-hooks-timerify-basic.js     |  25 ++++
 .../test-perf-hooks-timerify-histogram-sync.mjs    |  19 +++
 .../node/test/parallel/test-performance-global.js  |  16 +++
 .../parallel/test-performance-measure-detail.js    |  20 +++
 .../test/parallel/test-performance-timeline.mjs    |  56 ++++++++
 11 files changed, 285 insertions(+), 66 deletions(-)

gate history · 5 passed · 1 rejected · iteration 2

evidence per changed file
file                                                      reads  edits  tests
src/js/node/perf_hooks.ts                                     1      1      0
src/jsc/bindings/webcore/JSPerformance.cpp                    1      1      0
src/jsc/bindings/webcore/PerformanceUserTiming.cpp            2      2      0
test/js/node/perf_hooks/perf_hooks.test.ts                    2      2      0
test/js/node/test/common/index.js                             0      0      0
test/js/node/test/common/index.mjs                            0      0      0
…js/node/test/parallel/test-perf-hooks-timerify-basic.js      0      0      0
…st/parallel/test-perf-hooks-timerify-histogram-sync.mjs      0      0      0
test/js/node/test/parallel/test-performance-global.js         0      0      0
…s/node/test/parallel/test-performance-measure-detail.js      0      0      0
test/js/node/test/parallel/test-performance-timeline.mjs      0      0      0

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 `<ClassName> { ... }`
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.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator
Updated 1:08 AM PT - Jul 19th, 2026

@cirospaciari, your commit 22b294c has 1 failures in Build #75685 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 34518

That installs a local version of the PR into your bun-34518 executable, so you can run:

bun-34518 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. perf_hooks: PerformanceNodeTiming startTime/duration throw; nodeTiming shape differs from Node #23041 - Fixes PerformanceNodeTiming prototype chain so nodeTiming.startTime/duration no longer throw TypeError
  2. performance.timerify is not a function #9271 - Adds timerify as a property on Performance.prototype, fixing performance.timerify is not a function

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #23041
Fixes #9271

🤖 Generated with Claude Code

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. fix: add performance.timerify method to perf_hooks #27887 - Also adds performance.timerify to perf_hooks.ts
  2. feat(perf_hooks): add performance.timerify() implementation #27921 - Also implements performance.timerify() in node:perf_hooks
  3. node: fix prototype property descriptor on builtin constructors #32163 - Fixes the same $toClass bug that silently empties class prototypes
  4. fix(node:perf_hooks): report nodeTiming milestones as offsets from timeOrigin #32481 - Fixes PerformanceNodeTiming getters broken by the same $toClass root cause
  5. perf_hooks: implement performance.eventLoopUtilization() #32618 - Also implements performance.eventLoopUtilization() in perf_hooks.ts
  6. node:perf_hooks: make performance the global object, like node #33475 - Makes perf_hooks.performance export the real globalThis.performance and moves node-only members onto Performance.prototype — the central purpose of this PR

🤖 Generated with Claude Code

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt

@coderabbitai

coderabbitai Bot commented Jul 18, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 58 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8532dad9-4569-45a6-84ab-85bd7d8090ea

📥 Commits

Reviewing files that changed from the base of the PR and between 98f6649 and 22b294c.

📒 Files selected for processing (11)
  • src/js/node/perf_hooks.ts
  • src/jsc/bindings/webcore/JSPerformance.cpp
  • src/jsc/bindings/webcore/PerformanceUserTiming.cpp
  • test/js/node/perf_hooks/perf_hooks.test.ts
  • test/js/node/test/common/index.js
  • test/js/node/test/common/index.mjs
  • test/js/node/test/parallel/test-perf-hooks-timerify-basic.js
  • test/js/node/test/parallel/test-perf-hooks-timerify-histogram-sync.mjs
  • test/js/node/test/parallel/test-performance-global.js
  • test/js/node/test/parallel/test-performance-measure-detail.js
  • test/js/node/test/parallel/test-performance-timeline.mjs

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Merged in 97935c2.

Comment thread src/jsc/bindings/webcore/JSPerformance.cpp
robobun and others added 2 commits July 18, 2026 00:58
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.
Comment thread src/jsc/bindings/webcore/PerformanceUserTiming.cpp
…k 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.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found this pass; both earlier nits (clearMeasures Symbol guard, trailing-endMark test coverage) are addressed. Deferring to a human because this makes design choices worth a maintainer sign-off: mutating Performance.prototype / PerformanceEntry.prototype at first require('perf_hooks') (with the acknowledged bootstrap-timing limitation), and diverging isNonEmptyDictionary from User Timing L3 in favour of Node semantics.

What was reviewed:

  • perf_hooks.ts: real performance export, prototype-chain wiring replacing $toClass, custom inspect / toJSON for entries — checked the prototype-object guard in the inspector and that throwNotImplemented has no remaining callers.
  • PerformanceUserTiming.cpp: verified the reshaped visitor still rejects {start,duration,end} and {start|end}+endMark, and that duration is cleared before recursing.
  • JSPerformance.cpp: Symbol guards now cover mark/clearMarks/clearMeasures; measure correctly excluded.
Extended reasoning...

Overview

Rewrites node:perf_hooks to export the real global performance object instead of a forwarding literal, moving timerify/nodeTiming/eventLoopUtilization onto Performance.prototype as non-enumerable properties. Replaces $toClass(PerformanceNodeTiming, …) (which was dropping the class's own prototype methods) with direct setPrototypeOf wiring. Adds a nodejs.util.inspect.custom on PerformanceEntry.prototype and toJSON overrides on PerformanceMark/PerformanceMeasure so entries print with detail. In C++: switches getEntriesByType/getEntriesByName no-arg errors to ERR_MISSING_ARGS, adds V8-worded Symbol guards to mark/clearMarks/clearMeasures, and changes PerformanceUserTiming::measure so an options dict without start/end falls through (honouring a trailing endMark and keeping detail) instead of throwing per User Timing L3. Adds 5 upstream Node v26.3.0 tests plus local coverage.

Security risks

None identified. No auth/crypto/permissions/untrusted-input parsing. The prototype mutations use __proto__: null descriptors and are on WebCore-owned prototypes; the custom inspector guards against running on prototype objects.

Level of scrutiny

Medium-high. This is Node-compat surface, not a hot path, but it (a) mutates global prototypes process-wide when the module is first required — with an acknowledged limitation that behaviour depends on load order until a follow-up moves it to bootstrap; (b) deliberately diverges the WebCore measure() argument validation from the W3C spec toward Node's semantics; and (c) overlaps with six flagged open PRs (#27887, #27921, #32163, #32481, #32618, #33475). Those are architectural / roadmap calls a maintainer should own.

Other factors

Both prior review findings were addressed with tests. Test coverage is solid: the new perf_hooks.test.ts cases pin the Symbol wording across all three siblings and the three {detail}/{duration}/{} + endMark shapes, and five vendored Node tests exercise the exported-object identity, timeline ordering, ERR_MISSING_ARGS codes, and detail inspection. The common/index.js sleepSync addition mirrors upstream Node's helper. cirospaciari has already adopted the PR, so a maintainer is engaged; given the design surface here I'd rather they approve than auto-approve.

Comment thread src/jsc/bindings/webcore/PerformanceUserTiming.cpp Outdated
The body now checks only start||end, so the old name no longer
described the invariant. File-local static with one caller.
Comment thread src/js/node/perf_hooks.ts Outdated
@robobun
robobun force-pushed the claude/node-v26-perf-hooks branch from a0666a1 to 842adba Compare July 18, 2026 03:45

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No bugs found after four review rounds (all nits addressed). Deferring to a human because this patches global prototypes (Performance.prototype, PerformanceEntry.prototype, PerformanceMark/Measure.prototype.toJSON) at module-load time and shifts PerformanceUserTiming::measure away from the User Timing L3 spec toward Node semantics — worth a maintainer's eyes on the design tradeoff, especially the acknowledged "installs on first require, not bootstrap" limitation.

What was reviewed:

  • Symbol-argument guards on mark/clearMarks/clearMeasures (sibling coverage now complete; measure() correctly excluded).
  • measure(name, {detail|duration}, endMark) path — trailing endMark now honoured, duration cleared; test pins both clauses.
  • Checked that measure(name, {duration: N}) (2-arg) still works — falls through to the options-only overload where duration alone is ignored (start=0, end=now()), matching Node; not a regression.
  • hasStartOrEnd rename and comment-length trims verified in place.
Extended reasoning...

Overview

Restructures node:perf_hooks to export the real global performance object instead of a forwarding shim, moving Node-only members (timerify, nodeTiming, eventLoopUtilization) onto Performance.prototype as non-enumerable properties. Also installs a custom-inspect symbol on PerformanceEntry.prototype and overrides toJSON on PerformanceMark/PerformanceMeasure prototypes so entries print like Node's. Replaces the $toClass call on PerformanceNodeTiming (which was emptying the prototype) with direct setPrototypeOf. On the C++ side: swaps two createNotEnoughArgumentsError sites for ERR_MISSING_ARGS, adds V8-worded Symbol guards to mark/clearMarks/clearMeasures, and reworks the measure(name, options, endMark) visitor so a {detail}/{duration}-only dict falls through and honours the trailing endMark (Node's behaviour, diverging from User Timing L3). Adds 5 vendored Node v26.3.0 tests plus local coverage in perf_hooks.test.ts, and adds sleepSync to the vendored test-common harness.

Security risks

None identified. No untrusted-input parsing beyond existing IDL conversion; the new prototype patches use __proto__: null descriptors; the custom inspector guards against being invoked on prototype objects. No auth/crypto/permissions surface.

Level of scrutiny

Medium-high. The runtime change is Node-compat surface, not a hot path or memory-safety-sensitive code, but it mutates global prototypes at first-require time — anything that touched PerformanceEntry.prototype[Symbol.for('nodejs.util.inspect.custom')] or PerformanceMark.prototype.toJSON before/after this module loads now sees different behaviour. The C++ change deliberately diverges from the WebCore/spec path for Node compat, which is an architecture-layer decision ("never change a Bun-native default to fix Node compatibility — that belongs in the node: compat layer"; here it's done in the shared WebCore layer, though performance is only ever the one global so the distinction is thin). The PR description explicitly flags a known limitation (patches install on first require, not bootstrap) as follow-up. These are design calls a maintainer should sign off on rather than a bot.

Other factors

I left four rounds of inline nits on earlier revisions (missing clearMeasures sibling guard, unexercised !endMark.isNull() branch, misleading isNonEmptyDictionary name, >3-line comments); all four were addressed with follow-up commits and are verified present in the current diff. The bug-hunting system found no issues this run; one candidate — "measure(name, {duration: N}) no longer throws TypeError, duration silently ignored" — was examined and ruled out: with no start/end and no third arg, the fall-through measure(globalObject, measureName, measureOptions) computes start=0, end=now() and never reads duration, which matches Node v26.3.0 (Node's calculateStartDuration also ignores a lone duration). A maintainer has already adopted the PR, and CI build #75110 is running.

@cirospaciari

Copy link
Copy Markdown
Member Author

@robobun adopt it

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings; all four earlier review threads are resolved. Deferring to a maintainer given the scope — this restructures node:perf_hooks to mutate global Performance/PerformanceEntry/PerformanceMark/PerformanceMeasure prototypes on first require, and diverges PerformanceUserTiming::measure from the User Timing L3 spec toward Node's semantics.

What was reviewed:

  • measure(name, {duration: N}) no longer throwing TypeError — matches Node's calculateStartDuration, ruled out as intentional.
  • Symbol-argument guards now cover all three siblings (mark/clearMarks/clearMeasures); measure correctly excluded.
  • The new !endMark.isNull() branch and duration = std::nullopt clear are pinned by the added perf_hooks.test.ts cases.
  • Custom-inspect on PerformanceEntry.prototype guards against prototype-object receivers so console.log(PerformanceEntry.prototype) doesn't throw.
Extended reasoning...

Overview

This PR replaces the hand-written perf_hooks.performance forwarding object with the real global performance, and moves Node-only members (timerify, nodeTiming, eventLoopUtilization) onto Performance.prototype as non-enumerable properties. It also installs a nodejs.util.inspect.custom handler on PerformanceEntry.prototype and toJSON overrides on PerformanceMark/PerformanceMeasure prototypes, replaces $toClass with direct Object.setPrototypeOf for PerformanceNodeTiming, and swaps the PerformanceResourceTiming stub for the real global class. On the C++ side it changes getEntriesByType/getEntriesByName to throw ERR_MISSING_ARGS, adds V8-worded Symbol guards to mark/clearMarks/clearMeasures, and reworks PerformanceUserTiming::measure so an options dict without start/end falls through and honours a trailing endMark (renaming isNonEmptyDictionaryhasStartOrEnd). Five upstream Node v26.3.0 tests are vendored, plus new cases in perf_hooks.test.ts and a sleepSync helper in the test common harness.

Security risks

None identified. No auth/crypto/permissions surface. The prototype-pollution-safe patterns (__proto__: null in descriptors, kEmptyObject defaults) are preserved. The custom-inspect handler reads this.constructor and calls this.toJSON(), which are user-overridable, but that matches Node's own performance_entry.js behavior and is not a security boundary.

Level of scrutiny

Medium-high. This is Node-compat work with good test coverage (5 vendored upstream tests + targeted local tests, all verified against Node v26.3.0), but it makes several design decisions a maintainer should sign off on: (1) side-effecting global prototype mutation on require('perf_hooks') — the PR description flags this as a known limitation vs. installing at bootstrap; (2) deliberately diverging PerformanceUserTiming.cpp from the User Timing L3 spec toward Node behavior in shared WebCore code; (3) six potentially-overlapping open PRs were flagged by the duplicate-PR bot. None of these are bugs, but they're the kind of choices REVIEW.md's Architecture section says need maintainer agreement.

Other factors

I left four inline comments on earlier revisions (missing clearMeasures sibling, untested endMark branch, misleading isNonEmptyDictionary name, comment-length rule) — all four were addressed and the threads are resolved. The bug-hunting system found no issues this run; one candidate (measure(name, {duration: N}) no longer throwing) was examined and ruled out as the intended Node-compat behavior. CI build #75110 was reported green on 253 lanes with only agent-loss/unrelated flakes. The maintainer is actively engaged (two "adopt" pings), so this is ready for their review rather than mine to approve.

dylan-conway pushed a commit that referenced this pull request Jul 20, 2026
…v8 flag validation (+3 tests) (#34523)

Three independent Node v26.3.0 compatibility gaps, each shipped with the
upstream test that covers it, copied verbatim. None of them touch
`perf_hooks`.

Selected the same way as #34517: diffed Node v26.3.0's `test/parallel` +
`test/sequential` against main **and** against the vendored tests of all
32 open PRs, then ran the 956 missing tests through the runner's exact
invocation.

## `net` — connect errors lost the `- Local (…)` suffix

`ExceptionWithHostPort` dropped Node's fifth `additional` argument
(`lib/internal/errors.js:765-786`), which appends ` - Local
(address:port)`. Both connect-failure paths in `net.ts` already computed
that string and threw it away.

```
node: connect ECONNREFUSED 127.0.0.1:12399 - Local (127.0.0.1:12400)
bun:  connect ECONNREFUSED 127.0.0.1:12399
```

Test: `sequential/test-net-connect-local-error`

## `console` — per-stream `inspectOptions` was ignored

Node v26 lets `new Console({ inspectOptions })` take a `Map` keyed by
stream so stdout and stderr can be formatted differently
(`lib/internal/console/constructor.js:144-157, 334-335`). Bun stored the
value raw and looked it up unkeyed, so a Map was treated as an options
object with no `colors` and per-stream colors silently did nothing.

Looks the options up per stream instead; the plain-object form still
applies to both. Implemented with `$get` and **without** constructing a
`Map`, so unlike a direct port of Node's code a tampered global `Map`
cannot influence the result — verified against two of the three tamper
repros review raised (the third, `_times`/`kCounts` at
`ConsoleObject.ts:414,416`, is pre-existing and untouched).

Test: `test-console-tty-colors-per-stream`

## `v8.setFlagsFromString` — validation ran after the not-implemented
throw

Node rejects a non-string argument with `ERR_INVALID_ARG_TYPE`; Bun
threw `ERR_NOT_IMPLEMENTED` first. Now it validates first.

Deliberately **not** turned into a no-op that records flags, which is
what would be needed to also pass `test-v8-version-tag` — that would
tell callers a flag applied when it didn't.
`v8.setFlagsFromString('--allow_natives_syntax')` still reports
`ERR_NOT_IMPLEMENTED`.

Test: `test-v8-flag-type-check`

## Verification

Driven against the real Node v26.3.0 binary: console inspect output, the
connect error message and the v8 validation errors are
**byte-identical** across ten checks, including per-stream colors, a Map
with no entry for the stream being written to, and the `colorMode`
conflict in both the Map and plain-object forms.

- the 3 new tests: 3/3 green each under the runner's exact invocation
- no regressions: 249/251 vendored `test-net-*` / `test-dgram-*` /
`test-v8-*` / `test-console-*` tests pass; the 2 failures
(`test-net-connect-keepalive`, `test-net-server-keepalive`) were
confirmed pre-existing by running them on a build without this change
- `test/js/node/net` + `test/js/node/dgram` is 208 tests / 0 fail,
`test/js/node/console` 9 / 0 fail

## Noted, not fixed

`net.ts:2892` and `:3022` (the synchronous connect-failure paths) also
take a `details` argument in Node, computed from `self._getsockname()`.
Bun never computes it there, so those paths still lose the suffix.
Pre-existing and not reachable from this test; left for a follow-up.

Independent of #34517 (14 vendored tests) and #34518 (perf_hooks).
Comment thread src/js/node/perf_hooks.ts
@dylan-conway
dylan-conway merged commit 97935c2 into main Jul 21, 2026
77 of 78 checks passed
@dylan-conway
dylan-conway deleted the claude/node-v26-perf-hooks branch July 21, 2026 01:29
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants