diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index 903bcc766dbf..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); } @@ -178,15 +194,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 d9ea12c24de5..a57514eea35a 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -104,13 +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` 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.append(config.dir)?; + 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 246124740aae..7cff5101b4b9 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -126,6 +126,69 @@ 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:"); + + // 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([]); + + 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). 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), "nested", "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:"); + + // 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([]); + + expect(exitCode).toBe(0); +}); + 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..a458412941b0 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -125,6 +125,42 @@ 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). 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() { + const end = Date.now() + 100; + while (Date.now() < end) {} + } + loop(); + `, + }); + using targetDir = tempDir("cpu-prof-name-abs-target", {}); + const target = join(String(targetDir), "nested", "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": `