From 3c003078e0860d44e53d8011092a7be156872703 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 21 Jun 2026 16:26:12 +0000 Subject: [PATCH 1/4] Honor absolute --heap-prof-dir paths build_output_path appended config.dir onto a path already rooted at the current directory via AutoAbsPath::init_top_level_dir(). For a rooted absolute path, Path::append trims the input as relative and strips the leading separator, so an absolute --heap-prof-dir was resolved under CWD instead of at the given location. Use Path::join, which resets the accumulated path when a segment is absolute, matching the CPU profiler. --- src/jsc/BunHeapProfiler.rs | 7 +++++-- test/cli/heap-prof.test.ts | 30 ++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/src/jsc/BunHeapProfiler.rs b/src/jsc/BunHeapProfiler.rs index d9ea12c24de5..d963b06b1306 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -104,9 +104,12 @@ fn build_output_path(path: &mut AutoAbsPath, config: &HeapProfilerConfig) -> Res generate_default_filename(&mut filename_buf, config.text_format)? }; - // Append directory if specified + // Append directory if specified. Use `join` rather than `append` so an + // absolute `config.dir` is honored: `append` trims its input as relative + // to the already-rooted path and strips the leading separator, whereas + // `join` resets the accumulated path when a segment is absolute. if !config.dir.is_empty() { - path.append(config.dir)?; + path.join(&[config.dir])?; } // Append filename diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index 246124740aae..b28c7b3b31eb 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -126,6 +126,36 @@ test("--heap-prof-dir specifies output directory for V8 format", async () => { expect(files.length).toBeGreaterThan(0); }); +test("--heap-prof-dir honors an absolute output directory", async () => { + // Two separate directories: one is the CWD, the other is the absolute + // target. An absolute --heap-prof-dir must be written to that directory, not + // resolved relative to CWD (which stripped the leading separator). + using cwdDir = tempDir("heap-prof-abs-cwd", {}); + using targetDir = tempDir("heap-prof-abs-target", {}); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof", "--heap-prof-dir", String(targetDir), "-e", `console.log("hello");`], + cwd: String(cwdDir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + expect(exitCode).toBe(0); + + // The snapshot must land in the absolute target directory. + const targetFiles = Array.from(new Bun.Glob("Heap.*.heapsnapshot").scanSync({ cwd: String(targetDir) })); + expect(targetFiles.length).toBeGreaterThan(0); + + // And nothing should have been written anywhere under CWD. + const cwdFiles = Array.from(new Bun.Glob("**/Heap.*.heapsnapshot").scanSync({ cwd: String(cwdDir) })); + expect(cwdFiles).toEqual([]); +}); + test("--heap-prof-dir specifies output directory for markdown format", async () => { using dir = tempDir("heap-prof-md-dir-test", { "profiles": {}, From 96b9f0d5f5499845b01e6bebadfd64c853c3b96b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:01:16 +0000 Subject: [PATCH 2/4] ci: retrigger From 66cadceebf5bf2be92a2aef225617bd1c2ef2cfb Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:21:52 +0000 Subject: [PATCH 3/4] Honor absolute --heap-prof-name and --cpu-prof-name paths The profiler filename was appended with Path::append, which trims an absolute input as relative (stripping the leading separator in release, tripping a debug_assert in debug) the same way the directory did. Use Path::join for the filename in both the heap and CPU profilers so an absolute --heap-prof-name / --cpu-prof-name is honored and both profilers stay consistent. --- src/jsc/BunCPUProfiler.rs | 13 ++++++++----- src/jsc/BunHeapProfiler.rs | 13 +++++++------ test/cli/heap-prof.test.ts | 29 +++++++++++++++++++++++++++++ test/cli/run/cpu-prof.test.ts | 34 ++++++++++++++++++++++++++++++++++ 4 files changed, 78 insertions(+), 11 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 903bcc766dbf..f8d101bdf064 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -178,15 +178,18 @@ fn build_output_path( generate_default_filename(&mut filename_buf, is_md_format)? }; - // Append directory if specified + // Use `join` rather than `append` for both the directory and the filename + // so that an absolute `--cpu-prof-dir` or `--cpu-prof-name` is honored: + // `append` trims its input as relative to the already-rooted path and + // strips the leading separator, whereas `join` resets the accumulated path + // when a segment is absolute. + // AutoAbsPath uses CheckLength::ASSUME — Err arm is unreachable. + // See paths/Path.rs `options::Result` note. if !config.dir.is_empty() { - // AutoAbsPath uses CheckLength::ASSUME — Err arm is unreachable. - // See paths/Path.rs `options::Result` note. path.join(&[config.dir]).expect("unreachable"); } - // Append filename - path.append(filename).expect("unreachable"); + path.join(&[filename]).expect("unreachable"); Ok(()) } diff --git a/src/jsc/BunHeapProfiler.rs b/src/jsc/BunHeapProfiler.rs index d963b06b1306..a57514eea35a 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -104,16 +104,17 @@ fn build_output_path(path: &mut AutoAbsPath, config: &HeapProfilerConfig) -> Res generate_default_filename(&mut filename_buf, config.text_format)? }; - // Append directory if specified. Use `join` rather than `append` so an - // absolute `config.dir` is honored: `append` trims its input as relative - // to the already-rooted path and strips the leading separator, whereas - // `join` resets the accumulated path when a segment is absolute. + // Use `join` rather than `append` for both the directory and the filename + // so that an absolute `--heap-prof-dir` or `--heap-prof-name` is honored: + // `append` trims its input as relative to the already-rooted path and + // strips the leading separator (it also debug-asserts the input is + // relative), whereas `join` resets the accumulated path when a segment is + // absolute. if !config.dir.is_empty() { path.join(&[config.dir])?; } - // Append filename - path.append(filename)?; + path.join(&[filename])?; Ok(()) } diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index b28c7b3b31eb..2f1fd70fd9b2 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -156,6 +156,35 @@ test("--heap-prof-dir honors an absolute output directory", async () => { expect(cwdFiles).toEqual([]); }); +test("--heap-prof-name honors an absolute path", async () => { + // An absolute --heap-prof-name must be written verbatim, not resolved + // relative to CWD (which stripped the leading separator). + using cwdDir = tempDir("heap-prof-name-abs-cwd", {}); + using targetDir = tempDir("heap-prof-name-abs-target", {}); + const target = join(String(targetDir), "custom.heapsnapshot"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--heap-prof", "--heap-prof-name", target, "-e", `console.log("hello");`], + cwd: String(cwdDir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout.trim()).toBe("hello"); + expect(stderr).toContain("Heap profile written to:"); + expect(exitCode).toBe(0); + + // The snapshot must land at the absolute path. + expect(Bun.file(target).size).toBeGreaterThan(0); + + // And nothing should have been written anywhere under CWD. + const cwdFiles = Array.from(new Bun.Glob("**/*.heapsnapshot").scanSync({ cwd: String(cwdDir) })); + expect(cwdFiles).toEqual([]); +}); + test("--heap-prof-dir specifies output directory for markdown format", async () => { using dir = tempDir("heap-prof-md-dir-test", { "profiles": {}, diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 87f7ce6d06f9..58e83728ffda 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -125,6 +125,40 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + test("--cpu-prof-name honors an absolute path", async () => { + // An absolute --cpu-prof-name must be written verbatim, not resolved + // relative to CWD (which stripped the leading separator). + using cwdDir = tempDir("cpu-prof-name-abs-cwd", { + "test.js": ` + function loop() { + const end = Date.now() + 100; + while (Date.now() < end) {} + } + loop(); + `, + }); + using targetDir = tempDir("cpu-prof-name-abs-target", {}); + const target = join(String(targetDir), "custom.cpuprofile"); + + await using proc = Bun.spawn({ + cmd: [bunExe(), "--cpu-prof", "--cpu-prof-name", target, "test.js"], + cwd: String(cwdDir), + env: bunEnv, + stdout: "inherit", + stderr: "inherit", + }); + + const exitCode = await proc.exited; + + // The profile must land at the absolute path. + expect(Bun.file(target).size).toBeGreaterThan(0); + + // And nothing should have been written anywhere under CWD. + const cwdFiles = Array.from(new Bun.Glob("**/*.cpuprofile").scanSync({ cwd: String(cwdDir) })); + expect(cwdFiles).toEqual([]); + expect(exitCode).toBe(0); + }); + test("--cpu-prof-dir sets custom directory", async () => { using dir = tempDir("cpu-prof-dir", { "test.js": ` From 6a9a9a5f80e825fa05b80278efd8f38bb0ff457b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sun, 21 Jun 2026 17:36:45 +0000 Subject: [PATCH 4/4] Create missing parent dirs for absolute --cpu-prof-name The CPU profiler write-retry only created config.dir, so an absolute --cpu-prof-name pointing at a non-existent parent failed with ENOENT even after the join fix. Derive the directory from the final output path (like the heap profiler) so the parent is created before retrying. Strengthen the profiler-name tests to use a non-existent absolute parent and assert the exit code last. --- src/jsc/BunCPUProfiler.rs | 28 ++++++++++++++++++++++------ test/cli/heap-prof.test.ts | 12 ++++++++---- test/cli/run/cpu-prof.test.ts | 6 ++++-- 3 files changed, 34 insertions(+), 12 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index f8d101bdf064..88f15e061e99 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -115,24 +115,40 @@ fn write_profile_to_file( #[cfg(windows)] let output_path_os = bun_core::strings::convert_utf8_to_utf16_in_buffer_z(&mut path_buf_os, path_buf.slice_z()); - #[cfg(not(windows))] - let output_path_os = path_buf.slice_z(); - // Write the profile to disk using bun.sys.File.writeFile + // Write the profile to disk. + // `slice_z()` borrows `path_buf` mutably, so on non-Windows we re-derive it + // at each call site instead of holding a single binding. + #[cfg(windows)] let result = bun_sys::File::write_file_os_path(Fd::cwd(), output_path_os, profile_slice.slice()); + #[cfg(not(windows))] + let result = + bun_sys::File::write_file_os_path(Fd::cwd(), path_buf.slice_z(), profile_slice.slice()); if let Err(err) = result { - // If we got ENOENT, PERM, or ACCES, try creating the directory and retry + // If we got ENOENT, PERM, or ACCES, try creating the directory and retry. let errno = err.get_errno(); if errno == Errno::ENOENT || errno == Errno::EPERM || errno == Errno::EACCES { - if !config.dir.is_empty() { - let _ = Fd::cwd().make_path(config.dir); + // Derive the directory from the absolute output path so that a + // missing parent of an absolute --cpu-prof-name (not just + // --cpu-prof-dir) is created before the retry. + let dir_path = + bun_paths::resolve_path::dirname::(path_buf.slice()); + if !dir_path.is_empty() { + let _ = Fd::cwd().make_path(dir_path); // Retry write + #[cfg(windows)] let retry_result = bun_sys::File::write_file_os_path( Fd::cwd(), output_path_os, profile_slice.slice(), ); + #[cfg(not(windows))] + let retry_result = bun_sys::File::write_file_os_path( + Fd::cwd(), + path_buf.slice_z(), + profile_slice.slice(), + ); if retry_result.is_err() { return Err(ProfilerError::WriteFailed); } diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index 2f1fd70fd9b2..7cff5101b4b9 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -145,7 +145,6 @@ test("--heap-prof-dir honors an absolute output directory", async () => { expect(stdout.trim()).toBe("hello"); expect(stderr).toContain("Heap profile written to:"); - expect(exitCode).toBe(0); // The snapshot must land in the absolute target directory. const targetFiles = Array.from(new Bun.Glob("Heap.*.heapsnapshot").scanSync({ cwd: String(targetDir) })); @@ -154,14 +153,18 @@ test("--heap-prof-dir honors an absolute output directory", async () => { // And nothing should have been written anywhere under CWD. const cwdFiles = Array.from(new Bun.Glob("**/Heap.*.heapsnapshot").scanSync({ cwd: String(cwdDir) })); expect(cwdFiles).toEqual([]); + + expect(exitCode).toBe(0); }); test("--heap-prof-name honors an absolute path", async () => { // An absolute --heap-prof-name must be written verbatim, not resolved - // relative to CWD (which stripped the leading separator). + // relative to CWD (which stripped the leading separator). The target's + // parent directory does not exist yet, so this also covers creating the + // parent of an absolute output path. using cwdDir = tempDir("heap-prof-name-abs-cwd", {}); using targetDir = tempDir("heap-prof-name-abs-target", {}); - const target = join(String(targetDir), "custom.heapsnapshot"); + const target = join(String(targetDir), "nested", "custom.heapsnapshot"); await using proc = Bun.spawn({ cmd: [bunExe(), "--heap-prof", "--heap-prof-name", target, "-e", `console.log("hello");`], @@ -175,7 +178,6 @@ test("--heap-prof-name honors an absolute path", async () => { expect(stdout.trim()).toBe("hello"); expect(stderr).toContain("Heap profile written to:"); - expect(exitCode).toBe(0); // The snapshot must land at the absolute path. expect(Bun.file(target).size).toBeGreaterThan(0); @@ -183,6 +185,8 @@ test("--heap-prof-name honors an absolute path", async () => { // And nothing should have been written anywhere under CWD. const cwdFiles = Array.from(new Bun.Glob("**/*.heapsnapshot").scanSync({ cwd: String(cwdDir) })); expect(cwdFiles).toEqual([]); + + expect(exitCode).toBe(0); }); test("--heap-prof-dir specifies output directory for markdown format", async () => { diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 58e83728ffda..a458412941b0 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -127,7 +127,9 @@ describe.concurrent("--cpu-prof", () => { test("--cpu-prof-name honors an absolute path", async () => { // An absolute --cpu-prof-name must be written verbatim, not resolved - // relative to CWD (which stripped the leading separator). + // relative to CWD (which stripped the leading separator). The target's + // parent directory does not exist yet, so this also covers creating the + // parent of an absolute output path. using cwdDir = tempDir("cpu-prof-name-abs-cwd", { "test.js": ` function loop() { @@ -138,7 +140,7 @@ describe.concurrent("--cpu-prof", () => { `, }); using targetDir = tempDir("cpu-prof-name-abs-target", {}); - const target = join(String(targetDir), "custom.cpuprofile"); + const target = join(String(targetDir), "nested", "custom.cpuprofile"); await using proc = Bun.spawn({ cmd: [bunExe(), "--cpu-prof", "--cpu-prof-name", target, "test.js"],