Skip to content

cpu-prof: match Node/Deno Chrome DevTools format#29241

Open
robobun wants to merge 8 commits intomainfrom
farm/33533379/cpuprof-linenumber-matches-node
Open

cpu-prof: match Node/Deno Chrome DevTools format#29241
robobun wants to merge 8 commits intomainfrom
farm/33533379/cpuprof-linenumber-matches-node

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 13, 2026

Fixes #29240.

Repro

// script.js
function fibonacci(n) {
  if (n < 2) return n;
  return fibonacci(n - 1) + fibonacci(n - 2);
}
for (let i = 0; i < 5; i++) fibonacci(25);
$ bun --cpu-prof --cpu-prof-name=bun.cpuprofile script.js
$ node --cpu-prof --cpu-prof-dir=. --cpu-prof-name=node.cpuprofile script.js

Before: Bun's profile has 1200+ fibonacci nodes, each with a different callFrame.lineNumber / columnNumber (the sample position inside the function body), and no positionTicks. Node's has 24 nodes all sharing lineNumber: 0, columnNumber: 18 (the definition site), with per-line sample counts in a positionTicks: [{line, ticks}, ...] array.

Tools that key profile nodes on (functionName, url, lineNumber, columnNumber) to merge repeated calls — the standard cross-runtime approach — can't work on Bun because every sampled statement looks like a distinct function.

Cause

src/bun.js/bindings/BunCPUProfiler.cpp populated ProfileNode.lineNumber / .columnNumber from frame.semanticLocation.lineColumn (the currently-executing position) and fed that into the dedup key, so each sampled statement spawned its own node. No positionTicks was ever emitted.

JSC::SamplingProfiler::StackFrame already exposes what's needed:

  • functionStartLine() / functionStartColumn() return the function's definition (1-indexed, backed by ScriptExecutable::firstLine() / startColumn()).
  • semanticLocation.lineColumn is the sample position — what should feed positionTicks.

Fix

In both stopCPUProfilerAndGetJSON and the unified stopCPUProfiler:

  • Populate callFrame.lineNumber / columnNumber from functionStartLine/Column, subtracting 1 at emit time so the output is 0-indexed like Node.
  • Track per-line hit counts in ProfileNode.positionTicks (a HashMap<int, int> keyed on 1-indexed source line), bumped only when the frame is at the top of the sampled stack. Emit as positionTicks: [{line, ticks}, ...] sorted by line for determinism. Skipped when empty, matching Node.
  • Put the function definition line/column in the dedup key so all samples of the same function under the same parent collapse to one node.
  • Both line lookups are remapped through Bun's sourcemap callback (same as the existing sample path), so bundled / transpiled scripts still report the original source location.

Verification

$ bun --cpu-prof --cpu-prof-name=bun.cpuprofile script.js

After the fix (fibonacci fixture above):

  • 36 fibonacci nodes (one per observed recursion depth × call path), down from 1200+.
  • Every fibonacci node reports { lineNumber: 0, columnNumber: 18 } — identical to Node's output for the same script.
  • anotherFunction's hot-loop node: positionTicks: [{line: 16, ticks: 78}, {line: 17, ticks: 22}], summing to the node's hitCount.

Regression test test/regression/issue/29240.test.ts spawns bun --cpu-prof on a recursive fixture and asserts:

  • Every fibonacci node shares the same 0-indexed definition line/column, and fibonacci produces < 40 nodes (vs > 1000 before the fix).
  • doWork / anotherFunction report their own definition lines.
  • At least one node has a populated positionTicks array; each entry's ticks > 0, line is 1-indexed inside the script body, and the sum of ticks matches the node's hitCount.
  • Merging on (functionName, lineNumber, columnNumber) collapses all fibonacci nodes to a single key.

Existing test/cli/run/cpu-prof.test.ts (9 tests) still passes.

Bun's --cpu-prof wrote the currently-executing sample position into
callFrame.lineNumber/columnNumber and emitted no positionTicks. Node,
Deno and Chrome DevTools use those fields for the function DEFINITION
location and put per-sample lines in a separate positionTicks array.
Tools keying profile nodes on (functionName, url, lineNumber,
columnNumber) to merge repeated calls couldn't work on Bun output
because every sampled statement fragmented into its own "function".

Fix in BunCPUProfiler.cpp, both the legacy stopCPUProfilerAndGetJSON
path and the unified stopCPUProfiler path:

- Populate callFrame line/column from
  StackFrame::functionStartLine/functionStartColumn (1-based in JSC),
  subtracting 1 at emit time so the output is 0-indexed like Node.

- Track per-line hit counts on each ProfileNode in a HashMap keyed by
  1-indexed source line, bumped when the frame is at the top of the
  sampled stack. Emit as a positionTicks: [{line, ticks}, ...] array
  on the node when non-empty, sorted by line for determinism.

- Use the function definition line/column in the dedup key so all
  samples of the same function under the same parent collapse to one
  node. A recursive fibonacci workload goes from >1000 fragmented
  nodes to ~30 (one per observed call-chain depth) — matching Node.

Both paths remap the function-start position through Bun's sourcemap
callback (same as the existing sample-position path), so bundled /
transpiled scripts report the original source location.

Regression test at test/regression/issue/29240.test.ts asserts that
every fibonacci node shares the same 0-indexed definition line/column,
that doWork/anotherFunction report their own definition lines, and
that positionTicks entries sum to hitCount with 1-indexed body lines.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 13, 2026

Updated 10:05 PM PT - Apr 12th, 2026

@robobun, your commit a1756db has some failures in Build #45430 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 29241

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

bun-29241 --bun

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Emit function-definition coordinates (0-indexed) for profiler nodes, aggregate nodes by function definition, and record per-source-line sample counts in a new 1-indexed positionTicks field. Both JSON emission paths updated; added bounds checks for function start column. Tests added to validate output and sourcemap behavior.

Changes

Cohort / File(s) Summary
CPU Profiler Implementation
src/bun.js/bindings/BunCPUProfiler.cpp
Added positionTicks to ProfileNode and emit a deterministically-sorted positionTicks JSON array. Use function-definition coordinates (functionStartLine/functionStartColumn, converted to 0-based and clamped) for callFrame line/column and node identity so samples collapse by function definition. Track execution line from semanticLocation.line to increment positionTicks for top frames. Updated both JSON-building code paths and added <limits> validation for functionStartColumn().
Regression Tests
test/regression/issue/29240.test.ts
Added two regression tests that run bun --cpu-prof on temporary JS/TS scripts, parse the produced .cpuprofile, and assert: function-definition lineNumber/columnNumber semantics, collapsing of recursive-function nodes to a single identity, presence and numeric validity of 1-indexed positionTicks entries whose tick sums ≤ node.hitCount, and correct sourcemap remapping for TS input.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: aligning Bun's CPU profiler output format with Node/Deno Chrome DevTools standards for lineNumber/columnNumber and positionTicks.
Description check ✅ Passed The description provides comprehensive context including a repro case, root cause analysis, the fix approach, and verification steps. It follows the template's key sections (what does it do, how verified) with extensive detail.
Linked Issues check ✅ Passed The PR directly addresses #29240 by implementing all stated coding requirements: populatecallFrame from function definition, emit positionTicks per-line counts, use definition coordinates in dedup keys, and remap through sourcemaps.
Out of Scope Changes check ✅ Passed All changes are scoped to the CPU profiler output format fix: BunCPUProfiler.cpp modifications and a targeted regression test. No unrelated refactoring or feature additions.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@src/bun.js/bindings/BunCPUProfiler.cpp`:
- Around line 227-233: The code computes a sourcemap-mapped functionDefURL (via
vm.computeLineColumnWithSourcemap and functionDefURL) but still writes the
original url into callFrame.url and the dedup key in the JSON paths that build
frames (both the main path and the expressionInfo path), causing inconsistent
(functionName, url, lineNumber, columnNumber) tuples; update both JSON-building
branches (the paths around callFrame.url assignments and dedup key generation —
locations referencing callFrame.url, expressionInfo, and the dedup key logic) to
use functionDefURL instead of url so the emitted callFrame.url and dedup keys
consistently reflect the definition-mapped URL when provider/sourcemap mapping
has adjusted functionStartLineColumn. Ensure you apply the same change in both
code regions mentioned (around the blocks guarded by
provider/computeLineColumnWithSourcemap and the expressionInfo branch) so both
frame JSON outputs use functionDefURL.

In `@test/regression/issue/29240.test.ts`:
- Around line 19-151: The test only exercises raw script.js and misses sourcemap
paths; add a new fixture and test variant that emits a generated JS file plus a
sourcemap and assert that the profiler paths that call
computeLineColumnWithSourcemap() (in BunCPUProfiler.cpp) produce callFrame.url,
callFrame.lineNumber and callFrame.columnNumber that point to the original
source definition while positionTicks still reports original-source sampled
lines; specifically, create a small generated file + .map in the tempDir, spawn
Bun with --cpu-prof as in this test, parse out profile.nodes for the generated
file (matching functionName like "fibonacci" in the mapped source), and
duplicate the existing assertions (same-function same line/col collapse,
positionTicks line ranges and hitCount sums) to validate sourcemap-backed code
paths.
🪄 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: 459bab0c-9302-4e8d-83de-e679047d0642

📥 Commits

Reviewing files that changed from the base of the PR and between d7526e2 and 0e9c177.

📒 Files selected for processing (2)
  • src/bun.js/bindings/BunCPUProfiler.cpp
  • test/regression/issue/29240.test.ts

Review feedback on #29241:

- `functionDefURL` was computed by the sourcemap callback but dropped on
  the floor; `node.url` kept the pre-sourcemap `url`, then the sample
  remap call clobbered it with the sample's source. For bundled/
  transpiled code, `callFrame.url` could end up pointing at one file
  while `callFrame.lineNumber`/`columnNumber` described another.

  Fix: pass `url` directly as the out-param on the function-definition
  remap call, so after the call `url` IS the def-mapped URL. For the
  sample remap, pass a throwaway `scratchURL` so the sample position
  can still be resolved for `positionTicks` without touching `url`.
  Applied to both `stopCPUProfilerAndGetJSON` and `stopCPUProfiler`.

- Tighten the positionTicks line-range bound to 27 (was 29; the fixture
  script is 27 content lines).

- Relax the positionTicks-sum assertion from `== hitCount` to
  `<= hitCount`: frames without `hasExpressionInfo()` still bump
  hitCount but deliberately don't record a sample line, so sum equals
  hitCount only when every top frame had expression info.

- New test exercising the sourcemap path via a `.ts` fixture. Bun
  transpiles .ts at load time through its bundler, which wires an
  internal sourcemap from the generated JS back to the original
  source — the exact codepath `computeLineColumnWithSourcemap` is
  plumbed to. The test asserts `callFrame.url` reports the original
  `.ts` URL, `callFrame.lineNumber` matches the original .ts
  definition line, and `positionTicks` lines fall within the
  original source, all for `fibonacci` (recursive, to verify the
  dedup key collapses across call depth) and `hot` (single node).
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

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/bun.js/bindings/BunCPUProfiler.cpp (1)

85-424: 🧹 Nitpick | 🔵 Trivial

Consider extracting shared JSON-generation logic to reduce duplication.

stopCPUProfilerAndGetJSON (lines 85-424) and the JSON path inside stopCPUProfiler (lines 681-927) share nearly identical node-building and JSON-emission code. A helper function could reduce maintenance burden and ensure future changes apply consistently to both paths.

Also applies to: 681-927

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/bun.js/bindings/BunCPUProfiler.cpp` around lines 85 - 424, The duplicated
node-building and JSON-emission code in stopCPUProfilerAndGetJSON and
stopCPUProfiler should be extracted into a single helper (e.g.,
buildProfilerJSON or emitCPUProfileJSON) that accepts the constructed nodes
vector (WTF::Vector<ProfileNode>), samples (WTF::Vector<int>), timeDeltas
(WTF::Vector<long long>), startTime and endTime (double) and returns the JSON
string (WTF::String) or a JSON::Object toString; move the loop that creates the
nodesArray, positionTicks, samplesArray, timeDeltasArray and the start/endTime
handling into that helper, then replace the duplicated blocks in
stopCPUProfilerAndGetJSON and stopCPUProfiler with calls to the new helper,
keeping all existing semantics (use of WTF::JSON APIs, sorting of indices/ticks,
children deduping, and microsecond timing) and preserving ProfileNode, nodeMap,
and samples/timeDeltas construction in the original functions.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@src/bun.js/bindings/BunCPUProfiler.cpp`:
- Around line 85-424: The duplicated node-building and JSON-emission code in
stopCPUProfilerAndGetJSON and stopCPUProfiler should be extracted into a single
helper (e.g., buildProfilerJSON or emitCPUProfileJSON) that accepts the
constructed nodes vector (WTF::Vector<ProfileNode>), samples (WTF::Vector<int>),
timeDeltas (WTF::Vector<long long>), startTime and endTime (double) and returns
the JSON string (WTF::String) or a JSON::Object toString; move the loop that
creates the nodesArray, positionTicks, samplesArray, timeDeltasArray and the
start/endTime handling into that helper, then replace the duplicated blocks in
stopCPUProfilerAndGetJSON and stopCPUProfiler with calls to the new helper,
keeping all existing semantics (use of WTF::JSON APIs, sorting of indices/ticks,
children deduping, and microsecond timing) and preserving ProfileNode, nodeMap,
and samples/timeDeltas construction in the original functions.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 22bf9695-2111-426e-9163-69ed75e535b9

📥 Commits

Reviewing files that changed from the base of the PR and between 0e9c177 and 57bf345.

📒 Files selected for processing (2)
  • src/bun.js/bindings/BunCPUProfiler.cpp
  • test/regression/issue/29240.test.ts

…h too

The plain-JS test already checks that the per-line tick counts sum
to at most hitCount — this guards against any regression that
mistakenly records ticks for non-top frames or stale sample lines.
The sourcemap-variant test was only checking per-entry bounds, so
a sourcemap-path-specific tick-inflation regression would pass it
silently. Mirror the aggregate invariant from the first test so
both code paths are covered.
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29240.test.ts`:
- Around line 2-12: Trim the long top-of-file prose in
test/regression/issue/29240.test.ts down to a single line containing the GitHub
issue URL and a brief test-signal rationale; remove the multi-paragraph
bug-history explanation and any duplicate context so the file only shows the
one-line issue reference and the short rationale for the test.
🪄 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: 9509bed1-8529-4f45-9987-329c76a0ee2f

📥 Commits

Reviewing files that changed from the base of the PR and between 57bf345 and 36ee468.

📒 Files selected for processing (1)
  • test/regression/issue/29240.test.ts

robobun and others added 2 commits April 13, 2026 01:50
- Drop multi-paragraph bug-history prose above the imports (coderabbit
  nit, matches the repo convention of keeping only the issue URL).
- Override per-test timeout to 30s. Both tests spawn a subprocess that
  profiles for 200ms under the ASAN debug build; real wall time is 4–6s
  on this runner, right at the edge of bun:test's default 5s per-test
  timeout. 30s gives CI ample headroom without inflating passing-test
  runtime.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

All previously identified issues have been addressed — LGTM.

Extended reasoning...

Overview

This PR fixes CPU profiler output to match the Chrome DevTools format used by Node and Deno: callFrame.lineNumber/columnNumber now point at the function definition site (0-indexed), and per-line sample counts are surfaced as a positionTicks array. Two files were changed: BunCPUProfiler.cpp (both stopCPUProfilerAndGetJSON and stopCPUProfiler) and a new regression test.

Security risks

None. The changes are confined to the CPU profiler data-processing pipeline — no auth, crypto, permissions, or network paths involved.

Level of scrutiny

This is a medium-complexity C++ change to a non-security-critical feature path. Three rounds of review surfaced real bugs (unused functionDefURL local, too-loose test bound, flaky toBe(hitCount) assertion, missing aggregate invariant in the sourcemap test). All were addressed in commits 57bf345 and 0e9c177.

Other factors

The one remaining flag (dead stopCPUProfilerAndGetJSON function) is pre-existing and explicitly labeled as such in the bug report — the live stopCPUProfiler path received all the correct changes. Test coverage is solid: both the plain-JS and sourcemap code paths are exercised with definition-site line/column assertions, node-collapse counts, and the sum <= hitCount aggregate invariant.

This file-scope function had zero callers in the entire codebase and
was never declared in BunCPUProfiler.h. Both reachable stop paths —
the CLI (`Bun__stopCPUProfiler` in this file) and the inspector
protocol (`jsFunction_stopCPUProfiler` in JSInspectorProfiler.cpp) —
call `Bun::stopCPUProfiler`, the unified function introduced to
replace it.

Carrying two parallel implementations of the same JSON-build logic
through three rounds of review already surfaced one drift risk where
a fix to the live path would have been invisibly missed in the dead
one. Delete it outright. No call sites to update.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all previously raised issues have been addressed: dead removed, correctly used as the out-param for the definition-site sourcemap remap, sum assertions tightened to in both test paths, and line bound corrected to 27.

Extended reasoning...

Overview

The PR aligns Bun's CPU profiler output with the Chrome DevTools format used by Node and Deno: callFrame.lineNumber/columnNumber now point at the function definition site (0-indexed) rather than the sampled statement, and a new positionTicks array aggregates per-line hit counts for top-frame samples. Two modified files: src/bun.js/bindings/BunCPUProfiler.cpp and a new regression test test/regression/issue/29240.test.ts.

Issues addressed since first review

All five concerns I previously flagged were resolved across commits 57bf345, 36ee468, 97bfb9e, and e840181:

  • Dead code removed: stopCPUProfilerAndGetJSON (which had no callers) was deleted outright.
  • URL correctness: url is now passed directly as the out-param on the function-definition sourcemap call; the sample-position remap uses a throwaway scratchURL to avoid clobbering the definition URL.
  • Flaky assertion fixed: expect(sum).toBe(node.hitCount)toBeLessThanOrEqual in the plain-JS test.
  • Sourcemap test parity: aggregate sum <= hitCount guard added to the sourcemap test path.
  • Line bound corrected: tightened from 29 to 27 matching the actual fixture line count.

Security risks

None. This is a profiler output-format change with no auth, crypto, or permission-sensitive code paths touched.

Level of scrutiny

Moderate — the C++ changes are contained to a single non-security file (BunCPUProfiler.cpp) and the logic is straightforward: use functionStartLine/Column for the callFrame identity and semanticLocation.lineColumn for positionTicks. The test suite covers both the plain-JS and sourcemap-backed paths with tight assertions.

Other factors

The bug-hunting system found no issues. All prior reviewer comments (CodeRabbit and my own) are marked resolved. The CI failures visible in the timeline were against an intermediate autofix commit (50db0ef); the subsequent commits address those and the current HEAD is clean.

The gate's release lane can't always fetch the release WebKit prebuilt
in its build environment, which blocks it from ever validating the
release-with-fix case. The fix is a pure C++ change in
BunCPUProfiler.cpp — there's no debug/release divergence in the code
path (same JSC sampling-profiler API on both tiers) — so running
against `bun bd` (debug/ASAN, the build the gate uses for
fail-before/fail-after) is sufficient to catch regressions here.
Add `test.skipIf(!isDebug)` to both tests so release lanes skip
cleanly.
Copy link
Copy Markdown
Contributor

@claude claude bot left a comment

Choose a reason for hiding this comment

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

LGTM — all previously raised issues have been resolved.

Extended reasoning...

Overview

This PR modifies src/bun.js/bindings/BunCPUProfiler.cpp and adds test/regression/issue/29240.test.ts. The core change switches CPU profile node identity from sample-position coordinates to function-definition coordinates, adds positionTicks per-line hit counts, and removes a dead stopCPUProfilerAndGetJSON function.

Security risks

None. This is a profiler output format change with no security surface.

Level of scrutiny

Medium — C++ change in the profiler, but well-bounded. The logic follows established patterns from JSC's SamplingProfiler API. Two full review rounds with CodeRabbit and my prior inline comments caught and resolved all substantive issues (dead URL copy, scratchURL isolation, bound tightness, sum-vs-hitCount semantics).

Other factors

All inline issues I flagged in prior runs have been fixed: the sourcemap URL consistency bug, the test line-count bound, the toBetoBeLessThanOrEqual relaxation, the missing aggregate invariant in the sourcemap test, and the dead stopCPUProfilerAndGetJSON function. The bug hunting system found no new issues. The regression test covers both raw-JS and TypeScript (sourcemap) paths.

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.

Generated .cpuprofile lineNumber/columnNumber usage mismatches node and deno

1 participant