Skip to content

perf_hooks: record nodeTiming milestones and resolve their names in performance.measure() - #38477

Open
robobun wants to merge 1 commit into
mainfrom
farm/586821f4/perf-hooks-nodetiming-milestones
Open

perf_hooks: record nodeTiming milestones and resolve their names in performance.measure()#38477
robobun wants to merge 1 commit into
mainfrom
farm/586821f4/perf-hooks-nodetiming-milestones

Conversation

@robobun

@robobun robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • performance.measure("boot", "nodeStart", "bootstrapComplete") (and the other five nodeTiming names, in any of measure's argument shapes) throws SyntaxError: No mark named 'bootstrapComplete' exists. Node documents that measure() accepts these names and resolves them to performance.nodeTiming.
  • performance.mark("nodeStart"), new PerformanceMark("nodeStart") and performance.clearMarks("nodeStart") succeed; Node rejects the six names with ERR_INVALID_ARG_VALUE (The argument 'name' is invalid. Received 'nodeStart').
  • performance.nodeTiming itself was fake: nodeStart/v8Start/environment/bootstrapComplete were all performance.timeOrigin (an epoch timestamp, ~1.78e12), loopStart and idleTime were 1, startTime was the epoch value, and the values were data properties frozen at module load (src/js/node/perf_hooks.ts createPerformanceNodeTiming). Node reports milliseconds since timeOrigin, -1 for unreached milestones, and startTime: 0.
  • Cause: mark/measure are WebKit's User Timing code. PerformanceUserTiming::convertMarkToTimestamp (src/jsc/bindings/webcore/PerformanceUserTiming.cpp) only consulted the user marks map; the browser version's restricted-name table had been removed in the port, and nothing in the runtime recorded startup milestones for it to resolve against.

Fix

  • VirtualMachine (src/jsc/VirtualMachine.rs) records six milestones as nanoseconds since origin_timer (the same clock performance.now() uses), first write wins: nodeStart right after origin_timer is taken, v8Start just before the JSC VM and global object are created, environment at the end of init, bootstrapComplete in reload_entry_point (main entry, workers, -e) and in the test runner's equivalent, loopStart in auto_tick/auto_tick_active (plus on_before_exit, for scripts that never poll the loop), loopExit at the end of on_before_exit. process.exit() and a fatal uncaught exception never reach that point, so loopExit stays -1 for 'exit' listeners, as in Node. Bun__getNodeTimingMilestone(vm, index) (src/jsc/virtual_machine_exports.rs) returns a milestone in ms or -1.
  • PerformanceUserTiming gets WebKit's restricted-mark-name mechanism back, with the six names mapped to milestone indices: convertMarkToTimestamp returns the milestone for them (including -1, which is what Node returns for an unreached loopStart/loopExit), PerformanceMark::create (covers mark() and the constructor) and clearMarks() reject them. The rejection is a new ExceptionCode::InvalidArgValueError, mapped to ERR_INVALID_ARG_VALUE in createDOMException the same way EVENT_RECURSION is; clearMarks now returns ExceptionOr<void>, which the existing binding already handles.
  • perf_hooks.ts's PerformanceNodeTiming is ported from Node's nodetiming.js: own enumerable properties, startTime 0, milestones as getters backed by a small host function (jsPerformance_getNodeTimingMilestone, which goes through the same name table), so measure("x", name).startTime === nodeTiming[name] holds at every point in the process lifetime. idleTime is 0, consistent with the eventLoopUtilization() stub.
  • Per-VM storage means workers get their own milestones, like Node's per-thread nodeTiming.
  • Verified: test/js/node/perf_hooks/perf_hooks.test.ts (new nodeTiming milestones block: 6 of 7 tests fail on main, all pass with the fix); Node's test/parallel/test-performance-nodetiming.js vendored verbatim from v26.3.0 (fails on main at its first startTime assertion, passes with the fix); the existing vendored test-performance-*/test-perf-hooks-* files, test/js/web/timers/performance*.test.* and test/js/deno/performance/performance.test.ts still pass. Expected values in the new tests were checked against node v26.3.0.
  • Overlap with open PRs: fix(node:perf_hooks): report nodeTiming milestones as offsets from timeOrigin #32481 rewrites the same nodeTiming values in JS only (this supersedes it); perf_hooks: match Node's User Timing argument-validation contract #36069 adds the mark() rejection as part of a wider validation change (it explicitly leaves measure() resolution out); the nodeTiming part of the node:v8 profiling APIs, real perf_hooks nodeTiming, --diagnostic-dir and --heapsnapshot-signal (+11 tests) #35390 draft uses the same milestone indices and export name as this PR, so rebasing it onto this should be a matter of dropping its copy.

Background

  • performance.nodeTiming (node:perf_hooks) is a PerformanceEntry whose properties are timestamps of the process's startup phases, in milliseconds relative to performance.timeOrigin, like every other User Timing value. Node also lets performance.measure()'s start/end arguments name one of those properties instead of a user mark, and reserves the names so a user mark cannot shadow them.
  • In Bun, performance.timeOrigin is the moment the thread's VirtualMachine was set up (origin_timer), and performance.now() is Bun__readOriginTimer = elapsed time on that clock; the milestones are stored on the same clock so they are directly comparable with marks and now().
  • ExceptionOr/Exception is how the ported WebCore code reports errors without touching JS; createDOMException (src/jsc/bindings/JSDOMExceptionHandling.cpp) turns the ExceptionCode into the JS error when the binding returns. A few codes there already produce Node-style errors with a .code property instead of a DOMException; InvalidArgValueError is one more of those.
  • // HOST_EXPORT(Name, c) on a Rust function makes the build emit an extern "C" thunk of that name, which is how C++ reaches Bun__getNodeTimingMilestone; $newCppFunction in a builtin module wraps a C++ host function as a JS function, which is how perf_hooks.ts reads the values.

[review] gate passed · iteration 0 · 15 files touched

fails on main (without fix)
ASAN without fix: 6 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
bun test v1.4.0 (f8a07b31e)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [7.37ms]
(pass) doesn't throw [22.48ms]
(pass) Symbol name argument throws V8 wording [11.60ms]
(pass) measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark [7.75ms]
(pass) timerify entry shape [73.72ms]
(pass) timerify is exposed on both performance and as a top-level export (Node v25.2+) [1.43ms]
(pass) export surface matches Node v26.3.0 [7.31ms]
(pass) timerify and createHistogram survive Object.prototype option pollution [446.66ms]
(pass) timerify and AsyncResource.bind survive Object.prototype.get pollution [566.26ms]
(pass) net entries are instanceof PerformanceEntry [470.90ms]
207 |   }
208 | 
209 |   test("nodeTiming has Node's shape and its values are offsets from timeOrigin", () => {
210 |     const nodeTiming = perf.performance.nodeTiming;
211 |     expect(nodeTiming).toBeInstanceOf(PerformanceEntry);
212 |     expect(Object.keys(nodeTiming)).toEqual([
                     
... (truncated)

release without fix: 6 FAILED
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [0.05ms]
(pass) doesn't throw [0.13ms]
(pass) Symbol name argument throws V8 wording [0.11ms]
(pass) measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark [0.13ms]
(pass) timerify entry shape [0.87ms]
(pass) timerify is exposed on both performance and as a top-level export (Node v25.2+) [0.02ms]
(pass) export surface matches Node v26.3.0 [0.08ms]
(pass) timerify and createHistogram survive Object.prototype option pollution [10.98ms]
(pass) timerify and AsyncResource.bind survive Object.prototype.get pollution [10.17ms]
(pass) net entries are instanceof PerformanceEntry [8.02ms]
207 |   }
208 | 
209 |   test("nodeTiming has Node's shape and its values are offsets from timeOrigin", () => {
210 |     const nodeTiming = perf.performance.nodeTiming;
211 |     expect(nodeTiming).toBeInstanceOf(PerformanceEntry);
212 |     expect(Object.keys(nodeTiming)).toEqual([
                                          ^
error: expect(received).toEqual(expected)

  [
-   "name",
-   "entryType",
-   "startTime",
-   "duration",
-   "nodeStart",
    "v8Start",
+   "nodeS
... (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
bun test v1.4.0 (f8a07b31e)

test/js/node/perf_hooks/perf_hooks.test.ts:
(pass) stubs [5.45ms]
(pass) doesn't throw [15.36ms]
(pass) Symbol name argument throws V8 wording [7.06ms]
(pass) measure(name, optionsWithoutStartOrEnd, endMark) honours the trailing endMark [5.27ms]
(pass) timerify entry shape [52.61ms]
(pass) timerify is exposed on both performance and as a top-level export (Node v25.2+) [1.43ms]
(pass) export surface matches Node v26.3.0 [9.05ms]
(pass) timerify and createHistogram survive Object.prototype option pollution [442.72ms]
(pass) timerify and AsyncResource.bind survive Object.prototype.get pollution [506.47ms]
(pass) net entries are instanceof PerformanceEntry [396.51ms]
(pass) nodeTiming milestones > nodeTiming has Node's shape and its values are offsets from timeOrigin [13.42ms]
(pass) nodeTiming milestones > measure() resolves the milestone names to nodeTiming's values [15.55ms]
(pass) nodeTiming milestones > mark(), new PerformanceMark() and clearMarks() reject the mil
... (truncated)

release with fix: all passed
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     f8a07b31e4
  features     baseline

22 deps, 123 codegen, 1176 objects in 688ms

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

Checked 107 installs across 153 packages (no changes) [30.00ms]
[3/1238] gen bindgenv2
[4/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [8.00ms]
[5/1238] fetch tinycc
[tinycc] up to date
[6/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [9.00ms]
[7/1237] fetch zlib
[zlib] up to date
[8/1237] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[9/1237] gen ProcessBindingConstants.lut.h
Generating /workspace/bun/build/release/codegen/ProcessBindingConstants.lut.h from /workspace/bun/src/jsc/bindings/ProcessBindingConstants.cpp
[10/1237] gen .bind.ts → GeneratedBindings.cpp

... (truncated)
diff hotspot
src/js/node/perf_hooks.ts                          |  98 +++++----
 src/jsc/JSErrorCode.rs                             |   2 +
 src/jsc/VirtualMachine.rs                          |  79 +++++++
 src/jsc/bindings/ExceptionCode.h                   |   2 +
 src/jsc/bindings/JSDOMExceptionHandling.cpp        |   3 +
 src/jsc/bindings/webcore/JSPerformance.cpp         |  13 ++
 src/jsc/bindings/webcore/JSPerformance.h           |   3 +
 src/jsc/bindings/webcore/Performance.cpp           |   4 +-
 src/jsc/bindings/webcore/Performance.h             |   2 +-
 src/jsc/bindings/webcore/PerformanceMark.cpp       |   3 +
 src/jsc/bindings/webcore/PerformanceUserTiming.cpp |  43 +++-
 src/jsc/bindings/webcore/PerformanceUserTiming.h   |  13 +-
 src/jsc/virtual_machine_exports.rs                 |  10 +
 test/js/node/perf_hooks/perf_hooks.test.ts         | 234 ++++++++++++++++++++-
 .../test/parallel/test-performance-nodetiming.js   |  46 ++++
 15 files changed, 512 insertions(+), 43 deletions(-)

gate history · 1 passed · 0 rejected · iteration 0

evidence per changed file
file                                                      reads  edits  tests
src/js/node/perf_hooks.ts                                     2      6      0
src/jsc/JSErrorCode.rs                                        1      1      0
src/jsc/VirtualMachine.rs                                    11     15      0
src/jsc/bindings/ExceptionCode.h                              1      2      0
src/jsc/bindings/JSDOMExceptionHandling.cpp                   1      2      0
src/jsc/bindings/webcore/JSPerformance.cpp                    2      2      0
src/jsc/bindings/webcore/JSPerformance.h                      2      2      0
src/jsc/bindings/webcore/Performance.cpp                      2      1      0
src/jsc/bindings/webcore/Performance.h                        1      1      0
src/jsc/bindings/webcore/PerformanceMark.cpp                  1      1      0
src/jsc/bindings/webcore/PerformanceUserTiming.cpp            1      4      0
src/jsc/bindings/webcore/PerformanceUserTiming.h              1      2      0
src/jsc/virtual_machine_exports.rs                            1      1      0
test/js/node/perf_hooks/perf_hooks.test.ts                    2      4      0
…st/js/node/test/parallel/test-performance-nodetiming.js      0      0      0

…ser Timing

performance.nodeTiming reported timeOrigin (an epoch timestamp) for every
startup milestone, and performance.measure() could not reference the
milestone names because the native User Timing code only knew user marks.

The VM now stamps nodeStart, v8Start, environment, bootstrapComplete,
loopStart and loopExit relative to origin_timer, exposed through
Bun__getNodeTimingMilestone. PerformanceUserTiming gets back WebKit's
restricted-mark-name table, pointed at those milestones: measure()
resolves the six names to the milestone values, while mark(),
new PerformanceMark() and clearMarks() reject them with
ERR_INVALID_ARG_VALUE like Node. perf_hooks' nodeTiming reads the same
values through own accessor properties, matching Node's object shape.

Co-authored-by: Ciro Spaciari <ciro.spaciari@gmail.com>
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

@robobun, your commit f8a07b3 has some failures in Build #95952 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38477

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

bun-38477 --bun

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; needs a maintainer to merge (or to retry one CI lane, see below).

Reproduced on bun 1.4.0 and a debug build of main with the snippet from the report: all four performance.measure() forms that name a nodeTiming milestone throw SyntaxError: No mark named '...' exists, and performance.mark("nodeStart") succeeds; node v26.3.0 accepts the measures and rejects the mark with ERR_INVALID_ARG_VALUE. With this branch the snippet produces the same output shape as node.

Proof: the new nodeTiming milestones tests in test/js/node/perf_hooks/perf_hooks.test.ts and the vendored test/js/node/test/parallel/test-performance-nodetiming.js fail on main and pass here.

CI on f8a07b3 (build 95952): every test lane that ran passed (177 jobs, including macOS 14 x64 and macOS 26 arm64). The only thing marked failed is :darwin: 14 aarch64 - test-bun, which never started: its agent pool (the release-tier=previous arm64 runners) has a multi-hour backlog of PR jobs right now and the job expired four times waiting for an agent. Nothing in the diff is platform-specific. Retrying just that job in Buildkite is enough if a green run of that lane is wanted; I am not pushing a retrigger commit since that would re-queue the whole build behind the same backlog.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 15 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: 59904261-a5d6-402f-b9c2-44444616b75d

📥 Commits

Reviewing files that changed from the base of the PR and between 032b8db and f8a07b3.

📒 Files selected for processing (15)
  • src/js/node/perf_hooks.ts
  • src/jsc/JSErrorCode.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/ExceptionCode.h
  • src/jsc/bindings/JSDOMExceptionHandling.cpp
  • src/jsc/bindings/webcore/JSPerformance.cpp
  • src/jsc/bindings/webcore/JSPerformance.h
  • src/jsc/bindings/webcore/Performance.cpp
  • src/jsc/bindings/webcore/Performance.h
  • src/jsc/bindings/webcore/PerformanceMark.cpp
  • src/jsc/bindings/webcore/PerformanceUserTiming.cpp
  • src/jsc/bindings/webcore/PerformanceUserTiming.h
  • src/jsc/virtual_machine_exports.rs
  • test/js/node/perf_hooks/perf_hooks.test.ts
  • test/js/node/test/parallel/test-performance-nodetiming.js

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

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

I reviewed this PR and didn't find any bugs. It touches core VM lifecycle paths (init, auto_tick, on_before_exit, reload_entry_point) and makes judgement calls about where Bun's startup phases map to Node's milestone semantics, plus it overlaps with three open PRs — a human look at the milestone placement and PR coordination is worthwhile.

What was reviewed:

  • Milestone stamp sites in VirtualMachine.rs and the raw-pointer projection in record_node_timing_milestone_raw during partial init — only touches already-written fields.
  • SortedArrayMap key ordering (alphabetical, correct) and the clearMarks ExceptionOr<void> return flowing through toJS<IDLUndefined>.
  • EnumTraits<ExceptionCode> not listing the new variant — matches pre-existing omissions (EVENT_RECURSION, OutOfMemoryError); not load-bearing here.
  • Per-tick cost of record_node_timing_milestone in auto_tick — one indexed load + branch, inlined.
Extended reasoning...

Overview

This PR implements real performance.nodeTiming milestones and Node's reserved-name behaviour in User Timing. It spans 15 files across four layers: Rust VM state (VirtualMachine.rs adds a 6-slot [i64] array stamped at startup phases and exposes it via a new Bun__getNodeTimingMilestone FFI export), C++ WebCore bindings (PerformanceUserTiming gains a SortedArrayMap of the six names, convertMarkToTimestamp resolves them, PerformanceMark::create and clearMarks reject them via a new ExceptionCode::InvalidArgValueError mapped in createDOMException), the built-in JS module (perf_hooks.ts rewrites PerformanceNodeTiming as own-enumerable getter properties backed by a $newCppFunction host call), and tests (a 230-line describe block covering shape, measure() resolution, mark()/clearMarks() rejection, loopStart/loopExit lifecycle in subprocesses, workers, and process.exit(); plus Node's vendored test-performance-nodetiming.js).

Security risks

None identified. No user-controlled data reaches size arithmetic, allocation, or filesystem/network paths. The new FFI export bounds-checks its u32 index via .get(index as usize) and returns -1.0 on miss. The C++ SortedArrayMap is a compile-time constant table.

Level of scrutiny

High. The change is not mechanical: it instruments VirtualMachine::init (which builds the VM from a partially-initialized raw pointer), the per-tick hot path auto_tick/auto_tick_active, and the on_before_exit shutdown sequence (including a unhandled_error_counter == 0 gate on LoopExit). Each stamp site is a design decision about which Bun phase corresponds to which Node milestone, and auto_tick runs on every event-loop turn. The clearMarks return-type change from void to ExceptionOr<void> flows through generated-binding-style code. These are the kinds of placement and layering choices a maintainer should confirm.

Other factors

  • Test coverage is thorough and follows repo conventions (subprocess tests drain all pipes concurrently, use test.concurrent, assert stderr/stdout before exitCode, cover the variant matrix including workers and the no-async-work path).
  • The PR description explicitly names three overlapping open PRs (#32481, #36069, #35390); coordinating supersession/rebase is a human decision.
  • The record_node_timing_milestone_raw unsafe helper is carefully scoped (only projects the two already-initialized fields, no &mut VirtualMachine formed) with a SAFETY contract, but touching the partial-init block in init warrants a second pair of eyes.
  • The new DOMExceptionCode::EventRecursion variant added on the Rust side alongside InvalidArgValueError fills a pre-existing gap (C++ had EVENT_RECURSION, Rust didn't) — harmless but worth a maintainer noting.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

For whoever reviews the milestone placement, the mapping in one place (Node's definition on the left, where Bun stamps it on the right):

  • nodeStart (process start): right after origin_timer is taken in VirtualMachine::init, so it is a few microseconds past timeOrigin. Node reports ~0.4 ms here for the same reason.
  • v8Start (engine initialized, isolate about to be created): after init_runtime_state (transpiler, timers), immediately before Zig__GlobalObject__create. So measure("jsc", { start: "v8Start", end: "environment" }) is the JSC VM + global object setup.
  • environment (Environment constructed): end of init.
  • bootstrapComplete (markBootstrapComplete() in Node's internal/main/*, before the user entry): reload_entry_point after the pre-execution bootstrap, before preloads and the entry; the test runner's sibling function stamps the same point. Like Node's, it lands before --import-style preloads run.
  • loopStart (first uv_run): the first auto_tick/auto_tick_active, which is only reached after the entry's synchronous evaluation and microtasks, so top-level code sees -1 in both runtimes. on_before_exit also stamps it so a script that never polls still reports a start, which is what Node does for an empty script.
  • loopExit: end of on_before_exit, i.e. after the final beforeExit dispatch drained nothing. process.exit() and a fatal exception skip that point, and Node leaves -1 in both of those cases too (checked against v26.3.0).

Every stamp is first-write-wins, so hot reloads and repeated ticks keep the original values. Workers run through the same init/reload_entry_point, so they get their own set relative to their own timeOrigin.

On the overlapping PRs: this one only needs to land on its own; #32481 becomes unnecessary, #36069 would drop its mark() rejection on rebase, and the nodeTiming slice of #35390 uses the same indices and export name so it rebases to a deletion.

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.

2 participants