Skip to content

perf_hooks: alias globalThis.PerformanceObserver to the node:perf_hooks class - #33789

Open
robobun wants to merge 4 commits into
mainfrom
farm/62c00790/global-performance-observer
Open

perf_hooks: alias globalThis.PerformanceObserver to the node:perf_hooks class#33789
robobun wants to merge 4 commits into
mainfrom
farm/62c00790/global-performance-observer

Conversation

@robobun

@robobun robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator

What

globalThis.PerformanceObserver was the raw WebCore class, while require('node:perf_hooks').PerformanceObserver is a subclass that routes node-only entry types (http/net/dns) through a JS-side registry. Instrumentation written against the global accepted observe({entryTypes:['http','net']}) without error but never delivered any entries; only the perf_hooks export did. Node documents the two as the same object.

import { PerformanceObserver as ModulePO } from 'node:perf_hooks';
import http from 'node:http';

console.log(globalThis.PerformanceObserver === ModulePO);       // node: true,  bun: false
console.log(globalThis.PerformanceObserver.supportedEntryTypes);
// node: ['dns','function','gc','http','http2','mark','measure','net','quic','resource']
// bun:  ['mark','measure','resource']

const got = [];
new globalThis.PerformanceObserver(l => got.push(...l.getEntries().map(e => e.entryType)))
  .observe({ entryTypes: ['http', 'net'] });   // accepted silently
// ... make an http.request ...
// node: got = ['net','http','http']   bun: got = []

Companion to #33475 (perf_hooks.performance !== globalThis.performance); that was the performance object split, this is the observer class/registry split.

Fix

globalThis.PerformanceObserver is now a lazy custom accessor that resolves to the node:perf_hooks subclass, so the two are the same object with one supportedEntryTypes list that includes the node types.

Two mechanical pieces:

  • perf_hooks.ts previously read the native base class off globalThis; that would now re-enter the module load, so it fetches the native constructor directly via getPerformanceObserverConstructor in JSPerformanceObserver.cpp (same shape as getWebSocketConstructor).
  • The global cannot stay in the static lut as a PropertyCallback: JSScope::abstractResolve() reifies those under a VMInquiry PropertySlot that forbids VM re-entry, and loading perf_hooks runs JS (crashes with a checkVMEntryPermission abort on the first bare-identifier reference). It is installed via putDirectCustomAccessor instead, which only records the getter in the slot; the value is materialized on first runtime read and then cached with putDirect.

Verification

New tests in test/js/node/perf_hooks/perf_hooks.test.ts, both failing on main:

  • identity: globalThis.PerformanceObserver === perf.PerformanceObserver and supportedEntryTypes includes http/net/dns.
  • a spawned subprocess that resolves the global as a bare identifier without importing node:perf_hooks, observes ['http','net'], makes an http.request, and asserts the global observer receives the same http:HttpClient/http:HttpRequest/net:connect entries the perf_hooks observer does.

Also pass: performance-observer-leak.test.ts (Worker with an undisconnected observer, exercises the lazy accessor inside a worker VM), test/js/deno/performance/performance.test.ts, the vendored test-net-perf_hooks.js, test-http-perf_hooks.js, test-performance-measure.js, and web-globals.test.js.


[review] gate passed · iteration 1 · 6 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 (ad5d5ad5e)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [5.00ms]
(pass) doesn't throw [15.17ms]
22 |   expect(() => performance.timeOrigin).not.toThrow();
23 |   expect(() => performance.markResourceTiming()).not.toThrow();
24 | });
25 | 
26 | test("globalThis.PerformanceObserver is the node:perf_hooks PerformanceObserver", () => {
27 |   expect(globalThis.PerformanceObserver).toBe(perf.PerformanceObserver);
                                              ^
error: expect(received).toBe(expected)

Expected: [class PerformanceObserverForNodeTypes extends PerformanceObserver]
Received: [class PerformanceObserver]

      at <anonymous> (/workspace/bun/test/js/node/perf_hooks/perf_hooks.test.ts:27:42)
(fail) globalThi
... (truncated)

release without fix: 2 FAILED
bun test v1.4.0-canary.1 (1498d7b77)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [0.07ms]
(pass) doesn't throw [0.14ms]
22 |   expect(() => performance.timeOrigin).not.toThrow();
23 |   expect(() => performance.markResourceTiming()).not.toThrow();
24 | });
25 | 
26 | test("globalThis.PerformanceObserver is the node:perf_hooks PerformanceObserver", () => {
27 |   expect(globalThis.PerformanceObserver).toBe(perf.PerformanceObserver);
                                              ^
error: expect(received).toBe(expected)

Expected: [class PerformanceObserverForNodeTypes extends PerformanceObserver]
Received: [class PerformanceObserver]

      at <anonymous> (/workspace/bun/test/js/node/perf_hooks/perf_hooks.test.ts:27:42)
(fail) globalThis.PerformanceObserver is the node:perf_hooks PerformanceObserver [0.19ms]
94 |   try {
95 |     result = JSON.parse(stdout);
96 |   } catch {
97 |     result = { parseFailed: true, stdout };
98 |   }
99 |   expect({ ...result, stderr, exitCode }).toEqual({
                                               ^
error: expect(received).toEqual(expected)

  {
    "exitCode": 0,
-   "sameClass": true,
+   "sameClass": false,
    "st
... (truncated)
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 (ad5d5ad5e)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [4.58ms]
(pass) doesn't throw [15.13ms]
(pass) globalThis.PerformanceObserver is the node:perf_hooks PerformanceObserver [12.07ms]
(pass) globalThis.PerformanceObserver delivers node-only entry types [3357.55ms]

 4 pass
 0 fail
 19 expect() calls
Ran 4 tests across 1 file. [6.43s]
__F:0:S:0

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)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ad5d5ad5ef
  features     (none)

22 deps, 105 codegen, 1167 objects in 1271ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1230] gen ErrorCode+*.h
[2/1230] install /workspace/bun
bun install v1.4.0-canary.1 (1498d7b77)

Checked 124 installs across 170 packages (no changes) [13.00ms]
[3/1230] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (1498d7b77)

Checked 1 install across 2 packages (no changes) [1.00ms]
[4/1230] gen .bind.ts → GeneratedBindings.cpp
[5/1230] fetch picohttpparser
[picohttpparser] up to date
[6/1230] gen bindgenv2
[7/1230] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (1498d7b77)

Checked 129 installs across 147 packages (no changes) [10.00ms]
[8/1230
... (truncated)
diff hotspot
src/js/node/perf_hooks.ts                          | 17 ++---
 src/jsc/bindings/ZigGlobalObject.cpp               | 20 +++++-
 src/jsc/bindings/ZigGlobalObject.lut.txt           |  1 -
 src/jsc/bindings/webcore/JSPerformanceObserver.cpp |  5 ++
 src/jsc/bindings/webcore/JSPerformanceObserver.h   |  2 +
 test/js/node/perf_hooks/perf_hooks.test.ts         | 83 ++++++++++++++++++++++
 6 files changed, 116 insertions(+), 12 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                                reads  edits  tests
src/js/node/perf_hooks.ts                               3      2      0
src/jsc/bindings/ZigGlobalObject.cpp                    4      7      0
src/jsc/bindings/ZigGlobalObject.lut.txt                1      1      0
src/jsc/bindings/webcore/JSPerformanceObserver.cpp      1      1      0
src/jsc/bindings/webcore/JSPerformanceObserver.h        1      1      0
test/js/node/perf_hooks/perf_hooks.test.ts              4      5      0

…ks class

The global PerformanceObserver was the raw WebCore class, while
node:perf_hooks exports a subclass that routes node-only entry types
(http/net/dns) through a JS-side registry. Instrumentation written
against the global accepted observe({entryTypes:['http','net']})
without error but never delivered any entries; only the perf_hooks
export did.

The global is now a lazy custom accessor that returns the perf_hooks
subclass, so globalThis.PerformanceObserver ===
require('node:perf_hooks').PerformanceObserver with a single
supportedEntryTypes list. perf_hooks.ts fetches the native base
constructor directly via a C++ hook to avoid re-entering itself on
first access. A static-table PropertyCallback cannot be used for the
global because abstractResolve() reifies it under a VMInquiry slot
that forbids VM re-entry.
@github-actions github-actions Bot added the claude label Jul 8, 2026
@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:17 PM PT - Jul 8th, 2026

@robobun, your commit ad5d5ad has 3 failures in Build #70706 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33789

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

bun-33789 --bun

@coderabbitai

coderabbitai Bot commented Jul 8, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This PR changes how globalThis.PerformanceObserver resolves so it matches the PerformanceObserver exported by node:perf_hooks. It adds a native constructor accessor, replaces the generated global getter with a custom lazy-loading getter, updates perf_hooks.ts initialization, and adds tests for identity and entry types.

Changes

PerformanceObserver identity unification

Layer / File(s) Summary
Native constructor accessor for PerformanceObserver
src/jsc/bindings/webcore/JSPerformanceObserver.cpp, src/jsc/bindings/webcore/JSPerformanceObserver.h
Adds getPerformanceObserverConstructor(Zig::GlobalObject*) in the WebCore binding header and implementation, forwarding to WebCore::JSPerformanceObserver::getConstructor.
Custom global getter and lookup table update
src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/ZigGlobalObject.lut.txt
Replaces the generated global PerformanceObserver constructor getter with a custom accessor that lazily requires node:perf_hooks, returns its PerformanceObserver export, wires the property through putDirectCustomAccessor, and removes the lookup table entry.
perf_hooks.ts native subclass resolution
src/js/node/perf_hooks.ts
Updates src/js/node/perf_hooks.ts to stop aliasing globalThis.PerformanceObserver and instead assign NodePerformanceObserver from a direct $cpp(...getPerformanceObserverConstructor) lookup.
Tests validating identity and entry types
test/js/node/perf_hooks/perf_hooks.test.ts
Adds tests that compare globalThis.PerformanceObserver with perf.PerformanceObserver, check supportedEntryTypes, and verify subprocess behavior for global and required observers with node-only entry types.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely states the main change: aliasing globalThis.PerformanceObserver to node:perf_hooks.
Description check ✅ Passed The description covers the fix and verification well, though its headings differ slightly from the repository template.

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

@coderabbitai coderabbitai 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.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/js/node/perf_hooks.ts`:
- Around line 18-21: The comment for PerformanceObserverForNodeTypes is stale
and still describes NodePerformanceObserver as an alias destructured from
globalThis, which no longer matches the $cpp-based lookup used in perf_hooks.ts.
Update the documentation near PerformanceObserverForNodeTypes to reflect that
NodePerformanceObserver is obtained directly from JSPerformanceObserver.cpp via
getPerformanceObserverConstructor, and remove any mention of the old globalThis
destructuring. Keep the wording aligned with the current NodePerformanceObserver
initialization so future readers understand the native constructor is fetched
directly.

In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Around line 1196-1208: The getPerformanceObserverGlobal getter is
dereferencing perfHooks.getObject() without verifying the requireId() result is
actually an object, which can lead to a null-deref crash. Add the same object
guard pattern used elsewhere in ZigGlobalObject.cpp (for example, the
m_utilInspectFunction.initLater node:util path) before calling getObject(), and
fail loudly with an assertion or equivalent check before accessing
PerformanceObserver.
- Around line 3004-3005: The PerformanceObserver global is currently defined as
a getter-only accessor in ZigGlobalObject::putDirectCustomAccessor, which makes
assignment fail in strict mode until the getter is touched. Update the
PerformanceObserver binding in ZigGlobalObject to keep it writable by adding a
setter or converting it to a data property, while preserving the existing getter
behavior and property setup.

In `@test/js/node/perf_hooks/perf_hooks.test.ts`:
- Around line 92-101: The assertion block in the perf_hooks test should stop
requiring stderr to be exactly empty and should validate the process output more
robustly. Update the test around the Promise.all result so it checks the
combined stdout, stderr, and exitCode together instead of expecting empty
stderr, and in the JSON parsing path guard against empty or failed stdout by
surfacing stderr/exitCode when parsing cannot succeed. Keep the fix localized to
the test case using the existing proc.stdout.text(), proc.stderr.text(), and
proc.exited flow.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 3f001ea1-7df5-4149-b289-5bbd73ee5d56

📥 Commits

Reviewing files that changed from the base of the PR and between 4924862 and 13342f8.

📒 Files selected for processing (6)
  • src/js/node/perf_hooks.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/jsc/bindings/ZigGlobalObject.lut.txt
  • src/jsc/bindings/webcore/JSPerformanceObserver.cpp
  • src/jsc/bindings/webcore/JSPerformanceObserver.h
  • test/js/node/perf_hooks/perf_hooks.test.ts
💤 Files with no reviewable changes (1)
  • src/jsc/bindings/ZigGlobalObject.lut.txt

Comment thread src/js/node/perf_hooks.ts
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread src/jsc/bindings/ZigGlobalObject.cpp
Comment thread test/js/node/perf_hooks/perf_hooks.test.ts

@coderabbitai coderabbitai 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.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/jsc/bindings/ZigGlobalObject.cpp (1)

3005-3005: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Make PerformanceObserver non-enumerable on globalThis.

Both the accessor and the cached own property use 0 attributes, so PerformanceObserver will show up in Object.keys(globalThis) / for..in after first access. Mark both sites DontEnum to match the other global constructors.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/jsc/bindings/ZigGlobalObject.cpp` at line 3005, The PerformanceObserver
global accessor is currently defined with enumerable attributes, so it can
appear in Object.keys(globalThis) and for..in after first access. Update the
global property setup in ZigGlobalObject’s PerformanceObserver binding to use
DontEnum for both the accessor and the cached own property, matching the other
global constructors and keeping the property non-enumerable.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@src/jsc/bindings/ZigGlobalObject.cpp`:
- Line 3005: The PerformanceObserver global accessor is currently defined with
enumerable attributes, so it can appear in Object.keys(globalThis) and for..in
after first access. Update the global property setup in ZigGlobalObject’s
PerformanceObserver binding to use DontEnum for both the accessor and the cached
own property, matching the other global constructors and keeping the property
non-enumerable.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: bb7f43b1-e0ec-4e2d-92b5-e1e458dd86cf

📥 Commits

Reviewing files that changed from the base of the PR and between 7d72afe and c6c521c.

📒 Files selected for processing (3)
  • src/js/node/perf_hooks.ts
  • src/jsc/bindings/ZigGlobalObject.cpp
  • test/js/node/perf_hooks/perf_hooks.test.ts

@robobun

robobun commented Jul 8, 2026

Copy link
Copy Markdown
Collaborator Author

Re: making PerformanceObserver non-enumerable (outside-diff review comment):

This preserves the existing Bun behavior. The whole Performance* family is enumerable on globalThis in Bun today, and non-enumerable in Node:

$ node -e '["Performance","PerformanceEntry","PerformanceMark","PerformanceMeasure","PerformanceObserver","PerformanceObserverEntryList"].forEach(k=>console.log(k,Object.getOwnPropertyDescriptor(globalThis,k).enumerable))'
Performance false
PerformanceEntry false
PerformanceMark false
PerformanceMeasure false
PerformanceObserver false
PerformanceObserverEntryList false

$ bun -e '...'   # stock, pre-PR
Performance true
PerformanceEntry true
PerformanceMark true
PerformanceMeasure true
PerformanceObserver true
PerformanceObserverEntryList true

Flipping only PerformanceObserver here would make it inconsistent with its siblings still served from the lut (which all use bare PropertyCallback, no DontEnum). That is a separate cross-cutting fix for the lut entries, not this PR.

@robobun

robobun commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator Author

CI is red on unrelated lanes; the diff itself is green.

  • Build 70689: test/regression/issue/26030.test.ts on alpine 3.23 aarch64 (MySQL container health-check timeout) and test/js/bun/http/proxy-stress-concurrent.test.ts on macOS 14 x64 (known-flaky https proxy stress).
  • Build 70706 (re-roll): vendor/elysia/test/type-system/coercion-number.test.ts SIGTRAP on debian 13 aarch64 only.

None of these touch perf_hooks, PerformanceObserver, or any file this PR changes. test/js/node/perf_hooks/perf_hooks.test.ts, test/js/web/workers/performance-observer-leak.test.ts, and the vendored test-net-perf_hooks.js/test-http-perf_hooks.js/test-performance-measure.js pass on every lane in both runs. All review threads are resolved.

Ready for a maintainer to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant