From b0e804955f8def35bceda5fc7a3e2bb84fda3fa5 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:22:33 +0000 Subject: [PATCH 1/9] Fix segfault in jsc.startSamplingProfiler(directory) startSamplingProfiler with a directory argument assigned Options::samplingProfilerPath() at runtime. JSC option storage lives in the Config pages that Config::permanentlyFreeze() mprotects read-only during startup, so the assignment was a write to a read-only page and crashed every time. The stored pointer would also have dangled (it pointed into a function-local CString), and the atexit-based report mechanism never fires on Linux because Bun exits through quick_exit. Keep the directory on the Bun side instead: a registry of (profiler, vm, directory) entries writes the same report as SamplingProfiler::reportDataToOptionFile() through Bun__atexit for VMs still alive at process exit, and through the VM teardown paths (WebWorker__teardownJSCVM, Zig__GlobalObject__destructOnExit) for VMs torn down earlier. Reporting at worker teardown, on the worker thread, matters twice over: holding the VM past teardown would keep ~VM from shutting the sampling thread down (it then wedges trying to suspend the dead worker thread), and a VM's API lock is held by its owner thread for that thread's whole lifetime, so the exit callback can only safely report VMs whose lock the exiting thread already holds. Also shrinks the fib workload in the adjacent "profile can be called multiple times" test (fib(30) -> fib(26), three calls) so the file fits the debug ASAN time budget; all assertions are unchanged and the 50us sample interval still guarantees multiple samples per call. --- .../bindings/BunSamplingProfilerReporter.cpp | 119 ++++++++++++++++++ .../bindings/BunSamplingProfilerReporter.h | 33 +++++ src/jsc/bindings/ZigGlobalObject.cpp | 7 ++ src/jsc/modules/BunJSCModule.h | 5 +- test/js/bun/jsc/bun-jsc.test.ts | 76 ++++++++++- 5 files changed, 236 insertions(+), 4 deletions(-) create mode 100644 src/jsc/bindings/BunSamplingProfilerReporter.cpp create mode 100644 src/jsc/bindings/BunSamplingProfilerReporter.h diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.cpp b/src/jsc/bindings/BunSamplingProfilerReporter.cpp new file mode 100644 index 000000000000..eb05386d432a --- /dev/null +++ b/src/jsc/bindings/BunSamplingProfilerReporter.cpp @@ -0,0 +1,119 @@ +#include "root.h" +#include "BunSamplingProfilerReporter.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C" void Bun__atexit(void (*func)(void)); + +namespace Bun { + +namespace { + +struct SamplingProfilerExitReporter { + struct Entry { + Ref profiler; + // Keeps the profiler's VM alive while registered + // (SamplingProfiler::m_vm is a bare VM&), so reporting can never + // touch a freed VM. reportSamplingProfilerBeforeVMTeardown releases + // it so ~VM can run during teardown. + Ref vm; + CString directory; + }; + + Lock lock; + Vector entries WTF_GUARDED_BY_LOCK(lock); + + static SamplingProfilerExitReporter& singleton() + { + static NeverDestroyed reporter; + return reporter.get(); + } + + void add(JSC::VM& vm, JSC::SamplingProfiler& profiler, CString&& directory) + { + { + Locker locker { lock }; + for (auto& entry : entries) { + if (entry.profiler.ptr() == &profiler) { + entry.directory = WTF::move(directory); + return; + } + } + entries.append(Entry { profiler, vm, WTF::move(directory) }); + } + // Deduplicated by Bun__atexit, so registering on every add is fine. + Bun__atexit([] { + SamplingProfilerExitReporter::singleton().reportStillRegisteredAtExit(); + }); + } + + // Mirrors SamplingProfiler::reportDataToOptionFile(), minus the + // frozen-Options read. Both callers run on the thread that owns the + // VM's API lock, so the JSLockHolder acquisition is recursive and + // cannot block. + void writeReport(Entry& entry) WTF_REQUIRES_LOCK(lock) + { + JSC::JSLockHolder holder(entry.vm.get()); + StringPrintStream pathOut; + pathOut.print(entry.directory.data(), "/JSCSamplingProfile-", reinterpret_cast(entry.profiler.ptr()), ".txt"); + auto out = FilePrintStream::open(pathOut.toCString().data(), "w"); + if (!out) { + SAFE_FPRINTF(stderr, "could not open sampling profiler report file %s\n", pathOut.toCString()); + return; + } + entry.profiler->reportTopFunctions(*out); + entry.profiler->reportTopBytecodes(*out); + } + + void reportStillRegisteredAtExit() + { + Locker locker { lock }; + for (auto& entry : entries) { + // A Bun VM's API lock is held by its owner thread for that + // thread's whole lifetime (see WebWorker::thread_main), so a VM + // whose lock this thread does not hold belongs to another, + // possibly already-exited, thread: locking it or sampling it + // here would hang the exit. Those VMs report in + // reportSamplingProfilerBeforeVMTeardown instead. + if (!entry.vm->currentThreadIsHoldingAPILock()) + continue; + writeReport(entry); + } + // Entries are deliberately kept: the singleton is NeverDestroyed, so + // the Refs never release during exit and no VM teardown can start + // inside the exit callback. + } + + void reportAndRemove(JSC::VM& vm) + { + Locker locker { lock }; + for (size_t i = 0; i < entries.size(); i++) { + if (entries[i].vm.ptr() == &vm) { + writeReport(entries[i]); + entries.removeAt(i); + return; + } + } + } +}; + +} // namespace + +void registerSamplingProfilerReportAtExit(JSC::VM& vm, JSC::SamplingProfiler& profiler, WTF::CString&& directory) +{ + SamplingProfilerExitReporter::singleton().add(vm, profiler, WTF::move(directory)); +} + +void reportSamplingProfilerBeforeVMTeardown(JSC::VM& vm) +{ + SamplingProfilerExitReporter::singleton().reportAndRemove(vm); +} + +} // namespace Bun diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.h b/src/jsc/bindings/BunSamplingProfilerReporter.h new file mode 100644 index 000000000000..361456835427 --- /dev/null +++ b/src/jsc/bindings/BunSamplingProfilerReporter.h @@ -0,0 +1,33 @@ +#pragma once + +#include "root.h" +#include + +namespace JSC { +class SamplingProfiler; +class VM; +} + +namespace Bun { + +// Report-at-exit support for jsc.startSamplingProfiler(directory). +// +// JSC's own mechanism (SamplingProfiler::registerForReportAtExit) reads the +// directory from Options::samplingProfilerPath(), but JSC option storage +// lives in the Config pages that Config::permanentlyFreeze() mprotects +// read-only during startup, so assigning that option at runtime segfaults. +// The directory is stored on the Bun side instead, and reports are written: +// - for VMs torn down before process exit (workers, and the main VM under +// Zig__GlobalObject__destructOnExit), by +// reportSamplingProfilerBeforeVMTeardown; +// - for VMs still alive at process exit, through Bun__atexit, which runs on +// every Bun exit path (libc atexit does not: on Linux Bun exits via +// quick_exit). +void registerSamplingProfilerReportAtExit(JSC::VM&, JSC::SamplingProfiler&, WTF::CString&& directory); + +// Writes the pending report for this VM (if any) and drops its registration, +// releasing the refs that keep the VM alive. Must run on the VM's owner +// thread, before the deref that runs ~VM (which shuts the profiler down). +void reportSamplingProfilerBeforeVMTeardown(JSC::VM&); + +} // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index b5252a9fbe43..0d384a31806e 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -68,6 +68,7 @@ #include "GeneratedBunObject.h" #include "BunPlugin.h" #include "BunProcess.h" +#include "BunSamplingProfilerReporter.h" #include "BunSecureContextCache.h" #include "NodeV8.h" #include "ProcessIdentifier.h" @@ -4335,6 +4336,12 @@ extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* glo static void destroyVM(JSC::VM& vm) { + // Write any pending sampling profiler report and drop its registry entry + // while this thread (the VM's owner) is still alive. Must precede the + // deref loop below: the registry's Ref is one of the refs that loop + // releases, so a stale entry would point at a freed VM, and ~VM is what + // shuts the sampling thread down before its target thread exits. + Bun::reportSamplingProfilerBeforeVMTeardown(vm); vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); // Every JSLockHolder still on the native stack (process.exit() from inside a JS callback, // the worker thread's manual API lock) holds a RefPtr that will never destruct because diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 4597cdd17576..18f3700461f2 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -38,6 +38,7 @@ #include #include "BunProcess.h" +#include "BunSamplingProfilerReporter.h" #include "JSEnvironmentVariableMap.h" #include #if ENABLE(REMOTE_INSPECTOR) @@ -461,7 +462,6 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, if (directoryValue.isString()) { auto path = directoryValue.toWTFString(globalObject); if (!path.isEmpty()) { - StringPrintStream pathOut; auto pathCString = toCString(String(path)); if (!Bun__mkdirp(globalObject, pathCString.span().data())) { throwVMError( @@ -470,8 +470,7 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, return {}; } - Options::samplingProfilerPath() = pathCString.span().data(); - samplingProfiler.registerForReportAtExit(); + Bun::registerSamplingProfilerReportAtExit(vm, samplingProfiler, WTF::move(pathCString)); } } if (sampleValue.isNumber()) { diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 940270976509..93249f117613 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -24,7 +24,9 @@ import { totalCompileTime, } from "bun:jsc"; import { describe, expect, it } from "bun:test"; -import { bunEnv, bunExe, isBuildKite, isWindows } from "harness"; +import { bunEnv, bunExe, isBuildKite, isWindows, tempDir } from "harness"; +import { readdirSync, statSync } from "node:fs"; +import { join } from "node:path"; describe("bun:jsc", () => { function count() { @@ -594,3 +596,75 @@ describe("JsRef::Weak liveness", () => { expect(kept.keep).toBe(true); }); }); + +it("startSamplingProfiler with a directory writes a report at exit", async () => { + // https://github.com/oven-sh/bun/issues/32212 + using dir = tempDir("sampling-profiler", { + "entry.mjs": ` + import { startSamplingProfiler } from "bun:jsc"; + import { join } from "node:path"; + startSamplingProfiler(join(import.meta.dir, "profiles")); + let x = 0; + for (let i = 0; i < 2e6; i++) x += Math.sqrt(i); + console.log("done", x > 0); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("done true\n"); + expect(exitCode).toBe(0); + + const profileDir = join(String(dir), "profiles"); + const reports = readdirSync(profileDir).filter(name => name.startsWith("JSCSamplingProfile-")); + expect(reports).toHaveLength(1); + expect(statSync(join(profileDir, reports[0])).size).toBeGreaterThan(0); +}); + +it("startSamplingProfiler with a directory in a worker writes a report at worker teardown", async () => { + // https://github.com/oven-sh/bun/issues/32212 + using dir = tempDir("sampling-profiler-worker", { + "main.mjs": ` + import { readdirSync } from "node:fs"; + import { join } from "node:path"; + const worker = new Worker(new URL("./worker.mjs", import.meta.url)); + const message = await new Promise((resolve, reject) => { + worker.onmessage = e => resolve(e.data); + worker.onerror = e => reject(new Error(e.message)); + }); + console.log(message); + // The report is written during worker VM teardown, which completes + // before the close event is dispatched to the parent. + const closed = new Promise(resolve => worker.addEventListener("close", resolve)); + await worker.terminate(); + await closed; + const reports = readdirSync(join(import.meta.dir, "profiles")).filter(name => + name.startsWith("JSCSamplingProfile-"), + ); + console.log("reports", reports.length); + `, + "worker.mjs": ` + import { startSamplingProfiler } from "bun:jsc"; + import { join } from "node:path"; + startSamplingProfiler(join(import.meta.dir, "profiles")); + let x = 0; + for (let i = 0; i < 2e6; i++) x += Math.sqrt(i); + postMessage("done " + (x > 0)); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "main.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toBe("done true\nreports 1\n"); + expect(exitCode).toBe(0); +}); From 307413a0c46fb71e0d06616ab62a4fb8b70ae515 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 12 Jun 2026 22:33:35 +0000 Subject: [PATCH 2/9] test: assert empty stderr and run the new sampling profiler tests concurrently --- test/js/bun/jsc/bun-jsc.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 93249f117613..cad7be2db02c 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -597,7 +597,7 @@ describe("JsRef::Weak liveness", () => { }); }); -it("startSamplingProfiler with a directory writes a report at exit", async () => { +it.concurrent("startSamplingProfiler with a directory writes a report at exit", async () => { // https://github.com/oven-sh/bun/issues/32212 using dir = tempDir("sampling-profiler", { "entry.mjs": ` @@ -617,6 +617,7 @@ it("startSamplingProfiler with a directory writes a report at exit", async () => stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(stdout).toBe("done true\n"); expect(exitCode).toBe(0); @@ -626,7 +627,7 @@ it("startSamplingProfiler with a directory writes a report at exit", async () => expect(statSync(join(profileDir, reports[0])).size).toBeGreaterThan(0); }); -it("startSamplingProfiler with a directory in a worker writes a report at worker teardown", async () => { +it.concurrent("startSamplingProfiler with a directory in a worker writes a report at worker teardown", async () => { // https://github.com/oven-sh/bun/issues/32212 using dir = tempDir("sampling-profiler-worker", { "main.mjs": ` @@ -665,6 +666,7 @@ it("startSamplingProfiler with a directory in a worker writes a report at worker stderr: "pipe", }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); expect(stdout).toBe("done true\nreports 1\n"); expect(exitCode).toBe(0); }); From 9483d2f93edb2977a7db281049c071f136c6b213 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 13 Jun 2026 00:06:24 +0000 Subject: [PATCH 3/9] ci: retrigger From 4ea355f4d69675b5c937fcdf1e288563d054511d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 19:47:33 +0000 Subject: [PATCH 4/9] Condense lifecycle comments in the sampling profiler reporter --- .../bindings/BunSamplingProfilerReporter.cpp | 26 +++++++------------ .../bindings/BunSamplingProfilerReporter.h | 22 +++++----------- src/jsc/bindings/ZigGlobalObject.cpp | 7 ++--- 3 files changed, 17 insertions(+), 38 deletions(-) diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.cpp b/src/jsc/bindings/BunSamplingProfilerReporter.cpp index eb05386d432a..c40e4c427439 100644 --- a/src/jsc/bindings/BunSamplingProfilerReporter.cpp +++ b/src/jsc/bindings/BunSamplingProfilerReporter.cpp @@ -19,10 +19,8 @@ namespace { struct SamplingProfilerExitReporter { struct Entry { Ref profiler; - // Keeps the profiler's VM alive while registered - // (SamplingProfiler::m_vm is a bare VM&), so reporting can never - // touch a freed VM. reportSamplingProfilerBeforeVMTeardown releases - // it so ~VM can run during teardown. + // SamplingProfiler::m_vm is a bare VM&; this Ref keeps it valid + // until the entry is dropped. Ref vm; CString directory; }; @@ -54,10 +52,8 @@ struct SamplingProfilerExitReporter { }); } - // Mirrors SamplingProfiler::reportDataToOptionFile(), minus the - // frozen-Options read. Both callers run on the thread that owns the - // VM's API lock, so the JSLockHolder acquisition is recursive and - // cannot block. + // Mirrors SamplingProfiler::reportDataToOptionFile() minus the + // frozen-Options read; callers already own the VM's API lock. void writeReport(Entry& entry) WTF_REQUIRES_LOCK(lock) { JSC::JSLockHolder holder(entry.vm.get()); @@ -76,19 +72,15 @@ struct SamplingProfilerExitReporter { { Locker locker { lock }; for (auto& entry : entries) { - // A Bun VM's API lock is held by its owner thread for that - // thread's whole lifetime (see WebWorker::thread_main), so a VM - // whose lock this thread does not hold belongs to another, - // possibly already-exited, thread: locking it or sampling it - // here would hang the exit. Those VMs report in - // reportSamplingProfilerBeforeVMTeardown instead. + // Another thread's VM cannot be locked or sampled here without + // hanging the exit (its owner holds the API lock for the + // thread's lifetime); those VMs report at their own teardown. if (!entry.vm->currentThreadIsHoldingAPILock()) continue; writeReport(entry); } - // Entries are deliberately kept: the singleton is NeverDestroyed, so - // the Refs never release during exit and no VM teardown can start - // inside the exit callback. + // Entries stay registered so no VM teardown starts inside the exit + // callback. } void reportAndRemove(JSC::VM& vm) diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.h b/src/jsc/bindings/BunSamplingProfilerReporter.h index 361456835427..92ce43ea7e32 100644 --- a/src/jsc/bindings/BunSamplingProfilerReporter.h +++ b/src/jsc/bindings/BunSamplingProfilerReporter.h @@ -10,24 +10,14 @@ class VM; namespace Bun { -// Report-at-exit support for jsc.startSamplingProfiler(directory). -// -// JSC's own mechanism (SamplingProfiler::registerForReportAtExit) reads the -// directory from Options::samplingProfilerPath(), but JSC option storage -// lives in the Config pages that Config::permanentlyFreeze() mprotects -// read-only during startup, so assigning that option at runtime segfaults. -// The directory is stored on the Bun side instead, and reports are written: -// - for VMs torn down before process exit (workers, and the main VM under -// Zig__GlobalObject__destructOnExit), by -// reportSamplingProfilerBeforeVMTeardown; -// - for VMs still alive at process exit, through Bun__atexit, which runs on -// every Bun exit path (libc atexit does not: on Linux Bun exits via -// quick_exit). +// Report-at-exit support for jsc.startSamplingProfiler(directory). JSC's own +// registerForReportAtExit() reads Options::samplingProfilerPath(), which is +// frozen read-only after startup, and relies on libc atexit, which Bun's +// quick_exit path never runs; the directory is kept on the Bun side instead. void registerSamplingProfilerReportAtExit(JSC::VM&, JSC::SamplingProfiler&, WTF::CString&& directory); -// Writes the pending report for this VM (if any) and drops its registration, -// releasing the refs that keep the VM alive. Must run on the VM's owner -// thread, before the deref that runs ~VM (which shuts the profiler down). +// Writes the pending report for this VM (if any) and drops its entry. Must +// run on the VM's owner thread, before the deref that runs ~VM. void reportSamplingProfilerBeforeVMTeardown(JSC::VM&); } // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 0d384a31806e..3cbf48e5a7e6 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -4336,11 +4336,8 @@ extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* glo static void destroyVM(JSC::VM& vm) { - // Write any pending sampling profiler report and drop its registry entry - // while this thread (the VM's owner) is still alive. Must precede the - // deref loop below: the registry's Ref is one of the refs that loop - // releases, so a stale entry would point at a freed VM, and ~VM is what - // shuts the sampling thread down before its target thread exits. + // Flush the sampling profiler report and drop its registry entry before + // the deref loop below releases the registry's Ref. Bun::reportSamplingProfilerBeforeVMTeardown(vm); vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); // Every JSLockHolder still on the native stack (process.exit() from inside a JS callback, From 51efcccf80c9246abe2d1cbb8ae38b08c273f02d Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:11:24 +0000 Subject: [PATCH 5/9] Store the sampling profiler directory on VirtualMachine, report from on_exit Replaces the process-global registry (BunSamplingProfilerReporter) with per-VM state, matching REVIEW.md's rule and the cpu/heap profiler precedent: the directory lives on VirtualMachine next to cpu_profiler_config, and VirtualMachine::on_exit writes the report, which already runs once per VM (main thread and workers) on the VM's own thread with the VM alive and the API lock held. This removes the singleton, the Ref pin, the Bun__atexit hook, and the destroyVM ordering constraint. Also from review: the directory is resolved to an absolute path when the profiler starts (the report is written at exit, when the working directory may have changed), directories with embedded null bytes are rejected, and the worker test now rejects on early close and asserts the report is non-empty. --- packages/bun-types/jsc.d.ts | 3 +- src/jsc/BunCPUProfiler.rs | 26 ++++ src/jsc/VirtualMachine.rs | 19 +++ .../bindings/BunSamplingProfilerReporter.cpp | 111 ------------------ .../bindings/BunSamplingProfilerReporter.h | 23 ---- src/jsc/bindings/ZigGlobalObject.cpp | 4 - src/jsc/modules/BunJSCModule.h | 49 +++++++- src/jsc/virtual_machine_exports.rs | 26 ++++ test/js/bun/jsc/bun-jsc.test.ts | 57 +++++++-- 9 files changed, 170 insertions(+), 148 deletions(-) delete mode 100644 src/jsc/bindings/BunSamplingProfilerReporter.cpp delete mode 100644 src/jsc/bindings/BunSamplingProfilerReporter.h diff --git a/packages/bun-types/jsc.d.ts b/packages/bun-types/jsc.d.ts index 4d3a48c41904..61c6522ca046 100644 --- a/packages/bun-types/jsc.d.ts +++ b/packages/bun-types/jsc.d.ts @@ -757,7 +757,8 @@ declare module "bun:jsc" { * value, use {@link profile} instead. * * @param optionalDirectory A directory to write a text report of the hottest - * functions and bytecodes into when the process exits. Created if it does not exist. + * functions and bytecodes into when the process (or the worker that called + * this) exits. Created if it does not exist. * @param sampleInterval How often to sample the stack, in microseconds. Defaults to 1000 (once a millisecond). */ function startSamplingProfiler(optionalDirectory?: string, sampleInterval?: number): void; diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index f854f1aecca7..84756908eb68 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -53,6 +53,13 @@ unsafe extern "C" { ); /// Plain by-value `c_int`; sets a global sampler interval, no pointer invariants. safe fn Bun__setSamplingInterval(interval_microseconds: c_int); + /// `directory` must point at `directory_len` readable bytes (UTF-8) for the + /// duration of the call; the C++ side only reads them before returning. + fn Bun__SamplingProfiler__reportToDirectory( + vm: &mut VM, + directory: *const u8, + directory_len: usize, + ) -> bool; } pub fn set_sampling_interval(interval: u32) { @@ -66,6 +73,25 @@ pub fn start_cpu_profiler(vm: &mut VM) { Bun__startCPUProfiler(vm); } +/// Writes the JSC sampling profiler report for `vm` into `directory`, the +/// UTF-8 path `bun:jsc`'s `startSamplingProfiler()` was given. Called once +/// per VM from `VirtualMachine::on_exit`. +pub(crate) fn write_sampling_profiler_report( + vm: &mut VM, + directory: &[u8], +) -> Result<(), ProfilerError> { + // SAFETY: `directory` is a live slice for the duration of the call and the + // C++ side only reads it before returning. + let written = unsafe { + Bun__SamplingProfiler__reportToDirectory(vm, directory.as_ptr(), directory.len()) + }; + if written { + Ok(()) + } else { + Err(ProfilerError::WriteFailed) + } +} + pub(crate) fn stop_and_write_profile( vm: &mut VM, config: &CPUProfilerConfig, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index fc224cc8f479..1a62b83e3a44 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -207,6 +207,11 @@ pub struct VirtualMachine { pub dns_result_order: u8, pub cpu_profiler_config: Option, pub heap_profiler_config: Option, + /// UTF-8 directory given to `bun:jsc`'s `startSamplingProfiler()`. + /// JSC's own report-at-exit reads `Options::samplingProfilerPath()`, which + /// is frozen read-only before user JS can run, so the path lives here and + /// [`Self::on_exit`] writes the report. + pub sampling_profiler_directory: Option>, pub counters: Counters, /// `--hot` / `--watch` mode this VM runs under. @@ -1679,6 +1684,20 @@ impl VirtualMachine { bun_core::Output::err(e, "Failed to write heap profile", ()); } } + // Write the JSC sampling profiler report if bun:jsc's + // startSamplingProfiler() was given a directory. + if let Some(directory) = self.sampling_profiler_directory.take() { + if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( + self.jsc_vm_mut(), + &directory, + ) { + bun_core::Output::err( + <&'static str>::from(e), + "Failed to write sampling profiler report", + (), + ); + } + } ExitHandler::dispatch_on_exit(self); diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.cpp b/src/jsc/bindings/BunSamplingProfilerReporter.cpp deleted file mode 100644 index c40e4c427439..000000000000 --- a/src/jsc/bindings/BunSamplingProfilerReporter.cpp +++ /dev/null @@ -1,111 +0,0 @@ -#include "root.h" -#include "BunSamplingProfilerReporter.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -extern "C" void Bun__atexit(void (*func)(void)); - -namespace Bun { - -namespace { - -struct SamplingProfilerExitReporter { - struct Entry { - Ref profiler; - // SamplingProfiler::m_vm is a bare VM&; this Ref keeps it valid - // until the entry is dropped. - Ref vm; - CString directory; - }; - - Lock lock; - Vector entries WTF_GUARDED_BY_LOCK(lock); - - static SamplingProfilerExitReporter& singleton() - { - static NeverDestroyed reporter; - return reporter.get(); - } - - void add(JSC::VM& vm, JSC::SamplingProfiler& profiler, CString&& directory) - { - { - Locker locker { lock }; - for (auto& entry : entries) { - if (entry.profiler.ptr() == &profiler) { - entry.directory = WTF::move(directory); - return; - } - } - entries.append(Entry { profiler, vm, WTF::move(directory) }); - } - // Deduplicated by Bun__atexit, so registering on every add is fine. - Bun__atexit([] { - SamplingProfilerExitReporter::singleton().reportStillRegisteredAtExit(); - }); - } - - // Mirrors SamplingProfiler::reportDataToOptionFile() minus the - // frozen-Options read; callers already own the VM's API lock. - void writeReport(Entry& entry) WTF_REQUIRES_LOCK(lock) - { - JSC::JSLockHolder holder(entry.vm.get()); - StringPrintStream pathOut; - pathOut.print(entry.directory.data(), "/JSCSamplingProfile-", reinterpret_cast(entry.profiler.ptr()), ".txt"); - auto out = FilePrintStream::open(pathOut.toCString().data(), "w"); - if (!out) { - SAFE_FPRINTF(stderr, "could not open sampling profiler report file %s\n", pathOut.toCString()); - return; - } - entry.profiler->reportTopFunctions(*out); - entry.profiler->reportTopBytecodes(*out); - } - - void reportStillRegisteredAtExit() - { - Locker locker { lock }; - for (auto& entry : entries) { - // Another thread's VM cannot be locked or sampled here without - // hanging the exit (its owner holds the API lock for the - // thread's lifetime); those VMs report at their own teardown. - if (!entry.vm->currentThreadIsHoldingAPILock()) - continue; - writeReport(entry); - } - // Entries stay registered so no VM teardown starts inside the exit - // callback. - } - - void reportAndRemove(JSC::VM& vm) - { - Locker locker { lock }; - for (size_t i = 0; i < entries.size(); i++) { - if (entries[i].vm.ptr() == &vm) { - writeReport(entries[i]); - entries.removeAt(i); - return; - } - } - } -}; - -} // namespace - -void registerSamplingProfilerReportAtExit(JSC::VM& vm, JSC::SamplingProfiler& profiler, WTF::CString&& directory) -{ - SamplingProfilerExitReporter::singleton().add(vm, profiler, WTF::move(directory)); -} - -void reportSamplingProfilerBeforeVMTeardown(JSC::VM& vm) -{ - SamplingProfilerExitReporter::singleton().reportAndRemove(vm); -} - -} // namespace Bun diff --git a/src/jsc/bindings/BunSamplingProfilerReporter.h b/src/jsc/bindings/BunSamplingProfilerReporter.h deleted file mode 100644 index 92ce43ea7e32..000000000000 --- a/src/jsc/bindings/BunSamplingProfilerReporter.h +++ /dev/null @@ -1,23 +0,0 @@ -#pragma once - -#include "root.h" -#include - -namespace JSC { -class SamplingProfiler; -class VM; -} - -namespace Bun { - -// Report-at-exit support for jsc.startSamplingProfiler(directory). JSC's own -// registerForReportAtExit() reads Options::samplingProfilerPath(), which is -// frozen read-only after startup, and relies on libc atexit, which Bun's -// quick_exit path never runs; the directory is kept on the Bun side instead. -void registerSamplingProfilerReportAtExit(JSC::VM&, JSC::SamplingProfiler&, WTF::CString&& directory); - -// Writes the pending report for this VM (if any) and drops its entry. Must -// run on the VM's owner thread, before the deref that runs ~VM. -void reportSamplingProfilerBeforeVMTeardown(JSC::VM&); - -} // namespace Bun diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index 3cbf48e5a7e6..b5252a9fbe43 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -68,7 +68,6 @@ #include "GeneratedBunObject.h" #include "BunPlugin.h" #include "BunProcess.h" -#include "BunSamplingProfilerReporter.h" #include "BunSecureContextCache.h" #include "NodeV8.h" #include "ProcessIdentifier.h" @@ -4336,9 +4335,6 @@ extern "C" void Bun__GlobalObject__clearExceptionsForExit(Zig::GlobalObject* glo static void destroyVM(JSC::VM& vm) { - // Flush the sampling profiler report and drop its registry entry before - // the deref loop below releases the registry's Ref. - Bun::reportSamplingProfilerBeforeVMTeardown(vm); vm.heap.collectNow(JSC::Sync, JSC::CollectionScope::Full); // Every JSLockHolder still on the native stack (process.exit() from inside a JS callback, // the worker thread's manual API lock) holds a RefPtr that will never destruct because diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 18f3700461f2..0b34ddcb8b40 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -20,6 +20,7 @@ #include #include #include +#include #include #include #include @@ -35,10 +36,12 @@ #include #include #include +#include +#include +#include #include #include "BunProcess.h" -#include "BunSamplingProfilerReporter.h" #include "JSEnvironmentVariableMap.h" #include #if ENABLE(REMOTE_INSPECTOR) @@ -446,6 +449,35 @@ JSC_DEFINE_HOST_FUNCTION(functionNeverInlineFunction, } extern "C" bool Bun__mkdirp(JSC::JSGlobalObject*, const char*); +extern "C" void Bun__VirtualMachine__setSamplingProfilerDirectory(void* bunVM, const BunString* directory); + +// Writes `vm`'s sampling profiler report into `directory` (`directoryLength` +// bytes of UTF-8). Called once per VM from VirtualMachine::on_exit when +// bun:jsc's startSamplingProfiler() was given a directory. Returns false when +// the file could not be written. +extern "C" bool Bun__SamplingProfiler__reportToDirectory(JSC::VM* vm, const char* directory, size_t directoryLength) +{ + JSC::SamplingProfiler* samplingProfiler = vm->samplingProfiler(); + if (!samplingProfiler) + return true; + + WTF::StringPrintStream report; + { + JSC::JSLockHolder locker(*vm); + // A terminated Worker reaches on_exit with its JSC termination exception + // still pending but the termination request already cleared; the report's + // DeferTermination asserts on that mix, so park the exception while reporting. + JSC::SuspendExceptionScope suspendExceptions(*vm); + samplingProfiler->reportTopFunctions(report); + samplingProfiler->reportTopBytecodes(report); + } + + // One report per VM; workers share the pid, so key the name on the thread too. + auto filename = makeString("samplingProfile."_s, getCurrentProcessID(), '.', Thread::currentSingleton().uid(), ".txt"_s); + auto path = FileSystem::pathByAppendingComponent(String::fromUTF8(std::span { directory, directoryLength }), filename); + auto utf8 = report.toCString(); + return FileSystem::overwriteEntireFile(path, byteCast(utf8.span())).has_value(); +} JSC_DECLARE_HOST_FUNCTION(functionStartSamplingProfiler); JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, @@ -461,7 +493,14 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, auto scope = DECLARE_THROW_SCOPE(vm); if (directoryValue.isString()) { auto path = directoryValue.toWTFString(globalObject); + RETURN_IF_EXCEPTION(scope, {}); if (!path.isEmpty()) { + if (path.contains(static_cast(0))) { + throwVMError( + globalObject, scope, + createTypeError(globalObject, "directory must not contain null bytes"_s)); + return {}; + } auto pathCString = toCString(String(path)); if (!Bun__mkdirp(globalObject, pathCString.span().data())) { throwVMError( @@ -470,7 +509,13 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler, return {}; } - Bun::registerSamplingProfilerReportAtExit(vm, samplingProfiler, WTF::move(pathCString)); + // JSC's own report-at-exit reads the directory from + // Options::samplingProfilerPath(), but the options page was frozen + // read-only when the VM was constructed (Config::finalize), so + // assigning that option here segfaults. Keep the directory on Bun's + // per-VM state instead; on_exit writes the report. + auto directory = Bun::toString(path); + Bun__VirtualMachine__setSamplingProfilerDirectory(bunVM(globalObject), &directory); } } if (sampleValue.isNumber()) { diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index b44f9f4758fc..9ca130370332 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -62,6 +62,32 @@ pub fn exit_during_uncaught_exception(this: &mut VirtualMachine) { this.exit_on_uncaught_exception = true; } +/// `bun:jsc`'s `startSamplingProfiler(directory)`. The JSC option that +/// normally carries this path is read-only by the time user JS can run (see +/// [`crate::VirtualMachine::sampling_profiler_directory`]), so the directory +/// is kept here and `on_exit` writes the report. Resolved to an absolute path +/// now: the report is written at exit, when the working directory may differ. +// HOST_EXPORT(Bun__VirtualMachine__setSamplingProfilerDirectory, c) +pub fn set_sampling_profiler_directory(this: &mut VirtualMachine, directory: &BunString) { + let directory = directory.to_utf8_bytes(); + let directory = if bun_paths::is_absolute(&directory) { + directory + } else { + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + match bun_sys::getcwd_z(&mut cwd_buf) { + Ok(cwd) => { + bun_paths::resolve_path::join::(&[ + cwd.as_bytes(), + &directory, + ]) + .to_vec() + } + Err(_) => directory, + } + }; + this.sampling_profiler_directory = Some(directory.into_boxed_slice()); +} + // `Bun__Process__send` lives in `bun_runtime::ipc_host` (its body — via // `do_send` — names the `bun_runtime::Listener` type; LAYERING). diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index cad7be2db02c..21d4370a3fbb 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -622,7 +622,7 @@ it.concurrent("startSamplingProfiler with a directory writes a report at exit", expect(exitCode).toBe(0); const profileDir = join(String(dir), "profiles"); - const reports = readdirSync(profileDir).filter(name => name.startsWith("JSCSamplingProfile-")); + const reports = readdirSync(profileDir).filter(name => name.startsWith("samplingProfile.")); expect(reports).toHaveLength(1); expect(statSync(join(profileDir, reports[0])).size).toBeGreaterThan(0); }); @@ -631,12 +631,13 @@ it.concurrent("startSamplingProfiler with a directory in a worker writes a repor // https://github.com/oven-sh/bun/issues/32212 using dir = tempDir("sampling-profiler-worker", { "main.mjs": ` - import { readdirSync } from "node:fs"; + import { readdirSync, statSync } from "node:fs"; import { join } from "node:path"; const worker = new Worker(new URL("./worker.mjs", import.meta.url)); const message = await new Promise((resolve, reject) => { worker.onmessage = e => resolve(e.data); worker.onerror = e => reject(new Error(e.message)); + worker.addEventListener("close", () => reject(new Error("worker closed before posting a message"))); }); console.log(message); // The report is written during worker VM teardown, which completes @@ -644,10 +645,9 @@ it.concurrent("startSamplingProfiler with a directory in a worker writes a repor const closed = new Promise(resolve => worker.addEventListener("close", resolve)); await worker.terminate(); await closed; - const reports = readdirSync(join(import.meta.dir, "profiles")).filter(name => - name.startsWith("JSCSamplingProfile-"), - ); - console.log("reports", reports.length); + const profileDir = join(import.meta.dir, "profiles"); + const reports = readdirSync(profileDir).filter(name => name.startsWith("samplingProfile.")); + console.log("reports", reports.length, reports.every(name => statSync(join(profileDir, name)).size > 0)); `, "worker.mjs": ` import { startSamplingProfiler } from "bun:jsc"; @@ -667,6 +667,49 @@ it.concurrent("startSamplingProfiler with a directory in a worker writes a repor }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(stderr).toBe(""); - expect(stdout).toBe("done true\nreports 1\n"); + expect(stdout).toBe("done true\nreports 1 true\n"); expect(exitCode).toBe(0); }); + +it.concurrent( + "startSamplingProfiler resolves a relative directory against the working directory at call time", + async () => { + using dir = tempDir("sampling-profiler-relative", { + "entry.mjs": ` + import { startSamplingProfiler } from "bun:jsc"; + import { mkdirSync } from "node:fs"; + let nulThrew = false; + try { + startSamplingProfiler("bad\\0dir"); + } catch { + nulThrew = true; + } + console.log("nul-throws", nulThrew); + startSamplingProfiler("profiles"); + mkdirSync("elsewhere"); + process.chdir("elsewhere"); + let x = 0; + for (let i = 0; i < 2e6; i++) x += Math.sqrt(i); + console.log("done", x > 0); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "entry.mjs"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stderr).toBe(""); + expect(stdout).toBe("nul-throws true\ndone true\n"); + expect(exitCode).toBe(0); + + // The report lands in the directory as resolved when the profiler started, + // not relative to the working directory at exit. + const profileDir = join(String(dir), "profiles"); + const reports = readdirSync(profileDir).filter(name => name.startsWith("samplingProfile.")); + expect(reports).toHaveLength(1); + expect(statSync(join(profileDir, reports[0])).size).toBeGreaterThan(0); + }, +); From bc3d9dcf02283ecd42123ff73e5338b400931af8 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:13:53 +0000 Subject: [PATCH 6/9] [autofix.ci] apply automated fixes --- src/jsc/virtual_machine_exports.rs | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/src/jsc/virtual_machine_exports.rs b/src/jsc/virtual_machine_exports.rs index 9ca130370332..3bf06e5b0fce 100644 --- a/src/jsc/virtual_machine_exports.rs +++ b/src/jsc/virtual_machine_exports.rs @@ -75,13 +75,11 @@ pub fn set_sampling_profiler_directory(this: &mut VirtualMachine, directory: &Bu } else { let mut cwd_buf = bun_paths::path_buffer_pool::get(); match bun_sys::getcwd_z(&mut cwd_buf) { - Ok(cwd) => { - bun_paths::resolve_path::join::(&[ - cwd.as_bytes(), - &directory, - ]) - .to_vec() - } + Ok(cwd) => bun_paths::resolve_path::join::(&[ + cwd.as_bytes(), + &directory, + ]) + .to_vec(), Err(_) => directory, } }; From d53133fcf3e54969cd84c2d13916536b771be23b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:42:23 +0000 Subject: [PATCH 7/9] Join the report path with makeString, flush it on self-kill WTF::FileSystem::pathByAppendingComponent goes through libstdc++'s std::filesystem, whose internal string frees bypass mimalloc; on musl that frees a mimalloc allocation with mallocng and segfaults at exit (glibc builds masked it because mimalloc interposes malloc there). Join the report path with makeString instead, like JSC's own reportDataToOptionFile. Also mirror the sampling profiler flush into Bun__writeProfilesBeforeSelfKill so process.kill(process.pid) with no handler writes the report like the CPU and heap profiles, and assert the null-byte rejection throws specifically a TypeError in the test. --- src/jsc/VirtualMachine.rs | 12 ++++++++++++ src/jsc/modules/BunJSCModule.h | 9 ++++++--- test/js/bun/jsc/bun-jsc.test.ts | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 1a62b83e3a44..48214a3c1605 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -534,6 +534,18 @@ impl VMHolder { bun_core::Output::err(e, "Failed to write heap profile", ()); } } + if let Some(directory) = vm.sampling_profiler_directory.take() { + if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( + vm.jsc_vm_mut(), + &directory, + ) { + bun_core::Output::err( + <&'static str>::from(e), + "Failed to write sampling profiler report", + (), + ); + } + } // Node runs RunAtExit (incl. compile cache) on self-directed fatal signals. Non-latching: // the signal may prove non-fatal, and latching here would no-op the real exit's persist. // https://github.com/nodejs/node/blob/main/src/env.cc (AtExit(FlushCompileCache)) diff --git a/src/jsc/modules/BunJSCModule.h b/src/jsc/modules/BunJSCModule.h index 0b34ddcb8b40..aac143d3dd8a 100644 --- a/src/jsc/modules/BunJSCModule.h +++ b/src/jsc/modules/BunJSCModule.h @@ -472,9 +472,12 @@ extern "C" bool Bun__SamplingProfiler__reportToDirectory(JSC::VM* vm, const char samplingProfiler->reportTopBytecodes(report); } - // One report per VM; workers share the pid, so key the name on the thread too. - auto filename = makeString("samplingProfile."_s, getCurrentProcessID(), '.', Thread::currentSingleton().uid(), ".txt"_s); - auto path = FileSystem::pathByAppendingComponent(String::fromUTF8(std::span { directory, directoryLength }), filename); + // One report per VM; workers share the pid, so key the name on the thread + // too. Joined with makeString, not FileSystem::pathByAppendingComponent: + // the latter goes through libstdc++'s std::filesystem, whose string frees + // bypass mimalloc and crash on musl. + auto path = makeString(String::fromUTF8(std::span { directory, directoryLength }), '/', + "samplingProfile."_s, getCurrentProcessID(), '.', Thread::currentSingleton().uid(), ".txt"_s); auto utf8 = report.toCString(); return FileSystem::overwriteEntireFile(path, byteCast(utf8.span())).has_value(); } diff --git a/test/js/bun/jsc/bun-jsc.test.ts b/test/js/bun/jsc/bun-jsc.test.ts index 21d4370a3fbb..181fd0286a89 100644 --- a/test/js/bun/jsc/bun-jsc.test.ts +++ b/test/js/bun/jsc/bun-jsc.test.ts @@ -681,8 +681,8 @@ it.concurrent( let nulThrew = false; try { startSamplingProfiler("bad\\0dir"); - } catch { - nulThrew = true; + } catch (e) { + nulThrew = e instanceof TypeError; } console.log("nul-throws", nulThrew); startSamplingProfiler("profiles"); From db0d3ef9268b5204faa7bdd022c0f8d8e05e2ed3 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Sun, 16 Aug 2026 20:44:59 +0000 Subject: [PATCH 8/9] [autofix.ci] apply automated fixes --- src/jsc/VirtualMachine.rs | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 48214a3c1605..6a215fdf6ec8 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -535,10 +535,9 @@ impl VMHolder { } } if let Some(directory) = vm.sampling_profiler_directory.take() { - if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( - vm.jsc_vm_mut(), - &directory, - ) { + if let Err(e) = + crate::bun_cpu_profiler::write_sampling_profiler_report(vm.jsc_vm_mut(), &directory) + { bun_core::Output::err( <&'static str>::from(e), "Failed to write sampling profiler report", From d8c9e157d24e2cd75841ca1308403aed42f56626 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 16 Aug 2026 21:10:08 +0000 Subject: [PATCH 9/9] Flush the sampling report before the CPU profile at both exit sites stopCPUProfiler clears the shared per-VM SamplingProfiler's traces, so a run using both --cpu-prof and startSamplingProfiler(directory) got a header-only report; the report only reads the traces, so writing it first leaves the .cpuprofile unchanged. --- src/jsc/VirtualMachine.rs | 54 +++++++++++++++++++++------------------ 1 file changed, 29 insertions(+), 25 deletions(-) diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 6a215fdf6ec8..23bf0346afb3 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -520,6 +520,19 @@ impl VMHolder { let Some(vm_ptr) = VM.get() else { return }; // SAFETY: called on the JS thread that owns this VM (process._kill). let vm = unsafe { &mut *vm_ptr }; + // Sampling report before the CPU profile: they share the per-VM + // SamplingProfiler and stopCPUProfiler clears its traces. + if let Some(directory) = vm.sampling_profiler_directory.take() { + if let Err(e) = + crate::bun_cpu_profiler::write_sampling_profiler_report(vm.jsc_vm_mut(), &directory) + { + bun_core::Output::err( + <&'static str>::from(e), + "Failed to write sampling profiler report", + (), + ); + } + } if let Some(config) = vm.cpu_profiler_config.take() { if let Err(e) = crate::bun_cpu_profiler::stop_and_write_profile(vm.jsc_vm_mut(), &config) @@ -534,17 +547,6 @@ impl VMHolder { bun_core::Output::err(e, "Failed to write heap profile", ()); } } - if let Some(directory) = vm.sampling_profiler_directory.take() { - if let Err(e) = - crate::bun_cpu_profiler::write_sampling_profiler_report(vm.jsc_vm_mut(), &directory) - { - bun_core::Output::err( - <&'static str>::from(e), - "Failed to write sampling profiler report", - (), - ); - } - } // Node runs RunAtExit (incl. compile cache) on self-directed fatal signals. Non-latching: // the signal may prove non-fatal, and latching here would no-op the real exit's persist. // https://github.com/nodejs/node/blob/main/src/env.cc (AtExit(FlushCompileCache)) @@ -1676,6 +1678,22 @@ impl VirtualMachine { } } + // Write the JSC sampling profiler report if bun:jsc's + // startSamplingProfiler() was given a directory. Runs before the CPU + // profile flush: both share the per-VM SamplingProfiler and + // stopCPUProfiler clears its traces, while the report only reads them. + if let Some(directory) = self.sampling_profiler_directory.take() { + if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( + self.jsc_vm_mut(), + &directory, + ) { + bun_core::Output::err( + <&'static str>::from(e), + "Failed to write sampling profiler report", + (), + ); + } + } // Write CPU profile if profiling was enabled - do this FIRST before any // shutdown begins. Grab the config and null it out to make this // idempotent. @@ -1695,20 +1713,6 @@ impl VirtualMachine { bun_core::Output::err(e, "Failed to write heap profile", ()); } } - // Write the JSC sampling profiler report if bun:jsc's - // startSamplingProfiler() was given a directory. - if let Some(directory) = self.sampling_profiler_directory.take() { - if let Err(e) = crate::bun_cpu_profiler::write_sampling_profiler_report( - self.jsc_vm_mut(), - &directory, - ) { - bun_core::Output::err( - <&'static str>::from(e), - "Failed to write sampling profiler report", - (), - ); - } - } ExitHandler::dispatch_on_exit(self);