diff --git a/src/bun.js/bindings/BunCPUProfiler.cpp b/src/bun.js/bindings/BunCPUProfiler.cpp index ceb0d046824..a14d75dabf2 100644 --- a/src/bun.js/bindings/BunCPUProfiler.cpp +++ b/src/bun.js/bindings/BunCPUProfiler.cpp @@ -16,6 +16,7 @@ #include #include #include +#include extern "C" void Bun__startCPUProfiler(JSC::VM* vm); extern "C" void Bun__stopCPUProfiler(JSC::VM* vm, BunString* outJSON, BunString* outText); @@ -66,281 +67,21 @@ struct ProfileNode { WTF::String functionName; WTF::String url; int scriptId; + // lineNumber/columnNumber are the location where the function is DEFINED + // (matching Node/Deno/Chrome DevTools), stored as 0-indexed values ready + // for JSON emission. -1 means "unknown". int lineNumber; int columnNumber; int hitCount; WTF::Vector children; + // Per-line sample counts for this node, keyed by 1-indexed source line. + // Emitted as `positionTicks` in the JSON output when non-empty, matching + // the Chrome DevTools CPU profile format used by Node and Deno. + // Lines are guaranteed non-zero, so the default IntHashTraits (which reserve + // 0 and -1 as empty/deleted sentinels) are safe here. + WTF::HashMap> positionTicks; }; -WTF::String stopCPUProfilerAndGetJSON(JSC::VM& vm) -{ - s_isProfilerRunning = false; - - JSC::SamplingProfiler* profiler = vm.samplingProfiler(); - if (!profiler) - return WTF::String(); - - // JSLock is re-entrant, so always acquiring it handles both JS and shutdown contexts - JSC::JSLockHolder locker(vm); - - // Defer GC while we're working with stack traces - JSC::DeferGC deferGC(vm); - - // Pause the profiler while holding the lock - this is critical for thread safety. - // The sampling thread holds this lock while modifying traces, so holding it here - // ensures no concurrent modifications. We use pause() instead of shutdown() to - // allow the profiler to be restarted for the inspector API. - auto& lock = profiler->getLock(); - WTF::Locker profilerLocker { lock }; - profiler->pause(); - - // releaseStackTraces() calls processUnverifiedStackTraces() internally - auto stackTraces = profiler->releaseStackTraces(); - profiler->clearData(); - - // Build Chrome CPU Profiler format - // Map from stack frame signature to node ID - WTF::HashMap nodeMap; - WTF::Vector nodes; - - // Create root node - ProfileNode rootNode; - rootNode.id = 1; - rootNode.functionName = "(root)"_s; - rootNode.url = ""_s; - rootNode.scriptId = 0; - rootNode.lineNumber = -1; - rootNode.columnNumber = -1; - rootNode.hitCount = 0; - nodes.append(WTF::move(rootNode)); - - int nextNodeId = 2; - WTF::Vector samples; - WTF::Vector timeDeltas; - - // Create an index array to process stack traces in chronological order - // We can't sort stackTraces directly because StackTrace has deleted copy assignment - WTF::Vector sortedIndices; - sortedIndices.reserveInitialCapacity(stackTraces.size()); - for (size_t i = 0; i < stackTraces.size(); i++) { - sortedIndices.append(i); - } - - // Sort indices by monotonic timestamp to ensure chronological order - // Use timestamp instead of stopwatchTimestamp for better resolution - // This is critical for calculating correct timeDeltas between samples - std::sort(sortedIndices.begin(), sortedIndices.end(), [&stackTraces](size_t a, size_t b) { - return stackTraces[a].timestamp < stackTraces[b].timestamp; - }); - - // Use the profiling start time that was captured when profiling began - // This ensures the first timeDelta represents the time from profiling start to first sample - double startTime = s_profilingStartTime; - double lastTime = s_profilingStartTime; - - // Process each stack trace in chronological order - for (size_t idx : sortedIndices) { - auto& stackTrace = stackTraces[idx]; - if (stackTrace.frames.isEmpty()) { - samples.append(1); // Root node - // Use monotonic timestamp converted to wall clock time - double currentTime = stackTrace.timestamp.approximateWallTime().secondsSinceEpoch().value() * 1000000.0; - double delta = std::max(0.0, currentTime - lastTime); - timeDeltas.append(static_cast(delta)); - lastTime = currentTime; - continue; - } - - int currentParentId = 1; // Start from root - - // Process frames from bottom to top (reverse order for Chrome format) - for (int i = stackTrace.frames.size() - 1; i >= 0; i--) { - auto& frame = stackTrace.frames[i]; - - WTF::String functionName; - WTF::String url; - int scriptId = 0; - int lineNumber = -1; - int columnNumber = -1; - - // Get function name - displayName works for all frame types - functionName = frame.displayName(vm); - - if (frame.frameType == JSC::SamplingProfiler::FrameType::Executable && frame.executable) { - auto sourceProviderAndID = frame.sourceProviderAndID(); - auto* provider = std::get<0>(sourceProviderAndID); - if (provider) { - url = provider->sourceURL(); - scriptId = static_cast(provider->asID()); - - // Convert absolute paths to file:// URLs - // Check for: - // - Unix absolute path: /path/to/file - // - Windows drive letter: C:\path or C:/path - // - Windows UNC path: \\server\share - bool isAbsolutePath = false; - if (!url.isEmpty()) { - if (url[0] == '/') { - // Unix absolute path - isAbsolutePath = true; - } else if (url.length() >= 2 && url[1] == ':') { - // Windows drive letter (e.g., C:\) - char firstChar = url[0]; - if ((firstChar >= 'A' && firstChar <= 'Z') || (firstChar >= 'a' && firstChar <= 'z')) { - isAbsolutePath = true; - } - } else if (url.length() >= 2 && url[0] == '\\' && url[1] == '\\') { - // Windows UNC path (e.g., \\server\share) - isAbsolutePath = true; - } - } - - if (isAbsolutePath) { - url = WTF::URL::fileURLWithFileSystemPath(url).string(); - } - } - - if (frame.hasExpressionInfo()) { - // Apply sourcemap if available - JSC::LineColumn sourceMappedLineColumn = frame.semanticLocation.lineColumn; - if (provider) { -#if USE(BUN_JSC_ADDITIONS) - auto& fn = vm.computeLineColumnWithSourcemap(); - if (fn) { - fn(vm, provider, sourceMappedLineColumn, url); - } -#endif - } - lineNumber = static_cast(sourceMappedLineColumn.line); - columnNumber = static_cast(sourceMappedLineColumn.column); - } - } - - // Create a unique key for this frame based on parent + callFrame - // This creates separate nodes for the same function in different call paths - WTF::StringBuilder keyBuilder; - keyBuilder.append(currentParentId); - keyBuilder.append(':'); - keyBuilder.append(functionName); - keyBuilder.append(':'); - keyBuilder.append(url); - keyBuilder.append(':'); - keyBuilder.append(scriptId); - keyBuilder.append(':'); - keyBuilder.append(lineNumber); - keyBuilder.append(':'); - keyBuilder.append(columnNumber); - - WTF::String key = keyBuilder.toString(); - - int nodeId; - auto it = nodeMap.find(key); - if (it == nodeMap.end()) { - // Create new node - nodeId = nextNodeId++; - nodeMap.add(key, nodeId); - - ProfileNode node; - node.id = nodeId; - node.functionName = functionName; - node.url = url; - node.scriptId = scriptId; - node.lineNumber = lineNumber; - node.columnNumber = columnNumber; - node.hitCount = 0; - - nodes.append(WTF::move(node)); - - // Add this node as child of parent - if (currentParentId > 0) { - nodes[currentParentId - 1].children.append(nodeId); - } - } else { - // Node already exists with this parent+callFrame combination - nodeId = it->value; - } - - currentParentId = nodeId; - - // If this is the top frame, increment hit count - if (i == 0) { - nodes[nodeId - 1].hitCount++; - } - } - - // Add sample pointing to the top frame - samples.append(currentParentId); - - // Add time delta - // Use monotonic timestamp converted to wall clock time - double currentTime = stackTrace.timestamp.approximateWallTime().secondsSinceEpoch().value() * 1000000.0; - double delta = std::max(0.0, currentTime - lastTime); - timeDeltas.append(static_cast(delta)); - lastTime = currentTime; - } - - // endTime is the wall clock time of the last sample - double endTime = lastTime; - - // Build JSON using WTF::JSON - using namespace WTF; - auto json = JSON::Object::create(); - - // Add nodes array - auto nodesArray = JSON::Array::create(); - for (const auto& node : nodes) { - auto nodeObj = JSON::Object::create(); - nodeObj->setInteger("id"_s, node.id); - - auto callFrame = JSON::Object::create(); - callFrame->setString("functionName"_s, node.functionName); - callFrame->setString("scriptId"_s, WTF::String::number(node.scriptId)); - callFrame->setString("url"_s, node.url); - callFrame->setInteger("lineNumber"_s, node.lineNumber); - callFrame->setInteger("columnNumber"_s, node.columnNumber); - - nodeObj->setValue("callFrame"_s, callFrame); - nodeObj->setInteger("hitCount"_s, node.hitCount); - - if (!node.children.isEmpty()) { - auto childrenArray = JSON::Array::create(); - WTF::HashSet seenChildren; - for (int childId : node.children) { - if (seenChildren.add(childId).isNewEntry) { - childrenArray->pushInteger(childId); - } - } - nodeObj->setValue("children"_s, childrenArray); - } - - nodesArray->pushValue(nodeObj); - } - json->setValue("nodes"_s, nodesArray); - - // Add timing info in microseconds - // Note: Using setDouble() instead of setInteger() because setInteger() has precision - // issues with large values (> 2^31). Chrome DevTools expects microseconds since Unix epoch, - // which are typically 16-digit numbers. JSON numbers can represent these precisely. - json->setDouble("startTime"_s, startTime); - json->setDouble("endTime"_s, endTime); - - // Add samples array - auto samplesArray = JSON::Array::create(); - for (int sample : samples) { - samplesArray->pushInteger(sample); - } - json->setValue("samples"_s, samplesArray); - - // Add timeDeltas array - auto timeDeltasArray = JSON::Array::create(); - for (long long delta : timeDeltas) { - timeDeltasArray->pushInteger(delta); - } - json->setValue("timeDeltas"_s, timeDeltasArray); - - return json->toJSONString(); -} - // ============================================================================ // TEXT FORMAT OUTPUT (grep-friendly, designed for LLM analysis) // ============================================================================ @@ -638,8 +379,11 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) WTF::String functionName = frame.displayName(vm); WTF::String url; int scriptId = 0; - int lineNumber = -1; - int columnNumber = -1; + // Function-definition line/column (0-indexed) for callFrame. + int functionDefLine = -1; + int functionDefColumn = -1; + // Current-sample line (1-indexed) for positionTicks. + int sampleLine = 0; if (frame.frameType == JSC::SamplingProfiler::FrameType::Executable && frame.executable) { auto sourceProviderAndID = frame.sourceProviderAndID(); @@ -647,37 +391,104 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) if (provider) { url = provider->sourceURL(); scriptId = static_cast(provider->asID()); + } + // Absolute file path → `file://` URL. Chrome DevTools + // expects `callFrame.url` to be a proper URL; leaving + // the raw path breaks source-view resolution. We run + // this AFTER the sourcemap callbacks below because the + // callback (see FormatStackTraceForJS.cpp) unconditionally + // rewrites its out-param back to the raw provider URL when + // no sourcemap is found, which would undo an earlier + // normalization. See #29240. + auto normalizeURL = [](WTF::String& u) { + if (u.isEmpty()) + return; bool isAbsolutePath = false; - if (!url.isEmpty()) { - if (url[0] == '/') - isAbsolutePath = true; - else if (url.length() >= 2 && url[1] == ':') { - char firstChar = url[0]; - if ((firstChar >= 'A' && firstChar <= 'Z') || (firstChar >= 'a' && firstChar <= 'z')) - isAbsolutePath = true; - } else if (url.length() >= 2 && url[0] == '\\' && url[1] == '\\') + if (u[0] == '/') { + isAbsolutePath = true; + } else if (u.length() >= 2 && u[1] == ':') { + char firstChar = u[0]; + if ((firstChar >= 'A' && firstChar <= 'Z') || (firstChar >= 'a' && firstChar <= 'z')) isAbsolutePath = true; + } else if (u.length() >= 2 && u[0] == '\\' && u[1] == '\\') { + isAbsolutePath = true; } - if (isAbsolutePath) - url = WTF::URL::fileURLWithFileSystemPath(url).string(); + u = WTF::URL::fileURLWithFileSystemPath(u).string(); + }; + + // Function definition location. JSC returns these 1-based; + // Node/Deno/Chrome DevTools emit them 0-based in the JSON. + // The definition (not the sample position) is remapped + // through the sourcemap callback so callFrame.url and + // callFrame.line/column agree on the function's source. + int rawFunctionStartLine = frame.functionStartLine(); + unsigned rawFunctionStartColumn = frame.functionStartColumn(); + if (rawFunctionStartLine > 0 && rawFunctionStartColumn != std::numeric_limits::max()) { + JSC::LineColumn functionStartLineColumn { + static_cast(rawFunctionStartLine), + rawFunctionStartColumn, + }; + if (provider) { +#if USE(BUN_JSC_ADDITIONS) + auto& fn = vm.computeLineColumnWithSourcemap(); + if (fn) { + // `url` is the out-param — on a successful + // remap it becomes the original-source URL. + fn(vm, provider, functionStartLineColumn, url); + } +#endif + } + functionDefLine = functionStartLineColumn.line > 0 + ? static_cast(functionStartLineColumn.line) - 1 + : 0; + functionDefColumn = functionStartLineColumn.column > 0 + ? static_cast(functionStartLineColumn.column) - 1 + : 0; } + // Normalize `url` to a `file://` URL now that any + // sourcemap rewriting is done. + normalizeURL(url); + if (frame.hasExpressionInfo()) { + // Sample position for positionTicks. Use a throwaway + // out-param so the sample remap can't clobber `url` + // with a different file than the function definition. + // We also drop the sample line entirely if the sample + // maps back to a DIFFERENT original source file than + // the definition (cross-module inlining in bundled + // code) — attaching a line number from one file to a + // ProfileNode whose callFrame.url is a different file + // would mislocate the tick in Chrome DevTools. JSC::LineColumn sourceMappedLineColumn = frame.semanticLocation.lineColumn; + // Seed with the raw provider URL (NOT empty). If the + // sourcemap callback is a no-op — BUN_JSC_ADDITIONS + // off, fn null, or the provider has no sourcemap — + // sampleURL stays at this seed, and normalizeURL() + // below converts it to the same `file://` form as + // `url`, letting the `sampleURL == url` guard pass + // for plain .js files. Seeding empty would silently + // suppress positionTicks for every non-sourcemapped + // script. + WTF::String sampleURL = provider ? WTF::String(provider->sourceURL()) : WTF::String(); if (provider) { #if USE(BUN_JSC_ADDITIONS) auto& fn = vm.computeLineColumnWithSourcemap(); - if (fn) - fn(vm, provider, sourceMappedLineColumn, url); + if (fn) { + fn(vm, provider, sourceMappedLineColumn, sampleURL); + } #endif } - lineNumber = static_cast(sourceMappedLineColumn.line); - columnNumber = static_cast(sourceMappedLineColumn.column); + normalizeURL(sampleURL); + if (sourceMappedLineColumn.line > 0 && sampleURL == url) + sampleLine = static_cast(sourceMappedLineColumn.line); } } + // line/column here identify the function's DEFINITION, so all + // samples of the same function under the same parent collapse. WTF::StringBuilder keyBuilder; keyBuilder.append(currentParentId); keyBuilder.append(':'); @@ -687,9 +498,9 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) keyBuilder.append(':'); keyBuilder.append(scriptId); keyBuilder.append(':'); - keyBuilder.append(lineNumber); + keyBuilder.append(functionDefLine); keyBuilder.append(':'); - keyBuilder.append(columnNumber); + keyBuilder.append(functionDefColumn); WTF::String key = keyBuilder.toString(); @@ -704,8 +515,8 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) node.functionName = functionName; node.url = url; node.scriptId = scriptId; - node.lineNumber = lineNumber; - node.columnNumber = columnNumber; + node.lineNumber = functionDefLine; + node.columnNumber = functionDefColumn; node.hitCount = 0; nodes.append(WTF::move(node)); @@ -718,8 +529,11 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) currentParentId = nodeId; - if (i == 0) + if (i == 0) { nodes[nodeId - 1].hitCount++; + if (sampleLine > 0) + nodes[nodeId - 1].positionTicks.add(sampleLine, 0).iterator->value++; + } } samples.append(currentParentId); @@ -761,6 +575,26 @@ void stopCPUProfiler(JSC::VM& vm, WTF::String* outJSON, WTF::String* outText) nodeObj->setValue("children"_s, childrenArray); } + // Per-line sample counts (Chrome DevTools format). Emit sorted by + // line for deterministic output. + if (!node.positionTicks.isEmpty()) { + WTF::Vector> sortedTicks; + sortedTicks.reserveInitialCapacity(node.positionTicks.size()); + for (auto& entry : node.positionTicks) + sortedTicks.append({ entry.key, entry.value }); + std::sort(sortedTicks.begin(), sortedTicks.end(), [](const auto& a, const auto& b) { + return a.first < b.first; + }); + auto positionTicksArray = JSON::Array::create(); + for (auto& [line, ticks] : sortedTicks) { + auto tickObj = JSON::Object::create(); + tickObj->setInteger("line"_s, line); + tickObj->setInteger("ticks"_s, ticks); + positionTicksArray->pushValue(tickObj); + } + nodeObj->setValue("positionTicks"_s, positionTicksArray); + } + nodesArray->pushValue(nodeObj); } json->setValue("nodes"_s, nodesArray); diff --git a/test/regression/issue/29240.test.ts b/test/regression/issue/29240.test.ts new file mode 100644 index 00000000000..85eaa555781 --- /dev/null +++ b/test/regression/issue/29240.test.ts @@ -0,0 +1,254 @@ +// https://github.com/oven-sh/bun/issues/29240 + +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { readFileSync, readdirSync } from "node:fs"; +import { join } from "node:path"; + +test("cpu-prof callFrame.lineNumber/columnNumber point at function definition, not sample position (#29240)", async () => { + // fibonacci is recursive so it shows up on many stacks at many different + // sample lines — this is the exact case where the old Bun output fragmented + // into dozens of nodes per function. Same for the busy loop body in + // `anotherFunction`, which gives us per-line ticks to assert on. + using dir = tempDir("issue-29240", { + "script.js": `function fibonacci(n) { + if (n < 2) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function doWork() { + let sum = 0; + for (let i = 0; i < 30; i++) { + sum += fibonacci(22); + } + return sum; +} + +function anotherFunction() { + let x = 0; + for (let i = 0; i < 200000; i++) { + x += Math.sqrt(i); + } + return x; +} + +const deadline = performance.now() + 200; +while (performance.now() < deadline) { + doWork(); + anotherFunction(); +} +console.log("done"); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-dir=.", "--cpu-prof-name=out.cpuprofile", "script.js"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + // The script must run cleanly. Stderr may contain the debug-build ASAN + // warning, so we only assert that stdout/exitCode look right. + expect(stdout).toBe("done\n"); + expect(exitCode).toBe(0); + + const files = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); + expect(files).toEqual(["out.cpuprofile"]); + + const profile = JSON.parse(readFileSync(join(String(dir), "out.cpuprofile"), "utf8")); + + // callFrame.url must be a proper `file://` URL for absolute-path scripts — + // Chrome DevTools and other cpuprofile viewers won't resolve source views + // from bare paths. Matches Node's `file:///path/to/script.js` format. + const scriptNodes = profile.nodes.filter( + (n: any) => typeof n.callFrame.url === "string" && n.callFrame.url.endsWith("/script.js"), + ); + expect(scriptNodes.length).toBeGreaterThan(0); + for (const n of scriptNodes) { + expect(n.callFrame.url).toStartWith("file://"); + } + + const fibNodes = scriptNodes.filter((n: any) => n.callFrame.functionName === "fibonacci"); + expect(fibNodes.length).toBeGreaterThan(0); + + // Every fibonacci node must report the SAME callFrame line/column, pointing + // at the definition site of `function fibonacci(n) {` on line 1. Emitted + // 0-indexed, so lineNumber must be 0. Before the fix, each sampled line + // became its own node with a different line/col. + for (const n of fibNodes) { + expect(n.callFrame.functionName).toBe("fibonacci"); + expect(n.callFrame.lineNumber).toBe(0); + // Column must be on the definition line, not e.g. column 10 of `return fibonacci`. + expect(n.callFrame.columnNumber).toBeGreaterThanOrEqual(0); + } + // All fibonacci nodes agree on column (they're the same function). + const fibColumns = new Set(fibNodes.map((n: any) => n.callFrame.columnNumber)); + expect(fibColumns.size).toBe(1); + + // The dedup key now collapses same-function-same-parent into one node, so + // fibonacci should produce at most a small call-chain of nodes (one per + // recursion depth observed), not one node per sampled statement. Before the + // fix this was >100 nodes on a tight recursive workload; cap loosely at 40. + expect(fibNodes.length).toBeLessThan(40); + + // Other functions report their own definition lines. Line numbers are + // 0-indexed in the callFrame, so `function doWork() {` on line 6 → 5. + const doWorkNodes = scriptNodes.filter((n: any) => n.callFrame.functionName === "doWork"); + expect(doWorkNodes.length).toBeGreaterThan(0); + for (const n of doWorkNodes) { + expect(n.callFrame.lineNumber).toBe(5); + } + + const anotherNodes = scriptNodes.filter((n: any) => n.callFrame.functionName === "anotherFunction"); + expect(anotherNodes.length).toBeGreaterThan(0); + for (const n of anotherNodes) { + expect(n.callFrame.lineNumber).toBe(13); + } + + // positionTicks: at least one node from the script must have a populated + // positionTicks array (the hot loop is long enough that SOME sampled line + // gets recorded). Each entry is {line, ticks} with line 1-indexed inside + // the source file and ticks being positive integers whose sum is bounded + // above by the node's hitCount — only top-frame samples with expression + // info contribute, so a JIT frame without expression info can bump + // hitCount without adding a tick. + const nodesWithTicks = scriptNodes.filter((n: any) => Array.isArray(n.positionTicks) && n.positionTicks.length > 0); + expect(nodesWithTicks.length).toBeGreaterThan(0); + + for (const node of nodesWithTicks) { + let sum = 0; + for (const entry of node.positionTicks) { + expect(typeof entry.line).toBe("number"); + expect(typeof entry.ticks).toBe("number"); + // Lines are 1-indexed and must fall within the script body + // (27 content lines; last line is `console.log("done")`). + expect(entry.line).toBeGreaterThan(0); + expect(entry.line).toBeLessThanOrEqual(27); + expect(entry.ticks).toBeGreaterThan(0); + sum += entry.ticks; + } + // positionTicks only records samples that had expression info — which + // is most but not all of them. Its total is therefore bounded above by + // the node's hitCount and bounded below by 1 (we filtered for > 0). + expect(sum).toBeGreaterThan(0); + expect(sum).toBeLessThanOrEqual(node.hitCount); + } + + // Keying on (functionName, url, lineNumber, columnNumber) must collapse + // repeated calls of the same function — the exact guarantee the issue + // reporter asked for so cross-runtime cpuprofile code can merge nodes. + const uniqueFibKeys = new Set( + fibNodes.map( + (n: any) => + `${n.callFrame.functionName}|${n.callFrame.url}|${n.callFrame.lineNumber}|${n.callFrame.columnNumber}`, + ), + ); + expect(uniqueFibKeys.size).toBe(1); +}); + +test("cpu-prof respects sourcemaps for both function definition and positionTicks (#29240)", async () => { + // Bun transpiles `.ts` files through its bundler at load time, which sets + // up an internal sourcemap from the generated JS back to the original TS. + // That's the exact path `computeLineColumnWithSourcemap` is wired to. + // A TS-specific type annotation forces the transpile step (a plain JS file + // can be loaded raw), giving us a reliable way to exercise the sourcemap + // codepath in the CPU profiler without hand-rolling a .js.map file. + using dir = tempDir("issue-29240-sourcemap", { + "script.ts": `function fibonacci(n: number): number { + if (n < 2) return n; + return fibonacci(n - 1) + fibonacci(n - 2); +} + +function hot(): number { + let total = 0; + const deadline = performance.now() + 200; + while (performance.now() < deadline) { + for (let i = 0; i < 40; i++) { + total += fibonacci(20); + } + } + return total; +} + +console.log("result", hot() > 0); +`, + }); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-dir=.", "--cpu-prof-name=out.cpuprofile", "script.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, _stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("result true\n"); + expect(exitCode).toBe(0); + + const files = readdirSync(String(dir)).filter(f => f.endsWith(".cpuprofile")); + expect(files).toEqual(["out.cpuprofile"]); + + const profile = JSON.parse(readFileSync(join(String(dir), "out.cpuprofile"), "utf8")); + + // After sourcemap remapping, callFrame.url should be the ORIGINAL .ts URL + // (not a transpiled-bundle `bun://` / `file://...js` URL), and must still + // be wrapped in a `file://` scheme for tool compatibility. + const scriptNodes = profile.nodes.filter( + (n: any) => typeof n.callFrame.url === "string" && n.callFrame.url.endsWith("/script.ts"), + ); + expect(scriptNodes.length).toBeGreaterThan(0); + for (const n of scriptNodes) { + expect(n.callFrame.url).toStartWith("file://"); + } + + // `fibonacci` is defined on line 1 of the ORIGINAL TS source. After the + // sourcemap is applied, callFrame.lineNumber must be 0 (0-indexed). + const fibNodes = scriptNodes.filter((n: any) => n.callFrame.functionName === "fibonacci"); + expect(fibNodes.length).toBeGreaterThan(0); + for (const n of fibNodes) { + expect(n.callFrame.lineNumber).toBe(0); + } + + // `hot` is defined on line 6 of the ORIGINAL TS source → 0-indexed 5. + const hotNodes = scriptNodes.filter((n: any) => n.callFrame.functionName === "hot"); + expect(hotNodes.length).toBeGreaterThan(0); + for (const n of hotNodes) { + expect(n.callFrame.lineNumber).toBe(5); + } + + // positionTicks line numbers must also be remapped to the ORIGINAL TS — + // within the 17 content lines of script.ts above. If positionTicks surfaced + // transpiled-source lines, this would drift into high numbers. + const nodesWithTicks = scriptNodes.filter((n: any) => Array.isArray(n.positionTicks) && n.positionTicks.length > 0); + expect(nodesWithTicks.length).toBeGreaterThan(0); + for (const node of nodesWithTicks) { + let sum = 0; + for (const entry of node.positionTicks) { + expect(entry.line).toBeGreaterThan(0); + expect(entry.line).toBeLessThanOrEqual(17); + expect(entry.ticks).toBeGreaterThan(0); + sum += entry.ticks; + } + // Same aggregate invariant as the plain-JS test: positionTicks only + // records top-frame samples that had expression info, so its total is + // bounded above by the node's hitCount. Guards against a sourcemap-path + // regression that could inflate ticks (e.g. mistakenly recording ticks + // for non-top frames or for frames with stale sample lines). + expect(sum).toBeGreaterThan(0); + expect(sum).toBeLessThanOrEqual(node.hitCount); + } + + // Crucially, tools keying on (url, lineNumber, columnNumber) must see the + // same triplet for every fibonacci node — same URL (the original .ts), + // same definition line/column. If the sourcemap-mapped URL and line/column + // were ever computed from different remap calls, recursive fibonacci would + // fragment into multiple keys. + const fibKeys = new Set( + fibNodes.map((n: any) => `${n.callFrame.url}|${n.callFrame.lineNumber}|${n.callFrame.columnNumber}`), + ); + expect(fibKeys.size).toBe(1); +});