Skip to content

node:inspector: owner-refcount the shared CPU profiler; drop post() command errors without a callback - #36019

Closed
robobun wants to merge 1 commit into
mainfrom
farm/6c967f63/inspector-profiler-owners
Closed

node:inspector: owner-refcount the shared CPU profiler; drop post() command errors without a callback#36019
robobun wants to merge 1 commit into
mainfrom
farm/6c967f63/inspector-profiler-owners

Conversation

@robobun

@robobun robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator

Problem

BunCPUProfiler wraps the VM-singleton JSC::SamplingProfiler behind a thread-local s_isProfilerRunning bool with no notion of who started it. That makes every node:inspector Session and the --cpu-prof CLI flag fight over one shared instance:

  • Session.disconnect() checked the global flag and unconditionally ran pause() + releaseStackTraces() + clearData(), so a bare connect()/disconnect() emptied the buffer that --cpu-prof writes at exit, leaving a startTime == endTime stub with no samples. Profiler.disable did the same.
  • Profiler.start on a second Session silently adopted the already-running buffer (and the shared s_profilingStartTime, so its profile carried pre-start samples); Profiler.stop on the first Session to stop consumed everyone's data and every later stop got -32000 Profiler is not started.

Separately, Session.post() without a callback threw the Error / kProtocolError result returned by #handleMethod, while Node's post() without a callback is fire-and-forget and drops error responses.

Repro (all cells pass on Node, all but the control fail on Bun before this change):

import inspector from "node:inspector";
const post = (s, m, p) => new Promise((res, rej) => s.post(m, p, (e, r) => (e ? rej(e) : res(r))));
const A = new inspector.Session(); A.connect();
const B = new inspector.Session(); B.connect();
await post(A, "Profiler.enable"); await post(A, "Profiler.start");
/* work */; await post(B, "Profiler.enable"); await post(B, "Profiler.start"); /* work */;
const b = (await post(B, "Profiler.stop")).profile; // bun: includes A's pre-B samples
const a = (await post(A, "Profiler.stop")).profile; // bun: -32000 Profiler is not started

Fix

Owner-refcount the VM profiler (BunCPUProfiler.cpp). Each caller acquires an owner token that records its own monotonic start timestamp and wall-clock startTime. The sampler starts on the 0→1 owner transition and keeps running while any owner is live. Releasing an owner drains releaseStackTraces() into a retained trace buffer, builds that owner's profile from traces with timestamp >= owner.start (so a late-starting Session reports only its own window), and only the last owner to release pauses and clears. The retained buffer is trimmed to the earliest remaining owner's start between releases; the executables it references stay GC-live via the profiler's m_liveCellPointers until the final clearData(). The CLI --cpu-prof path and per-Worker profiling keep their existing startCPUProfiler/stopCPUProfiler entry points via a single implicit owner held in their stead.

node:inspector's Session now holds a per-session owner token (#cpuProfilerOwner). Profiler.start acquires one, Profiler.stop/Profiler.disable/disconnect() release only that session's claim.

post() without a callback now drops command failures (method-not-found, -32000 protocol errors) and returns undefined, matching Node's fire-and-forget contract. The pre-dispatch validation throws (ERR_INVALID_ARG_TYPE for method/params/callback, ERR_INSPECTOR_NOT_CONNECTED) are unchanged; Node throws those synchronously too.

Verification

New tests in test/js/node/inspector/inspector-profiler.test.ts:

  • a bare Session connect/disconnect does not clear --cpu-prof's samples
  • concurrent Sessions each get a profile for their own window (both non-empty, second session's duration bounded by its own window)
  • Profiler.disable on one Session does not stop another Session's profile
  • post() without a callback drops command failures instead of throwing / still throws for argument validation and not-connected

All fail on main (-32000 Profiler is not started, B duration ~700ms for a 300ms window, ERR_INSPECTOR_COMMAND thrown) and pass with this change. Existing cpu-prof.test.ts and inspector tests still pass.

…ommand errors without a callback

BunCPUProfiler wraps the VM-singleton JSC::SamplingProfiler. Previously a
thread-local bool tracked whether it was running, so any Session.disconnect()
or Profiler.disable would pause() and clearData() the one shared instance,
emptying the buffer `--cpu-prof` writes at exit, and a second concurrent
Session would adopt (on start) or consume (on stop) the first Session's
samples.

Each caller now acquires a per-owner token that records its own start
timestamp. The sampler starts on the first owner and stays running while any
owner is live; stopping one owner drains the sampler into a retained trace
buffer, returns only traces inside that owner's window (so a late-starting
Session no longer reports pre-start samples), and only the last owner to
release pauses/clears. The CLI path and per-Worker profiling keep their
existing entry points via a single implicit owner held in their stead.

Session.post() without a callback now drops command failures (method not
found, -32000 protocol errors) instead of throwing, matching Node's
fire-and-forget contract. Argument validation and ERR_INSPECTOR_NOT_CONNECTED
still throw synchronously, as Node does.
@coderabbitai

coderabbitai Bot commented Jul 26, 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: 4 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: e778fa39-e339-4d6d-9629-40407744eb44

📥 Commits

Reviewing files that changed from the base of the PR and between 44f6469 and e70f810.

📒 Files selected for processing (6)
  • src/js/node/inspector.ts
  • src/jsc/bindings/BunCPUProfiler.cpp
  • src/jsc/bindings/BunCPUProfiler.h
  • src/jsc/bindings/JSInspectorProfiler.cpp
  • test/js/node/inspector/inspector-profiler.test.ts
  • test/js/node/inspector/inspector.test.ts

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

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:25 PM PT - Jul 26th, 2026

@robobun, your commit e70f810 is building: #82895

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status: ready for review, waiting on CI.

Reproduced with the two-session start/stop sequence in the PR body: before this change session A's Profiler.stop gets -32000 Profiler is not started and session B's reported duration is ~700ms for a 300ms window; after, both sessions get independent non-empty profiles and B's duration is ~300ms. The --cpu-prof + bare connect()/disconnect() case now writes a populated .cpuprofile.

Locally green: test/js/node/inspector/inspector-profiler.test.ts, test/js/node/inspector/inspector.test.ts, test/cli/run/cpu-prof.test.ts (81 pass / 0 fail).

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:inspector: reference-count the shared CPU sampling profiler across --cpu-prof and sessions #36017 - Covers the same CPU profiler owner-refcounting work (acquireCPUProfilerOwner/releaseCPUProfilerOwner in BunCPUProfiler.cpp/h and JSInspectorProfiler.cpp) as part 1 of this PR.
  2. node:inspector: make Session.post() without a callback fire-and-forget #36018 - Covers the same Session.post() fire-and-forget (drop errors without callback) change in inspector.ts as part 2 of this PR.

🤖 Generated with Claude Code

@robobun

robobun commented Jul 26, 2026

Copy link
Copy Markdown
Collaborator Author

Duplicate of #36017 (profiler owner-refcounting) and #36018 (Session.post fire-and-forget), both opened earlier. Closing in favour of those.

@robobun robobun closed this Jul 26, 2026
Comment on lines +973 to +982
// Drain newly-collected traces into the retained buffer. releaseStackTraces()
// moves m_stackTraces out but leaves m_liveCellPointers intact, so the
// executables/callees referenced by s_retainedTraces stay GC-live until the
// final owner's clearData() below.
{
auto newTraces = profiler->releaseStackTraces();
s_retainedTraces.reserveCapacity(s_retainedTraces.size() + newTraces.size());
for (auto& trace : newTraces)
s_retainedTraces.append(WTF::move(trace));
}

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.

🔴 The GC-safety of s_retainedTraces depends on the SamplingProfiler's m_liveCellPointers surviving until the last owner's clearData(), but require('bun:jsc').profile() (functionRunProfiler in src/jsc/modules/BunJSCModule.h:715-716, and its reportFailure twin at :731-732) calls samplingProfiler.pause() + clearData() directly, outside the owner-refcount system. If ≥2 owners are live, one releases (populating s_retainedTraces), then user code calls bun:jsc profile(), clearData() wipes m_liveCellPointers, GC frees a sampled executable, and the remaining owner's buildProfileOutput dereferences a freed cell — before this PR that external clearData() was data-loss only, but s_retainedTraces upgrades it to a use-after-free. functionRunProfiler/reportFailure should route through acquireCPUProfilerOwner/releaseCPUProfilerOwner (or s_retainedTraces must not retain raw cell pointers across GC under an assumption an unrelated public API can violate).

Extended reasoning...

What the bug is

This PR introduces s_retainedTraces — a thread-local Vector<SamplingProfiler::StackTrace> that persists drained traces across calls when more than one profiler owner is live. Each StackFrame inside those traces holds raw ExecutableBase* / JSObject* pointers. The PR's own comment at BunCPUProfiler.cpp:52-57 and the drain site here document the GC-safety invariant: those raw pointers stay live because releaseStackTraces() moves m_stackTraces out but leaves m_liveCellPointers intact, and SamplingProfiler::visit() marks that set during GC — until the last owner calls clearData().

But functionRunProfiler in src/jsc/modules/BunJSCModule.h:712-717 — the user-reachable require('bun:jsc').profile(fn) API — calls samplingProfiler.pause() + samplingProfiler.clearData() directly on the same VM-singleton profiler, entirely outside the new owner-refcount system. Its reportFailure lambda at :727-732 does the same. clearData() clears m_liveCellPointers, breaking the invariant s_retainedTraces depends on.

Code path that triggers it

  1. --cpu-prof (or inspector Session A's Profiler.start) → acquireCPUProfilerOwner — one owner.
  2. Inspector Session B: Profiler.start → second owner.
  3. Session B: Profiler.stopreleaseCPUProfilerOwner. Not the last owner, so it drains releaseStackTraces() into s_retainedTraces and does not call clearData(). s_retainedTraces now holds StackFrames whose executable/callee cells are rooted only via the profiler's m_liveCellPointers.
  4. User code calls require('bun:jsc').profile(fn) → the report (or reportFailure) lambda runs samplingProfiler.clearData(), wiping m_liveCellPointers.
  5. GC runs. A short-lived sampled executable (an IIFE, an inline callback that already returned) whose only root was m_liveCellPointers is collected.
  6. The remaining owner releases (--cpu-prof at process exit, or Session A's Profiler.stop) → releaseCPUProfilerOwner calls buildProfileOutput over s_retainedTraces, which invokes frame.displayName(vm), frame.sourceProviderAndID(), and dynamicDowncast<JSC::FunctionExecutable>(frame.executable) on a freed cell → use-after-free.

Why existing code doesn't prevent it

The DeferGC inside releaseCPUProfilerOwner only covers that single call — it does not span step 3 → step 6. Between those steps, arbitrary JS runs and GC is free to collect. The owner-refcount system is closed: it knows about --cpu-prof, per-Worker profiling, and node:inspector Sessions, but functionRunProfiler is a fourth sibling that touches the same VM-singleton SamplingProfiler and was never wired in. Nothing in BunCPUProfiler.cpp guards against an external clearData().

Why this is new in this PR

Before this PR, stopCPUProfiler held traces only on-stack, under a single DeferGC, then immediately called clearData() itself. An external clearData() from bun:jsc profile() racing with that path caused data loss (an empty profile), never memory corruption. This PR introduces the first cross-call, cross-GC retention of raw StackFrame cell pointers (s_retainedTraces in the !lastOwner branch), which upgrades the pre-existing conflict to a memory-safety bug.

Impact

User-reachable UAF. bun:jsc profile() is public API; --cpu-prof + a node:inspector Session is exactly the concurrent-owner scenario this PR is designed to enable. Per REVIEW.md, memory safety ("Never let a pointer or slice outlive the memory it points into") is the most-blocked category, and "Fix the whole class in the same PR" applies — functionRunProfiler is a sibling site touching the shared SamplingProfiler that the owner-refcount design must account for.

Fix

Route functionRunProfiler (and reportFailure) through Bun::acquireCPUProfilerOwner / Bun::releaseCPUProfilerOwner instead of direct ensureSamplingProfiler/start/pause/clearData. Alternatively, if bun:jsc profile() must keep its own trace-consumption path, have releaseCPUProfilerOwner clear s_retainedTraces whenever it observes m_liveCellPointers may have been cleared externally, or don't retain raw cell pointers past DeferGC at all (e.g. eagerly stringify frame metadata at drain time so s_retainedTraces holds no GC pointers).

Comment on lines 89 to 93
void startCPUProfiler(JSC::VM& vm)
{
// Capture the wall clock time when profiling starts (before creating stopwatch)
// This will be used as the profile's startTime
s_profilingStartTime = MonotonicTime::now().approximate<WTF::WallTime>().secondsSinceEpoch().value() * 1000000.0;

// Create a stopwatch and start it
auto stopwatch = WTF::Stopwatch::create();
stopwatch->start();

JSC::SamplingProfiler& samplingProfiler = vm.ensureSamplingProfiler(WTF::move(stopwatch));
samplingProfiler.setTimingInterval(WTF::Seconds::fromMicroseconds(s_samplingInterval));
samplingProfiler.noticeCurrentThreadAsJSCExecutionThread();
samplingProfiler.start();
s_isProfilerRunning = true;
if (s_implicitProfilerOwner == 0)
s_implicitProfilerOwner = acquireCPUProfilerOwner(vm);
}

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.

🔴 The if (!Bun::isCPUProfilerRunning()) Bun::startCPUProfiler(...) guard in JSWorker.cpp (jsWorkerPrototypeFunction_startCpuProfileInternalBody, line 818) was not updated for the new semantics: isCPUProfilerRunning() now returns true when any owner (including an explicit node:inspector Session owner) exists, so worker.startCpuProfile() skips acquiring the implicit owner and handle.stop() then hits the new s_implicitProfilerOwner == 0 early-return, giving the parent kEmptyCpuProfileJSON — a regression from before this PR. Drop the guard (and the mirror at line 843); startCPUProfiler() is now internally idempotent on s_implicitProfilerOwner.

Extended reasoning...

What the bug is

This PR changes the semantics of two helpers in BunCPUProfiler.cpp:

  • startCPUProfiler() used to mean "unconditionally start the VM sampler"; it now means "acquire the implicit owner (s_implicitProfilerOwner) if not already held".
  • isCPUProfilerRunning() used to read a single s_isProfilerRunning bool; it now returns !s_profilerOwners.isEmpty(), i.e. true when any owner exists — implicit or explicit (a node:inspector Session that called Profiler.start).

The header comment at BunCPUProfiler.h:25-27 explicitly names "per-Worker profiling" as an implicit-owner client. But that caller was not updated. src/jsc/bindings/webcore/JSWorker.cpp:818-819 still does:

if (!Bun::isCPUProfilerRunning())
    Bun::startCPUProfiler(workerCtx.vm());

and the stop side at :843-844:

if (Bun::isCPUProfilerRunning())
    Bun::stopCPUProfiler(workerCtx.vm(), &result, nullptr);

Under the old single-bool model this guard just avoided a redundant re-start. Under the new model it prevents the Worker's implicit owner from ever being acquired whenever any other owner exists on that thread.

Step-by-step trigger

All profiler state is thread_local and postTaskToWorkerGlobalScope runs the lambda on the worker's JS thread, so a Session inside the worker and the parent's worker.startCpuProfile() share the same s_profilerOwners / s_implicitProfilerOwner.

  1. Inside a Worker, user code creates a node:inspector Session and calls Profiler.start. This routes to jsFunction_startCPUProfileracquireCPUProfilerOwner, which appends an explicit owner to s_profilerOwners. s_implicitProfilerOwner stays 0.
  2. The parent calls worker.startCpuProfile(). The posted task at JSWorker.cpp:818 evaluates isCPUProfilerRunning()!s_profilerOwners.isEmpty()true, so it skips startCPUProfiler(). s_implicitProfilerOwner stays 0.
  3. The parent later calls handle.stop(). The posted task at JSWorker.cpp:843 sees isCPUProfilerRunning() == true and calls stopCPUProfiler().
  4. The new stopCPUProfiler() checks s_implicitProfilerOwner == 0, hits the early-return, and writes *outJSON = WTF::String().
  5. Back in JSWorker.cpp:845-846, result.isEmpty() is true, so the parent receives kEmptyCpuProfileJSON — zero samples.

Why existing code doesn't prevent it

The guard at :818 was correct pre-PR because isCPUProfilerRunning() and startCPUProfiler() operated on the same single bool. Post-PR they operate on different state: the guard checks "any owner" but the call it guards only manipulates the implicit owner. Nothing else on this path touches s_implicitProfilerOwner.

Impact

Before this PR the same sequence returned a populated profile (the old stopCPUProfiler unconditionally drained the shared buffer). After this PR it silently returns an empty stub. This is exactly the Session×implicit-owner interaction the PR is fixing for Session×Session and Session×--cpu-prof, but the per-Worker path — which the PR's own header comment names as an implicit-owner client — is left with a stale guard that defeats it. Per REVIEW.md: "Fix the whole class in the same PR… every caller of a changed helper. Prefer moving the guard into the shared helper."

Fix

Drop both guards — startCPUProfiler() is now internally idempotent on s_implicitProfilerOwner, and stopCPUProfiler() already handles the "not held" case:

// JSWorker.cpp:818
Bun::startCPUProfiler(workerCtx.vm());
// JSWorker.cpp:843
Bun::stopCPUProfiler(workerCtx.vm(), &result, nullptr);

Comment on lines +471 to +473
// consumed the shared buffer and the second got -32000 "not started").
expect(aSamples).toBeGreaterThan(0);
expect(bSamples).toBeGreaterThan(0);

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.

🟡 This is a hard wall-clock upper bound on b.endTime - b.startTime, where B's window is a while (performance.now() - t < 300) busy loop running under test.concurrent alongside two other CPU-spinning subprocesses — if the child is descheduled mid-loop on an oversubscribed CI runner, the loop overshoots and this fails on a correct build. Consider asserting the invariant structurally instead: emit a.startTime/b.startTime and assert b.startTime - a.startTime > 200_000 (a lower bound, which scheduling jitter can only widen) — before this PR the two shared s_profilingStartTime so the difference was 0, after it's ≥ the work(400) between them.

Extended reasoning...

What this asserts

bDurationMicros is b.endTime - b.startTime, where b.startTime is the wall-clock time at B's acquireCPUProfilerOwner() and b.endTime is the wall-clock timestamp of the last sample recorded before B's Profiler.stop. Between those two points the child runs work(300), defined as:

const work = ms => { const t = performance.now(); let x = 0; while (performance.now() - t < ms) x += Math.sqrt(x + 1); };

This loop is wall-clock-bounded, so debug/ASAN slowness does not stretch it — it always exits at ~300ms wall time regardless of how slow each iteration is. The flake window is a single OS deschedule: if the child process is preempted for a contiguous ≥250ms between the last performance.now() check and the synchronous stopCPUProfiler inside s.post, the SamplingProfiler's timer thread keeps timestamping samples while the JS thread is parked, so b.endTime advances past 550ms and the assertion fails on a correct build.

Why this shape is flagged

The three test.concurrent cases in this describe block each spawn a subprocess that busy-spins the CPU (work(200)+work(200), work(400)+work(300)+work(150), work(200)), all running simultaneously on a shared CI host. REVIEW.md's test rules call out this shape directly: "Await the actual observable condition … Don't raise per-test timeouts to make a slow test pass" and CLAUDE.md's "You are not testing the TIME PASSING, you are testing the CONDITION". A hard microsecond ceiling on real elapsed time is exactly the pattern those rules warn about.

Step-by-step failure trace

  1. Three test.concurrent subprocesses start together on a 2-core CI runner already running other test files.
  2. In the B-window subprocess, work(300) enters its loop at t₀.
  3. At t₀+298ms the loop condition check passes (298 < 300); the process is then descheduled.
  4. The OS gives the quantum to the other two spinning children plus whatever else is on the box; this child gets no CPU for 260ms.
  5. At t₀+558ms the child is rescheduled, the next performance.now() check sees 558 ≥ 300 and exits the loop; Profiler.stop runs.
  6. The SamplingProfiler's timer thread was never descheduled with the JS thread, so it kept appending samples during the 260ms gap; the last sample's wall-clock time is ~t₀+558ms.
  7. bDurationMicros ≈ 558_000toBeLessThan(550_000) fails.

Why existing code doesn't prevent it

The 250ms slack (83% over the 300ms target) is generous — typical Linux CFS timeslices are 1-10ms, so a single 250ms starvation event with only ~3 competing processes is uncommon. This may well never fire in practice, which is why it's a nit. But it's an upper bound on wall-clock time under concurrent CPU contention, and nothing in the test bounds how long a single deschedule can last.

Structural alternative

The invariant being proven is "B's profile does not include A's pre-B window" — i.e., that B got its own startTime rather than inheriting the shared s_profilingStartTime. That can be asserted without a wall-clock ceiling by also emitting a.startTime and b.startTime and checking:

expect(bStartTime - aStartTime).toBeGreaterThan(200_000);

Before this PR both sessions shared s_profilingStartTime so the difference was 0; after the fix B's startTime is captured after work(400), so the difference is ≥400ms. This is a lower bound on elapsed wall-clock time, which scheduling jitter can only make larger — it cannot flake on an oversubscribed runner and still discriminates the fixed from unfixed build.

Comment on lines +27 to 33
JSC_DEFINE_HOST_FUNCTION(jsFunction_stopCPUProfiler, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
auto& vm = globalObject->vm();
uint32_t owner = callFrame->argument(0).toUInt32(globalObject);
WTF::String result;
Bun::stopCPUProfiler(vm, &result, nullptr);
Bun::releaseCPUProfilerOwner(vm, owner, &result, nullptr);
return JSValue::encode(jsString(vm, result));

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.

🟡 toUInt32(globalObject) is a coercion that can throw, but there's no DECLARE_THROW_SCOPE / RETURN_IF_EXCEPTION around it — REVIEW.md's exception-check rule requires one (or scope.assertNoException() with a comment when the argument is provably a number), and the sibling jsFunction_setCPUSamplingInterval right below follows the convention. In practice this can't trip the validator because the only caller (the builtin src/js/node/inspector.ts) always passes a plain Int32 and toUInt32 takes the isInt32() fast path without constructing a ThrowScope, so it's just a hygiene fix.

Extended reasoning...

What the finding is

jsFunction_stopCPUProfiler now reads its argument via callFrame->argument(0).toUInt32(globalObject) without a DECLARE_THROW_SCOPE in scope and without RETURN_IF_EXCEPTION afterwards. REVIEW.md's memory-safety section is explicit about this pattern:

Every call that can throw or run user code (toString/toNumber, getIfPropertyExists, getIndex, coercions, callbacks) needs RETURN_IF_EXCEPTION under a ThrowScope (C++) … before its result is used. … Verify with BUN_JSC_validateExceptionChecks=1 instead of adding suppression-list entries; for calls that provably cannot throw, use scope.assertNoException() with a comment.

toUInt32 is a coercion — in the general case it dispatches to toNumber/toNumberSlowCase, which declare their own ThrowScope and can throw. The sibling function jsFunction_setCPUSamplingInterval in the same file follows the discipline (declares a scope, checks after validateInteger), and elsewhere in the codebase (JSNodePerformanceHooksHistogramPrototype.cpp, NodeFSStatBinding.cpp) toInt32/toUInt32 calls are consistently paired with a ThrowScope and RETURN_IF_EXCEPTION.

Why this is a nit, not a runtime bug

The only caller of this host function is the builtin src/js/node/inspector.ts, which always passes this.#cpuProfilerOwner. That field is initialized to 0 and only ever assigned the return value of startCPUProfiler(), which is jsNumber(uint32_t) — a boxed Int32. JSValue::toUInt32toInt32 hits the if (isInt32()) return asInt32(); fast path, which returns without ever entering toNumberSlowCase and therefore without constructing any ThrowScope. Because no inner scope is created on the reachable path, BUN_JSC_validateExceptionChecks=1 has nothing to reconcile and will not abort here in CI.

There is also precedent for the no-scope pattern on internal-only $newCppFunction callees whose argument is provably a number — BunDebugger.cpp's jsFunctionCreateConnection does the same argument(0).toUInt32(globalObject) without a scope.

Step-by-step trace

  1. Profiler.startthis.#cpuProfilerOwner = startCPUProfiler()jsFunction_startCPUProfiler returns JSValue::encode(jsNumber(owner)) where owner is a uint32_t. jsNumber(uint32_t) yields an Int32-tagged JSValue for values ≤ INT32_MAX (owner IDs start at 1 and increment).
  2. Profiler.stop / Profiler.disable / disconnect() call stopCPUProfiler(this.#cpuProfilerOwner).
  3. jsFunction_stopCPUProfiler executes callFrame->argument(0).toUInt32(globalObject). The argument isInt32(), so toInt32 returns asInt32() immediately — no ThrowScope constructed, no exception possible.
  4. Because no nested ThrowScope was created and no exception was set, the missing outer scope/check is unobservable at runtime and to the exception-check validator.

Impact

None at runtime on any reachable path. This is a coding-discipline violation against the repo's own review rules, and a divergence from the sibling function's style in the same file. It becomes a real footgun only if a future caller (or a refactor of the JS side) passes something other than a plain number.

Suggested fix

Add the two lines to match the sibling and the REVIEW.md rule:

JSC_DEFINE_HOST_FUNCTION(jsFunction_stopCPUProfiler, (JSGlobalObject * globalObject, CallFrame* callFrame))
{
    auto& vm = globalObject->vm();
    auto scope = DECLARE_THROW_SCOPE(vm);
    uint32_t owner = callFrame->argument(0).toUInt32(globalObject);
    RETURN_IF_EXCEPTION(scope, {});
    WTF::String result;
    Bun::releaseCPUProfilerOwner(vm, owner, &result, nullptr);
    return JSValue::encode(jsString(vm, result));
}

Alternatively, since the argument is provably an Int32 from a trusted builtin, scope.assertNoException() with a one-line comment is also acceptable per REVIEW.md.

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