From 1d3cc08e57763298fbc760f2976fb88a4b46de69 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:48:27 +0000 Subject: [PATCH 1/6] node:v8: create the writeHeapSnapshot() file with mode 0600 like Node v8.writeHeapSnapshot() wrote the snapshot through fs.writeFileSync with no mode, so a new file got 0666 minus the umask (0644 with umask 022). Node opens the file with mode 0600 in WriteSnapshot (src/heap_utils.cc). Pass mode 0o600 to the one writeFileSync call. The mode only applies when the call creates the file. An existing file keeps its mode, as in Node. The mode has no effect on Windows. --- src/js/node/v8.ts | 3 ++- test/js/bun/util/v8-heap-snapshot-fixture.ts | 5 ++++- test/js/bun/util/v8-heap-snapshot.test.ts | 23 ++++++++++++++++++-- 3 files changed, 27 insertions(+), 4 deletions(-) diff --git a/src/js/node/v8.ts b/src/js/node/v8.ts index b5fae9334a97..7ce040774086 100644 --- a/src/js/node/v8.ts +++ b/src/js/node/v8.ts @@ -366,7 +366,8 @@ function writeHeapSnapshot(path, _options) { if (!fs) { fs = require("node:fs"); } - fs.writeFileSync(path, Bun.generateHeapSnapshot("v8"), "utf-8"); + // 0600 on create, like Node's WriteSnapshot: https://github.com/nodejs/node/blob/v26.3.0/src/heap_utils.cc#L361-L372 + fs.writeFileSync(path, Bun.generateHeapSnapshot("v8"), { encoding: "utf-8", mode: 0o600 }); return path; } diff --git a/test/js/bun/util/v8-heap-snapshot-fixture.ts b/test/js/bun/util/v8-heap-snapshot-fixture.ts index 93af3a82a831..1f25fd997e15 100644 --- a/test/js/bun/util/v8-heap-snapshot-fixture.ts +++ b/test/js/bun/util/v8-heap-snapshot-fixture.ts @@ -89,15 +89,18 @@ switch (mode) { break; } + // The test checks the permissions of the file that the two modes below leave + // behind, so each one pins the umask those permissions depend on. case "write-default": { + process.umask(0o022); const path = require("node:v8").writeHeapSnapshot(); const json = await Bun.file(path).json(); - require("node:fs").rmSync(path); result = { filename: require("node:path").basename(path), ...describeSnapshot(json) }; break; } case "write-path": { + process.umask(0o022); const returnedPath = require("node:v8").writeHeapSnapshot(pathArg); result = { returnedPath, ...describeSnapshot(await Bun.file(pathArg).json()) }; break; diff --git a/test/js/bun/util/v8-heap-snapshot.test.ts b/test/js/bun/util/v8-heap-snapshot.test.ts index 8141ce96d21a..ec43086f6cb4 100644 --- a/test/js/bun/util/v8-heap-snapshot.test.ts +++ b/test/js/bun/util/v8-heap-snapshot.test.ts @@ -1,7 +1,11 @@ -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isDebug, isWindows, tempDir } from "harness"; +import { chmodSync, statSync } from "node:fs"; import { join } from "node:path"; +// On a debug build a fixture takes 4 to 6 s and `bun bd test` allows 5 s. CI passes its own --timeout. +if (isDebug) setDefaultTimeout(60_000); + const fixture = join(import.meta.dir, "v8-heap-snapshot-fixture.ts"); const NODE_FIELDS = ["type", "name", "id", "self_size", "edge_count", "trace_node_id", "detachedness"]; @@ -75,18 +79,33 @@ test("v8.getHeapSnapshot()", async () => { expect(await runFixture("get-heap-snapshot")).toEqual({ chunks: true, emptyChunks: 0, ...structure }); }); +// Node creates the snapshot file with mode 0600 (WriteSnapshot in src/heap_utils.cc). +// Windows has no permission bits to check. +const permissions = (path: string) => (statSync(path).mode & 0o777).toString(8); + test("v8.writeHeapSnapshot()", async () => { // Without a path the snapshot is written to the cwd, so give it one we own. using dir = tempDir("v8-heap-snapshot", {}); const { filename, ...rest } = await runFixture("write-default", { cwd: String(dir) }); expect(filename).toMatch(/^Heap-\d{8}-\d{6}-\d+-\d+\.heapsnapshot$/); expect(rest).toEqual(structure); + if (!isWindows) expect(permissions(join(String(dir), filename))).toBe("600"); }); test("v8.writeHeapSnapshot() with path", async () => { using dir = tempDir("v8-heap-snapshot", {}); const path = join(String(dir), "test.heapsnapshot"); expect(await runFixture("write-path", { args: [path] })).toEqual({ returnedPath: path, ...structure }); + if (!isWindows) expect(permissions(path)).toBe("600"); +}); + +// As in Node, the mode only applies when the call creates the file. +test.skipIf(isWindows)("v8.writeHeapSnapshot() keeps the mode of an existing file", async () => { + using dir = tempDir("v8-heap-snapshot", { "test.heapsnapshot": "not a snapshot" }); + const path = join(String(dir), "test.heapsnapshot"); + chmodSync(path, 0o644); + expect(await runFixture("write-path", { args: [path] })).toEqual({ returnedPath: path, ...structure }); + expect(permissions(path)).toBe("644"); }); test("v8 heap snapshot labels Web Streams internal edges", async () => { From f8a8ea275f7192f0dd7f699c19b99024ef1d7172 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 13 Oct 2026 20:48:27 +0000 Subject: [PATCH 2/6] cli: create --heap-prof and --cpu-prof files with mode 0600 like Node The profile writers went through File::write_file and File::write_file_os_path, which open with mode 0o664. Node writes --heap-prof and --cpu-prof files through WriteFileSync (src/node_file_utils.cc), which opens with mode 0600. Add write_owner_only in BunCPUProfiler.rs and call it from the six profile opens (first try and retry after mkdir, both profilers). The --heap-prof-md and --cpu-prof-md files have no Node counterpart. They get the same mode as their sibling files. The mode only applies when the file is created. Directories and the shared 0o664 in bun_sys are unchanged. --- src/jsc/BunCPUProfiler.rs | 16 ++++++------ src/jsc/BunHeapProfiler.rs | 20 +++++++++------ test/cli/heap-prof.test.ts | 46 ++++++++++++++++++++++++++++++++- test/cli/run/cpu-prof.test.ts | 48 ++++++++++++++++++++++++++++++++++- 4 files changed, 112 insertions(+), 18 deletions(-) diff --git a/src/jsc/BunCPUProfiler.rs b/src/jsc/BunCPUProfiler.rs index e19965f5986e..fc769a9c6f05 100644 --- a/src/jsc/BunCPUProfiler.rs +++ b/src/jsc/BunCPUProfiler.rs @@ -78,6 +78,12 @@ pub(crate) fn stop_and_write_profile( Ok(()) } +/// 0600 on create, like Node's WriteFileSync: https://github.com/nodejs/node/blob/v26.3.0/src/node_file_utils.cc#L29-L36 +pub(crate) fn write_owner_only(path: &bun_paths::OSPathSliceZ, data: &[u8]) -> bun_sys::Maybe<()> { + const FLAGS: i32 = bun_sys::O::WRONLY | bun_sys::O::CREAT | bun_sys::O::TRUNC; + bun_sys::File::openat_os_path(Fd::cwd(), path, FLAGS, 0o600)?.write_all(data) +} + fn write_profile_to_file( profile_string: &BunString, config: &CPUProfilerConfig, @@ -100,9 +106,7 @@ fn write_profile_to_file( #[cfg(not(windows))] let output_path_os = path_buf.slice_z(); - // Write the profile to disk using bun.sys.File.writeFile - let result = - bun_sys::File::write_file_os_path(Fd::cwd(), output_path_os, profile_slice.slice()); + let result = write_owner_only(output_path_os, profile_slice.slice()); if let Err(err) = result { // If we got ENOENT, PERM, or ACCES, try creating the directory and retry let errno = err.get_errno(); @@ -110,11 +114,7 @@ fn write_profile_to_file( if !config.dir.is_empty() { let _ = Fd::cwd().make_path(config.dir); // Retry write - let retry_result = bun_sys::File::write_file_os_path( - Fd::cwd(), - output_path_os, - profile_slice.slice(), - ); + let retry_result = write_owner_only(output_path_os, profile_slice.slice()); if retry_result.is_err() { return Err(ProfilerError::WriteFailed); } diff --git a/src/jsc/BunHeapProfiler.rs b/src/jsc/BunHeapProfiler.rs index 1bb3babf3493..3e1328ce9e1f 100644 --- a/src/jsc/BunHeapProfiler.rs +++ b/src/jsc/BunHeapProfiler.rs @@ -2,7 +2,7 @@ use crate::CrateError as Error; use bun_core::Output; use bun_core::String as BunString; use bun_paths::{AutoAbsPathChecked, PathBuffer, resolve_path}; -use bun_sys::{self as sys, E, Fd, FdDirExt}; +use bun_sys::{E, Fd, FdDirExt}; use crate::VM; @@ -54,13 +54,13 @@ pub(crate) fn generate_and_write_profile( path_buf.slice_z().as_bytes(), ); - // Write the profile to disk using bun.sys.File.writeFile // `slice_z()` borrows `path_buf` mutably, so we re-derive it at each call // site instead of holding a single binding. #[cfg(windows)] - let result = sys::File::write_file_os_path(Fd::cwd(), output_path_os, profile_slice.slice()); + let result = crate::bun_cpu_profiler::write_owner_only(output_path_os, profile_slice.slice()); #[cfg(not(windows))] - let result = sys::File::write_file(Fd::cwd(), path_buf.slice_z(), profile_slice.slice()); + let result = + crate::bun_cpu_profiler::write_owner_only(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 let errno = err.get_errno(); @@ -71,11 +71,15 @@ pub(crate) fn generate_and_write_profile( let _ = Fd::cwd().make_path(dir_path); // Retry write #[cfg(windows)] - let retry_result = - sys::File::write_file_os_path(Fd::cwd(), output_path_os, profile_slice.slice()); + let retry_result = crate::bun_cpu_profiler::write_owner_only( + output_path_os, + profile_slice.slice(), + ); #[cfg(not(windows))] - let retry_result = - sys::File::write_file(Fd::cwd(), path_buf.slice_z(), profile_slice.slice()); + let retry_result = crate::bun_cpu_profiler::write_owner_only( + path_buf.slice_z(), + profile_slice.slice(), + ); if retry_result.is_err() { return Err(crate::CrateError::WriteFailed); } diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index f94ce71e788e..fcf15684ddb0 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -2,7 +2,8 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "path"; -const testScript = `const arr = []; for (let i = 0; i < 100; i++) arr.push({ x: i, y: "hello" + i }); console.log("done");`; +// The umask is pinned for the tests that check the permissions of the profile: bun writes it at exit. +const testScript = `process.umask(0o022); const arr = []; for (let i = 0; i < 100; i++) arr.push({ x: i, y: "hello" + i }); console.log("done");`; // Node's DiagnosticFilename format: Heap......heapprofile const nodeFilenameRe = /^Heap\.\d{8}\.\d{6}\.\d+\.0\.\d{3}\.heapprofile$/; @@ -23,6 +24,11 @@ function expectV8HeapSnapshotShape(profile: any) { expect(Array.isArray(profile.strings)).toBe(true); } +// The permission bits in octal. Windows has none to check. +async function permissions(path: string) { + return ((await Bun.file(path).stat()).mode & 0o777).toString(8); +} + test("--heap-prof writes a .heapprofile with node's filename format on exit", async () => { using dir = tempDir("heap-prof-v8-test", {}); @@ -43,6 +49,8 @@ test("--heap-prof writes a .heapprofile with node's filename format on exit", as const files = Array.from(glob.scanSync({ cwd: String(dir) })); expect(files.length).toBe(1); expect(files[0]).toMatch(nodeFilenameRe); + // Node creates the file with mode 0600 (WriteFileSync in src/node_file_utils.cc). + if (!isWindows) expect(await permissions(join(String(dir), files[0]))).toBe("600"); const profile = await readProfile(String(dir), files[0]); expectV8HeapSnapshotShape(profile); @@ -120,6 +128,8 @@ test("--heap-prof-md generates markdown heap profile on exit", async () => { // Read and validate the heap profile content const profilePath = join(String(dir), files[0]); + // Node has no --heap-prof-md. The file gets the mode of the --heap-prof file. + if (!isWindows) expect(await permissions(profilePath)).toBe("600"); const content = await Bun.file(profilePath).text(); // Check for markdown headers @@ -228,6 +238,40 @@ test("--heap-prof-name specifies output filename", async () => { expectV8HeapSnapshotShape(profile); }); +// The first open fails because the directory is missing. The open after the mkdir creates the file. +test.skipIf(isWindows)("--heap-prof-dir is created when missing, and the profile gets mode 0600", async () => { + using dir = tempDir("heap-prof-missing-dir-test", {}); + + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "--heap-prof", + "--heap-prof-dir", + "missing/dir", + "--heap-prof-name", + "retry.heapprofile", + "-e", + "process.umask(0o022);", + ], + 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, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + + const entries = [...new Bun.Glob("**").scanSync({ cwd: String(dir), onlyFiles: false })]; + const modes = await Promise.all(entries.map(async entry => [entry, await permissions(join(String(dir), entry))])); + expect(Object.fromEntries(modes)).toEqual({ + "missing": "755", + "missing/dir": "755", + "missing/dir/retry.heapprofile": "600", + }); + expectV8HeapSnapshotShape(await readProfile(join(String(dir), "missing", "dir"), "retry.heapprofile")); +}); + test("--heap-prof-name and --heap-prof-dir work together", async () => { using dir = tempDir("heap-prof-both-test", { "output": {}, diff --git a/test/cli/run/cpu-prof.test.ts b/test/cli/run/cpu-prof.test.ts index 4d5fe51aea7b..f2681ba2a8ee 100644 --- a/test/cli/run/cpu-prof.test.ts +++ b/test/cli/run/cpu-prof.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test"; -import { readdirSync, readFileSync } from "fs"; +import { chmodSync, readdirSync, readFileSync, statSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "path"; @@ -189,6 +189,52 @@ describe.concurrent("--cpu-prof", () => { expect(exitCode).toBe(0); }); + // Node creates a --cpu-prof file with mode 0600 (WriteFileSync in src/node_file_utils.cc). + // Node has no --cpu-prof-md. That file gets the same mode. + test.skipIf(isWindows)("--cpu-prof and --cpu-prof-md create the profile with mode 0600", async () => { + using dir = tempDir("cpu-prof-mode", { + // Longer than a profile, and not JSON: the run must replace all of it. + "existing.cpuprofile": Buffer.alloc(1024 * 1024, "x").toString(), + }); + chmodSync(join(String(dir), "existing.cpuprofile"), 0o644); + + const results = await Promise.all( + [ + // With both formats, each file gets its extension: new.cpuprofile and new.md. + ["--cpu-prof", "--cpu-prof-md", "--cpu-prof-name", "new"], + // The first open fails because the directory is missing. The open after the mkdir creates the file. + ["--cpu-prof", "--cpu-prof-dir", "missing/dir", "--cpu-prof-name", "retry.cpuprofile"], + ["--cpu-prof", "--cpu-prof-name", "existing.cpuprofile"], + ].map(async flags => { + await using proc = Bun.spawn({ + // The profile is written at exit, so the umask that the script sets applies to it. + cmd: [bunExe(), ...flags, "-e", "process.umask(0o022);"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }), + ); + const silentSuccess = { stdout: "", stderr: "", exitCode: 0 }; + expect(results).toEqual([silentSuccess, silentSuccess, silentSuccess]); + + const entries = [...new Bun.Glob("**").scanSync({ cwd: String(dir), onlyFiles: false })]; + const permissions = entries.map(entry => [entry, (statSync(join(String(dir), entry)).mode & 0o777).toString(8)]); + expect(Object.fromEntries(permissions)).toEqual({ + "new.cpuprofile": "600", + "new.md": "600", + "missing": "755", + "missing/dir": "755", + "missing/dir/retry.cpuprofile": "600", + // The mode only applies when the file is created, as in node. + "existing.cpuprofile": "644", + }); + expect(JSON.parse(readFileSync(join(String(dir), "existing.cpuprofile"), "utf-8"))).toHaveProperty("nodes"); + }); + test("profile captures function names", async () => { using dir = tempDir("cpu-prof-functions", { "test.js": ` From 799f2033ebf877d909b5624a92f15a5677443a52 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:35:12 +0000 Subject: [PATCH 3/6] test: check the writeHeapSnapshot() file mode in v8-module.test.ts Move the mode checks for v8.writeHeapSnapshot() out of v8-heap-snapshot.test.ts and its fixture, which are back to their state on main. That file needed a longer timeout on debug builds, and its fixture modes had changed for the new checks. The new tests in v8-module.test.ts spawn one process for each case: a new file at a path, a new file with the default name, and an existing file that keeps its mode. --- test/js/bun/util/v8-heap-snapshot-fixture.ts | 5 +-- test/js/bun/util/v8-heap-snapshot.test.ts | 23 +--------- test/js/node/v8/v8-module.test.ts | 47 +++++++++++++++++++- 3 files changed, 49 insertions(+), 26 deletions(-) diff --git a/test/js/bun/util/v8-heap-snapshot-fixture.ts b/test/js/bun/util/v8-heap-snapshot-fixture.ts index 1f25fd997e15..93af3a82a831 100644 --- a/test/js/bun/util/v8-heap-snapshot-fixture.ts +++ b/test/js/bun/util/v8-heap-snapshot-fixture.ts @@ -89,18 +89,15 @@ switch (mode) { break; } - // The test checks the permissions of the file that the two modes below leave - // behind, so each one pins the umask those permissions depend on. case "write-default": { - process.umask(0o022); const path = require("node:v8").writeHeapSnapshot(); const json = await Bun.file(path).json(); + require("node:fs").rmSync(path); result = { filename: require("node:path").basename(path), ...describeSnapshot(json) }; break; } case "write-path": { - process.umask(0o022); const returnedPath = require("node:v8").writeHeapSnapshot(pathArg); result = { returnedPath, ...describeSnapshot(await Bun.file(pathArg).json()) }; break; diff --git a/test/js/bun/util/v8-heap-snapshot.test.ts b/test/js/bun/util/v8-heap-snapshot.test.ts index ec43086f6cb4..8141ce96d21a 100644 --- a/test/js/bun/util/v8-heap-snapshot.test.ts +++ b/test/js/bun/util/v8-heap-snapshot.test.ts @@ -1,11 +1,7 @@ -import { expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isWindows, tempDir } from "harness"; -import { chmodSync, statSync } from "node:fs"; +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; import { join } from "node:path"; -// On a debug build a fixture takes 4 to 6 s and `bun bd test` allows 5 s. CI passes its own --timeout. -if (isDebug) setDefaultTimeout(60_000); - const fixture = join(import.meta.dir, "v8-heap-snapshot-fixture.ts"); const NODE_FIELDS = ["type", "name", "id", "self_size", "edge_count", "trace_node_id", "detachedness"]; @@ -79,33 +75,18 @@ test("v8.getHeapSnapshot()", async () => { expect(await runFixture("get-heap-snapshot")).toEqual({ chunks: true, emptyChunks: 0, ...structure }); }); -// Node creates the snapshot file with mode 0600 (WriteSnapshot in src/heap_utils.cc). -// Windows has no permission bits to check. -const permissions = (path: string) => (statSync(path).mode & 0o777).toString(8); - test("v8.writeHeapSnapshot()", async () => { // Without a path the snapshot is written to the cwd, so give it one we own. using dir = tempDir("v8-heap-snapshot", {}); const { filename, ...rest } = await runFixture("write-default", { cwd: String(dir) }); expect(filename).toMatch(/^Heap-\d{8}-\d{6}-\d+-\d+\.heapsnapshot$/); expect(rest).toEqual(structure); - if (!isWindows) expect(permissions(join(String(dir), filename))).toBe("600"); }); test("v8.writeHeapSnapshot() with path", async () => { using dir = tempDir("v8-heap-snapshot", {}); const path = join(String(dir), "test.heapsnapshot"); expect(await runFixture("write-path", { args: [path] })).toEqual({ returnedPath: path, ...structure }); - if (!isWindows) expect(permissions(path)).toBe("600"); -}); - -// As in Node, the mode only applies when the call creates the file. -test.skipIf(isWindows)("v8.writeHeapSnapshot() keeps the mode of an existing file", async () => { - using dir = tempDir("v8-heap-snapshot", { "test.heapsnapshot": "not a snapshot" }); - const path = join(String(dir), "test.heapsnapshot"); - chmodSync(path, 0o644); - expect(await runFixture("write-path", { args: [path] })).toEqual({ returnedPath: path, ...structure }); - expect(permissions(path)).toBe("644"); }); test("v8 heap snapshot labels Web Streams internal edges", async () => { diff --git a/test/js/node/v8/v8-module.test.ts b/test/js/node/v8/v8-module.test.ts index fe9fa3a8788f..77852c8a24e9 100644 --- a/test/js/node/v8/v8-module.test.ts +++ b/test/js/node/v8/v8-module.test.ts @@ -1,5 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { chmodSync, readFileSync, statSync } from "node:fs"; +import { join } from "node:path"; import { GCProfiler, isStringOneByteRepresentation } from "node:v8"; describe("v8.isStringOneByteRepresentation", () => { @@ -131,3 +133,46 @@ describe("v8.GCProfiler", () => { }); }); }); + +// Node creates the snapshot file with mode 0600 (WriteSnapshot in src/heap_utils.cc), and only when +// the call creates the file. Windows has no permission bits to check. +describe.skipIf(isWindows)("v8.writeHeapSnapshot", () => { + // Writes one snapshot into `cwd`, to `path` or to the default name, and returns where it is. + async function writeHeapSnapshot(cwd: string, path?: string) { + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + // The umask is pinned so that the mode of the file depends on the call alone. + `process.umask(0o022); console.log(require("node:v8").writeHeapSnapshot(process.argv[1]));`, + ...(path === undefined ? [] : [path]), + ], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); + return join(cwd, stdout.trim()); + } + + const permissions = (path: string) => (statSync(path).mode & 0o777).toString(8); + + test.each([ + ["a path", "new.heapsnapshot"], + ["the default name", undefined], + ])("creates the file with mode 0600 for %s", async (_label, path) => { + using dir = tempDir("v8-write-heap-snapshot", {}); + expect(permissions(await writeHeapSnapshot(String(dir), path))).toBe("600"); + }); + + test("keeps the mode of an existing file", async () => { + using dir = tempDir("v8-write-heap-snapshot", { "existing.heapsnapshot": "not a snapshot" }); + const existing = join(String(dir), "existing.heapsnapshot"); + chmodSync(existing, 0o644); + expect(await writeHeapSnapshot(String(dir), "existing.heapsnapshot")).toBe(existing); + expect(JSON.parse(readFileSync(existing, "utf8"))).toHaveProperty("snapshot"); + expect(permissions(existing)).toBe("644"); + }); +}); From 9053c12a033955483c78b2cccd83e6312d19257b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:35:13 +0000 Subject: [PATCH 4/6] test: check the heap profile file modes in tests of their own The existing --heap-prof tests are back to their state on main. Three new cases check the mode: --heap-prof, --heap-prof-md, and --heap-prof with a missing --heap-prof-dir. --- test/cli/heap-prof.test.ts | 64 +++++++++++++++++++------------------- 1 file changed, 32 insertions(+), 32 deletions(-) diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index fcf15684ddb0..2a6ed8e2fd6a 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -1,9 +1,9 @@ import { expect, test } from "bun:test"; +import { statSync } from "fs"; import { bunEnv, bunExe, isWindows, tempDir } from "harness"; import { join } from "path"; -// The umask is pinned for the tests that check the permissions of the profile: bun writes it at exit. -const testScript = `process.umask(0o022); const arr = []; for (let i = 0; i < 100; i++) arr.push({ x: i, y: "hello" + i }); console.log("done");`; +const testScript = `const arr = []; for (let i = 0; i < 100; i++) arr.push({ x: i, y: "hello" + i }); console.log("done");`; // Node's DiagnosticFilename format: Heap......heapprofile const nodeFilenameRe = /^Heap\.\d{8}\.\d{6}\.\d+\.0\.\d{3}\.heapprofile$/; @@ -24,11 +24,6 @@ function expectV8HeapSnapshotShape(profile: any) { expect(Array.isArray(profile.strings)).toBe(true); } -// The permission bits in octal. Windows has none to check. -async function permissions(path: string) { - return ((await Bun.file(path).stat()).mode & 0o777).toString(8); -} - test("--heap-prof writes a .heapprofile with node's filename format on exit", async () => { using dir = tempDir("heap-prof-v8-test", {}); @@ -49,8 +44,6 @@ test("--heap-prof writes a .heapprofile with node's filename format on exit", as const files = Array.from(glob.scanSync({ cwd: String(dir) })); expect(files.length).toBe(1); expect(files[0]).toMatch(nodeFilenameRe); - // Node creates the file with mode 0600 (WriteFileSync in src/node_file_utils.cc). - if (!isWindows) expect(await permissions(join(String(dir), files[0]))).toBe("600"); const profile = await readProfile(String(dir), files[0]); expectV8HeapSnapshotShape(profile); @@ -128,8 +121,6 @@ test("--heap-prof-md generates markdown heap profile on exit", async () => { // Read and validate the heap profile content const profilePath = join(String(dir), files[0]); - // Node has no --heap-prof-md. The file gets the mode of the --heap-prof file. - if (!isWindows) expect(await permissions(profilePath)).toBe("600"); const content = await Bun.file(profilePath).text(); // Check for markdown headers @@ -238,21 +229,34 @@ test("--heap-prof-name specifies output filename", async () => { expectV8HeapSnapshotShape(profile); }); -// The first open fails because the directory is missing. The open after the mkdir creates the file. -test.skipIf(isWindows)("--heap-prof-dir is created when missing, and the profile gets mode 0600", async () => { - using dir = tempDir("heap-prof-missing-dir-test", {}); +// Node creates a --heap-prof file with mode 0600 (WriteFileSync in src/node_file_utils.cc). +// Node has no --heap-prof-md. That file gets the same mode. +test.skipIf(isWindows).each([ + { + name: "--heap-prof", + flags: ["--heap-prof", "--heap-prof-name", "out.heapprofile"], + stderr: /^$/, + modes: { "out.heapprofile": "600" }, + }, + { + name: "--heap-prof-md", + flags: ["--heap-prof-md", "--heap-prof-name", "out.md"], + stderr: /^Heap profile written to: .*out\.md\n$/, + modes: { "out.md": "600" }, + }, + { + // The first open fails because the directory is missing. The open after the mkdir creates the file. + name: "--heap-prof with a missing --heap-prof-dir", + flags: ["--heap-prof", "--heap-prof-dir", "missing/dir", "--heap-prof-name", "out.heapprofile"], + stderr: /^$/, + modes: { "missing": "755", "missing/dir": "755", "missing/dir/out.heapprofile": "600" }, + }, +])("$name creates the profile with mode 0600", async ({ flags, stderr: expectedStderr, modes }) => { + using dir = tempDir("heap-prof-mode-test", {}); await using proc = Bun.spawn({ - cmd: [ - bunExe(), - "--heap-prof", - "--heap-prof-dir", - "missing/dir", - "--heap-prof-name", - "retry.heapprofile", - "-e", - "process.umask(0o022);", - ], + // The profile is written at exit, so the umask that the script sets applies to it. + cmd: [bunExe(), ...flags, "-e", "process.umask(0o022);"], cwd: String(dir), env: bunEnv, stdout: "pipe", @@ -260,16 +264,12 @@ test.skipIf(isWindows)("--heap-prof-dir is created when missing, and the profile }); const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - expect({ stdout, stderr, exitCode }).toEqual({ stdout: "", stderr: "", exitCode: 0 }); + expect(stderr).toMatch(expectedStderr); + expect({ stdout, exitCode }).toEqual({ stdout: "", exitCode: 0 }); const entries = [...new Bun.Glob("**").scanSync({ cwd: String(dir), onlyFiles: false })]; - const modes = await Promise.all(entries.map(async entry => [entry, await permissions(join(String(dir), entry))])); - expect(Object.fromEntries(modes)).toEqual({ - "missing": "755", - "missing/dir": "755", - "missing/dir/retry.heapprofile": "600", - }); - expectV8HeapSnapshotShape(await readProfile(join(String(dir), "missing", "dir"), "retry.heapprofile")); + const permissions = entries.map(entry => [entry, (statSync(join(String(dir), entry)).mode & 0o777).toString(8)]); + expect(Object.fromEntries(permissions)).toEqual(modes); }); test("--heap-prof-name and --heap-prof-dir work together", async () => { From 0c20d3df883d79483ba14c036a008e74365f57ce Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 16 Oct 2026 16:43:16 +0000 Subject: [PATCH 5/6] test: run the new file mode tests concurrently The three v8.writeHeapSnapshot() tests and the three --heap-prof mode cases each spawn one process in a directory of their own. --- test/cli/heap-prof.test.ts | 2 +- test/js/node/v8/v8-module.test.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/test/cli/heap-prof.test.ts b/test/cli/heap-prof.test.ts index 2a6ed8e2fd6a..24a4a5dfd384 100644 --- a/test/cli/heap-prof.test.ts +++ b/test/cli/heap-prof.test.ts @@ -231,7 +231,7 @@ test("--heap-prof-name specifies output filename", async () => { // Node creates a --heap-prof file with mode 0600 (WriteFileSync in src/node_file_utils.cc). // Node has no --heap-prof-md. That file gets the same mode. -test.skipIf(isWindows).each([ +test.skipIf(isWindows).concurrent.each([ { name: "--heap-prof", flags: ["--heap-prof", "--heap-prof-name", "out.heapprofile"], diff --git a/test/js/node/v8/v8-module.test.ts b/test/js/node/v8/v8-module.test.ts index 77852c8a24e9..3392be316ca5 100644 --- a/test/js/node/v8/v8-module.test.ts +++ b/test/js/node/v8/v8-module.test.ts @@ -136,7 +136,7 @@ describe("v8.GCProfiler", () => { // Node creates the snapshot file with mode 0600 (WriteSnapshot in src/heap_utils.cc), and only when // the call creates the file. Windows has no permission bits to check. -describe.skipIf(isWindows)("v8.writeHeapSnapshot", () => { +describe.concurrent.skipIf(isWindows)("v8.writeHeapSnapshot", () => { // Writes one snapshot into `cwd`, to `path` or to the default name, and returns where it is. async function writeHeapSnapshot(cwd: string, path?: string) { await using proc = Bun.spawn({ From 9464713a81632b7eacd62b6ef7f99fa62ca7bf57 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Sat, 24 Oct 2026 17:45:11 +0000 Subject: [PATCH 6/6] ci: retrigger