Skip to content
3 changes: 2 additions & 1 deletion packages/bun-types/jsc.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
26 changes: 26 additions & 0 deletions src/jsc/BunCPUProfiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn Bun__SamplingProfiler__reportToDirectory(
vm: &mut VM,
directory: *const u8,
directory_len: usize,
) -> bool;
}

pub fn set_sampling_interval(interval: u32) {
Expand All @@ -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`.
Comment thread
robobun marked this conversation as resolved.
Resolved
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,
Expand Down
34 changes: 34 additions & 0 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -207,6 +207,11 @@ pub struct VirtualMachine {
pub dns_result_order: u8,
pub cpu_profiler_config: Option<crate::bun_cpu_profiler::CPUProfilerConfig>,
pub heap_profiler_config: Option<crate::bun_heap_profiler::HeapProfilerConfig>,
/// 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.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub sampling_profiler_directory: Option<Box<[u8]>>,
pub counters: Counters,

/// `--hot` / `--watch` mode this VM runs under.
Expand Down Expand Up @@ -515,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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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)
Expand Down Expand Up @@ -1660,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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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.
Expand Down
53 changes: 50 additions & 3 deletions src/jsc/modules/BunJSCModule.h
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
#include <JavaScriptCore/DeferTermination.h>
#include <JavaScriptCore/Error.h>
#include <JavaScriptCore/ErrorInstance.h>
#include <JavaScriptCore/FrameTracers.h>
#include <JavaScriptCore/HeapSnapshotBuilder.h>
#include <JavaScriptCore/JIT.h>
#include <JavaScriptCore/JSBasePrivate.h>
Expand All @@ -35,6 +36,9 @@
#include <cstddef>
#include <wtf/FileSystem.h>
#include <wtf/MemoryFootprint.h>
#include <wtf/ProcessID.h>
#include <wtf/StringPrintStream.h>
#include <wtf/Threading.h>
#include <wtf/text/WTFString.h>

#include "BunProcess.h"
Expand Down Expand Up @@ -445,6 +449,38 @@ 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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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.
Comment thread
robobun marked this conversation as resolved.
Resolved
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. Joined with makeString, not FileSystem::pathByAppendingComponent:
// the latter goes through libstdc++'s std::filesystem, whose string frees
// bypass mimalloc and crash on musl.
Comment thread
robobun marked this conversation as resolved.
Resolved
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<uint8_t>(utf8.span())).has_value();
}

JSC_DECLARE_HOST_FUNCTION(functionStartSamplingProfiler);
JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler,
Expand All @@ -460,8 +496,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()) {
StringPrintStream pathOut;
if (path.contains(static_cast<UChar>(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(
Expand All @@ -470,8 +512,13 @@ JSC_DEFINE_HOST_FUNCTION(functionStartSamplingProfiler,
return {};
}

Options::samplingProfilerPath() = pathCString.span().data();
samplingProfiler.registerForReportAtExit();
// 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.
Comment thread
robobun marked this conversation as resolved.
Resolved
auto directory = Bun::toString(path);
Bun__VirtualMachine__setSamplingProfilerDirectory(bunVM(globalObject), &directory);
}
}
if (sampleValue.isNumber()) {
Expand Down
24 changes: 24 additions & 0 deletions src/jsc/virtual_machine_exports.rs
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,30 @@ 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)
Comment thread
robobun marked this conversation as resolved.
Resolved
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::<bun_paths::resolve_path::platform::Auto>(&[
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).

Expand Down
121 changes: 120 additions & 1 deletion test/js/bun/jsc/bun-jsc.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -594,3 +596,120 @@ describe("JsRef::Weak liveness", () => {
expect(kept.keep).toBe(true);
});
});

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": `
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(stderr).toBe("");
expect(stdout).toBe("done true\n");
expect(exitCode).toBe(0);

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);
});

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": `
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
// before the close event is dispatched to the parent.
const closed = new Promise(resolve => worker.addEventListener("close", resolve));
await worker.terminate();
await closed;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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";
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(stderr).toBe("");
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 (e) {
nulThrew = e instanceof TypeError;
}
Comment thread
robobun marked this conversation as resolved.
Resolved
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);
},
);
Loading