Skip to content

perf_hooks: implement eventLoopUtilization() - #34514

Closed
cirospaciari wants to merge 2 commits into
ciro/worker-error-code-across-threadsfrom
ciro/worker-eventlooputil
Closed

perf_hooks: implement eventLoopUtilization()#34514
cirospaciari wants to merge 2 commits into
ciro/worker-error-code-across-threadsfrom
ciro/worker-eventlooputil

Conversation

@cirospaciari

Copy link
Copy Markdown
Member

Stacked on #34509#34424. Review those first; this PR is the last commit only.

performance.eventLoopUtilization() returned hardcoded {idle:0, active:0, utilization:0} and worker.performance's was a notImplemented stub. So test-worker-eventlooputil didn't fail — it hung forever, spinning on if (elu().idle <= 0) return setTimeout(r, 5).

Design

The loop already knew when it was about to park (will_idle_inside_event_loop), so the accounting is two clock reads on ticks that were going to make a sleeping syscall anyway — a busy tick pays nothing. Windows gets it from libuv's own metrics, enabled with uv_loop_configure(UV_METRICS_IDLE_TIME) (node does this unconditionally: node.cc:1587, node_worker.cc:175). Cross-thread reads are safe by construction: the counter is atomic, loop_start is fixed before the VM is published, and vm_lock only closes the TOCTOU on vm itself.

Two bugs worth recording

1. us_internal_loop_data_t is us_loop_t's FIRST member and is MIRRORED in Rust (src/uws_sys/InternalLoopData.rs). Adding a field without updating the mirror shifted num_polls, so us_loop_run_bun_tick took its num_polls == 0 early return and the loop stopped parking. No compile error. I twice concluded this was an architectural wall before a one-line printf of num_polls (1 without the field, 0 with it) named it. Anyone touching that struct should know.

2. A counter only folded in when a park ends reads stale mid-park, over-reporting active — 49.8 against the required 50. libuv has the identical problem and solves it the identical way: publish the park's entry time and let the reader add the in-progress interval (uv_metrics_idle_time, uv-common.c:1042-1056).

node parity (verified against the v26.3.0 binary)

case node bun
main-script (before the loop turns) {0,0,0} {0,0,0}
2-arg identical utilization NaN NaN
no-arg after the loop turns 0 < utilization < 1 same

The unguarded divisions are deliberate — node returns NaN for a zero total, so collapsing it to 0 would diverge. Read order is idle-then-now, matching node. The shared math lives in internal/perf/event_loop_utilization, exactly as node shares it between perf_hooks and worker_threads.

Also fixed

MessagePort listeners were invoked with this === undefined where node passes the port — injectFakeEmitter's wrapper had the receiver and dropped it. Scoped to MessagePort (its only caller); Worker uses a real EventEmitter and was already correct.

Verification

test-worker-eventlooputil: hung → 10/10, byte-identical to node, clean under BUN_JSC_validateExceptionChecks. perf_hooks 8 pass/0 fail. worker_threads unchanged at its 2 known failures. Windows target compiles.

Known divergence: node reports {0,0,0} during synchronous main-script evaluation because its loopStart milestone is still unset; Bun's loop_start is fixed at VM init. Modelling that needs node's real milestone, not a proxy — I tried iteration_nr and it's wrong, because a worker's script runs after its loop starts while the main script runs before, and it regressed the test 10/10 → 0/10.

eventLoopUtilization() returned hardcoded zeros and worker.performance's was a
notImplemented stub, so test-worker-eventlooputil did not fail — it HUNG
FOREVER, spinning on `if (elu().idle <= 0) return setTimeout(r, 5)`.

The loop already knew when it was about to park (`will_idle_inside_event_loop`),
so the accounting is two clock reads on ticks that were going to sleep anyway; a
busy tick pays nothing. libuv's own idle metrics cover the Windows path, enabled
with uv_loop_configure(UV_METRICS_IDLE_TIME) as node does unconditionally.

Two things this got wrong first, both worth recording:

`us_internal_loop_data_t` is us_loop_t's FIRST member and is MIRRORED in Rust
(src/uws_sys/InternalLoopData.rs). Adding a field without the mirror shifted
num_polls, so us_loop_run_bun_tick took its `num_polls == 0` early return and the
loop stopped parking — no compile error, and it read as an architectural wall
until a printf of num_polls showed 1 vs 0.

A counter only folded in when a park ENDS reads stale mid-park, which
over-reports active (49.8 vs the required 50). libuv has the same problem and
solves it the same way: publish the park's entry time and let the reader add the
in-progress interval (uv_metrics_idle_time, uv-common.c:1042).

The read order — idle, then now — and the unguarded divisions both match node:
eventLoopUtilization(u, u) yields NaN there, verified on v26.3.0, so collapsing
it to 0 would diverge. The shared math lives in internal/perf/event_loop_utilization
exactly as node shares it between perf_hooks and worker_threads.

Also fixes MessagePort listeners being called with `this === undefined` where
node passes the port; injectFakeEmitter's wrapper had the receiver and dropped
it. Worker is unaffected (real EventEmitter, already correct).

test-worker-eventlooputil: hung -> 10/10, byte-identical to node, clean under
BUN_JSC_validateExceptionChecks. perf_hooks 8 pass/0 fail; worker_threads
unchanged at its 2 known failures. Matches node on main-script ({0,0,0} before
the loop turns), 2-arg identical (NaN), and no-arg (0 < utilization < 1).

Known divergence: node reports {0,0,0} during synchronous main-script evaluation
because its loopStart milestone is still unset; Bun's loop_start is fixed at VM
init. Modelling that needs node's real milestone, not a proxy — iteration_nr
looks like one but is wrong, since a worker's script runs after its loop starts
and the main script runs before.
@robobun

robobun commented Jul 17, 2026

Copy link
Copy Markdown
Collaborator
Updated 4:13 PM PT - Jul 17th, 2026

@autofix-ci[bot], your commit b24e939 is building: #74776

@github-actions

Copy link
Copy Markdown
Contributor

Found 2 issues this PR may fix:

  1. perf_hooks eventLoopUtilization()/heapUsed stubs silently break @fastify/under-pressure load shedding #34068 - PR replaces the hardcoded {idle: 0, active: 0, utilization: 0} stub with real idle-time tracking, fixing the silent breakage of @fastify/under-pressure load shedding
  2. node:worker_threads does not implement Worker.performance and weird stub behaviour #32609 - PR implements worker.performance.eventLoopUtilization() with real cross-thread ELU reads, replacing the notImplemented stub

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

Fixes #34068
Fixes #32609

🤖 Generated with Claude Code

@cirospaciari

Copy link
Copy Markdown
Member Author

Folding this into #34424 — three stacked PRs was more confusing than it was worth. Same commits, same tests, one review.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. perf_hooks: implement performance.eventLoopUtilization() #32618 - Also implements performance.eventLoopUtilization() in perf_hooks with event loop idle time tracking

🤖 Generated with Claude Code

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.

2 participants