From 830648024264c021b76b1d67808286b606b03fba Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:44:30 +0000 Subject: [PATCH 01/24] bun run --filter: use original-case basename for workspace dirs (#29941) --- src/resolver/lib.rs | 37 ++--------------- test/cli/run/filter-workspace.test.ts | 60 +++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 33 deletions(-) diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 40f158b9468e..466c2aa92824 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1732,38 +1732,16 @@ pub mod dir_entry_accessor { } pub struct DirEntryIterResult { - pub(crate) name: DirEntryNameWrapper, + pub(crate) entry: bun_ptr::BackRef, pub(crate) kind: bun_sys::FileKind, /// Resolver-cached real path of a symlink entry's target /// (`Interned::EMPTY` for non-symlinks). pub(crate) symlink_target: bun_ptr::Interned, } - pub(crate) struct DirEntryNameWrapper { - // BACKREF: borrowed slice into a `Box<[u8]>` key owned by - // `DirEntry.data: HashMap`. Valid only while the parent `DirEntry` - // is live and not regenerated by `read_directory`. Stored as - // [`bun_ptr::RawSlice`] (not `&'static [u8]`) per PORTING.md - // §Forbidden — the key is individually heap-allocated by the HashMap, - // not a BSS-arena slice, so minting a `'static` borrow via - // `from_raw_parts` would be a lifetime lie. `RawSlice` encapsulates - // the outlives-holder invariant so `slice()` is safe. - pub value: bun_ptr::RawSlice, - } - - impl DirEntryNameWrapper { - #[inline] - fn slice(&self) -> &[u8] { - // BACKREF — see field comment. The GlobWalker consumes - // `name_slice()` before advancing the iterator or reopening the - // directory, so the pointee `Box<[u8]>` is still alive here. - self.value.slice() - } - } - impl AccessorDirEntry for DirEntryIterResult { fn name_slice(&self) -> &[u8] { - self.name.slice() + self.entry.base() } fn kind(&self) -> bun_sys::FileKind { self.kind @@ -1781,7 +1759,7 @@ pub mod dir_entry_accessor { #[inline] fn next(&mut self) -> Maybe> { if let Some(value) = &mut self.value { - let Some((key, val)) = value.next() else { + let Some((_, val)) = value.next() else { return Ok(None); }; // BACKREF: ARENA — `*mut Entry` points into the EntryStore @@ -1806,15 +1784,8 @@ pub mod dir_entry_accessor { // `Entry::kind` resolved through symlinks above; a non-empty // cached realpath is what records the entry as a symlink. let symlink_target = entry.cache().symlink; - // BACKREF: wrap the HashMap key's bytes in a `RawSlice` - // instead of fabricating `&'static [u8]` (PORTING.md §Forbidden). - // The key is a `Box<[u8]>` owned by `DirEntry.data` and valid - // until the next `read_directory` regeneration; `name_slice()` - // re-narrows the lifetime so it never escapes the iter result. Ok(Some(DirEntryIterResult { - name: DirEntryNameWrapper { - value: bun_ptr::RawSlice::new(&**key), - }, + entry, kind: fskind, symlink_target, })) diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index 423eeead4645..9363f349d1b1 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -836,6 +836,66 @@ describe("bun", () => { expect(stderr).toContain("skipping this workspace package"); expect(exitCode).toBe(0); }); + + // https://github.com/oven-sh/bun/issues/21670 + describe("a workspace directory with an upper-case letter in its name", () => { + const member = (name: string) => ({ + "package.json": JSON.stringify({ name, scripts: { build: `echo BUILD-${name}` } }), + }); + + test.concurrent.each([ + { + entry: "a glob", + workspaces: ["packages/*"], + files: { packages: { Core: member("core"), lower: member("lower") } }, + }, + { + entry: "a literal path", + workspaces: ["packages/Core", "packages/lower"], + files: { packages: { Core: member("core"), lower: member("lower") } }, + }, + { + entry: "a nested glob", + workspaces: ["packages/*/*"], + files: { packages: { UPPER: { Inner: member("core") }, group: { lower: member("lower") } } }, + }, + ])("is found through $entry in workspaces", async ({ workspaces, files }) => { + using dir = tempDir("filter-upper-case-dir", { + ...files, + "package.json": JSON.stringify({ name: "root", private: true, workspaces }), + }); + + async function run(...selection: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", ...selection, "build"], + 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 { + ran: [...new Set(stdout.match(/BUILD-\w+/g))].sort(), + errors: stderr.split("\n").filter(line => line.startsWith("error")), + exitCode, + }; + } + + const [all, everyWorkspace, byName, byPath] = await Promise.all([ + run("--filter", "*"), + run("--workspaces"), + run("--filter", "lower"), + run("--filter", "./packages/**"), + ]); + const both = { ran: ["BUILD-core", "BUILD-lower"], errors: [], exitCode: 0 }; + expect({ all, everyWorkspace, byName, byPath }).toEqual({ + all: both, + everyWorkspace: both, + byName: { ran: ["BUILD-lower"], errors: [], exitCode: 0 }, + byPath: both, + }); + }); + }); }); describe("selectors", () => { From 16ece2e522d5a3dc09262e53806b8aa62a302ca7 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:44:36 +0000 Subject: [PATCH 02/24] dotenv: keep $ literal before a digit (#36030) --- src/dotenv/env_loader.rs | 6 ++++- test/cli/run/env.test.ts | 47 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index dc1e5546d9b9..3ced1d884eae 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -1109,6 +1109,10 @@ impl<'a> Parser<'a> { fn is_ident(b: u8) -> bool { matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'0'..=b'9' | b'_') } + #[inline] + fn is_ident_start(b: u8) -> bool { + matches!(b, b'a'..=b'z' | b'A'..=b'Z' | b'_') + } let mut pos = 0; let mut changed = false; @@ -1183,7 +1187,7 @@ impl<'a> Parser<'a> { pos = close + 1; continue; } - if is_ident(next) { + if is_ident_start(next) { changed = true; let key_start = pos + 1; let mut k = key_start; diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index d2d1468ae41f..30dbfe40ab06 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -398,6 +398,53 @@ test.concurrent(".env escaped dollar sign", async () => { expect(stdout).toBe("foo $FOO"); }); +test.concurrent(".env leaves $ literal when not followed by an identifier (issue #4994)", async () => { + // https://github.com/oven-sh/bun/issues/4994 + // Expansion only fires on `$IDENT` (letter or underscore start) or `${...}`. + // A `$` followed by a digit, another `$`, `(`, `-`, or the end of the value + // stays literal, inside and outside quotes and inside a `:-` default. + const expected = { + ISSUE: "123$567", + P1: "price$5.00", + P2: "a$(b)c", + P3: "cost$-1", + P4: "$1abc", + P5: "end$", + P6: "pa$$", + P7: "$5.00", + P8: "hit$5", + P9: "hit$5", + P10: "$5", + P11: "123$567", + P12: "123$567", + }; + using dir = tempDir("dotenv-literal-dollar", { + ".env": [ + "ISSUE=123$567", + "P1=price$5.00", + "P2=a$(b)c", + "P3=cost$-1", + "P4=$1abc", + "P5=end$", + "P6=pa$$", + "P7=\\$5.00", + "SET=hit", + "P8=$SET$5", + "P9=${SET}$5", + "P10=${UNSET:-$5}", + 'P11="123$567"', + "P12='123$567'", + ].join("\n"), + "index.ts": + `const keys = ${JSON.stringify(Object.keys(expected))};` + + "const out = {};" + + "for (const k of keys) out[k] = process.env[k];" + + "console.log(JSON.stringify(out));", + }); + const { stdout } = await bunRun(`${dir}/index.ts`); + expect(JSON.parse(stdout)).toEqual(expected); +}); + test.concurrent(".env doesnt crash with 159 bytes", async () => { using dir = tempDir("dotenv-159", { ".env": From 6b0a25993dffe7ffc44597f8f2879e70f18ae6a6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:44:41 +0000 Subject: [PATCH 03/24] Suggest bounded fd limits in the fd-exhaustion error hints (#37339) --- src/crash_handler/lib.rs | 22 ++++++++-------- test/cli/run/run-crash-handler.test.ts | 35 +++++++++++++++++++++++++- 2 files changed, 45 insertions(+), 12 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 4ae24e90d6b9..decae93654f0 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -1282,21 +1282,21 @@ mod draft { #[cfg(target_os = "macos")] { pretty_error!( - "error: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n sudo launchctl limit maxfiles 2147483646\n ulimit -n 2147483646\n\nThat will only work until you reboot.\n", + "error: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n sudo launchctl limit maxfiles 65536 200000\n ulimit -n 65536\n\nThat will only work until you reboot.\n", bun_fmt::nullable_fallback(limit, b""), ); } #[cfg(target_os = "freebsd")] { pretty_error!( - "\nerror: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n sudo sysctl kern.maxfiles=2147483646 kern.maxfilesperproc=2147483646\n ulimit -n 2147483646\n\nTo persist across reboots, add to /etc/sysctl.conf and edit /etc/login.conf.\n", + "\nerror: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n sudo sysctl kern.maxfiles=200000 kern.maxfilesperproc=65536\n ulimit -n 65536\n\nTo persist across reboots, add to /etc/sysctl.conf and edit /etc/login.conf.\n", bun_fmt::nullable_fallback(limit, b""), ); } #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] { pretty_error!( - "\nerror: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n sudo echo -e \"\\nfs.file-max=2147483646\\n\" >> /etc/sysctl.conf\n sudo sysctl -p\n ulimit -n 2147483646\n", + "\nerror: Your computer ran out of file descriptors (SystemFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n echo \"fs.file-max=2097152\" | sudo tee -a /etc/sysctl.conf\n sudo sysctl -p\n ulimit -n 65536\n", bun_fmt::nullable_fallback(limit, b""), ); @@ -1304,7 +1304,7 @@ mod draft { if !user.is_empty() { let user = bstr::BStr::new(user); pretty_error!( - "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 2147483646\n {} hard nofile 2147483646\n", + "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 65536\n {} hard nofile 200000\n", user, user, ); @@ -1325,21 +1325,21 @@ mod draft { #[cfg(target_os = "macos")] { pretty_error!( - "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 2147483646\n\nYou may also need to run:\n\n sudo launchctl limit maxfiles 2147483646\n", + "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 65536\n\nYou may also need to run:\n\n sudo launchctl limit maxfiles 65536 200000\n", bun_fmt::nullable_fallback(limit, b""), ); } #[cfg(target_os = "freebsd")] { pretty_error!( - "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 2147483646\n sudo sysctl kern.maxfilesperproc=2147483646\n", + "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 65536\n sudo sysctl kern.maxfilesperproc=65536\n", bun_fmt::nullable_fallback(limit, b""), ); } #[cfg(not(any(target_os = "macos", target_os = "freebsd")))] { pretty_error!( - "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 2147483646\n\nThat will only work for the current shell. To fix this for the entire system, run:\n\n sudo echo -e \"\\nfs.file-max=2147483646\\n\" >> /etc/sysctl.conf\n sudo sysctl -p\n", + "\nerror: bun ran out of file descriptors (ProcessFdQuotaExceeded)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 65536\n\nThat will only work for the current shell. To fix this for the entire system, run:\n\n echo \"fs.file-max=2097152\" | sudo tee -a /etc/sysctl.conf\n sudo sysctl -p\n", bun_fmt::nullable_fallback(limit, b""), ); @@ -1347,7 +1347,7 @@ mod draft { if !user.is_empty() { let user = bstr::BStr::new(user); pretty_error!( - "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 2147483646\n {} hard nofile 2147483646\n", + "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 65536\n {} hard nofile 200000\n", user, user, ); @@ -1370,7 +1370,7 @@ mod draft { if limit.rlim_cur > 0 && limit.rlim_cur < (8192 * 2) { pretty_error!( - "\nerror: An unknown error occurred, possibly due to low max file descriptors (Unexpected)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 2147483646\n", + "\nerror: An unknown error occurred, possibly due to low max file descriptors (Unexpected)\n\nCurrent limit: {}\n\nTo fix this, try running:\n\n ulimit -n 65536\n", limit.rlim_cur, ); @@ -1380,7 +1380,7 @@ mod draft { if !user.is_empty() { let user = bstr::BStr::new(user); pretty_error!( - "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 2147483646\n {} hard nofile 2147483646\n", + "\nIf that still doesn't work, you may need to add these lines to /etc/security/limits.conf:\n\n {} soft nofile 65536\n {} hard nofile 200000\n", user, user, ); @@ -1390,7 +1390,7 @@ mod draft { #[cfg(target_os = "macos")] { pretty_error!( - "\nIf that still doesn't work, you may need to run:\n\n sudo launchctl limit maxfiles 2147483646\n", + "\nIf that still doesn't work, you may need to run:\n\n sudo launchctl limit maxfiles 65536 200000\n", ); } } else { diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 5657f420ca6b..54965b1af506 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -1,6 +1,6 @@ import { crash_handler } from "bun:internal-for-testing"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isLinux, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; +import { bunEnv, bunExe, isDebug, isLinux, isMacOS, isPosix, isWindows, mergeWindowEnvs, tempDir } from "harness"; import { rmSync } from "node:fs"; import { constants as osConstants } from "node:os"; import path from "path"; @@ -191,6 +191,39 @@ describe.if(isPosix)("cwd deleted before startup", () => { }); }); +// The fd-exhaustion remediation hint must suggest bounded limits. It used to +// print `ulimit -n 2147483646` (and `sudo launchctl limit maxfiles 2147483646` +// on macOS, where a maxfiles limit that large makes every new terminal's +// `login` iterate the fd table for minutes, and SIP blocks lowering it back +// without a reboot). See #37337. +test.if(isPosix)("fd limit hint suggests bounded values", async () => { + await using proc = Bun.spawn({ + // The hint only prints when the current fd limit is low (< 16384); lower + // it in a wrapper shell so the check triggers deterministically. + cmd: [ + "/bin/sh", + "-c", + `ulimit -n 8192 && exec "$@"`, + "--", + bunExe(), + path.join(import.meta.dir, "fixture-crash.js"), + "rootError", + "--debug-crash-handler-use-trace-string", + ], + env: noReportEnv, + stdio: ["ignore", "ignore", "pipe"], + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("possibly due to low max file descriptors"); + expect(stderr).toContain("ulimit -n 65536"); + if (isMacOS) { + expect(stderr).toContain("sudo launchctl limit maxfiles 65536 200000"); + } + expect(stderr).not.toContain("2147483646"); + expect(exitCode).not.toBe(0); +}); + // Windows: the VEH handler must walk the stack from the fault CONTEXT record // (RtlVirtualUnwind), not from inside the handler. When the fault is in an // external DLL the old RtlCaptureStackBackTrace path could stop at From 960fe301b419ed144c3d2c4b9d3c29c999c3fc4c Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:44:49 +0000 Subject: [PATCH 04/24] cli: read the script from stdin for `node -` in node emulation (#38566) --- src/runtime/cli/run_command.rs | 15 +++++++++-- test/cli/run/as-node.test.ts | 47 ++++++++++++++++++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 9196a2c70e8d..6593676149f1 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -2999,8 +2999,9 @@ impl RunCommand { Self::boot_and_handle_error(ctx, &absolute_script_path, None) } - /// `bun run -` — read script from stdin into `ctx.runtime_options.eval` - /// and boot the VM with the synthetic `[stdin]` path. + /// `bun run -` / `node -` — read script from stdin into + /// `ctx.runtime_options.eval` and boot the VM with the synthetic `[stdin]` + /// path. `false` means stdin could not be read; the caller exits 1. fn exec_stdin(ctx: &mut ContextData) -> crate::Result { bun_core::scoped_log!(RUN_LOG, "Executing from stdin"); @@ -3141,6 +3142,16 @@ impl RunCommand { Self::exec_as_if_node_missing_script(); } + // `node -` reads the script from stdin. `Arguments::parse` stopped at + // this positional, so everything after the `-` is already in + // `ctx.passthrough`; `exec_stdin` puts the `-` itself back in front. + if ctx.positionals[0].as_ref() == b"-" { + if Self::exec_stdin(ctx)? { + return Ok(()); + } + Global::exit(1); + } + // borrowck — `boot_and_handle_error` takes `&mut ctx`, so // dupe the positional out before the call. let filename: Box<[u8]> = ctx.positionals[0].clone(); diff --git a/test/cli/run/as-node.test.ts b/test/cli/run/as-node.test.ts index 34ee77efd693..a0208c51ad53 100644 --- a/test/cli/run/as-node.test.ts +++ b/test/cli/run/as-node.test.ts @@ -111,4 +111,51 @@ describe("fake node cli", () => { expect(result.stderr.toString()).toContain("Missing script"); expect(result.success).toBe(false); }); + + // `node -` runs the script piped to stdin. Like node, the `-` stays in + // process.argv[1] and everything after it is user argv, even when a file + // literally named `-` exists in cwd. + describe("node - runs the script from stdin", () => { + function nodeStdin(args: string[], script: string, files: Record = {}) { + using temp = tempDir("fake-node", { "-": "console.log('fail: ran the file named -')", ...files }); + const result = Bun.spawnSync([bunExe(), "--bun", "node", ...args], { + cwd: String(temp), + env: { ...bunEnv, NODE_ENV: undefined }, + stdin: Buffer.from(script), + }); + return { + stdout: result.stdout.toString("utf8"), + stderr: result.stderr.toString("utf8"), + exitCode: result.exitCode, + }; + } + + const logArgv = "console.log(JSON.stringify(process.argv.slice(1)))"; + test.each([ + { args: ["-"], argv: ["-"] }, + { args: ["-", "a", "b"], argv: ["-", "a", "b"] }, + { args: ["-", "--flag", "-x"], argv: ["-", "--flag", "-x"] }, + { args: ["--", "-", "a"], argv: ["-", "a"] }, + ])("node $args", ({ args, argv }) => { + const { stdout, stderr, exitCode } = nodeStdin(args, logArgv); + expect(stdout).toBe(JSON.stringify(argv) + "\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + + test("the stdin script's exit code is the process exit code", () => { + const { stdout, exitCode } = nodeStdin(["-"], "console.log('ran'); process.exitCode = 3"); + expect(stdout).toBe("ran\n"); + expect(exitCode).toBe(3); + }); + + test("--require preloads run before the stdin script", () => { + const { stdout, stderr, exitCode } = nodeStdin(["--require", "./pre.js", "-"], "console.log(globalThis.pre)", { + "pre.js": "globalThis.pre = 'preloaded'", + }); + expect(stdout).toBe("preloaded\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + }); }); From cce7f7ced53e9d898a563392252cd6914662cc87 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:44:55 +0000 Subject: [PATCH 05/24] test: un-todo the process.env string coercion test on POSIX (#38864) --- test/cli/run/env.test.ts | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/test/cli/run/env.test.ts b/test/cli/run/env.test.ts index 30dbfe40ab06..e08c7692fab1 100644 --- a/test/cli/run/env.test.ts +++ b/test/cli/run/env.test.ts @@ -1270,21 +1270,25 @@ for (const shell of ["system", "bun"]) { }); } -const todoOnPosix = process.platform !== "win32" ? test.todo : test; -todoOnPosix("setting process.env coerces the value to a string", () => { - // @ts-expect-error - process.env.SET_TO_TRUE = true; - let did_call = 0; - // @ts-expect-error - process.env.SET_TO_BUN = { - toString() { - did_call++; - return "bun!"; - }, - }; - expect(process.env.SET_TO_TRUE).toBe("true"); - expect(process.env.SET_TO_BUN).toBe("bun!"); - expect(did_call).toBe(1); +test("setting process.env coerces the value to a string", () => { + try { + // @ts-expect-error + process.env.SET_TO_TRUE = true; + let did_call = 0; + // @ts-expect-error + process.env.SET_TO_BUN = { + toString() { + did_call++; + return "bun!"; + }, + }; + expect(process.env.SET_TO_TRUE).toBe("true"); + expect(process.env.SET_TO_BUN).toBe("bun!"); + expect(did_call).toBe(1); + } finally { + delete process.env.SET_TO_TRUE; + delete process.env.SET_TO_BUN; + } }); test.concurrent("NODE_ENV=test loads .env.test even when .env.production exists", async () => { From 868553d34bf261a68a40a8d45ec1c7f74795bfda Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:04 +0000 Subject: [PATCH 06/24] crash handler: report failed Rust allocations as out of memory (#39635) --- src/crash_handler/lib.rs | 61 ++++++++++-- src/js/internal-for-testing.ts | 1 + src/runtime/api/crash_handler_jsc.rs | 16 +++ test/cli/run/fixture-crash.js | 2 +- test/cli/run/run-crash-handler.test.ts | 131 +++++++++++++++++-------- 5 files changed, 157 insertions(+), 54 deletions(-) diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index decae93654f0..9475e295161e 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -20,6 +20,7 @@ // builds only. Declaring the feature where neither is compiled (Windows // release) trips `unused_features`. #![cfg_attr(any(not(windows), debug_assertions), feature(core_intrinsics))] +#![feature(alloc_error_hook)] #![allow(internal_features)] #![warn(unused_must_use)] #[path = "CPUFeatures.rs"] @@ -40,7 +41,9 @@ pub use error::{Error, Result}; #[inline(never)] fn out_of_memory() -> ! { draft::crash_handler( - draft::CrashReason::OutOfMemory, + draft::CrashReason::OutOfMemory { + requested_bytes: None, + }, draft::TraceSeed::BeginAddr(bun_core::return_address()), ) } @@ -639,7 +642,10 @@ mod draft { /// Either `main` returned an error, or somewhere else in the code a trace string is printed. ZigError(&'static [u8]), - OutOfMemory, + OutOfMemory { + /// `None` when the caller only has an `AllocError`. + requested_bytes: Option, + }, } impl CrashReason { @@ -662,7 +668,7 @@ mod draft { | CrashReason::DatatypeMisalignment | CrashReason::StackOverflow | CrashReason::ZigError(_) - | CrashReason::OutOfMemory => libc::SIGABRT, + | CrashReason::OutOfMemory { .. } => libc::SIGABRT, } } } @@ -691,7 +697,23 @@ mod draft { CrashReason::ZigError(err_name) => { write!(writer, "error.{}", bstr::BStr::new(err_name)) } - CrashReason::OutOfMemory => writer.write_str("Bun ran out of memory"), + CrashReason::OutOfMemory { requested_bytes } => write!( + writer, + "Bun ran out of memory{}", + RequestedBytes(*requested_bytes) + ), + } + } + } + + /// ` (failed to allocate N bytes)`, or nothing when the size is unknown. + struct RequestedBytes(Option); + + impl fmt::Display for RequestedBytes { + fn fmt(&self, writer: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.0 { + Some(bytes) => write!(writer, " (failed to allocate {bytes} bytes)"), + None => Ok(()), } } } @@ -921,7 +943,7 @@ mod draft { } } - if !matches!(reason, CrashReason::OutOfMemory) || debug_trace { + if !matches!(reason, CrashReason::OutOfMemory { .. }) || debug_trace { if enable_ansi_colors_stderr() { if writer .write_all(&Output::pretty_fmt::("")) @@ -1115,10 +1137,16 @@ mod draft { { abort(); } - } else if matches!(reason, CrashReason::OutOfMemory) { - if writer.write_all( - b"Bun has run out of memory.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n", - ).is_err() { abort(); } + } else if let CrashReason::OutOfMemory { requested_bytes } = reason { + if write!( + writer, + "Bun has run out of memory{}.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n", + RequestedBytes(requested_bytes) + ) + .is_err() + { + abort(); + } } else { if writer.write_all( b"Bun has crashed. This indicates a bug in Bun, not your code.\n\nTo send a redacted crash report to Bun's team,\nplease file a GitHub issue using the link below:\n\n", @@ -1706,6 +1734,19 @@ mod draft { // this hook, a bare `panic!` would print the std // default hook + unwind with no trace string and no upload. std::panic::set_hook(Box::new(rust_panic_hook)); + std::alloc::set_alloc_error_hook(rust_alloc_error_hook); + } + + /// Not a null check in `GlobalAlloc`: `try_reserve` reports failure with null too. Must not allocate. + #[cold] + #[inline(never)] + fn rust_alloc_error_hook(layout: core::alloc::Layout) { + crash_handler( + CrashReason::OutOfMemory { + requested_bytes: Some(layout.size()), + }, + TraceSeed::BeginAddr(debug::return_address()), + ) } /// `std::panic` hook: emit the same trace-string + auto-report as the fatal @@ -2680,7 +2721,7 @@ mod draft { writer.write_all(err_name)?; } - CrashReason::OutOfMemory => writer.write_byte(b'9')?, + CrashReason::OutOfMemory { .. } => writer.write_byte(b'9')?, CrashReason::Abort => writer.write_byte(b'a')?, CrashReason::Trap(addr) => { diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 4e31f23e7af1..a997d7d018b5 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -151,6 +151,7 @@ export const crash_handler = $rust("crash_handler.rs", "js_bindings.generate") a panic: () => void; rootError: () => void; outOfMemory: () => void; + allocError: () => void; abort: () => void; fastfail: () => void; trap: () => void; diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index e042f1124cf6..6c5234b45a34 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -24,6 +24,7 @@ pub(crate) mod js_bindings { ("panic", __jsc_host_js_panic), ("rootError", __jsc_host_js_root_error), ("outOfMemory", __jsc_host_js_out_of_memory), + ("allocError", __jsc_host_js_alloc_error), ("abort", __jsc_host_js_abort), ("fastfail", __jsc_host_js_fastfail), ("trap", __jsc_host_js_trap), @@ -206,6 +207,21 @@ pub(crate) mod js_bindings { bun_core::out_of_memory(); } + /// Fails a real infallible allocation; `outOfMemory` covers the explicit `AllocError` path. + #[bun_jsc::host_fn] + fn js_alloc_error(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + crash_handler::suppress_core_dumps_if_necessary(); + // Above any 64-bit address space (null even with overcommit), below `isize::MAX` (no capacity panic). + const SIZE: usize = 1 << 62; + // ASAN's allocator aborts on an oversized request instead of returning null. + if Environment::ENABLE_ASAN { + std::alloc::handle_alloc_error(core::alloc::Layout::from_size_align(SIZE, 1).unwrap()); + } + let buf: Vec = Vec::with_capacity(SIZE); + core::hint::black_box(buf); + Ok(JSValue::UNDEFINED) + } + #[bun_jsc::host_fn] fn js_raise_ignoring_panic_handler( _global: &JSGlobalObject, diff --git a/test/cli/run/fixture-crash.js b/test/cli/run/fixture-crash.js index d44ba5bbc61d..332663c8c092 100644 --- a/test/cli/run/fixture-crash.js +++ b/test/cli/run/fixture-crash.js @@ -12,6 +12,6 @@ if (approach in crash_handler) { crash_handler[approach](); } else { console.error( - "usage: bun fixture-crash.js ", + "usage: bun fixture-crash.js ", ); } diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 54965b1af506..200deccb2644 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -15,6 +15,10 @@ const noReportEnv = { ...bunEnv, BUN_CRASH_REPORT_URL: "", BUN_ENABLE_CRASH_REPO // without it the fallback printer has no Rust symbol names to assert on. const hasSymbolizer = !!(Bun.which("llvm-symbolizer") || Bun.which("llvm-symbolizer-23")); +// The allocation the `allocError` test hook requests (`js_alloc_error` in +// src/runtime/api/crash_handler_jsc.rs). The crash report prints it. +const allocErrorSize = 2n ** 62n; + test.if(isDebug && isLinux && hasSymbolizer)( "crash trace starts at the crash site, not inside the crash handler", async () => { @@ -44,6 +48,38 @@ test.if(isDebug && isLinux && hasSymbolizer)( 60_000, // symbolizing the debug binary takes several seconds ); +// A failed infallible allocation (`Vec` growth, `Box::new`, ...) reaches std's +// alloc error handler. Without the alloc error hook std prints "memory +// allocation of N bytes failed" and calls abort(), so the report said +// "abort() called" and its trace was seeded inside libc, where the frame +// pointer walk stops after one frame. The hook reports it as out of memory, +// with the requested size, and captures the trace while the allocating +// frame is still on the stack. +test.if(isDebug && isLinux && hasSymbolizer)( + "failed Rust allocation is reported as out of memory with the allocating frame in the trace", + async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "allocError"], + env: noReportEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + // Header on stderr, symbolized frames on stdout (see the test above). + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toContain(`panic(main thread): Bun ran out of memory (failed to allocate ${allocErrorSize} bytes)`); + expect(stderr).not.toContain("abort() called"); + expect(stderr).not.toContain("memory allocation of"); + expect(exitCode).not.toBe(0); + + // The trace walks from the alloc error hook through std's handler into + // the test hook that made the allocation... + expect(stdout).toContain("js_alloc_error"); + // ...and does not include the capture machinery. + expect(stdout).not.toContain("capture_stack_trace"); + }, + 60_000, // symbolizing the debug binary takes several seconds +); + // `crash()` resets fatal-signal dispositions to SIG_DFL before re-raising so // that JS-registered listeners (`process.on("SIGABRT")` etc., installed by // npm's widely-used signal-exit package) cannot swallow the termination. A @@ -99,6 +135,7 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { test.each([ ["panic", "SIGABRT"], ["outOfMemory", "SIGABRT"], + ["allocError", "SIGABRT"], ["segfault", "SIGSEGV"], ["abort", "SIGABRT"], ["trap", "SIGTRAP"], @@ -119,6 +156,11 @@ describe.if(isPosix)("terminal signal reflects the crash cause", () => { expect(stderr).toContain("Segmentation fault at address"); } else if (approach === "panic") { expect(stderr).toContain("invoked crashByPanic() handler"); + } else if (approach === "outOfMemory") { + expect(stderr).toContain("Bun has run out of memory."); + } else if (approach === "allocError") { + expect(stderr).toContain(`Bun has run out of memory (failed to allocate ${allocErrorSize} bytes).`); + expect(stderr).not.toContain("abort() called"); } else if (approach === "abort") { expect(stderr).toContain("abort() called"); } else if (approach === "trap") { @@ -616,52 +658,55 @@ describe.if(isPosix)("process.kill() aimed at the process itself is not reported }); describe("automatic crash reporter", () => { - for (const approach of ["panic", "segfault", "outOfMemory"]) { - test(`${approach} should report`, async () => { - let sent = false; - const resolve_handler = Promise.withResolvers(); - - // Self host the crash report backend. - using server = Bun.serve({ - port: 0, - fetch(request, server) { - expect(request.url).toEndWith("/ack"); - sent = true; - resolve_handler.resolve(); - return new Response("OK"); - }, - }); + const crashed = "oh no: Bun has crashed. This indicates a bug in Bun, not your code"; + // The uploaded URL is the trace string followed by "/ack". The trace string + // ends with the reason; both out-of-memory paths encode it as "9" (see + // encode_trace_string in src/crash_handler/lib.rs), which is what tells + // bun.report to classify the report as out of memory. + test.each([ + ["panic", crashed, "/ack"], + ["segfault", crashed, "/ack"], + ["outOfMemory", "oh no: Bun has run out of memory.", "9/ack"], + ["allocError", `oh no: Bun has run out of memory (failed to allocate ${allocErrorSize} bytes).`, "9/ack"], + ])("%s should report", async (approach, expectedMessage, expectedUrlSuffix) => { + const reported = Promise.withResolvers(); + + // Self host the crash report backend. + using server = Bun.serve({ + port: 0, + fetch(request) { + reported.resolve(request.url); + return new Response("OK"); + }, + }); - const proc = Bun.spawn({ - cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), approach], - env: mergeWindowEnvs([ - bunEnv, - { - BUN_CRASH_REPORT_URL: server.url.toString(), - BUN_ENABLE_CRASH_REPORTING: "1", - GITHUB_ACTIONS: undefined, - CI: undefined, - }, - ]), - stdio: ["ignore", "pipe", "pipe"], - }); - const exitCode = await proc.exited; - const stderr = await proc.stderr.text(); - console.log(stderr); + const proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), approach], + env: mergeWindowEnvs([ + bunEnv, + { + BUN_CRASH_REPORT_URL: server.url.toString(), + BUN_ENABLE_CRASH_REPORTING: "1", + GITHUB_ACTIONS: undefined, + CI: undefined, + }, + ]), + stdio: ["ignore", "pipe", "pipe"], + }); + const exitCode = await proc.exited; + const stderr = await proc.stderr.text(); + console.log(stderr); - await resolve_handler.promise; + const reportedUrl = await reported.promise; - expect(exitCode).not.toBe(0); - expect(stderr).toContain(server.url.toString()); - if (approach !== "outOfMemory") { - expect(stderr).toContain("oh no: Bun has crashed. This indicates a bug in Bun, not your code"); - } else { - expect(stderr.toLowerCase()).toContain("out of memory"); - expect(stderr.toLowerCase()).not.toContain("panic"); - } - expect(sent).toBe(true); - }); - } + expect(exitCode).not.toBe(0); + expect(stderr).toContain(server.url.toString()); + expect(stderr).toContain(expectedMessage); + if (approach === "outOfMemory" || approach === "allocError") { + expect(stderr.toLowerCase()).not.toContain("panic"); + } + expect(reportedUrl).toEndWith(expectedUrlSuffix); + }); }); test.if(isWindows)( From 2feba7e17da5f6f2d93c1370cc9583467faf21a3 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:18 +0000 Subject: [PATCH 07/24] output: keep a bare `>` in pretty templates instead of dropping it (#40713) --- src/bun_core/output.rs | 27 ++++++++-- src/bun_core_macros/lib.rs | 9 ++-- src/clap_macros/lib.rs | 7 +-- src/runtime/cli/init_command.rs | 7 +-- test/cli/bun.test.ts | 24 ++++++++- test/cli/init/init.test.ts | 96 +++++++++++++++++++++++++++++++++ 6 files changed, 153 insertions(+), 17 deletions(-) diff --git a/src/bun_core/output.rs b/src/bun_core/output.rs index 27f6012c8bc7..78f6f5d352fc 100644 --- a/src/bun_core/output.rs +++ b/src/bun_core/output.rs @@ -1934,9 +1934,6 @@ pub fn pretty_fmt_runtime(fmt: &[u8], is_enabled: bool) -> Vec { } } } - b'>' => { - i += 1; - } b'{' => { while i < fmt.len() && fmt[i] != b'}' { out.push(fmt[i]); @@ -1954,6 +1951,10 @@ pub fn pretty_fmt_runtime(fmt: &[u8], is_enabled: bool) -> Vec { i += 1; } let color_name = &fmt[start..i]; + // Skip the tag's closing `>`. Any other `>` is text. + if i < fmt.len() { + i += 1; + } let color_str: &str = 'picker: { if let Some(lit) = color_map::get(color_name) { break 'picker lit; @@ -2927,6 +2928,23 @@ mod pretty_fmt_tests { assert_eq!(pretty_fmt!("\\", false), ""); } + #[test] + fn bare_closing_bracket_is_literal() { + // Only the `>` that closes a tag is markup. + assert_eq!(pretty_fmt!("-> {}", true), "-> {}"); + assert_eq!(pretty_fmt!("-> {}", false), "-> {}"); + assert_eq!(pretty_fmt!(">", true), "\x1b[36m>\x1b[0m"); + assert_eq!(pretty_fmt!(">", false), ">"); + assert_eq!( + pretty_fmt!("echo x >> /etc/sysctl.conf", false), + "echo x >> /etc/sysctl.conf" + ); + assert_eq!( + pretty_fmt_runtime(b"> a -> b", false), + b"> a -> b".as_slice() + ); + } + #[test] fn format_specs_with_angle_brackets_pass_through() { // `{:<16}` / `{:>.2}` contain `<`/`>` *inside* a format spec — the @@ -3009,6 +3027,9 @@ mod pretty_fmt_tests { check!("Usage: bun init [flags] [\\]\n"); check!("[{d:>.2}ms]"); check!("error: "); + check!("> "); + check!("-> {}"); + check!(" sudo echo x >> /etc/sysctl.conf\n"); check!( " link [\\] Register or link a local npm package\n" ); diff --git a/src/bun_core_macros/lib.rs b/src/bun_core_macros/lib.rs index 352a3c61f11b..dc9739dedcb0 100644 --- a/src/bun_core_macros/lib.rs +++ b/src/bun_core_macros/lib.rs @@ -109,10 +109,6 @@ fn rewrite(fmt: &str, is_enabled: bool) -> Result { } } } - b'>' => { - // stray closer — dropped - i += 1; - } b'{' => { // copy `{ ... }` verbatim, optionally rewriting legacy specs let start = i; @@ -139,6 +135,10 @@ fn rewrite(fmt: &str, is_enabled: bool) -> Result { i += 1; } let name = &fmt[start..i]; + // Skip the tag's closing `>`. Any other `>` is text. + if i < bytes.len() { + i += 1; + } let seq: &str = if let Some(c) = color_for(name) { c } else if name == "r" { @@ -152,7 +152,6 @@ fn rewrite(fmt: &str, is_enabled: bool) -> Result { if is_enabled { out.push_str(if is_reset { RESET } else { seq }); } - // trailing `>` consumed by the `'>'` arm next iteration } _ => { // Preserve full UTF-8: push the char at this byte position. diff --git a/src/clap_macros/lib.rs b/src/clap_macros/lib.rs index 0969a83031f5..5e4c1512e10e 100644 --- a/src/clap_macros/lib.rs +++ b/src/clap_macros/lib.rs @@ -294,9 +294,6 @@ fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Vec { } } } - b'>' => { - i += 1; - } b'{' => { while i < fmt.len() && fmt[i] != b'}' { out.push(fmt[i]); @@ -314,6 +311,10 @@ fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Vec { i += 1; } let name = &fmt[start..i]; + // Skip the tag's closing `>`. Any other `>` is text. + if i < fmt.len() { + i += 1; + } let seq: &str = if let Some(c) = color_for_bytes(name) { c } else if name == b"r" { diff --git a/src/runtime/cli/init_command.rs b/src/runtime/cli/init_command.rs index ac428b724a8c..c8fbf8a1d2aa 100644 --- a/src/runtime/cli/init_command.rs +++ b/src/runtime/cli/init_command.rs @@ -133,13 +133,10 @@ impl InitCommand { if i == selected.to_index() { if colors { bun_core::pretty!("❯ "); - } else { - bun_core::pretty!("> "); - } - if colors { Output::print(format_args!("\x1B[4m{}\x1B[24m\x1B[0K\n", option)); } else { - Output::print(format_args!(" {}\x1B[0K\n", option)); + bun_core::pretty!("> "); + Output::print(format_args!("{}\x1B[0K\n", option)); } } else { Output::print(format_args!(" {}\x1B[0K\n", option)); diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index 736bfe37ab7d..a63c61987961 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -1,7 +1,7 @@ import { spawnSync } from "bun"; import { dlopen, FFIType } from "bun:ffi"; import { describe, expect, test } from "bun:test"; -import { bunEnv, bunExe, isDebug, isMusl, isWindows, tempDir } from "harness"; +import { bunEnv, bunExe, isDebug, isLinux, isMusl, isWindows, tempDir } from "harness"; import fs from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; @@ -175,6 +175,28 @@ describe("bun", () => { ); }); }); + + // `bun discord` runs the platform opener (`xdg-open` on Linux) and prints the + // URL when that fails. Linux only: an empty PATH makes `xdg-open` fail to + // resolve, while macOS spawns `/usr/bin/open` by absolute path and would + // open a browser. + describe.skipIf(!isLinux)("discord", () => { + test("prints the URL when the opener is not on PATH", async () => { + using emptyPath = tempDir("bun-discord-empty-path", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), "discord"], + env: { ...bunEnv, PATH: String(emptyPath) }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe("-> https://bun.com/discord\n"); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + }); + }); + describe("getcompletes", () => { test("getcompletes should not panic and should not be empty", () => { const { stdout, exitCode } = spawnSync({ diff --git a/test/cli/init/init.test.ts b/test/cli/init/init.test.ts index 113be9d6abcc..ff3ee799b165 100644 --- a/test/cli/init/init.test.ts +++ b/test/cli/init/init.test.ts @@ -143,6 +143,102 @@ const initEnv = { ...bunEnv, BUN_AGENT_RULE_DISABLED: "1" }; expect(fs.existsSync(path.join(temp, "package.json"))).toBe(false); }); + // Windows is skipped: ConPTY rewrites the child's output into its own escape + // sequences, so the raw rows cannot be compared. + test.skipIf(isWindows)("bun init template menu marks the selected row when colors are off", async () => { + await using temp = tempDir("bun-init-menu-no-color", {}); + + const received: string[] = []; + let eof = false; + let wake: (() => void) | null = null; + const decoder = new TextDecoder(); + // bunEnv sets NO_COLOR=1, so the menu takes its no-color branch: an ASCII + // `>` in place of the colored `❯` and no underline on the selected row. + await using proc = Bun.spawn({ + cmd: [bunExe(), "init"], + cwd: temp, + env: initEnv, + // An inline terminal is owned by the subprocess: when the child exits, the + // remaining output is drained and then `exit` fires. So `eof` means that + // no more output can arrive. + terminal: { + cols: 80, + rows: 24, + data(_term, chunk) { + received.push(decoder.decode(chunk, { stream: true })); + wake?.(); + }, + exit() { + eof = true; + wake?.(); + }, + }, + }); + await using terminal = proc.terminal!; + + // Resolves with everything received so far, once `ready` accepts it. + // Fails at once, with that output, if the process exits first. + const waitFor = async (ready: (all: string) => boolean): Promise => { + const deadline = Date.now() + 10_000; + while (true) { + const all = received.join(""); + if (ready(all)) return all; + if (eof) { + throw new Error(`bun init exited before the expected output. Received:\n${JSON.stringify(all)}`); + } + const remaining = deadline - Date.now(); + if (remaining <= 0) { + throw new Error(`Timed out waiting for the menu. Received so far:\n${JSON.stringify(all)}`); + } + await new Promise(resolve => { + const timer = setTimeout(resolve, remaining); + wake = () => { + clearTimeout(timer); + resolve(); + }; + }); + wake = null; + } + }; + // Every draw of the menu ends with "clear to end of screen", then the + // process blocks on stdin. So once the `count`-th one has arrived, the + // output is complete and stable. + const drawn = (count: number) => (all: string) => all.split("\x1b[0J").length > count; + // The menu is redrawn in place on every keypress (cursor up, then the three + // rows again). The last three non-empty lines are the current state. + const currentMenu = (all: string) => + all + .replace(/\x1b\[[0-9;?]*[A-Za-z]/g, "") + .split("\r\n") + .filter(Boolean) + .slice(-3); + + expect(currentMenu(await waitFor(drawn(1)))).toMatchInlineSnapshot(` + [ + "> Blank", + " React", + " Library", + ] + `); + + // Down arrow: the marker follows the selection. + terminal.write("\x1b[B"); + expect(currentMenu(await waitFor(drawn(2)))).toMatchInlineSnapshot(` + [ + " Blank", + "> React", + " Library", + ] + `); + + // Ctrl-C cancels the menu without creating a project. + terminal.write("\x03"); + await waitFor(all => all.includes("Cancelled")); + const exitCode = await proc.exited; + expect(fs.existsSync(path.join(temp, "package.json"))).toBe(false); + expect(exitCode).toBe(0); + }); + test("bun init in folder", async () => { await using temp = tempDir("bun-init-in-folder", { "mydir": { From 9301e3a86c0bb39fd6dfa16b598531d8dd71beab Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:28 +0000 Subject: [PATCH 08/24] bun run: print the package.json parse error instead of "Script not found" (#41700) --- src/runtime/cli/run_command.rs | 20 +++++++++++++++- test/cli/run/run_command.test.ts | 39 ++++++++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 6593676149f1..3fb8bb7ff204 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -2839,6 +2839,20 @@ impl RunCommand { } if log_errors { + // The resolver records a package.json that fails to load (a + // syntax error, an unreadable file) in `ctx.log` and returns a + // `DirInfo` without it. + // + // SAFETY: `ctx.log` set in `create_context_data` (single-threaded + // CLI startup), process-lifetime. + let log = unsafe { ctx.log() }; + let has_log_errors = log.has_errors(); + if has_log_errors { + let _ = log.print(std::ptr::from_mut::( + Output::error_writer(), + )); + } + if let Some((path, loader)) = resolved_to_unrunnable_file { bun_core::pretty_error!( "error: Cannot run \"{}\"\n", @@ -2867,7 +2881,11 @@ impl RunCommand { "error: File not found \"{}\"", bstr::BStr::new(target_name), ); - } else { + } else if !(has_log_errors && root_dir.enclosing_package_json.is_none()) { + // With no usable package.json and an error that says + // why, the error is the message. "Script not found" + // would claim the script is absent from a file that + // did not load. pretty_errorln!( "error: Script not found \"{}\"", bstr::BStr::new(target_name), diff --git a/test/cli/run/run_command.test.ts b/test/cli/run/run_command.test.ts index 90e59c3349d1..1190b3c3ba23 100644 --- a/test/cli/run/run_command.test.ts +++ b/test/cli/run/run_command.test.ts @@ -40,6 +40,45 @@ describe("bun", () => { expect(stderr.toString()).toMatch(/Script not found/); expect(exitCode).toBe(1); }); + + test("a package.json that fails to parse reports the parse error, not 'Script not found'", () => { + using dir = tempDir("broken-package-json", { + "package.json": `{"name": "p", "scripts": {"s": "echo hi"`, + }); + const { exitCode, stdout, stderr } = spawnSync({ + cwd: String(dir), + cmd: [bunExe(), "run", "s"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(stdout.toString()).toBeEmpty(); + const err = stderr.toString(); + expect(err).toContain(`Expected "}" but found end of file`); + expect(err).toContain(join(String(dir), "package.json") + ":1:40"); + expect(err).not.toContain("Script not found"); + expect(exitCode).toBe(1); + }); + + test("a package.json that fails to parse in an ancestor is printed before 'Script not found'", () => { + using dir = tempDir("broken-ancestor-package-json", { + "package.json": `{"name": "top", "private": tru`, + "a/b/package.json": JSON.stringify({ name: "deep", scripts: { s: "echo hi" } }), + }); + const { exitCode, stdout, stderr } = spawnSync({ + cwd: join(String(dir), "a", "b"), + cmd: [bunExe(), "run", "nope"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(stdout.toString()).toBeEmpty(); + const err = stderr.toString(); + expect(err).toContain("Unexpected tru"); + expect(err).toContain(join(String(dir), "package.json") + ":1:28"); + expect(err).toContain('Script not found "nope"'); + expect(exitCode).toBe(1); + }); }); // A working directory can be PATH_MAX - 1 bytes long. A file in it whose own path no longer fits a path buffer From f7f75a617da197bfd7f28a366e64f6b63b719693 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:37 +0000 Subject: [PATCH 09/24] Do not forward the inherited IPC channel to spawned scripts and tools (#41739) --- src/bun_core/env_var.rs | 2 + src/dotenv/env_loader.rs | 17 +++++++ src/jsc/VirtualMachine.rs | 30 +++++------- src/runtime/cli/run_command.rs | 12 +++++ test/js/bun/spawn/ipc-channel-scrub.test.ts | 51 +++++++++++++++++++++ 5 files changed, 93 insertions(+), 19 deletions(-) create mode 100644 test/js/bun/spawn/ipc-channel-scrub.test.ts diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index 2c8b1a99f387..3f65e1cc2ca9 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -178,6 +178,8 @@ new!(pub JENKINS_URL: string, "JENKINS_URL", {}); new!(pub MI_VERBOSE: boolean, "MI_VERBOSE", { default: false }); new!(pub NO_COLOR: boolean, "NO_COLOR", { default: false }); new!(pub NODE_CHANNEL_FD: string, "NODE_CHANNEL_FD", {}); +// "advanced" (structured clone) or "json" for the channel named by NODE_CHANNEL_FD. +new!(pub NODE_CHANNEL_SERIALIZATION_MODE: string, "NODE_CHANNEL_SERIALIZATION_MODE", {}); // A string, not a boolean: node suppresses warnings only when the value is // exactly "1" (lib/internal/process/pre_execution.js). new!(pub NODE_NO_WARNINGS: string, "NODE_NO_WARNINGS", {}); diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 3ced1d884eae..9e7a680c79f0 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -162,6 +162,18 @@ static NODE_PATH_TO_USE_SET_ONCE: bun_core::RwLock>> = bun_core // PORTING.md §Concurrency: OnceLock — set once from CLI flag, read many. pub static HAS_NO_CLEAR_SCREEN_CLI_FLAG: OnceLock = OnceLock::new(); +/// Compares like [`HashTable`] keys: ASCII case-insensitive on Windows. +fn is_ipc_channel_key(key: &[u8]) -> bool { + const KEYS: [&[u8]; 2] = [b"NODE_CHANNEL_FD", b"NODE_CHANNEL_SERIALIZATION_MODE"]; + KEYS.iter().any(|&k| { + if cfg!(windows) { + strings::eql_case_insensitive_ascii(key, k, true) + } else { + strings::eql(key, k) + } + }) +} + impl Loader { /// Shared "empty-ish" predicate for proxy env vars: an unset/empty value, /// or a literal empty-quote pair left over from shell `export FOO=""` / @@ -569,6 +581,11 @@ impl Loader { let key = &env[..i as usize]; let value = &env[i as usize + 1..]; if !key.is_empty() { + // Our inherited IPC channel is not for children spawned from + // this env (Node drops these too); `bun run` re-adds them. + if is_ipc_channel_key(key) { + continue; + } self.map.put(key, value)?; } } else { diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 7f98053db4e7..65f0ebc5f6f0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4315,7 +4315,7 @@ impl VirtualMachine { // `&'static mut Loader` is independent of `&self`, so `map` may be held // across the `&mut self` writes below. let env = self.transpiler.env_mut(); - let map = &mut env.map; + let map = &env.map; ensure_source_code_printer(); // The runtime VM owns the printer from here on — even if a macro had @@ -4332,23 +4332,15 @@ impl VirtualMachine { self.transpiler_store.enabled = false; } - if let Some(idx) = map.map.get_index(b"NODE_CHANNEL_FD") { - let (_, kv) = map.map.swap_remove_at(idx); - let fd_s = kv.value; - let advanced = map - .map - .get_index(b"NODE_CHANNEL_SERIALIZATION_MODE") - .map(|i| map.map.swap_remove_at(i).1) - .is_some_and(|v| &v.value[..] == b"advanced"); - // Accept only - // non-negative values that fit in i31 (i.e. `0..=i32::MAX`). - // Parsing as `u32` then `as i32` would silently wrap values in - // `2^31..2^32` to a negative fd instead of taking the warn branch. - // The channel belongs to the process (its main thread). A worker - // sees the same inherited variables but must not open a second - // endpoint over the same fd (Node: no process.send() in workers). - if self.is_main_thread() { - match bun_core::fmt::parse_int::(&fd_s, 10) + // Not in `map`: `Loader::load_process` drops it so children don't inherit + // the channel. Main thread only (Node: no process.send() in workers). + if self.is_main_thread() { + if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { + let advanced = bun_core::env_var::NODE_CHANNEL_SERIALIZATION_MODE + .get() + .is_some_and(|v| bun_core::strings::eql(v, b"advanced")); + // i32, not u32-then-cast: `2^31..` must warn, not wrap negative. + match bun_core::fmt::parse_int::(fd_s, 10) .ok() .filter(|&n| n >= 0) { @@ -4360,7 +4352,7 @@ impl VirtualMachine { } None => bun_core::warn!( "Failed to parse IPC channel number '{}'", - bstr::BStr::new(&fd_s[..]) + bstr::BStr::new(fd_s) ), } } diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 3fb8bb7ff204..f990e9bf607a 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -437,6 +437,18 @@ Full documentation is available at https://bun.com/docs/cli/run #[cfg(windows)] let ipc_fd: Option = None; // TODO: implement on Windows + // `Loader::load_process` drops these; a package script inherits the + // channel on purpose (#11132), so restore them alongside `ipc_fd`. + #[cfg(not(windows))] + if ipc_fd.is_some() { + if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { + env.map.put(b"NODE_CHANNEL_FD", fd_s)?; + } + if let Some(mode) = bun_core::env_var::NODE_CHANNEL_SERIALIZATION_MODE.get() { + env.map.put(b"NODE_CHANNEL_SERIALIZATION_MODE", mode)?; + } + } + // TODO: remember to free this when we add --filter or --concurrent // in the meantime we don't need to free it. let envp = env.map.create_null_delimited_env_map()?; diff --git a/test/js/bun/spawn/ipc-channel-scrub.test.ts b/test/js/bun/spawn/ipc-channel-scrub.test.ts new file mode 100644 index 000000000000..962d01247bf7 --- /dev/null +++ b/test/js/bun/spawn/ipc-channel-scrub.test.ts @@ -0,0 +1,51 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; + +// A bun CLI process that inherits an IPC channel (NODE_CHANNEL_FD) must not +// forward that channel to the scripts and tools it spawns, the way Node sets +// the channel fd close-on-exec and deletes the variables before it spawns. +// +// The grandchild is a POSIX shell, not bun or node: bun and node remove +// NODE_CHANNEL_FD from `process.env` while they adopt the channel, so only a +// shell reports the raw inherited value. +const SHOW_CHANNEL = [ + "#!/bin/sh", + `printf 'NODE_CHANNEL_FD=[%s]\\n' "\${NODE_CHANNEL_FD:-}"`, + `printf 'NODE_CHANNEL_SERIALIZATION_MODE=[%s]\\n' "\${NODE_CHANNEL_SERIALIZATION_MODE:-}"`, + "", +].join("\n"); + +const NO_CHANNEL = "NODE_CHANNEL_FD=[]\nNODE_CHANNEL_SERIALIZATION_MODE=[]\n"; + +// Spawn `bun ` with an IPC channel (`ipc` makes Bun.spawn create one and +// set NODE_CHANNEL_FD for the direct child) and report what the grandchild saw. +async function runWithChannel(args: string[], cwd: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + ipc() {}, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +test.concurrent.skipIf(isWindows)("bun exec does not forward the inherited IPC channel", async () => { + using dir = tempDir("ipc-scrub-exec", { "show.sh": SHOW_CHANNEL }); + const { stdout, stderr, exitCode } = await runWithChannel(["exec", "sh show.sh"], String(dir)); + expect(stdout).toBe(NO_CHANNEL); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); +}); + +test.concurrent.skipIf(isWindows)("bun run --shell=bun does not forward the inherited IPC channel", async () => { + using dir = tempDir("ipc-scrub-bunshell", { + "package.json": JSON.stringify({ name: "x", version: "1.0.0", scripts: { show: "sh show.sh" } }), + "show.sh": SHOW_CHANNEL, + }); + const { stdout, stderr, exitCode } = await runWithChannel(["run", "--silent", "--shell=bun", "show"], String(dir)); + expect(stdout).toBe(NO_CHANNEL); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); +}); From 82e6cec4ef67a7606810d0a4b64a108826146502 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:42 +0000 Subject: [PATCH 10/24] cli: fix the `--no-module-preload` help text and two bunfig error messages (#42967) --- src/bunfig/bunfig.rs | 6 +-- src/clap_macros/lib.rs | 53 +++++++++++------------- src/runtime/cli/Arguments.rs | 2 +- test/cli/bun.test.ts | 16 +++++++ test/config/bunfig/bunfig-errors.test.ts | 45 ++++++++++++++++++++ 5 files changed, 90 insertions(+), 32 deletions(-) diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index b92f66a06a3f..23f39aa35756 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -769,7 +769,7 @@ impl<'a> Parser<'a, '_> { None => { self.add_error( auto_install_expr.loc, - b"Invalid auto install setting, must be one of true, false, or \"force\" \"fallback\" \"disable\"", + b"Invalid auto install setting, must be one of true, false, \"auto\", \"force\", \"fallback\", or \"disable\"", )?; return Ok(()); } @@ -783,7 +783,7 @@ impl<'a> Parser<'a, '_> { } else { self.add_error( auto_install_expr.loc, - b"Invalid auto install setting, must be one of true, false, or \"force\" \"fallback\" \"disable\"", + b"Invalid auto install setting, must be one of true, false, \"auto\", \"force\", \"fallback\", or \"disable\"", )?; return Ok(()); } @@ -797,7 +797,7 @@ impl<'a> Parser<'a, '_> { } else { self.add_error( prefer_expr.loc, - b"Invalid prefer setting, must be one of online or offline", + b"Invalid prefer setting, must be one of \"online\", \"offline\", or \"latest\"", )?; } } diff --git a/src/clap_macros/lib.rs b/src/clap_macros/lib.rs index 5e4c1512e10e..7699358ee6a0 100644 --- a/src/clap_macros/lib.rs +++ b/src/clap_macros/lib.rs @@ -265,14 +265,14 @@ fn byte_str_b(s: &[u8]) -> LitByteStr { LitByteStr::new(s, Span::call_site()) } -/// 1:1 port of `bun_core::output::pretty_fmt_runtime` — rewrites Bun's `` +/// Port of `bun_core::output::pretty_fmt_runtime` — rewrites Bun's `` /// colour markup to ANSI escape sequences when `is_enabled`, or strips it when /// not. Run here at macro-expansion time with `is_enabled = false` so each param /// description's tag-stripped form (`Help::msg_plain`) is a `const` byte literal /// in rodata. The ANSI form is *not* baked in — it is rare (only `bun --help` on /// a colour TTY) and would otherwise roughly triple the help-string rodata, so /// `bun_clap::pretty_help_desc` derives it from `Help::msg` on demand instead. -fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Vec { +fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Result, String> { use bun_output_tags::{RESET, color_for_bytes}; let mut out: Vec = Vec::with_capacity(fmt.len() * 2); let mut i = 0usize; @@ -321,12 +321,10 @@ fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Vec { is_reset = true; "" } else { - // Unknown tag: `pretty_fmt_runtime` (the path this replaces) - // drops it silently. Match - // the lenient runtime behaviour — a compile error would be - // stricter than what shipped, and param specs don't carry - // unknown tags anyway. - "" + return Err(format!( + "`<{}>` in the help text is not a colour tag and would be dropped from --help; write `\\\\<` and `\\\\>` for literal angle brackets", + name.escape_ascii() + )); }; if is_enabled { out.extend_from_slice(if is_reset { @@ -342,15 +340,15 @@ fn pretty_rewrite(fmt: &[u8], is_enabled: bool) -> Vec { } } } - out + Ok(out) } -fn emit_param(krate: &Path, p: &Param) -> TokenStream2 { +fn emit_param(krate: &Path, p: &Param) -> Result { let msg = byte_str(&p.id.msg); // Precompute only the tag-stripped form (the non-TTY help path needs it ready // without a TTY check); the ANSI form is derived lazily from `msg` by // `bun_clap::pretty_help_desc`, so it stays out of rodata. - let msg_plain = byte_str_b(&pretty_rewrite(p.id.msg.as_bytes(), false)); + let msg_plain = byte_str_b(&pretty_rewrite(p.id.msg.as_bytes(), false)?); let value = byte_str(&p.id.value); let short = match p.names.short { @@ -386,7 +384,7 @@ fn emit_param(krate: &Path, p: &Param) -> TokenStream2 { Values::OneOptional => quote! { #krate::Values::OneOptional }, }; - quote! { + Ok(quote! { #krate::Param::<#krate::Help> { id: #krate::Help { msg: #msg, @@ -400,7 +398,7 @@ fn emit_param(krate: &Path, p: &Param) -> TokenStream2 { }, takes_value: #takes_value, } - } + }) } // ───────────────────────────────────────────────────────────────────────────── @@ -423,17 +421,21 @@ impl Parse for ParseParamInput { } } +/// One param spec literal → const `Param` literal, or a compile error at the literal. +fn expand_param(krate: &Path, lit: &LitStr, macro_name: &str) -> syn::Result { + let line = lit.value(); + let error = |msg: &str| syn::Error::new(lit.span(), format!("{macro_name}: {msg}")); + let param = parse_param(line.as_bytes()).map_err(|e| error(e.msg()))?; + emit_param(krate, ¶m).map_err(|msg| error(&msg)) +} + /// `__parse_param_impl!($crate, "…")` → const `Param` literal. #[proc_macro] pub fn __parse_param_impl(input: TokenStream) -> TokenStream { let ParseParamInput { krate, lit } = parse_macro_input!(input as ParseParamInput); - let line = lit.value(); - match parse_param(line.as_bytes()) { - Ok(p) => emit_param(&krate, &p).into(), - Err(e) => syn::Error::new(lit.span(), format!("parse_param!: {}", e.msg())) - .to_compile_error() - .into(), - } + expand_param(&krate, &lit, "parse_param!") + .unwrap_or_else(|e| e.to_compile_error()) + .into() } struct ParseParamsInput { @@ -457,14 +459,9 @@ pub fn __parse_params_impl(input: TokenStream) -> TokenStream { let mut items = Vec::with_capacity(lits.len()); for lit in &lits { - let line = lit.value(); - match parse_param(line.as_bytes()) { - Ok(p) => items.push(emit_param(&krate, &p)), - Err(e) => { - return syn::Error::new(lit.span(), format!("parse_params!: {}", e.msg())) - .to_compile_error() - .into(); - } + match expand_param(&krate, lit, "parse_params!") { + Ok(item) => items.push(item), + Err(e) => return e.to_compile_error().into(), } } diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index d2a105468ec6..ab9c5b07e06b 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -486,7 +486,7 @@ pub(crate) const BUILD_ONLY_PARAMS: &[ParamType] = concat_params!( "--no-split-require With --splitting and --target bun: keep a require()'d ESM file in the calling chunk instead of emitting a chunk loaded at the call" ), parse_param!( - "--no-module-preload With --splitting and --target browser: don't emit for the chunks an entry or import() loads" + "--no-module-preload With --splitting and --target browser: don't emit \\ for the chunks an entry or import() loads" ), parse_param!( "--min-chunk-size With --splitting, also fold side-effect-free chunks smaller than this many source bytes into a chunk more entry points load. Default: 0 (off); 16384 is a good value for browser builds" diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index a63c61987961..eefd93179321 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -462,6 +462,7 @@ describe("bun", () => { ["test", "--rerun-each", "Re-run each test file times"], ["test", "--bail", "Exit the test suite after failures"], ["build", "--allow-unresolved", "Use '' for opaque specifiers"], + ["build", "--no-module-preload", "don't emit for the chunks"], ["add", "-F, --filter", "Add the package(s) to the matching workspaces instead of the current package"], ["add", "--catalog", 'depend on it as "catalog:" (use --catalog=NAME for a named catalog)'], ["remove", "-F, --filter", "Remove the package(s) from the matching workspaces instead of the current package"], @@ -494,6 +495,21 @@ describe("bun", () => { expect(out).not.toContain(""); expect(exitCode).toBe(0); }); + + // With colors on, a flag description goes through the runtime markup pass, not the build-time one. + test("bun build --help keeps with FORCE_COLOR=1", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "--help"], + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + const line = (stdout + stderr).split(/\r?\n/).find(l => l.includes("--no-module-preload")) ?? ""; + // The cyan flag name proves that colors are on, so the runtime pass wrote this line. + expect(line).toContain("\x1b[36m--no-module-preload\x1b[0m"); + expect(line).toContain("don't emit for the chunks"); + expect(exitCode).toBe(0); + }); }); describe("--help lists package manager commands and examples", () => { diff --git a/test/config/bunfig/bunfig-errors.test.ts b/test/config/bunfig/bunfig-errors.test.ts index e1581e953929..4dc71148896a 100644 --- a/test/config/bunfig/bunfig-errors.test.ts +++ b/test/config/bunfig/bunfig-errors.test.ts @@ -33,6 +33,51 @@ describe.concurrent("bunfig.toml type-mismatch error messages", () => { }); }); +describe.concurrent("bunfig.toml invalid-value error messages name every accepted value", () => { + const settings: [key: string, accepted: string[], expected: string][] = [ + [ + "prefer", + [`"online"`, `"offline"`, `"latest"`], + `Invalid prefer setting, must be one of "online", "offline", or "latest"`, + ], + [ + "auto", + [`true`, `false`, `"auto"`, `"force"`, `"fallback"`, `"disable"`], + `Invalid auto install setting, must be one of true, false, "auto", "force", "fallback", or "disable"`, + ], + ]; + + async function runWithInstallSetting(key: string, value: string) { + using dir = tempDir("bunfig-invalid-value", { + "bunfig.toml": `[install]\n${key} = ${value}\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "-e", "console.log('ran')"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.each(settings)('install.%s = "bogus"', async (key, accepted, expected) => { + const { stdout, stderr, exitCode } = await runWithInstallSetting(key, `"bogus"`); + const errorLine = stderr.split("\n").find(l => l.startsWith("error:")) ?? stderr; + expect(errorLine).toBe(`error: ${expected}`); + // The message names every value that the next test shows bun accepts. + expect(accepted.filter(value => !expected.includes(value))).toEqual([]); + expect(stdout).toBe(""); + expect(exitCode).not.toBe(0); + }); + + const acceptedCases = settings.flatMap(([key, accepted]) => accepted.map((value): [string, string] => [key, value])); + test.each(acceptedCases)("install.%s = %s is accepted", async (key, value) => { + expect(await runWithInstallSetting(key, value)).toEqual({ stdout: "ran\n", stderr: "", exitCode: 0 }); + }); +}); + // bun builds every config path in a stack buffer of MAX_PATH_BYTES (the platform's // PATH_MAX, see bun_core), so the longest path it can hold is MAX_PATH_BYTES - 1 // bytes plus the NUL terminator. Paths that do not fit used to overflow the buffer From 96ffb6722787d7cee77395e908de05e4771d866a Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:46 +0000 Subject: [PATCH 11/24] bun run: do not look for a system shell when --shell=bun runs the script (#43231) --- src/crash_handler/lib.rs | 5 + src/runtime/cli/run_command.rs | 14 ++- test/cli/run/hide-paths.c | 216 +++++++++++++++++++++++++++++++++ test/cli/run/run-shell.test.ts | 89 +++++++++++++- 4 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 test/cli/run/hide-paths.c diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index 9475e295161e..95f7eb4d00bd 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -1444,6 +1444,11 @@ mod draft { } else if name == b"MissingPackageJSON" { err_generic!("Bun could not find a package.json file to install from"); bun_core::note!("Run \"bun init\" to initialize a project"); + } else if name == b"MissingShell" { + err_generic!("Bun could not find a system shell (bash, sh, or zsh) to run this script"); + bun_core::note!( + "To use Bun's built-in shell, pass --shell=bun to \"bun run\" or set shell = \"bun\" under [run] in bunfig.toml" + ); } else { // The macros need // `:literal`, so branch on the const and call separately. diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index f990e9bf607a..d53c5853c48a 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -319,9 +319,13 @@ Full documentation is available at https://bun.com/docs/cli/run use_system_shell: bool, shell_path: Option<&[u8]>, ) -> crate::Result<()> { - let shell_search_path = shell_path.unwrap_or_else(|| env.get(b"PATH").unwrap_or(b"")); - let shell_bin = - Self::find_shell(shell_search_path, cwd).ok_or(crate::Error::MissingShell)?; + // `--shell=bun` must work on an image that has no system shell. + let shell_bin = if use_system_shell { + let shell_search_path = shell_path.unwrap_or_else(|| env.get(b"PATH").unwrap_or(b"")); + Some(Self::find_shell(shell_search_path, cwd).ok_or(crate::Error::MissingShell)?) + } else { + None + }; env.map .put(b"npm_lifecycle_event", name) .expect("unreachable"); @@ -368,7 +372,7 @@ Full documentation is available at https://bun.com/docs/cli/run Output::flush(); } - if !use_system_shell { + let Some(shell_bin) = shell_bin else { // SAFETY: `MiniEventLoop` stores `env` as a raw `*mut`; the loader // outlives the call (process-lifetime in `configure_env_for_run`). let mini = bun_event_loop::MiniEventLoop::init_global( @@ -412,7 +416,7 @@ Full documentation is available at https://bun.com/docs/cli/run Global::exit(code as u32); } return Ok(()); - } + }; use crate::api::bun_process::{Status as SpawnStatus, sync}; diff --git a/test/cli/run/hide-paths.c b/test/cli/run/hide-paths.c new file mode 100644 index 000000000000..ff11b335c710 --- /dev/null +++ b/test/cli/run/hide-paths.c @@ -0,0 +1,216 @@ +// Test helper: run a command with some paths made to look absent. +// +// hide-paths /bin/sh /bin/bash -- bun run start +// +// A mount namespace needs privileges that CI does not have. This uses seccomp +// user notification instead: the child's path syscalls stop in the kernel, the +// parent reads the path from the child's memory, and answers ENOENT for a +// hidden path (the path itself or anything below it) or lets the syscall +// continue. Exit code 77 means the kernel or the sandbox does not allow this. +// The path check is racy by design, which does not matter for a test. +#define _GNU_SOURCE +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#define SKIP 77 + +#if defined(__x86_64__) +#define HP_AUDIT_ARCH AUDIT_ARCH_X86_64 +#elif defined(__aarch64__) +#define HP_AUDIT_ARCH AUDIT_ARCH_AARCH64 +#else +#define HP_AUDIT_ARCH 0 +#endif + +// Linux 5.0 (listener) and 5.5 (continue). Declared here so that older kernel +// headers still compile; an older kernel answers EINVAL and the helper skips. +struct hp_notif { uint64_t id; uint32_t pid; uint32_t flags; struct seccomp_data data; }; +struct hp_notif_resp { uint64_t id; int64_t val; int32_t error; uint32_t flags; }; +struct hp_notif_sizes { uint16_t notif, resp, data; }; +#define HP_NEW_LISTENER (1UL << 3) +#define HP_GET_NOTIF_SIZES 3 +#define HP_CONTINUE (1UL << 0) +#define HP_NOTIF_RECV _IOWR('!', 0, struct hp_notif) +#define HP_NOTIF_SEND _IOWR('!', 1, struct hp_notif_resp) +#ifndef SECCOMP_RET_USER_NOTIF +#define SECCOMP_RET_USER_NOTIF 0x7fc00000U +#endif +#ifndef __NR_faccessat2 +#define __NR_faccessat2 439 +#endif +#ifndef __NR_openat2 +#define __NR_openat2 437 +#endif + +// Syscalls that take the path in args[0], then those that take it in args[1]. +static const int path_arg0[] = { +#ifdef __NR_open + __NR_open, __NR_stat, __NR_lstat, __NR_readlink, __NR_access, +#endif + __NR_execve, +}; +static const int path_arg1[] = { + __NR_openat, __NR_openat2, __NR_newfstatat, __NR_statx, __NR_readlinkat, __NR_faccessat, __NR_faccessat2, __NR_execveat, +}; +#define N0 ((int)(sizeof(path_arg0) / sizeof(path_arg0[0]))) +#define N1 ((int)(sizeof(path_arg1) / sizeof(path_arg1[0]))) + +static int install_filter(void) { + struct sock_filter f[N0 + N1 + 6]; + int n = 0, total = N0 + N1; + f[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); + f[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, HP_AUDIT_ARCH, 1, 0); + f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + f[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); + for (int i = 0; i < total; i++) { + int nr = i < N0 ? path_arg0[i] : path_arg1[i - N0]; + // On a match, jump over the remaining compares and the ALLOW to the NOTIF return. + f[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, total - i, 0); + } + f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); + f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF); + struct sock_fprog prog = {.len = (unsigned short)n, .filter = f}; + return (int)syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, HP_NEW_LISTENER, &prog); +} + +static int send_fd(int sock, int fd) { + char control[CMSG_SPACE(sizeof(int))] = {0}, byte = 0; + struct iovec io = {.iov_base = &byte, .iov_len = 1}; + struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)}; + struct cmsghdr *c = CMSG_FIRSTHDR(&msg); + c->cmsg_level = SOL_SOCKET; + c->cmsg_type = SCM_RIGHTS; + c->cmsg_len = CMSG_LEN(sizeof(int)); + memcpy(CMSG_DATA(c), &fd, sizeof(int)); + return sendmsg(sock, &msg, 0) < 0 ? -1 : 0; +} + +static int recv_fd(int sock) { + char control[CMSG_SPACE(sizeof(int))] = {0}, byte; + struct iovec io = {.iov_base = &byte, .iov_len = 1}; + struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)}; + if (recvmsg(sock, &msg, 0) <= 0) return -1; + struct cmsghdr *c = CMSG_FIRSTHDR(&msg); + if (!c) return -1; + int fd; + memcpy(&fd, CMSG_DATA(c), sizeof(int)); + return fd; +} + +static int is_hidden(const char *path, char **hidden, int count) { + for (int i = 0; i < count; i++) { + size_t len = strlen(hidden[i]); + if (strncmp(path, hidden[i], len) == 0 && (path[len] == 0 || path[len] == '/')) return 1; + } + return 0; +} + +// Reads the NUL-terminated path at `addr` in the child. Returns 0, or -1 if the read is not permitted. +static int read_path(pid_t pid, uint64_t addr, char *out, size_t cap) { + out[0] = 0; + if (!addr) return 0; + // Do not cross into the next page unless the string does: it may be unmapped. + size_t first = 4096 - (size_t)(addr & 4095); + if (first > cap - 1) first = cap - 1; + struct iovec local = {.iov_base = out, .iov_len = first}; + struct iovec remote = {.iov_base = (void *)(uintptr_t)addr, .iov_len = first}; + ssize_t got = process_vm_readv(pid, &local, 1, &remote, 1, 0); + if (got < 0) return errno == EPERM || errno == ENOSYS ? -1 : 0; + if (memchr(out, 0, (size_t)got) == NULL && (size_t)got < cap - 1) { + local.iov_base = out + got; + remote.iov_base = (void *)(uintptr_t)(addr + (uint64_t)got); + local.iov_len = remote.iov_len = cap - 1 - (size_t)got; + ssize_t more = process_vm_readv(pid, &local, 1, &remote, 1, 0); + got += more > 0 ? more : 0; + } + out[got] = 0; + return 0; +} + +int main(int argc, char **argv) { + int sep = 0; + for (int i = 1; i < argc && !sep; i++) + if (strcmp(argv[i], "--") == 0) sep = i; + if (sep < 2 || sep + 1 >= argc) { + fprintf(stderr, "usage: %s ... -- [args...]\n", argv[0]); + return 2; + } + if (HP_AUDIT_ARCH == 0) return SKIP; + + int pair[2]; + if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair) != 0) return SKIP; + pid_t child = fork(); + if (child < 0) return SKIP; + if (child == 0) { + close(pair[0]); + if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) _exit(SKIP); + int listener = install_filter(); + if (listener < 0 || send_fd(pair[1], listener) != 0) _exit(SKIP); + close(listener); + close(pair[1]); + execvp(argv[sep + 1], argv + sep + 1); + perror("execvp"); + _exit(127); + } + close(pair[1]); + int listener = recv_fd(pair[0]); + close(pair[0]); + + struct hp_notif_sizes sizes = {0}; + if (listener < 0 || syscall(__NR_seccomp, HP_GET_NOTIF_SIZES, 0, &sizes) != 0) { + kill(child, SIGKILL); + return SKIP; + } + size_t notif_size = sizes.notif > sizeof(struct hp_notif) ? sizes.notif : sizeof(struct hp_notif); + size_t resp_size = sizes.resp > sizeof(struct hp_notif_resp) ? sizes.resp : sizeof(struct hp_notif_resp); + struct hp_notif *req = malloc(notif_size); + struct hp_notif_resp *resp = malloc(resp_size); + + for (;;) { + struct pollfd pfd = {.fd = listener, .events = POLLIN}; + int ready = poll(&pfd, 1, 100); + if (ready < 0 && errno != EINTR) break; + if (ready <= 0 || !(pfd.revents & POLLIN)) { + int status; + if (waitpid(child, &status, WNOHANG) == child) + return WIFSIGNALED(status) ? 128 + WTERMSIG(status) : WEXITSTATUS(status); + continue; + } + memset(req, 0, notif_size); + if (ioctl(listener, HP_NOTIF_RECV, req) != 0) { + if (errno == EINTR || errno == ENOENT) continue; + break; + } + int index = 1; + for (int i = 0; i < N0; i++) + if (path_arg0[i] == req->data.nr) index = 0; + char path[4096]; + if (read_path((pid_t)req->pid, req->data.args[index], path, sizeof(path)) != 0) break; + + memset(resp, 0, resp_size); + resp->id = req->id; + if (is_hidden(path, argv + 1, sep - 1)) resp->error = -ENOENT; + else resp->flags = HP_CONTINUE; + if (ioctl(listener, HP_NOTIF_SEND, resp) != 0 && errno != ENOENT) break; + } + // The kernel or the sandbox refused a step. Do not report a result. + kill(child, SIGKILL); + waitpid(child, NULL, 0); + return SKIP; +} diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts index 4d2778abb7cd..44214f99e781 100644 --- a/test/cli/run/run-shell.test.ts +++ b/test/cli/run/run-shell.test.ts @@ -1,6 +1,7 @@ -import { describe, expect, test } from "bun:test"; +import { afterAll, describe, expect, test } from "bun:test"; +import { spawnSync } from "child_process"; import { chmodSync, mkdirSync } from "fs"; -import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; import { join } from "path"; describe.concurrent("run-shell", () => { @@ -80,3 +81,87 @@ test.skipIf(isWindows)( expect(exitCode).toBe(0); }, ); + +// hide-paths.c makes chosen paths look absent (seccomp user notification). `bin` is the helper, +// `skip` means this host cannot use it (no cc, missing headers, a kernel or sandbox that refuses +// the filter), `error` is a compile failure of the helper itself. +const hidePaths: { bin: string } | { skip: true } | { error: string } = (() => { + if (!isLinux) return { skip: true }; + const dir = tempDir("hide-paths", {}); + const result = ((): typeof hidePaths => { + const bin = join(String(dir), "hide-paths"); + const compile = spawnSync("cc", ["-O0", "-o", bin, join(import.meta.dir, "hide-paths.c")], { stdio: "pipe" }); + if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return { skip: true }; + if (compile.status !== 0) { + const stderr = compile.stderr?.toString() ?? ""; + // A missing header or libc means this host cannot build C. Anything else is a bug in the helper. + return stderr.includes("No such file or directory") ? { skip: true } : { error: stderr }; + } + // Probe: the helper runs itself with no arguments, which prints the usage and exits 2. 77 means skip. + return spawnSync(bin, ["/hide-paths-probe", "--", bin], { stdio: "pipe" }).status === 2 ? { bin } : { skip: true }; + })(); + // The tests need the binary until the file ends. Every other outcome is done with the directory now. + if ("bin" in result) afterAll(() => dir[Symbol.dispose]()); + else dir[Symbol.dispose](); + return result; +})(); + +// A FROM-scratch or distroless image has no /bin/sh. `--shell=bun` must not need one. Every path +// that the shell lookup probes looks absent, and PATH points at an empty directory. +describe.skipIf("skip" in hidePaths)("bun run on a system with no shell", () => { + // Keep in sync with HARDCODED_POPULAR_ONES in src/runtime/cli/run_command.rs. + const shellPaths = [ + "/bin/bash", + "/usr/bin/bash", + "/usr/local/bin/bash", + "/bin/sh", + "/usr/bin/sh", + "/usr/bin/zsh", + "/usr/local/bin/zsh", + "/system/bin/sh", + ]; + + async function runWithoutShells(files: Record, args: string[]) { + using dir = tempDir("run-no-shell", { + "package.json": JSON.stringify({ + name: "no-shell-fixture", + version: "1.0.0", + scripts: { prestart: "echo pre", start: "echo started", poststart: "echo post" }, + }), + "empty-path/.keep": "", + ...files, + }); + if ("error" in hidePaths) throw new Error("failed to compile hide-paths.c:\n" + hidePaths.error); + await using proc = Bun.spawn({ + cmd: [(hidePaths as { bin: string }).bin, ...shellPaths, "--", bunExe(), "run", ...args], + env: { ...bunEnv, PATH: join(String(dir), "empty-path") }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + test.concurrent("--shell=bun runs the pre, main and post scripts", async () => { + const { stdout, exitCode } = await runWithoutShells({}, ["--shell=bun", "start"]); + expect(stdout).toBe("pre\nstarted\npost\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent('bunfig [run] shell = "bun" runs the scripts', async () => { + const { stdout, exitCode } = await runWithoutShells({ "bunfig.toml": '[run]\nshell = "bun"\n' }, ["start"]); + expect(stdout).toBe("pre\nstarted\npost\n"); + expect(exitCode).toBe(0); + }); + + test.concurrent("the system shell reports what is missing and how to continue", async () => { + const { stdout, stderr, exitCode } = await runWithoutShells({}, ["start"]); + expect(stderr).toBe( + "error: Bun could not find a system shell (bash, sh, or zsh) to run this script\n" + + 'note: To use Bun\'s built-in shell, pass --shell=bun to "bun run" or set shell = "bun" under [run] in bunfig.toml\n', + ); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); +}); From 0e0b3a85ae0bfb8f66268db28b4a6bfe7a3ee4c5 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:50 +0000 Subject: [PATCH 12/24] run: run a package once when two "workspaces" entries match it (#43357) --- src/runtime/cli/filter_arg.rs | 6 +++ test/cli/run/filter-workspace.test.ts | 56 +++++++++++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/src/runtime/cli/filter_arg.rs b/src/runtime/cli/filter_arg.rs index 82f282c8811d..a9045de7265e 100644 --- a/src/runtime/cli/filter_arg.rs +++ b/src/runtime/cli/filter_arg.rs @@ -2,6 +2,7 @@ use core::ptr::NonNull; use bstr::BStr; use bun_ast::{self, ExprData, Log}; +use bun_collections::StringHashMap; use bun_core::Global; use bun_core::Output; use bun_core::{ZStr, strings}; @@ -183,7 +184,12 @@ pub(crate) fn select_packages( let mut iter = PackageFilterIterator::init(&glob_patterns, &negated, &root_dir)?; let mut discovered: Vec = Vec::new(); + // Each "workspaces" entry is walked on its own, so two entries can yield the same path. + let mut seen_paths: StringHashMap<()> = StringHashMap::default(); while let Some(package_json_path) = iter.next()? { + if seen_paths.get_or_put(&package_json_path)?.found_existing { + continue; + } let dir = strings::without_trailing_slash(resolve_path::dirname::( &package_json_path, )); diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index 9363f349d1b1..99c209b09d45 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -898,6 +898,62 @@ describe("bun", () => { }); }); +describe.concurrent('"workspaces" entries that match the same package', () => { + function fixture(workspaces: string[] | { packages: string[] }) { + return tempDir("filter-workspaces-overlap", { + packages: { + app: { "package.json": JSON.stringify({ name: "app", scripts: { build: "echo ran-app" } }) }, + legacy: { "package.json": JSON.stringify({ name: "legacy", scripts: { build: "echo ran-legacy" } }) }, + }, + "package.json": JSON.stringify({ name: "root", private: true, workspaces }), + }); + } + + // `ran` has one element per script run, so a package that runs twice is listed twice. + async function run(cwd: string, args: string[]) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", ...args, "build"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { ran: (stdout.match(/ran-\w+/g) ?? []).sort(), stderr, exitCode }; + } + + test.each([ + ["a glob and a path", ["packages/*", "packages/legacy"]], + ["two globs", ["packages/*", "packages/l*"]], + ["one path written two ways", ["packages/app", "packages/legacy", "./packages/legacy/"]], + ['the { "packages": [...] } form', { packages: ["packages/*", "packages/legacy"] }], + ])("%s: each package runs once", async (_, workspaces) => { + using dir = fixture(workspaces); + const { ran, exitCode } = await run(String(dir), ["--filter", "*"]); + expect({ ran, exitCode }).toEqual({ ran: ["ran-app", "ran-legacy"], exitCode: 0 }); + }); + + test.each([[["--workspaces"]], [["--parallel", "--workspaces"]], [["--sequential", "--filter", "*"]]])( + "%j runs each package once", + async args => { + using dir = fixture(["packages/*", "packages/legacy"]); + const { ran, exitCode } = await run(String(dir), args); + expect({ ran, exitCode }).toEqual({ ran: ["ran-app", "ran-legacy"], exitCode: 0 }); + }, + ); + + test("a package.json that fails to parse is reported once", async () => { + using dir = fixture(["packages/*", "packages/legacy"]); + await Bun.write(join(String(dir), "packages", "legacy", "package.json"), "this is { not valid json"); + const { ran, stderr, exitCode } = await run(String(dir), ["--filter", "*"]); + expect({ ran, warnings: stderr.split("skipping this workspace package").length - 1, exitCode }).toEqual({ + ran: ["ran-app"], + warnings: 1, + exitCode: 0, + }); + }); +}); + describe("selectors", () => { test("'foo...' runs foo and the workspaces it depends on", () => { runInCwdSuccess({ From bfb8d14dddbb36f34b194956b7b4c8fca83c14c1 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:54 +0000 Subject: [PATCH 13/24] docs: describe where bun build --compile --asset mounts a file or directory (#44065) --- docs/bundler/executables.mdx | 4 ++-- packages/bun-types/bun.d.ts | 7 ++++--- src/runtime/cli/Arguments.rs | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index a05760ff47be..dfa286d45f38 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -897,7 +897,7 @@ If you're using `@mapbox/node-pre-gyp` or similar tools, require the `.node` fil ### Embed directories -Use `--asset` (or `compile.assets` in the JavaScript API) to embed a file or directory tree into the executable under its original relative path. The embedded files live under `import.meta.dir` at runtime and are reachable via `node:fs` (`existsSync`, `statSync`, `readdirSync`, `readFileSync`) and `Bun.file()`. +Use `--asset` (or `compile.assets` in the JavaScript API) to embed a file or directory tree into the executable. Bun mounts each asset under its basename, next to the entry point: `--asset ./build/client` lands at `path.join(import.meta.dir, "client")`, and `--asset ./config/app.json` at `path.join(import.meta.dir, "app.json")`. The leading directories of the path you pass are dropped, and a directory keeps its internal tree. The embedded files are reachable via `node:fs` (`existsSync`, `statSync`, `readdirSync`, `readFileSync`) and `Bun.file()`. @@ -931,7 +931,7 @@ for (const entry of fs.readdirSync(publicDir, { withFileTypes: true })) { const html = await Bun.file(path.join(publicDir, "index.html")).text(); ``` -Pass `--asset` multiple times to embed several directories (for example `--asset ./client --asset ./prerendered` for a SvelteKit build). Bun embeds only regular files; it skips symlinks and empty subdirectories inside the tree. +Pass `--asset` multiple times to embed several directories (for example `--asset ./client --asset ./prerendered` for a SvelteKit build). Two assets with the same basename are merged into one directory (`--asset a/b --asset x/b` both land under `b/`). Two files that resolve to the same embedded path are an error. Bun embeds only regular files; it skips symlinks and empty subdirectories inside the tree. You can also embed individual files via the `with { type: "file" }` import attribute or, for files that use the `file` loader (images, fonts, and so on) as well as `.wasm` and `.node` files, by adding them as extra entry points. Bun renames imported assets according to `--asset-naming` (default `[name]-[hash].[ext]`): diff --git a/packages/bun-types/bun.d.ts b/packages/bun-types/bun.d.ts index 5379ed670831..5ecff8c3a09d 100644 --- a/packages/bun-types/bun.d.ts +++ b/packages/bun-types/bun.d.ts @@ -3740,9 +3740,10 @@ declare module "bun" { executablePath?: string; outfile?: string; /** - * Files or directories to embed into the executable under their original - * relative paths. At runtime they are reachable via `node:fs` and - * `Bun.file()` relative to `import.meta.dir`. + * Files or directories to embed into the executable. Each one is mounted + * under its basename, next to the entry point: `"./build/client"` lands at + * `path.join(import.meta.dir, "client")`. At runtime the files are + * reachable via `node:fs` and `Bun.file()`. * * Equivalent CLI flag: `--asset` (repeatable) */ diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index ab9c5b07e06b..664e7127915d 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -440,7 +440,7 @@ pub(crate) const BUILD_ONLY_PARAMS: &[ParamType] = concat_params!( "--compile-executable-path Path to a Bun executable to use for cross-compilation instead of downloading" ), parse_param!( - "--asset ... Embed a file or directory into the compiled executable, preserving its relative path (requires --compile)" + "--asset ... Embed a file or directory into the compiled executable under its basename, next to the entry point (requires --compile)" ), parse_param!("--bytecode Use a bytecode cache"), parse_param!( From 5df28e492603afd28e828debb4f2bafab2f57bb6 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:45:58 +0000 Subject: [PATCH 14/24] cli: read `--conditions` for every command, so node emulation applies it (#44611) --- src/runtime/cli/Arguments.rs | 13 +++---------- test/cli/run/as-node.test.ts | 26 ++++++++++++++++++++++++++ 2 files changed, 29 insertions(+), 10 deletions(-) diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 664e7127915d..653b9dcf3cd2 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -1032,16 +1032,9 @@ pub(crate) fn parse(cmd: CommandTag, ctx: Context<'_>) -> crate::Result { ); }); + describe("--conditions", () => { + const files = { + "node_modules/condpkg/package.json": JSON.stringify({ + name: "condpkg", + exports: { ".": { custom: "./custom.js", default: "./default.js" } }, + }), + "node_modules/condpkg/custom.js": "module.exports = 'custom';", + "node_modules/condpkg/default.js": "module.exports = 'default';", + "require.cjs": "console.log(require('condpkg'));", + "import.mjs": ` + import value from "condpkg"; + import { createRequire } from "node:module"; + console.log(value, createRequire(import.meta.url)("condpkg")); + `, + }; + test.each([ + [["--conditions=custom", "require.cjs"], "custom"], + [["--conditions", "custom", "require.cjs"], "custom"], + [["--conditions=custom", "import.mjs"], "custom custom"], + [["require.cjs"], "default"], + ])("node %j", (args, expected) => { + using temp = tempDir("fake-node", files); + expect(fakeNodeRun(temp, args)).toEqual({ stdout: expected, stderr: "" }); + }); + }); + // Bare `node` now matches Node.js: a TTY stdin enters the REPL, a // non-TTY stdin (pipe) prints "Missing script". fakeNodeRun's default // stdin is platform-dependent (Windows may inherit a console), so pin From d373fc5ff70d3977837b73c4e61181fc583076c0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:46:02 +0000 Subject: [PATCH 15/24] bun patch --help: add --commit to the custom patches directory example (#45417) --- completions/bun-cli.json | 2 +- .../PackageManager/CommandLineArguments.rs | 2 +- test/cli/install/bun-patch.test.ts | 51 +++++++++++++++++++ 3 files changed, 53 insertions(+), 2 deletions(-) diff --git a/completions/bun-cli.json b/completions/bun-cli.json index e89d30390121..6711e639136e 100644 --- a/completions/bun-cli.json +++ b/completions/bun-cli.json @@ -3885,7 +3885,7 @@ "examples": [ "bun patch jquery", "bun patch --commit 'node_modules/jquery'", - "bun patch --patches-dir 'my-patches' 'node_modules/jquery'" + "bun patch --commit --patches-dir 'my-patches' 'node_modules/jquery'" ], "usage": "Usage: bun patch [flags or options] @", "documentationUrl": "https://bun.com/docs/install/patch.", diff --git a/src/install/PackageManager/CommandLineArguments.rs b/src/install/PackageManager/CommandLineArguments.rs index aac18351dfa9..c765f8b5b3a0 100644 --- a/src/install/PackageManager/CommandLineArguments.rs +++ b/src/install/PackageManager/CommandLineArguments.rs @@ -1047,7 +1047,7 @@ Full documentation is available at https://bun.com/docs/cli/update. bun patch --commit 'node_modules/jquery' Generate a patch file in a custom directory for changes made to jquery - bun patch --patches-dir 'my-patches' 'node_modules/jquery' + bun patch --commit --patches-dir 'my-patches' 'node_modules/jquery' Full documentation is available at https://bun.com/docs/install/patch. ", diff --git a/test/cli/install/bun-patch.test.ts b/test/cli/install/bun-patch.test.ts index 10203f1bc209..a12b81848d93 100644 --- a/test/cli/install/bun-patch.test.ts +++ b/test/cli/install/bun-patch.test.ts @@ -201,6 +201,57 @@ describe.concurrent("error messages", () => { }); }); +// Each example is run as the help prints it, in a project where `jquery` is an alias of a stand-in. +describe.concurrent("the examples in 'bun patch --help'", () => { + test.each([ + ["Generate a patch file for changes made to jquery", "patches"], + ["Generate a patch file in a custom directory for changes made to jquery", "my-patches"], + ])("%s", async (commitExample, patchesDir) => { + await using project = await installedProject({ dependencies: { jquery: "npm:no-deps@1.0.0" } }); + const { dir, env } = project; + const indexJs = join(dir, "node_modules", "jquery", "index.js"); + const help = (await bunOk(dir, env, "patch", "--help")).split(/\r?\n/).map(line => line.trim()); + + /** Runs the command that the help prints under `description`, without the quotes a shell removes. */ + async function runExample(description: string) { + expect(help).toContain(description); + const [bun, ...args] = help[help.indexOf(description) + 1].split(" "); + expect(bun).toBe("bun"); + await bunOk(dir, env, ...args.map(arg => arg.replace(/^'(.*)'$/, "$1"))); + } + + await runExample("Prepare jquery for patching"); + await Bun.write(indexJs, patched); + await runExample(commitExample); + + expect({ + patchedDependencies: (await Bun.file(join(dir, "package.json")).json()).patchedDependencies, + patchFile: existsSync(join(dir, patchesDir, "no-deps@1.0.0.patch")), + indexJs: await Bun.file(indexJs).text(), + }).toEqual({ + patchedDependencies: { "no-deps@1.0.0": `${patchesDir}/no-deps@1.0.0.patch` }, + patchFile: true, + indexJs: patched, + }); + expect(await Bun.file(join(dir, patchesDir, "no-deps@1.0.0.patch")).text()).toMatchInlineSnapshot(` + "diff --git a/index.js b/index.js + index bb9c6f6876154493527577cd78279aa6bb686ebb..486c735f2b33e6d1beb1de227b99684124172cb9 100644 + --- a/index.js + +++ b/index.js + @@ -1,7 +1 @@ + -module.exports = require(\`./package.json\`); + - + -for (const key of [\`dependencies\`, \`devDependencies\`, \`peerDependencies\`]) { + - for (const dep of Object.keys(module.exports[key] || {})) { + - module.exports[key][dep] = require(dep); + - } + -} + +module.exports = 'patched'; + " + `); + }); +}); + // `bun patch` identifies packages by `name@label`, where a tarball package's label is // the spec it was installed from. These labels used to be formatted into 1024 byte // stack buffers (512 bytes in the installer itself), so a long enough spec crashed From b941e92b9987facd19ccc265e9f8c479fdd5c320 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:46:07 +0000 Subject: [PATCH 16/24] Wake the event loop from the bun test --parallel and REPL signal handlers (#45756) --- src/runtime/cli/repl.rs | 24 ++++-- src/runtime/cli/test/parallel/Coordinator.rs | 37 +++++++-- src/runtime/cli/test/parallel/runner.rs | 10 ++- test/cli/test/parallel-signal.test.ts | 87 ++++++++++++++++++++ test/js/bun/repl/repl.test.ts | 21 +++++ 5 files changed, 162 insertions(+), 17 deletions(-) create mode 100644 test/cli/test/parallel-signal.test.ts diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index 99f8a2f90f1e..ca00b44d6226 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -1085,6 +1085,8 @@ impl<'a> Repl<'a> { #[cfg(unix)] { + SIGINT_WAKES.store(bun_uws::Loop::get(), core::sync::atomic::Ordering::Release); + // Switch to normal terminal mode (has ISIG) so Ctrl+C generates SIGINT let _ = self .tty_state @@ -2845,17 +2847,29 @@ impl<'a> Drop for Repl<'a> { } } -/// The REPL is waiting on a promise and wants SIGINT to cut the wait short (async-signal-safe: atomics only). +/// The REPL is waiting on a promise and wants SIGINT to cut the wait short. static SIGINT_ARMED: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); -/// A SIGINT arrived during such a wait: stop waiting (the signal itself interrupts the loop's poll). Not a -/// VM stop — the VM stays usable. +/// A SIGINT arrived during such a wait: stop waiting. Not a VM stop — the VM stays usable. static SIGINT_DURING_WAIT: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); +/// The loop that wait parks in. The poll retries on EINTR, so the signal does not end it: +/// `sigint_handler` wakes it. +#[cfg(unix)] +static SIGINT_WAKES: core::sync::atomic::AtomicPtr = + core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); +/// Async-signal-safe: atomics and one write to the loop's wakeup fd. #[cfg(unix)] extern "C" fn sigint_handler(_: c_int) { - if SIGINT_ARMED.load(core::sync::atomic::Ordering::Acquire) { - SIGINT_DURING_WAIT.store(true, core::sync::atomic::Ordering::Release); + use core::sync::atomic::Ordering; + if SIGINT_ARMED.load(Ordering::Acquire) { + SIGINT_DURING_WAIT.store(true, Ordering::Release); + let loop_ = SIGINT_WAKES.load(Ordering::Acquire); + if !loop_.is_null() { + // SAFETY: the loop is process-lifetime, and the raw extern is the + // thread-safe entry point (no `&mut Loop` formed off the main thread). + unsafe { bun_uws::us_wakeup_loop(loop_) }; + } } } diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index 25e1a381ad91..93348a1feadc 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -7,7 +7,7 @@ use core::ffi::c_void; #[cfg(unix)] use core::mem::MaybeUninit; -use core::sync::atomic::{AtomicI32, Ordering}; +use core::sync::atomic::{AtomicI32, AtomicPtr, Ordering}; use std::io::Write as _; use bun_core::strings; @@ -1035,17 +1035,33 @@ fn describe_status<'b>(buf: &'b mut [u8; 32], status: &SpawnStatus) -> &'b [u8] } } -/// Coordinator-side SIGINT/SIGTERM handling. The signal handler only sets a -/// flag; `Coordinator::drive` checks it and tears down workers itself so we -/// don't do non-signal-safe work in the handler. Linux PDEATHSIG and the -/// Windows Job Object are the safety net for when the coordinator can't run -/// this (SIGKILL). +/// Coordinator-side SIGINT/SIGTERM handling. The handler records the signal +/// and wakes the loop; `Coordinator::drive` checks it and tears down workers +/// itself so we don't do non-signal-safe work in the handler. Linux PDEATHSIG +/// and the Windows Job Object are the safety net for when the coordinator +/// can't run this (SIGKILL). pub(crate) mod abort_handler { use super::*; /// The signal that asked for the abort, or 0. Windows console events count as SIGINT. pub(crate) static ABORT_SIGNAL: AtomicI32 = AtomicI32::new(0); + /// The loop `Coordinator::drive` ticks. Set by `install`, cleared by `uninstall`. + static LOOP: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); + + /// Signal context on POSIX; the console control thread on Windows. The + /// poll retries on EINTR, so without the wakeup `drive` reads the flag + /// only when something else ends the poll. + fn request_abort(signal: i32) { + ABORT_SIGNAL.store(signal, Ordering::Release); + let loop_ = LOOP.load(Ordering::Acquire); + if !loop_.is_null() { + // SAFETY: the loop is process-lifetime, and the raw extern is the + // thread-safe entry point (no `&mut Loop` formed off the main thread). + unsafe { bun_uws::us_wakeup_loop(loop_) }; + } + } + // PORTING.md §Global mutable state: written once in `install()` (single // call site), read once in `uninstall()`. RacyCell — `sigaction` is POD, // no concurrent access. @@ -1058,7 +1074,7 @@ pub(crate) mod abort_handler { #[cfg(unix)] extern "C" fn posix_handler(sig: i32, _: *const libc::siginfo_t, _: *const c_void) { - ABORT_SIGNAL.store(sig, Ordering::Release); + request_abort(sig); } #[cfg(windows)] @@ -1068,7 +1084,7 @@ pub(crate) mod abort_handler { use bun_sys::windows; match ctrl { windows::CTRL_C_EVENT | windows::CTRL_BREAK_EVENT | windows::CTRL_CLOSE_EVENT => { - ABORT_SIGNAL.store(libc::SIGINT, Ordering::Release); + request_abort(libc::SIGINT); windows::TRUE } _ => windows::FALSE, @@ -1086,7 +1102,9 @@ pub(crate) mod abort_handler { } } - pub(crate) fn install() -> Guard { + /// `loop_` is the loop `Coordinator::drive` ticks. + pub(crate) fn install(loop_: *mut bun_uws::Loop) -> Guard { + LOOP.store(loop_, Ordering::Release); #[cfg(unix)] { // SAFETY: signal handler installation; PREV_* are written before @@ -1146,5 +1164,6 @@ pub(crate) mod abort_handler { bun_sys::windows::FALSE, ); } + LOOP.store(core::ptr::null_mut(), Ordering::Release); } } diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 0272a3d8a68b..a8bf832b7208 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -192,7 +192,13 @@ pub(crate) fn run_as_coordinator( windows_job: Coordinator::create_windows_kill_on_close_job(), }; - let _abort_guard = abort_handler::install(); + // SAFETY: event_loop pointer is valid while vm lives. + let uws_loop = unsafe { + let event_loop = (*vm_ptr).event_loop(); + (*event_loop).ensure_waker(); + (*event_loop).usockets_loop() + }; + let _abort_guard = abort_handler::install(uws_loop); // Patch the Worker→Coordinator backref now that `coord`'s address is fixed. // Access workers through `coord.workers` to avoid a second &mut on the Vec. @@ -210,8 +216,6 @@ pub(crate) fn run_as_coordinator( } } - // SAFETY: event_loop pointer is valid while vm lives. - unsafe { (*(*vm_ptr).event_loop()).ensure_waker() }; // SAFETY: see vm_ptr note above. unsafe { &*vm_ptr }.run_with_api_lock(|| coord.drive()); coord.end_group(); diff --git a/test/cli/test/parallel-signal.test.ts b/test/cli/test/parallel-signal.test.ts new file mode 100644 index 000000000000..750e484e0492 --- /dev/null +++ b/test/cli/test/parallel-signal.test.ts @@ -0,0 +1,87 @@ +// Kept separate from parallel.test.ts for the reason parallel-startup-failure.test.ts gives: +// file-level pass/fail is what the surrounding tooling checks, and that file has cases that +// depend on how fast a debug build starts its workers. + +import { expect, test } from "bun:test"; +import { readFileSync } from "fs"; +import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; + +// With every worker in the middle of a file, nothing arrives on the coordinator's loop, and the +// poll it waits in is not ended by a signal: it retries on EINTR. A handler that only records +// the signal is then acted on when something else turns the loop, which on Linux shows as the +// coordinator's voluntary context switches. Wait for such a turn to pass, so that the signal +// lands at the start of a quiet period and not just before the next turn. +async function waitForCoordinatorToPark(pid: number) { + const switches = () => + Number(/voluntary_ctxt_switches:\s+(\d+)/.exec(readFileSync(`/proc/${pid}/status`, "utf8"))![1]); + const deadline = Date.now() + 3000; + const before = switches(); + while (switches() === before && Date.now() < deadline) await Bun.sleep(1); + let last = switches(); + let quietSince = Date.now(); + while (Date.now() - quietSince < 100 && Date.now() < deadline) { + await Bun.sleep(1); + const now = switches(); + if (now !== last) { + last = now; + quietSince = Date.now(); + } + } +} + +test.skipIf(isWindows).concurrent.each([ + ["SIGTERM", 143], + ["SIGINT", 130], +] as const)( + "--parallel: the coordinator acts on %s at once while every worker is mid-file", + async (signal, code) => { + const fixture = ` + import { test } from "bun:test"; + import { appendFileSync } from "fs"; + test("slow", async () => { + appendFileSync(process.env.PIDS, process.pid + "\\n"); + await Bun.sleep(60000); + }); + `; + using dir = tempDir("parallel-signal", { "a.test.ts": fixture, "b.test.ts": fixture }); + const pids = String(dir) + "/pids.txt"; + + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", "--parallel-delay=0"], + env: { ...bunEnv, PIDS: pids }, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + + const workersRunning = () => { + try { + return readFileSync(pids, "utf8").split("\n").filter(Boolean).length; + } catch { + return 0; + } + }; + while (workersRunning() < 2 && proc.exitCode === null) await Bun.sleep(10); + expect(workersRunning()).toBe(2); + if (isLinux) await waitForCoordinatorToPark(proc.pid); + + // "Interrupted" is the first thing the coordinator prints once it has seen the signal. The + // time up to that leaves out how long the processes take to go away. + const stderr = proc.stderr.getReader(); + const decoder = new TextDecoder(); + let seen = ""; + const start = performance.now(); + proc.kill(signal); + while (!seen.includes("Interrupted")) { + const { value, done } = await stderr.read(); + if (done) break; + seen += decoder.decode(value, { stream: true }); + } + const reactedAfterMs = performance.now() - start; + + expect(seen).toContain("Interrupted"); + expect(reactedAfterMs).toBeLessThan(500); + expect(await proc.exited).toBe(code); + }, + 30_000, +); diff --git a/test/js/bun/repl/repl.test.ts b/test/js/bun/repl/repl.test.ts index 5947a8d30a3f..13b4290ce69a 100644 --- a/test/js/bun/repl/repl.test.ts +++ b/test/js/bun/repl/repl.test.ts @@ -1079,6 +1079,27 @@ describe.todoIf(isWindows).concurrent("Bun REPL (Terminal)", () => { }); }); + // The poll that the REPL waits in for a promise is not ended by a signal: it retries on + // EINTR. The timer keeps the loop parked in that poll, and the idle GC timer is off so that + // nothing else ends it. + test("SIGINT interrupts an await while the event loop is parked", async () => { + await withTerminalRepl( + async ({ send, waitFor, proc }) => { + // "parked" is printed from inside the wait, when the REPL's SIGINT handler is installed. + // The echo of this line does not contain the word. + send( + "await new Promise(() => { setTimeout(() => console.log('par' + 'ked'), 1); setTimeout(() => {}, 600000); })\n", + ); + await waitFor("parked"); + proc.kill("SIGINT"); + await waitFor(/\u276f|> /); + send("1 + 1\n"); + await waitFor("2"); + }, + { env: { BUN_GC_TIMER_DISABLE: "1" } }, + ); + }); + test("require works in terminal", async () => { await withTerminalRepl(async ({ send, waitFor }) => { send("typeof require\n"); From 28a2f1ae95d134ef76a28ee792e7b71bd08f79d0 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 00:46:11 +0000 Subject: [PATCH 17/24] macros: give each parse-worker macro VM its own env loader (#45960) --- src/dotenv/env_loader.rs | 15 +++ src/js_parser_jsc/Macro.rs | 13 ++- src/jsc/VirtualMachine.rs | 6 + src/runtime/jsc_hooks.rs | 22 +++- test/bundler/transpiler/macro-test.test.ts | 122 +++++++++++++++++++++ 5 files changed, 173 insertions(+), 5 deletions(-) diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 9e7a680c79f0..dd6a969344ac 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -567,6 +567,21 @@ impl Loader { }) } + /// A copy for a VM on another thread: the map plus the record of what the + /// source already loaded (process env, default and explicit `.env` files). + /// The copy reads only the files the source skipped, into its own map. + pub fn clone_loaded(&self) -> Result { + Ok(Loader { + map: self.map.clone_with_allocator()?, + default_files_loaded: self.default_files_loaded, + custom_files_loaded: self.custom_files_loaded.clone()?, + quiet: self.quiet, + did_load_process: self.did_load_process, + reject_unauthorized: Cell::new(None), + aws_credentials: None, + }) + } + pub fn load_process(&mut self) -> Result<(), AllocError> { if self.did_load_process { return Ok(()); diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index a48a00f5f9e0..b7918323713b 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -442,10 +442,21 @@ impl Macro { // JSC needs to be initialized if building from CLI jsc::initialize(jsc::InitializeOptions::default()); + // `env` is the build's loader. Every parse thread shares it, and the + // build thread reads it while this thread parses. This VM's transpiler + // loads `.env` files into its loader, so it gets a copy, owned by the VM. + // SAFETY: `env` comes from `MacroContext.env`, the transpiler's loader, + // which is live for the whole build. The bundler does not write it + // while parse tasks run. + let owned_env_loader = match unsafe { env.as_ref() } { + Some(env) => Some(Box::new(env.clone_loaded()?)), + None => None, + }; + let _vm = VirtualMachine::init(VirtualMachineInitOptions { transform_options, log: Some(NonNull::from(&mut *log)), - env_loader: NonNull::new(env), + owned_env_loader, is_main_thread: false, ..Default::default() })?; diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 65f0ebc5f6f0..db9bbe3804a0 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -93,6 +93,11 @@ pub struct InitOptions { /// `RuntimeHooks::init_runtime_state` so the high-tier `Transpiler::init` /// reuses the caller's env loader. pub env_loader: Option>, + /// An env loader the VM owns, for a VM whose caller's loader belongs to + /// another thread (a parse-worker macro VM copies the build's loader). + /// The VM's transpiler uses it in place of `env_loader`, and the VM's + /// runtime state frees it on teardown. + pub owned_env_loader: Option>, pub graph: Option<&'static dyn bun_resolver::StandaloneModuleGraph>, /// `--install=...`; the resolver needs it before `configure_linker()` caches package.json. pub global_cache: bun_options_types::global_cache::GlobalCache, @@ -119,6 +124,7 @@ impl Default for InitOptions { debugger: Default::default(), log: None, env_loader: None, + owned_env_loader: None, graph: None, global_cache: bun_options_types::global_cache::GlobalCache::disable, smol: false, diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index 5e6e7e37adbc..b9ba6310cbbb 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -84,6 +84,10 @@ pub(crate) struct RuntimeState { /// leaked per-VM (PORTING.md §Forbidden: leaking only for true /// process-lifetime singletons via `OnceLock`, which a per-VM arena is not). pub(crate) transpiler_arena: Box, + /// The env loader `vm.transpiler` points at when the VM owns its own + /// (`InitOptions::owned_env_loader`). `None` when the transpiler shares + /// the caller's loader. Freed with the state, after the VM. + pub(crate) owned_env_loader: Option>, /// `vm.body_value_pool` — pooled storage for `Body.Value` /// (`Request.body` payloads). /// Boxed because `HiveAllocator` is `Fallback, 256>` @@ -351,6 +355,7 @@ unsafe fn init_runtime_state( // allocations use the same heap as the global allocator and skip the // `mi_heap_new`/`mi_heap_destroy` pair. transpiler_arena: Box::new(bun_alloc::Arena::borrowing_default()), + owned_env_loader: opts.owned_env_loader.take(), body_value_pool: { // `Box::new(ManuallyDrop::new(HiveAllocator::init()))` still builds the // ~100 KB pool in a stack temporary before moving it into the box (see @@ -418,10 +423,19 @@ unsafe fn init_runtime_state( // `Transpiler` (reclaimed in `deinit_runtime_state` after the VM — // and hence `vm.transpiler` — is done). let arena: &'static bun_alloc::Arena = unsafe { &*(*state).transpiler_arena }; - // Forward `opts.env_loader` so the VM - // shares the caller's `DotEnv.Loader` (e.g. `bun test` writes - // `NODE_ENV=test` into it after init). - match bun_bundler::Transpiler::init(arena, log, args, opts.env_loader.map(|p| p.as_ptr())) { + // The VM's own loader when it has one. Otherwise forward + // `opts.env_loader` so the VM shares the caller's `DotEnv.Loader` + // (e.g. `bun test` writes `NODE_ENV=test` into it after init). + // SAFETY: `state` is the unique freshly-boxed `RuntimeState`; the + // inner `Box` payload is heap-stable and, like the arena, + // outlives the `Transpiler`. + let env_loader: Option<*mut bun_dotenv::Loader> = unsafe { + match (*state).owned_env_loader.as_deref_mut() { + Some(owned) => Some(ptr::from_mut(owned)), + None => opts.env_loader.map(|p| p.as_ptr()), + } + }; + match bun_bundler::Transpiler::init(arena, log, args, env_loader) { Ok(transpiler) => { // SAFETY: `vm` is the unique freshly-boxed VM; `transpiler` // field is zero-init'd uninhabited memory (never dropped). diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 4b5254346695..58ee6f1440d6 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -625,3 +625,125 @@ describe("--no-macros", () => { expect(existsSync(path.join(String(dir), "MACRO_RAN"))).toBe(true); }); }); + +// Each parse-worker thread creates its own macro VM at its first macro call. The VM's transpiler +// loads the `.env` files of the cwd into the VM's env loader, so the macro's `process.env` sees +// them. That loader used to be the build's own loader, shared by every worker thread. +describe("the macro VM's env loader is its own", () => { + const macroModules = 8; + const files: Record = { + ".env": Array.from({ length: 5000 }, (_, i) => `VAR_${i}=value_number_${i}`).join("\n") + "\n", + "macro.ts": `export function value(name: string) {\n return name;\n}\n`, + "entry.ts": + Array.from({ length: macroModules }, (_, i) => `import m${i} from "./m${i}.ts";`).join("\n") + + `\nconsole.log(${Array.from({ length: macroModules }, (_, i) => `m${i}`).join(", ")});\n`, + }; + for (let i = 0; i < macroModules; i++) { + files[`m${i}.ts`] = `import { value } from "./macro.ts" with { type: "macro" };\nexport default value("m${i}");\n`; + } + + // Several worker threads load the same `.env` into one map at the same time. A few builds at + // once, each with a fixed pool of eight threads, make the overlap likely in every environment. + test("bun build with several macro modules and a .env does not crash", async () => { + using dir = tempDir("macro-env-loader-race", files); + const builds = 4; + const results = await Promise.all( + Array.from({ length: builds }, async (_, i) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "./entry.ts", "--target=bun", "--outdir", `out${i}`], + env: { ...bunEnv, UV_THREADPOOL_SIZE: "8" }, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stderr, exitCode }; + }), + ); + expect(results).toEqual(results.map(() => ({ stderr: "", exitCode: 0 }))); + for (let i = 0; i < builds; i++) { + const out = await Bun.file(path.join(String(dir), `out${i}`, "entry.js")).text(); + for (let m = 0; m < macroModules; m++) { + expect(out).toContain(`var m${m}_default = "m${m}";`); + } + } + }, 60_000); + + // `bun build` without `--env` does not read `.env`. The macro VM reads it for its own + // `process.env`, and that read must not change what the build's resolver sees. + test.concurrent("a macro's .env load does not reach the build's resolver", async () => { + using dir = tempDir("macro-env-loader-isolated", { + "vendor/dep/index.ts": `export const dep = "from NODE_PATH";\n`, + "macro.ts": `export function value(name: string) {\n return name;\n}\n`, + "with-macro.ts": `import { value } from "./macro.ts" with { type: "macro" };\nimport { dep } from "dep";\nconsole.log(value("a"), dep);\n`, + "without-macro.ts": `import { dep } from "dep";\nconsole.log("b", dep);\n`, + }); + await Bun.write(path.join(String(dir), ".env"), `NODE_PATH=${path.join(String(dir), "vendor")}\n`); + const build = async (entry: string) => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", entry, "--target=bun"], + env: bunEnv, + cwd: String(dir), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { + resolved: stdout.includes("from NODE_PATH"), + unresolved: stderr.includes('Could not resolve: "dep"'), + exitCode, + }; + }; + const [withMacro, withoutMacro] = await Promise.all([build("./with-macro.ts"), build("./without-macro.ts")]); + expect(withoutMacro).toEqual({ resolved: false, unresolved: true, exitCode: 1 }); + expect(withMacro).toEqual(withoutMacro); + }); + + test.concurrent("a macro reads .env through its own loader", async () => { + using dir = tempDir("macro-env-loader-reads-dotenv", { + ".env": "FROM_DOTENV=hello\n", + "macro.ts": `export function fromDotenv() {\n return process.env.FROM_DOTENV ?? "unset";\n}\n`, + "entry.ts": `import { fromDotenv } from "./macro.ts" with { type: "macro" };\nconsole.log(fromDotenv());\n`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "./entry.ts", "--target=bun"], + 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({ lastLine: stdout.trim().split("\n").pop(), exitCode }).toEqual({ + lastLine: `console.log("hello");`, + exitCode: 0, + }); + }); + + // The copy carries the record of what the program's loader already read. `Bun.build()` in a + // program that loaded `.env` at start does not read it again. + test.concurrent("Bun.build() gives the macro VM a copy of the program's env", async () => { + using dir = tempDir("macro-env-loader-build-api", { + ".env": "FROM_DOTENV=hello\n", + "macro.ts": `export function vars() {\n return [process.env.FROM_DOTENV ?? "unset", process.env.ADDED_AFTER_START ?? "unset"].join(",");\n}\n`, + "entry.ts": `import { vars } from "./macro.ts" with { type: "macro" };\nconsole.log(vars());\n`, + "build.ts": ` + import { appendFileSync } from "node:fs"; + appendFileSync(".env", "ADDED_AFTER_START=yes\\n"); + const result = await Bun.build({ entrypoints: ["./entry.ts"], target: "bun" }); + console.log(await result.outputs[0].text()); + `, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "run", "build.ts"], + 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({ lastLine: stdout.trim().split("\n").pop(), exitCode }).toEqual({ + lastLine: `console.log("hello,unset");`, + exitCode: 0, + }); + }); +}); From 10e06565f1b6e5076376183d4533f5d887b47246 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 01:53:08 +0000 Subject: [PATCH 18/24] cli: port the rest of the chosen pull requests --- Cargo.lock | 2 +- completions/bun.bash | 107 ++-- completions/bun.fish | 28 +- completions/bun.zsh | 8 +- docs/bundler/esbuild.mdx | 2 +- docs/bundler/executables.mdx | 2 +- docs/bundler/index.mdx | 2 +- docs/pm/filter.mdx | 3 + docs/runtime/bunfig.mdx | 46 +- docs/runtime/environment-variables.mdx | 2 + docs/runtime/index.mdx | 4 +- docs/runtime/jsx.mdx | 2 +- docs/runtime/templating/init.mdx | 2 +- docs/test/configuration.mdx | 24 + docs/test/discovery.mdx | 9 + docs/test/parallel.mdx | 10 +- src/bun_core/Progress.rs | 4 +- src/bun_core/output.rs | 19 +- src/bun_core/util.rs | 20 + src/bundler/options.rs | 6 +- src/bundler/transpiler.rs | 10 + src/bunfig/Cargo.toml | 1 + src/bunfig/arguments.rs | 96 +++- src/bunfig/bunfig.rs | 230 ++++++--- src/clap/comptime.rs | 9 + src/clap/lib.rs | 7 +- src/clap/streaming.rs | 4 + src/crash_handler/lib.rs | 2 +- src/dotenv/env_loader.rs | 123 +++-- src/dotenv/lib.rs | 4 +- src/glob/lib.rs | 13 + src/install/PackageManager.rs | 19 +- .../PackageManagerDirectories.rs | 1 - src/install/PackageManager/ProgressStrings.rs | 1 - .../PackageManager/install_with_manager.rs | 1 - src/install/hoisted_install.rs | 1 - src/install/isolated_install.rs | 1 - src/install/lockfile/Package.rs | 6 +- src/jsc/VirtualMachine.rs | 21 +- src/options_types/context.rs | 52 ++ src/options_types/jsx.rs | 3 + src/options_types/schema.rs | 6 +- src/resolver/resolver.rs | 7 +- src/runtime/api/BunObject.rs | 6 + src/runtime/api/JSBundler.rs | 7 +- src/runtime/cli/Arguments.rs | 90 +++- src/runtime/cli/build_command.rs | 3 +- src/runtime/cli/bunx_command.rs | 8 +- src/runtime/cli/create_command.rs | 89 +--- src/runtime/cli/filter_arg.rs | 89 +++- src/runtime/cli/filter_run.rs | 138 +---- src/runtime/cli/init_command.rs | 471 ++++-------------- src/runtime/cli/lint_command.rs | 4 +- src/runtime/cli/mod.rs | 137 +++-- src/runtime/cli/multi_run.rs | 95 ++-- src/runtime/cli/pack_command.rs | 1 - src/runtime/cli/pm_trusted_command.rs | 1 - src/runtime/cli/run_command.rs | 301 ++++++----- src/runtime/cli/test/Scanner.rs | 14 +- src/runtime/cli/test/parallel/Coordinator.rs | 17 +- src/runtime/cli/test/parallel/Frame.rs | 3 + src/runtime/cli/test/parallel/runner.rs | 27 +- src/runtime/cli/test_command.rs | 40 +- src/runtime/cli/upgrade_command.rs | 22 +- src/runtime/node/node_process.rs | 17 +- src/spawn/Cargo.toml | 1 - src/spawn/lib.rs | 2 +- src/spawn/process.rs | 4 +- src/spawn_sys/spawn_process.rs | 1 + src/sys/lib.rs | 15 + test/bundler/bun-build-api.test.ts | 18 +- test/bundler/bundler_compile_autoload.test.ts | 29 ++ test/bundler/bundler_jsx.test.ts | 32 ++ test/bundler/bundler_minify.test.ts | 40 +- test/bundler/cli.test.ts | 17 + test/bundler/compile-argv.test.ts | 30 ++ .../transpiler/jsx-tsconfig-react-jsx.test.ts | 21 + test/cli/bun.test.ts | 224 +++++++++ test/cli/bunfig-test-options.test.ts | 212 +++++++- test/cli/hot/hot.test.ts | 42 +- test/cli/init/init.test.ts | 188 ++++++- test/cli/install/bun-create.test.ts | 16 + test/cli/install/bun-install.test.ts | 43 +- test/cli/install/bun-run-bunfig.test.ts | 175 ++++++- test/cli/install/bun-run.test.ts | 96 +++- test/cli/install/bun-upgrade.test.ts | 22 + test/cli/install/bunx.test.ts | 82 +++ test/cli/run/as-node.test.ts | 22 +- test/cli/run/env.test.ts | 79 +++ test/cli/run/filter-workspace.test.ts | 459 +++++++++++++---- test/cli/run/if-present.test.ts | 30 +- test/cli/run/multi-run.test.ts | 55 ++ test/cli/run/no-envfile.test.ts | 105 +++- test/cli/run/run-autoinstall.test.ts | 10 +- test/cli/run/run-eval.test.ts | 64 +++ test/cli/run/run-extensionless.test.ts | 22 +- test/cli/run/run_command.test.ts | 60 +++ test/cli/test/coverage.test.ts | 32 ++ test/cli/test/parallel.test.ts | 41 ++ test/cli/test/test-changed.test.ts | 18 + test/cli/watch/watch.test.ts | 122 ++++- test/config/bunfig/preload.test.ts | 103 +++- test/js/bun/resolve/resolve.test.ts | 109 ++++ test/js/bun/spawn/spawn.test.ts | 21 + test/js/junit-reporter/junit.test.js | 22 + test/js/node/process/process.test.js | 16 + test/js/web/console/console-log.test.ts | 31 +- 107 files changed, 3995 insertions(+), 1236 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 91c05b2f032f..7348df1f830c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,6 +304,7 @@ dependencies = [ "bun_clap", "bun_collections", "bun_core", + "bun_glob", "bun_install_types", "bun_io", "bun_options_types", @@ -1770,7 +1771,6 @@ dependencies = [ "bun_alloc", "bun_analytics", "bun_core", - "bun_crash_handler", "bun_dispatch", "bun_errno", "bun_event_loop", diff --git a/completions/bun.bash b/completions/bun.bash index e512a7ff1bf5..7f4898d29757 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -1,17 +1,19 @@ -#/usr/bin/env bash - +# adds the directories, and the files that match the pattern _file_arguments() { - local extensions="${1}" - local reset=$(shopt -p globstar) - shopt -s globstar + local pattern="${1}"; + local file; - if [[ -z "${cur_word}" ]]; then - COMPREPLY=( $(compgen -fG -X "${extensions}" -- "${cur_word}") ); - else - COMPREPLY=( $(compgen -f -X "${extensions}" -- "${cur_word}") ); - fi + for file in $(compgen -f -- "${cur_word}"); do + if [[ -d "${file}" ]]; then + # a subcommand goes before a directory of the same name: `bun te` gives `bun test ` + [[ " ${COMPREPLY[*]} " == *" ${file} "* ]] || COMPREPLY+=( "${file}/" ); + elif [[ "${file}" == ${pattern} ]]; then + COMPREPLY+=( "${file}" ); + fi + done - $reset + # no space after a directory, so that the next lists what is in it + [[ "${#COMPREPLY[@]}" -eq 1 && "${COMPREPLY[0]}" == */ ]] && compopt -o nospace 2>/dev/null; } _long_short_completion() { @@ -69,17 +71,6 @@ _read_scripts_in_package_json() { [[ -n "${keep}" ]] && filtered_reply+=( "${reply_word}" ); done COMPREPLY=( "${filtered_reply[@]}" ); - replaced_script="${prev}"; - } -} - - -_subcommand_comp_reply() { - local cur_word="${1}" - local sub_commands="${2}" - local regexp_subcommand="^[dbcriauh]"; - [[ "${prev}" =~ ${regexp_subcommand} ]] && { - COMPREPLY+=( $(compgen -W "${sub_commands}" -- "${cur_word}") ); } } @@ -90,9 +81,10 @@ _bun_completions() { declare -A PM_OPTIONS; local SUBCOMMANDS="dev bun create run install add remove upgrade completions discord help init pm x test repl update audit dedupe prune outdated publish link unlink build check lint fmt"; + local RUNNABLE_FILES="*.@(js|mjs|cjs|ts|mts|cts|jsx|tsx|wasm|html)"; - GLOBAL_OPTIONS[LONG_OPTIONS]="--use --cwd --bunfile --server-bunfile --config --disable-react-fast-refresh --disable-hmr --env-file --extension-order --jsx-factory --jsx-fragment --extension-order --jsx-factory --jsx-fragment --jsx-import-source --jsx-production --jsx-runtime --main-fields --no-summary --version --platform --public-dir --tsconfig-override --define --external --help --inject --loader --origin --port"; - GLOBAL_OPTIONS[SHORT_OPTIONS]="-c -v -d -e -h -i -l -u -p"; + GLOBAL_OPTIONS[LONG_OPTIONS]="--use --cwd --bunfile --server-bunfile --config --disable-react-fast-refresh --disable-hmr --env-file --extension-order --jsx-factory --jsx-fragment --extension-order --jsx-factory --jsx-fragment --jsx-import-source --jsx-production --jsx-runtime --main-fields --no-summary --version --platform --public-dir --tsconfig-override --define --external --help --inject --loader --origin --port --hot --watch --no-clear-screen --smol --bun --silent --preload --require --import --inspect --inspect-brk --inspect-wait"; + GLOBAL_OPTIONS[SHORT_OPTIONS]="-c -v -d -e -h -i -l -u -p -b -r"; PACKAGE_OPTIONS[ADD_OPTIONS_LONG]="--development --optional --peer --save-prod --catalog --filter"; PACKAGE_OPTIONS[ADD_OPTIONS_SHORT]="-d -F"; @@ -116,13 +108,26 @@ _bun_completions() { local cur_word="${COMP_WORDS[${COMP_CWORD}]}"; local prev="${COMP_WORDS[$(( COMP_CWORD - 1 ))]}"; + # the subcommand or file: the first word that is not a flag or the value of one (`bun --hot run`, `bun --cwd dir run`) + local first_word="" i; + for (( i=1; i < COMP_CWORD; i++ )); do + case "${COMP_WORDS[i]}" in + # readline splits `--flag=value` and `K:V` at the `=` and the `:` + =|:) (( i++ ));; + --cwd|--env-file|--preload|--require|--import|--filter|--define|--loader|--port|--conditions|--tsconfig-override|-r|-F|-d|-l) + (( i++ )); [[ "${COMP_WORDS[i]}" == "=" ]] && (( i++ ));; + -*) ;; + *) first_word="${COMP_WORDS[i]}"; break;; + esac + done + case "${prev}" in help|--help|-h|-v|--version) return;; - -c|--config) _file_arguments "!*.toml" && return;; - --bunfile) _file_arguments "!*.bun" && return;; - --server-bunfile) _file_arguments "!*.server.bun" && return;; + -c|--config) _file_arguments "*.toml"; return;; + --bunfile) _file_arguments "*.bun"; return;; + --server-bunfile) _file_arguments "*.server.bun"; return;; --backend) - case "${COMP_WORDS[1]}" in + case "${first_word}" in a|add|remove|rm|install|i|dedupe|update|up) COMPREPLY=( $(compgen -W "clonefile copyfile hardlink clonefile_each_dir symlink" -- "${cur_word}") ); ;; @@ -132,7 +137,7 @@ _bun_completions() { COMPREPLY=( $(compgen -W "dev optional peer" -- "${cur_word}") ); return;; -F|--filter) - case "${COMP_WORDS[1]}" in + case "${first_word}" in a|add|remove|rm|i|install|prune|update|up) return;; esac ;; @@ -159,8 +164,8 @@ _bun_completions() { return;; esac - case "${COMP_WORDS[1]}" in - help|completions|--help|-h|-v|--version) return;; + case "${first_word}" in + help|completions) return;; add|a) _long_short_completion \ "${PACKAGE_OPTIONS[ADD_OPTIONS_LONG]} ${PACKAGE_OPTIONS[ADD_OPTIONS_SHORT]} ${PACKAGE_OPTIONS[SHARED_OPTIONS_LONG]} ${PACKAGE_OPTIONS[SHARED_OPTIONS_SHORT]}" \ @@ -219,45 +224,31 @@ _bun_completions() { COMPREPLY=( $(compgen -W "--help -h --eval -e --print -p --preload -r --smol --config -c --cwd --env-file --no-env-file" -- "${cur_word}") ); return;; run) - _file_arguments "!(*.@(js|ts|jsx|tsx|mjs|cjs)?($|))"; - COMPREPLY+=( $(compgen -W "--version --cwd --help --silent -v -h" -- "${cur_word}" ) ); _read_scripts_in_package_json; + COMPREPLY+=( $(compgen -W "--version --cwd --help --silent -v -h" -- "${cur_word}" ) ); + _file_arguments "${RUNNABLE_FILES}"; return;; pm) _long_short_completion \ "${PM_OPTIONS[LONG_OPTIONS]} ${PM_OPTIONS[SHORT_OPTIONS]}"; COMPREPLY+=( $(compgen -W "bin ls licenses cache hash hash-print hash-string" -- "${cur_word}") ); return;; - *) - local replaced_script; + "") _long_short_completion \ "${GLOBAL_OPTIONS[*]}" \ "${GLOBAL_OPTIONS[SHORT_OPTIONS]}" - - _read_scripts_in_package_json; - _subcommand_comp_reply "${cur_word}" "${SUBCOMMANDS}"; - - # determine if completion should be continued - # when the current word is an empty string - # the previous word is not part of the allowed completion - # the previous word is not an argument to the last two option - [[ -z "${cur_word}" ]] && { - local prev_in_reply=""; - local reply_word; - for reply_word in "${COMPREPLY[@]}"; do - [[ "${reply_word}" == "${prev}" ]] && { prev_in_reply=1; break; }; - done - [[ -z "${prev_in_reply}" ]] && { - local re_prev_prev="(^| )${COMP_WORDS[(( COMP_CWORD - 2 ))]}($| )"; - local global_option_with_extra_args="--bunfile --server-bunfile --config --port --cwd --public-dir --jsx-runtime --platform --loader"; - [[ - ( -n "${replaced_script}" && "${replaced_script}" == "${prev}" ) || \ - ( "${global_option_with_extra_args}" =~ ${re_prev_prev} ) - ]] && return; - unset COMPREPLY; - } + [[ "${cur_word}" != -* ]] && { + _read_scripts_in_package_json; + COMPREPLY+=( $(compgen -W "${SUBCOMMANDS}" -- "${cur_word}") ); + _file_arguments "${RUNNABLE_FILES}"; } return;; + *) + _long_short_completion \ + "${GLOBAL_OPTIONS[*]}" \ + "${GLOBAL_OPTIONS[SHORT_OPTIONS]}" + [[ "${cur_word}" != -* ]] && _file_arguments "*"; + return;; esac } diff --git a/completions/bun.fish b/completions/bun.fish index 2993fd63b061..79957667ecb5 100644 --- a/completions/bun.fish +++ b/completions/bun.fish @@ -38,6 +38,7 @@ set -l bun_install_boolean_flags_descriptions "Write a yarn.lock file (yarn v1)" set -l bun_builtin_cmds_without_run dev create help bun upgrade discord install remove add update audit dedupe prune publish init pm x repl check lint fmt # The package manager commands and `bun x` reject a flag they do not have. set -l bun_builtin_cmds_accepting_flags create help bun upgrade discord run init +set -l bun_cmds_without_files create c help upgrade discord install i ci add a remove rm update up audit dedupe prune pm x repl link unlink outdated why info exec completions function __bun_complete_bins_scripts --inherit-variable bun_builtin_cmds_without_run -d "Emit bun completions for bins and scripts" # Do nothing if we already have a builtin subcommand, @@ -79,6 +80,9 @@ complete -e -c bun # Dynamically emit scripts and binaries complete -c bun -f -a "(__bun_complete_bins_scripts)" +# Complete files for `bun `, `bun run `, `bun test ` and the arguments of a script. +complete -c bun -n "not __fish_seen_subcommand_from $bun_cmds_without_files" -F + # Complete flags if we have no subcommand or a flag-friendly one. set -l flag_applies "__fish_use_subcommand; or __fish_seen_subcommand_from $bun_builtin_cmds_accepting_flags" complete -c bun \ @@ -92,7 +96,29 @@ complete -c bun \ complete -c bun \ -n $flag_applies --no-files -l 'use' -r -d 'Use a framework (ex: next)' complete -c bun \ - -n $flag_applies --no-files -l 'hot' -r -d 'Enable hot reloading in Bun\'s JavaScript runtime' + -n $flag_applies --no-files -l 'hot' -d 'Enable hot reloading in Bun\'s JavaScript runtime' +complete -c bun \ + -n $flag_applies --no-files -l 'watch' -d 'Automatically restart the process on file change' +complete -c bun \ + -n $flag_applies --no-files -l 'no-clear-screen' -d 'Disable clearing the terminal screen on reload when --hot or --watch is enabled' +complete -c bun \ + -n $flag_applies --no-files -l 'smol' -d 'Use less memory, but run garbage collection more often' +complete -c bun \ + -n $flag_applies --no-files -s 'b' -l 'bun' -d 'Force a script or package to use Bun\'s runtime instead of Node.js' +complete -c bun \ + -n $flag_applies --no-files -l 'silent' -d 'Don\'t print the script command' +complete -c bun \ + -n $flag_applies --no-files -l 'inspect' -d 'Activate Bun\'s debugger' +complete -c bun \ + -n $flag_applies --no-files -l 'inspect-brk' -d 'Activate Bun\'s debugger, set breakpoint on first line of code and wait' +complete -c bun \ + -n $flag_applies --no-files -l 'inspect-wait' -d 'Activate Bun\'s debugger, wait for a connection before executing' +complete -c bun \ + -n $flag_applies -s 'r' -l 'preload' -r -d 'Import a module before other modules are loaded' +complete -c bun \ + -n $flag_applies -l 'cwd' -r -d 'Absolute path to resolve files & entry points from' +complete -c bun \ + -n $flag_applies -l 'env-file' -r -d 'Load environment variables from the specified file(s)' # Complete dev and create as first subcommand. complete -c bun \ diff --git a/completions/bun.zsh b/completions/bun.zsh index 3c3b27e60f55..055e6b103b67 100644 --- a/completions/bun.zsh +++ b/completions/bun.zsh @@ -1145,7 +1145,7 @@ _bun() { 'help\:"Show all supported flags and commands" ' ) main_commands=($main_commands) - _alternative "$scripts" "args:command:(($main_commands))" "files:files:compadd -a files_list" + _alternative "$scripts" "args:command:(($main_commands))" "files:files:compadd -a files_list" "globbed-files:file:_files -g '*.(js|mjs|cjs|ts|mts|cts|jsx|tsx|wasm|html)'" ;; args) @@ -1375,6 +1375,10 @@ _bun() { ;; esac + ;; + *) + _files + ;; esac @@ -1394,7 +1398,7 @@ _bun_run_param_script_completion() { _alternative "scripts:scripts:compadd -a scripts_list" _alternative "bin:bin:compadd -a bins" - _alternative "files:file:_files -g '*.(js|ts|jsx|tsx|wasm)'" + _alternative "files:file:_files -g '*.(js|mjs|cjs|ts|mts|cts|jsx|tsx|wasm|html)'" } _bun_link_param_package_completion() { diff --git a/docs/bundler/esbuild.mdx b/docs/bundler/esbuild.mdx index 39ccc51a9630..fcb5798e510b 100644 --- a/docs/bundler/esbuild.mdx +++ b/docs/bundler/esbuild.mdx @@ -89,7 +89,7 @@ In Bun's CLI, boolean flags like `--minify` take no argument. Flags that take on | `--minify-syntax` | `--minify-syntax` | | | `--out-extension` | n/a | Not supported | | `--outbase` | `--root` | | -| `--preserve-symlinks` | n/a | Not supported | +| `--preserve-symlinks` | `--preserve-symlinks` | Applies to imports. An entry point that is a symlink is still resolved to its target | | `--public-path` | `--public-path` | | | `--pure` | n/a | Not supported | | `--reserve-props` | n/a | Not supported | diff --git a/docs/bundler/executables.mdx b/docs/bundler/executables.mdx index dfa286d45f38..58b3318de046 100644 --- a/docs/bundler/executables.mdx +++ b/docs/bundler/executables.mdx @@ -445,7 +445,7 @@ BUN_OPTIONS="--smol --cpu-prof-md" ./myapp ## Automatic config loading -Standalone executables can automatically load configuration files from the directory where they are run. By default: +Standalone executables can automatically load configuration files from the directory where they are run. They do not search parent directories. By default: - **`tsconfig.json`** and **`package.json`** loading is **disabled** — these are typically only needed at development time, and the bundler already uses them when compiling - **`.env`** and **`bunfig.toml`** loading is **enabled** — these often contain runtime configuration that may vary per deployment diff --git a/docs/bundler/index.mdx b/docs/bundler/index.mdx index dcbed53d79c0..9ce0fbb211f1 100644 --- a/docs/bundler/index.mdx +++ b/docs/bundler/index.mdx @@ -1200,7 +1200,7 @@ A map of global identifiers to be replaced at build time. Keys of this object ar ### loader -A map of file extensions to built-in loader names. Use this to customize how certain files are loaded. +A map of file extensions to built-in loader names. Use this to customize how certain files are loaded. The empty extension (`""`, or `--loader :tsx` on the command line) is for files that have none. diff --git a/docs/pm/filter.mdx b/docs/pm/filter.mdx index ec8a989f959a..e40162067d47 100644 --- a/docs/pm/filter.mdx +++ b/docs/pm/filter.mdx @@ -159,6 +159,9 @@ bun run --parallel --no-exit-on-error --filter '*' test # Run multiple scripts across all packages bun run --parallel --filter '*' build lint + +# Pass arguments to every script: each one runs as "\n', + "server.ts": ` + import index from "./index.html"; + + using server = Bun.serve({ port: 0, development: false, routes: { "/": index } }); + const html = await (await fetch(server.url)).text(); + const script = await (await fetch(new URL(/src="([^"]+)"/.exec(html)![1], server.url))).text(); + console.log(/console\\.log\\((.*?)\\)/.exec(script)![1]); + `, + }); + const { stdout, exitCode } = await run(String(dir), args); + expect(stdout).toBe(expected + "\n"); + expect(exitCode).toBe(0); + }); + + // A worker of `bun test --parallel` reads no env file when it starts, with or without the switch. + test.concurrent.each([ + ["--no-env-file", ["--no-env-file"], "process.env.API_KEY"], + ["no switch", [], '"from-dot-env"'], + ])("bun test --parallel, %s: a build in a directory with a .env of its own", async (_, flags, expected) => { + const testFile = ` + import { test } from "bun:test"; + import { inlined } from "./inlined.ts"; + + test("build", async () => { + process.chdir(import.meta.dir + "/pkg"); + console.log("INLINED " + (await inlined())); + }); + `; + using dir = tempDir("no-env-file-parallel", { + "pkg/.env": files[".env"], + "pkg/client.ts": files["client.ts"], + "inlined.ts": files["inlined.ts"], + "a.test.ts": testFile, + "b.test.ts": testFile, + }); + const { stdout, stderr, exitCode } = await run(String(dir), ["test", "--parallel=2", ...flags]); + expect([...(stdout + stderr).matchAll(/^INLINED (.*)$/gm)].map(match => match[1])).toEqual([expected, expected]); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/cli/run/run-autoinstall.test.ts b/test/cli/run/run-autoinstall.test.ts index 95ee83d0ac08..7aea1b139c6d 100644 --- a/test/cli/run/run-autoinstall.test.ts +++ b/test/cli/run/run-autoinstall.test.ts @@ -2591,13 +2591,15 @@ describe.concurrent("auto-install is off when a security scanner is configured", ["cannot be parsed", "[install.security\n", unreadable], ["configures a scanner", scanner, configured], ])("a bunfig.toml that %s", (_, bunfig, why) => { + // Run from outside the project: inside it bun reads that file itself, and does not start if it cannot be parsed. test("in the project root", async () => { - expect(await run({ ...project, "app/bunfig.toml": bunfig }, { cwd: "app/src" })).toEqual(refused(why)); + const options = { cwd: "elsewhere", args: ["../app/src/index.js"] }; + expect(await run({ ...project, "app/bunfig.toml": bunfig }, options)).toEqual(refused(why)); }); - test("between the working directory and the project root", async () => { + test("between the importing file and the project root", async () => { const files = { ...project, "app/src/bunfig.toml": bunfig, "app/src/deep/index.js": requireIt }; - expect(await run(files, { cwd: "app/src/deep" })).toEqual(refused(why)); + expect(await run(files, { cwd: "elsewhere", args: ["../app/src/deep/index.js"] })).toEqual(refused(why)); }); test("above the project root is not read", async () => { @@ -2635,7 +2637,7 @@ describe.concurrent("auto-install is off when a security scanner is configured", test("the error of an import statement names the file that cannot be parsed", async () => { const files = { ...project, "app/bunfig.toml": "[install.security\n", "app/src/index.js": `import "no-deps";\n` }; - const result = await run(files, { cwd: "app/src" }); + const result = await run(files, { cwd: "elsewhere", args: ["../app/src/index.js"] }); expect(result.stderr).toContain(unreadable); expect(result.stderr).toMatch(/cannot read or parse "\S+[\\/]bunfig\.toml"/); expect({ ...result, stderr: "" }).toEqual({ stdout: "", stderr: "", exitCode: 1, requests: [] }); diff --git a/test/cli/run/run-eval.test.ts b/test/cli/run/run-eval.test.ts index 0c7462e3b5ec..5f8d80b56763 100644 --- a/test/cli/run/run-eval.test.ts +++ b/test/cli/run/run-eval.test.ts @@ -108,6 +108,70 @@ for (const flag of ["-e", "--print"]) { }); } +describe.concurrent("bun run -e / -p", () => { + async function run(cwd: string, ...args: string[]) { + await using proc = Bun.spawn({ cmd: [bunExe(), "run", ...args], cwd, env: bunEnv, stdout: "pipe", stderr: "pipe" }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout: stdout.replaceAll("\r\n", "\n"), stderr, exitCode }; + } + + test.each([ + [["-e", "console.log(process.argv.length)"], "1\n"], + [["--eval=console.log(1 + 2)"], "3\n"], + [["-p", "1 + 2"], "3\n"], + ])("%j evaluates the code", async (args, stdout) => { + using dir = tempDir("run-eval", { "package.json": JSON.stringify({ scripts: { dev: "echo dev" } }) }); + expect(await run(String(dir), ...args)).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + + test("with a script after it, the script runs", async () => { + using dir = tempDir("run-eval-target", { "package.json": JSON.stringify({ scripts: { dev: "echo dev" } }) }); + expect(await run(String(dir), "-p", "3000", "dev")).toEqual({ + stdout: "dev\n", + stderr: "$ echo dev\n", + exitCode: 0, + }); + }); +}); + +describe.concurrent("a word that names a command, after the flag", () => { + async function run(...args: string[]) { + using dir = tempDir("eval-command-word", {}); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + 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 argv = "JSON.stringify(process.argv.slice(1))"; + + test.each([ + ["--print", "x"], + ["--eval", "test"], + ])("bun %s %s evaluates the word", async (flag, word) => { + const { stdout, stderr, exitCode } = await run(flag, word); + expect(stderr).toContain(`ReferenceError: ${word} is not defined`); + expect(stdout).toBe(""); + expect(exitCode).toBe(1); + }); + + test.each([[[`--print=${argv}`, "test"]], [[`--eval=console.log(${argv})`, "test"]], [["-p", argv, "test"]]])( + "bun %j passes the word to the code", + async args => { + expect(await run(...args)).toEqual({ stdout: `["test"]\n`, stderr: "", exitCode: 0 }); + }, + ); + + test("bun -p test runs the tests", async () => { + const { stdout } = await run("-p", "test"); + expect(stdout).toContain("bun test"); + }); +}); + describe("--print for cjs/esm", () => { test("eval result between esm imports", async () => { let cwd = tmpdirSync(); diff --git a/test/cli/run/run-extensionless.test.ts b/test/cli/run/run-extensionless.test.ts index 9541b6cc4943..9bd1bb49aa62 100644 --- a/test/cli/run/run-extensionless.test.ts +++ b/test/cli/run/run-extensionless.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test"; import { mkdirSync, writeFileSync } from "fs"; -import { bunEnv, bunExe, isWindows, tmpdirSync } from "harness"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import { join } from "path"; describe.concurrent("run-extensionless", () => { @@ -32,4 +32,24 @@ describe.concurrent("run-extensionless", () => { const stdout = await proc.stdout.text(); expect(stdout).toEqual("hello world\n"); }); + + // `(x: T) => x` is a generic arrow function to the ts loader and an unclosed tag to the tsx loader. + test.each([ + ["--loader=:ts", {}, ["--loader=:ts"]], + [`"" = "ts" under [loader] in bunfig.toml`, { "bunfig.toml": `[loader]\n"" = "ts"\n` }, []], + ])("%s sets the loader of a file with no extension", async (_, files, args) => { + using dir = tempDir("run-extensionless-loader", { + ...files, + "cool": `const id = (x: T) => x; console.log(id("hello world"));`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args, "./cool"], + 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: "hello world\n", stderr: "", exitCode: 0 }); + }); }); diff --git a/test/cli/run/run_command.test.ts b/test/cli/run/run_command.test.ts index 1190b3c3ba23..9bce69a96894 100644 --- a/test/cli/run/run_command.test.ts +++ b/test/cli/run/run_command.test.ts @@ -79,6 +79,53 @@ describe("bun", () => { expect(err).toContain('Script not found "nope"'); expect(exitCode).toBe(1); }); + + describe.each(["buildall", "--if-present buildall"])("bun run %s, below a package that has the script", args => { + const run = (packageJson: string) => { + using dir = tempDir("package-json-below-a-package", { + "package.json": JSON.stringify({ name: "root", scripts: { buildall: "echo root" } }), + "packages/a/package.json": packageJson, + }); + const { exitCode, stdout, stderr } = spawnSync({ + cwd: join(String(dir), "packages", "a"), + cmd: [bunExe(), "run", ...args.split(" ")], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + return { stdout: stdout.toString().trim(), stderr: stderr.toString(), exitCode }; + }; + + test("a package.json that fails to parse is an error", () => { + expect(run(`{\n<<<<<<< HEAD\n "scripts": { "buildall": "echo a" }\n}`)).toEqual({ + stdout: "", + stderr: expect.stringContaining(join("packages", "a", "package.json") + ":2:1"), + exitCode: 1, + }); + }); + + test.each(["\n", "[]"])("a package.json that is %j is no package.json", packageJson => { + expect(run(packageJson)).toEqual({ stdout: "root", stderr: "$ echo root\n", exitCode: 0 }); + }); + }); + + test.skipIf(isWindows)("a program in node_modules/.bin runs beside a package.json that fails to parse", () => { + using dir = tempDir("broken-package-json-bin", { + "package.json": "{", + "node_modules/.bin/tool": "#!/bin/sh\necho tool\n", + }); + chmodSync(join(String(dir), "node_modules", ".bin", "tool"), 0o755); + const { exitCode, stdout, stderr } = spawnSync({ + cwd: String(dir), + cmd: [bunExe(), "run", "tool"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + expect(stderr.toString()).toBeEmpty(); + expect(stdout.toString()).toBe("tool\n"); + expect(exitCode).toBe(0); + }); }); // A working directory can be PATH_MAX - 1 bytes long. A file in it whose own path no longer fits a path buffer @@ -142,6 +189,19 @@ describe.concurrent.skipIf(!isLinux)("a working directory close to PATH_MAX", () }); }); +test.skipIf(isWindows)("runs an entry point that is a pipe", async () => { + await using proc = Bun.spawn({ + cmd: ["sh", "-c", `echo 'console.log(process.argv.slice(1).join())' | "$0" /dev/stdin extra`, bunExe()], + env: bunEnv, + 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("/dev/stdin,extra\n"); + expect(exitCode).toBe(0); +}); + test.if(isWindows)("[windows] A file in drive root runs", async () => { const path = "C:\\root-file" + Math.random().toString().slice(2) + ".js"; try { diff --git a/test/cli/test/coverage.test.ts b/test/cli/test/coverage.test.ts index f1caa6bfa312..9691adeabeb7 100644 --- a/test/cli/test/coverage.test.ts +++ b/test/cli/test/coverage.test.ts @@ -695,6 +695,38 @@ test("only imports the function", () => { expect(exitCode).toBe(0); }); +test.concurrent.each([ + { threshold: "0.9", message: true, exitCode: 1 }, + { threshold: "0.25", message: false, exitCode: 0 }, +])("coverageThreshold = $threshold with one of two functions covered exits $exitCode", async expected => { + using dir = tempDir("cov-threshold", { + "bunfig.toml": `[test]\ncoverageSkipTestFiles = true\ncoverageThreshold = ${expected.threshold}\n`, + "subject.ts": `export function called() { + return 1; +} +export function neverCalled() { + return 2; +} +`, + "subject.test.ts": `import { called } from "./subject.ts"; +test("calls one", () => { + called(); +}); +`, + }); + // Without the table, the message is all that says why the run fails. + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--coverage", "--coverage-reporter=lcov"], + env: bunEnv, + cwd: String(dir), + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const message = stderr.includes("error: coverage below coverageThreshold (functions 90.00%, lines 90.00%)\n"); + expect({ threshold: expected.threshold, message, exitCode }).toEqual(expected); +}); + // https://github.com/oven-sh/bun/issues/40586 // Each worker executes a different function of the same module; the merged // report must count a function as covered if any worker ran it. diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index e27cba3868a9..0a628a4438d5 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -1,6 +1,7 @@ import { expect, test } from "bun:test"; import { bunEnv, bunExe, isASAN, isDebug, isLinux, isWindows, normalizeBunSnapshot, tempDir, tls } from "harness"; import { mkfifo } from "mkfifo"; +import { symlinkSync } from "node:fs"; import path from "path"; test("--parallel: each worker has a unique JEST_WORKER_ID and BUN_TEST_WORKER_ID", async () => { @@ -995,6 +996,46 @@ test("--parallel writes new snapshots from every worker", async () => { expect(code2).toBe(0); }); +test("--parallel exits 1 when a worker cannot update an inline snapshot", async () => { + using dir = tempDir("parallel-inline-snapshot-conflict", { + "a.test.js": `import {test,expect} from "bun:test"; test.each([1, 2])("value %i", v => { expect(v).toMatchInlineSnapshot(); });`, + "b.test.js": `import {test,expect} from "bun:test"; test("b",()=>expect(1).toBe(1));`, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2"], + env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0", CI: "false" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PARALLEL"); + expect(stderr).toContain("Failed to update inline snapshot"); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(1); +}); + +test.skipIf(!isLinux)("--parallel exits 1 when a worker cannot write a .snap file", async () => { + using dir = tempDir("parallel-snap-disk-full", { + "a.test.js": `import {test,expect} from "bun:test"; test("a",()=>expect("value").toMatchSnapshot());`, + "b.test.js": `import {test,expect} from "bun:test"; test("b",()=>expect(1).toBe(1));`, + "__snapshots__": {}, + }); + symlinkSync("/dev/full", path.join(String(dir), "__snapshots__", "a.test.js.snap")); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2"], + env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0", CI: "false" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PARALLEL"); + expect(stderr).toContain("error: Failed to write snapshot file"); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(1); +}); + test("--parallel: a test producing a >64MB result line is truncated, not treated as a crash", async () => { // Test name just over the 64MB IPC frame limit → the per-test status line // itself exceeds it. The encoder must truncate so the receiver doesn't drop diff --git a/test/cli/test/test-changed.test.ts b/test/cli/test/test-changed.test.ts index abea2aadcd25..082986d228fc 100644 --- a/test/cli/test/test-changed.test.ts +++ b/test/cli/test/test-changed.test.ts @@ -393,6 +393,24 @@ describe.concurrent("bun test --changed", () => { expect(ranFiles(stderr, testNames)).toEqual(["alias.test.ts", "relative.test.ts"]); expect(exitCode).toBe(0); }); + + test("a WebAssembly read out of bounds throws in a selected test", async () => { + using dir = tempDir("test-changed-wasm-oob", { + "package.json": JSON.stringify({ name: "wasm-oob", type: "module" }), + // (memory 1) (func (export "f") (param i32) (result i32) (i32.load (local.get 0))) + "oob.test.ts": `import { test, expect } from "bun:test"; +const bytes = new Uint8Array([0,0x61,0x73,0x6d,1,0,0,0, 1,6,1,0x60,1,0x7f,1,0x7f, 3,2,1,0, 5,3,1,0,1, 7,5,1,1,0x66,0,0, 0x0a,9,1,7,0,0x20,0,0x28,2,0,0x0b]); +const { f } = new WebAssembly.Instance(new WebAssembly.Module(bytes)).exports; +test("oob", () => expect(() => f(70000)).toThrow(WebAssembly.RuntimeError)); +`, + }); + initRepo(String(dir)); + appendFileSync(join(String(dir), "oob.test.ts"), "// touched\n"); + + const { stderr, exitCode } = await runTestChanged(String(dir)); + expect(stderr).toContain("(pass) oob"); + expect(exitCode).toBe(0); + }); }); // On Windows, `bun test --watch` runs as a parent watcher-manager that diff --git a/test/cli/watch/watch.test.ts b/test/cli/watch/watch.test.ts index 8a871093c192..d69a16c493a4 100644 --- a/test/cli/watch/watch.test.ts +++ b/test/cli/watch/watch.test.ts @@ -24,6 +24,35 @@ function stdoutWaiter(proc: Subprocess<"ignore", "pipe", any>) { }; } +/** Collects what a child of `spawn({ terminal })` draws. */ +function terminalOutput() { + const decoder = new TextDecoder(); + let output = ""; + let closed = false; + let wake: (() => void) | undefined; + return { + options: { + cols: 120, + rows: 24, + data(_: unknown, chunk: Uint8Array) { + output += decoder.decode(chunk, { stream: true }); + wake?.(); + }, + exit() { + closed = true; + wake?.(); + }, + }, + output: () => output, + async waitFor(needle: string) { + while (!output.includes(needle)) { + if (closed) throw new Error(`terminal closed, output so far: ${JSON.stringify(output)}`); + await new Promise(resolve => (wake = resolve)); + } + }, + }; +} + for (const dir of ["dir", "©️"]) { it.todoIf(isBroken && isWindows)( `should watch files${dir === "dir" ? "" : " (non-ascii path)"}`, @@ -344,11 +373,13 @@ it("--watch forces a restart when the kill-signal handler itself never returns", await watchee.exited; }, 30000); -// With colors enabled, a reload also clears the terminal. The forced reload +const clearScreen = "\x1b[2J\x1b[3J\x1b[H"; + +// On a terminal, a reload also clears the screen. The forced reload // runs on the grace thread, which has its own thread-local Output state; the // clear used to write through that thread's never-initialized writers and // segfault instead of restarting. -it("--watch forced restart clears the terminal when colors are enabled", async () => { +it("--watch forced restart clears the terminal", async () => { using dir = tempDir("watch-busy-sigterm-clear-screen", { "busy.js": ` process.on("SIGTERM", () => {}); @@ -359,42 +390,93 @@ it("--watch forced restart clears the terminal when colors are enabled", async ( `, }); - const env: typeof bunEnv = { ...bunEnv, FORCE_COLOR: "1" }; - delete env.NO_COLOR; - // stderr is piped, not inherited: the clear sequence below would otherwise - // wipe the terminal running the test suite. + const screen = terminalOutput(); const proc = spawn({ cmd: [bunExe(), "--watch", "busy.js"], cwd: String(dir), - env, - stdout: "pipe", - stderr: "pipe", + env: { ...bunEnv, NO_COLOR: undefined }, + terminal: screen.options, }); watchee = proc; - const stderr = proc.stderr.text(); - const { waitFor, release, output } = stdoutWaiter(proc); - - await waitFor("iter first"); + await screen.waitFor("iter first"); await Bun.write( join(String(dir), "busy.js"), `process.on("SIGTERM", () => {}); console.log("iter second"); process.exit(0);`, ); + await screen.waitFor("iter second"); + + proc.kill("SIGKILL"); + await proc.exited; + proc.terminal!.close(); + + // A Windows pseudoconsole repaints its screen instead of passing the bytes on. + if (!isWindows) { + const output = screen.output(); + const clearedAt = output.indexOf(clearScreen); + expect(clearedAt).toBeGreaterThan(-1); + expect(output.slice(0, clearedAt)).toContain("iter first"); + expect(output.slice(clearedAt)).toContain("iter second"); + } +}, 30000); + +it("--watch with FORCE_COLOR=1 does not write the screen clear into a pipe", async () => { + using dir = tempDir("watch-no-clear-in-pipe", { + "app.js": `console.log("iter first"); setInterval(() => {}, 1000);`, + }); + + const proc = spawn({ + cmd: [bunExe(), "--watch", "app.js"], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + watchee = proc; + const stderr = proc.stderr.text(); + const { waitFor, release, output } = stdoutWaiter(proc); + + await waitFor("iter first"); + await Bun.write(join(String(dir), "app.js"), `console.log("iter second"); setInterval(() => {}, 1000);`); await waitFor("iter second"); release(); proc.kill("SIGKILL"); await proc.exited; - const clearScreen = "\x1b[2J\x1b[3J\x1b[H"; - expect(output()).toContain(clearScreen); - const [beforeReload, afterReload] = output().split(clearScreen); - expect(beforeReload).toContain("iter first"); - expect(afterReload).toContain("iter second"); - expect(await stderr).toContain(clearScreen); -}, 30000); + expect(output()).toBe("iter first\niter second\n"); + expect(await stderr).not.toContain(clearScreen); +}); + +// `bun --watch server.ts | pino-pretty`: stderr is the terminal, and it is not cleared through it. +it.skipIf(isWindows)("--watch piped into another program leaves the terminal as it is", async () => { + using dir = tempDir("watch-piped-on-terminal", { + "app.js": `console.log("iter first"); setInterval(() => {}, 1000);`, + }); + + const screen = terminalOutput(); + const proc = spawn({ + cmd: ["sh", "-c", `"${bunExe()}" --watch app.js | cat`], + cwd: String(dir), + env: { ...bunEnv, NO_COLOR: undefined }, + terminal: screen.options, + }); + watchee = proc; + + await screen.waitFor("iter first"); + await Bun.write(join(String(dir), "app.js"), `console.log("iter second"); setInterval(() => {}, 1000);`); + await screen.waitFor("iter second"); + + // `sh` leads the process group of the terminal, which `bun` and `cat` are in. + process.kill(-proc.pid, "SIGKILL"); + await proc.exited; + proc.terminal!.close(); + + expect(screen.output()).not.toContain("\x1b[2J"); +}); // execve replaces the process without reaching on_exit(), so the compile // cache must be flushed explicitly on the reload path; otherwise diff --git a/test/config/bunfig/preload.test.ts b/test/config/bunfig/preload.test.ts index 112725f55cad..958f75ec4d2a 100644 --- a/test/config/bunfig/preload.test.ts +++ b/test/config/bunfig/preload.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; import { join, resolve } from "path"; const fixturePath = (...segs: string[]) => resolve(import.meta.dirname, "fixtures", "preload", ...segs); @@ -103,11 +103,11 @@ describe.concurrent("Given a `bunfig.toml` with a list of preloads", () => { // "--preload ./preload3.ts", "--preload=./preload3.ts", + "--preload=./preload3.ts run", + "run --preload ./preload3.ts", + "run --preload=./preload3.ts", // FIXME: Tests are failing due to active bugs // "--preload ./preload3.ts run", - // "--preload=./preload3.ts run", - // "run --preload ./preload3.ts", - // "run --preload=./preload3.ts", ])("When `bun %s index.ts` is run, `--preload` adds the target file to the list of preloads", async args => { const [out, err, code] = await run("index.ts", { args: args.split(" "), cwd: dir }); expect(err).toBe(""); @@ -210,3 +210,98 @@ test.concurrent("Test BUN_INSPECT_PRELOAD is used to set preloads", async () => expect(out).toMatchInlineSnapshot(`"[ "many/preload1.ts" ]"`); expect(code).toBe(0); }); // + +describe.concurrent("Given a working directory without a `bunfig.toml`", () => { + const rootBunfig = `preload = ["./preload.ts"]\n[define]\nFROM_ROOT = "1"\n[test]\npreload = ["./preload.ts"]\n`; + const index = `console.log(typeof FROM_ROOT, typeof FROM_A);`; + const monorepo = { + "package.json": JSON.stringify({ name: "root", workspaces: ["packages/*"] }), + "bunfig.toml": rootBunfig, + "preload.ts": `console.log("root preload");`, + "packages/a/package.json": JSON.stringify({ name: "a" }), + "packages/a/index.ts": index, + "packages/a/src/index.ts": index, + "packages/a/a.test.ts": `test("passes", () => {\n ${index}\n});`, + }; + const fromRoot = "root preload\nnumber undefined"; + const none = "undefined undefined"; + + it.each([ + { name: "`bun ` in a workspace reads the one of the workspace root", args: [], out: fromRoot }, + { name: "`bun run ` in a workspace reads the one of the workspace root", args: ["run"], out: fromRoot }, + { name: "a directory below a workspace reads it too", cwd: "packages/a/src", out: fromRoot }, + { + name: "a directory below a package.json reads the one next to the package.json", + files: { "package.json": `{}`, "scripts/index.ts": index }, + cwd: "scripts", + out: fromRoot, + }, + { + name: "the nearest one counts and the one above it does not", + files: { "packages/a/bunfig.toml": `[define]\nFROM_A = "1"\n` }, + cwd: "packages/a/src", + out: "undefined number", + }, + { + name: "the one above the workspace root does not count", + files: { "package.json": `{}`, "packages/package.json": JSON.stringify({ workspaces: ["*"] }) }, + out: none, + }, + { + name: "a package that is not a workspace of the root does not read the root's", + files: { "package.json": JSON.stringify({ workspaces: ["packages/*", "!packages/a"] }) }, + out: none, + }, + { + name: "a package below a package.json without workspaces does not read the one above", + files: { "package.json": `{}` }, + out: none, + }, + { + name: "a directory in node_modules does not read the project's", + files: { "node_modules/a/index.ts": index }, + cwd: "node_modules/a", + out: none, + }, + ])("$name", async ({ files = {}, cwd = "packages/a", args = [], out }) => { + using dir = tempDir("bunfig-above", { ...monorepo, ...files }); + expect(await run("index.ts", { args, cwd: join(String(dir), cwd) })).toEqual([out, "", 0]); + }); + + it("`bun ` in a workspace fails if the one of the workspace root cannot be parsed", async () => { + using dir = tempDir("bunfig-above", { ...monorepo, "bunfig.toml": `[define\n` }); + const [out, err, code] = await run("index.ts", { cwd: join(String(dir), "packages/a") }); + expect(err).toContain("failed to load bunfig"); + expect(out).toBe(""); + expect(code).toBe(1); + }); + + it("`bun test` in a workspace reads the one of the workspace root", async () => { + using dir = tempDir("bunfig-above", monorepo); + const [out, , code] = await run("a.test.ts", { args: ["test"], cwd: join(String(dir), "packages/a") }); + expect(out).toEndWith(fromRoot); + expect(code).toBe(0); + }); + + it("`bun build` in a workspace reads the one of the workspace root", async () => { + using dir = tempDir("bunfig-above", monorepo); + const [out, err, code] = await run("index.ts", { args: ["build"], cwd: join(String(dir), "packages/a") }); + expect(err).toBe(""); + expect(out).toContain(`console.log("number", typeof FROM_A);`); + expect(code).toBe(0); + }); +}); + +it.concurrent("`--config` in another directory loads preloads from the working directory", async () => { + using dir = tempDir("bunfig-named", { + "config/bunfig.toml": `preload = ["./preload.ts"]`, + "config/preload.ts": `console.log("preload next to the config");`, + "preload.ts": `console.log("preload in the working directory");`, + "index.ts": `console.log("index");`, + }); + expect(await run("index.ts", { args: ["--config=config/bunfig.toml"], cwd: String(dir) })).toEqual([ + "preload in the working directory\nindex", + "", + 0, + ]); +}); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index f4768a73c60a..802c61dfd77e 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -1892,6 +1892,33 @@ describe("tsconfig paths skip `.d.ts` substitutions", () => { }); }); +describe("a syntax error in tsconfig.json", () => { + const files = { + "tsconfig.json": `{ "compilerOptions": { "paths": { "@m/*": ["./src/*"] } } "include": [] }`, + "src/x.ts": `export const x = 1;`, + // Nothing is installed on the fly. + "node_modules/.keep": "", + }; + + test.concurrent("is printed when the entry point fails to load", async () => { + using dir = tempDir("tsconfig-syntax-error", { + ...files, + "entry.ts": `import { x } from "@m/x";\nconsole.log(x);`, + }); + const result = await runWildcardScript(String(dir), "entry.ts"); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain(`Cannot find module '@m/x'`); + expect(result.stderr).toContain(`error: Expected "," but found ""include""`); + expect(result.stderr).toContain(join(String(dir), "tsconfig.json") + ":1:"); + expect(result.exitCode).toBe(1); + }); + + test.concurrent("is not printed by a script that calls process.exit()", async () => { + using dir = tempDir("tsconfig-syntax-error-exit", { ...files, "entry.ts": `console.log("ran"); process.exit(0);` }); + expect(await runWildcardScript(String(dir), "entry.ts")).toEqual({ stdout: "ran", stderr: "", exitCode: 0 }); + }); +}); + it.skipIf(isWindows)("runs a script from a working directory nested 256 directories deep", async () => { using dir = tempDir("resolver-deep-cwd", { ".keep": "" }); const base = realpathSync(String(dir)); @@ -2121,3 +2148,85 @@ test.concurrent.each(["import", "require"])( expect({ stdout, stderr, exitCode }).toEqual({ stdout: "later.mjs\nlater.mjs\n", stderr: "", exitCode: 0 }); }, ); + +describe.concurrent("--preserve-symlinks", () => { + // app/imported.mjs and app/entry.cjs are links to files of shared/. app/node_modules/foo is a link to store/foo. + function makeTree() { + const dir = tempDir("preserve-symlinks", { + "shared/imported.mjs": ` + import { relative } from "node:path"; + import dep from "./dep.mjs"; + console.log(relative(process.cwd(), import.meta.path).replaceAll("\\\\", "/"), dep, import.meta.main); + `, + "shared/entry.cjs": `console.log(require("./dep.mjs").default, require.main === module);`, + "shared/dep.mjs": `export default "shared/dep.mjs";`, + "app/dep.mjs": `export default "app/dep.mjs";`, + "app/main.mjs": `import "./imported.mjs";`, + "app/worker.mjs": `new Worker("./imported.mjs");`, + "app/package-worker.mjs": `new Worker("./node_modules/foo/worker.js");`, + "app/node_modules/bar/index.js": `module.exports = "bar of app";`, + "store/foo/worker.js": `console.log(require("bar"));`, + }); + const root = String(dir); + symlinkSync(join(root, "shared", "imported.mjs"), join(root, "app", "imported.mjs"), "file"); + symlinkSync(join(root, "shared", "entry.cjs"), join(root, "app", "entry.cjs"), "file"); + symlinkSync(join(root, "store", "foo"), join(root, "app", "node_modules", "foo"), "junction"); + return dir; + } + + async function run(args: string[], env: Record = {}) { + using dir = makeTree(); + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: { ...bunEnv, ...env }, + cwd: join(String(dir), "app"), + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + const atTheLink = "imported.mjs app/dep.mjs"; + const atTheTarget = "../shared/imported.mjs shared/dep.mjs"; + + it.each([ + ["an imported link runs where the link is", ["--preserve-symlinks", "main.mjs"], {}, `${atTheLink} false\n`], + ["so it does with NODE_PRESERVE_SYMLINKS=1", ["main.mjs"], { NODE_PRESERVE_SYMLINKS: "1" }, `${atTheLink} false\n`], + ["without the flag it runs where the link points", ["main.mjs"], {}, `${atTheTarget} false\n`], + ["an entry point runs where the link points", ["--preserve-symlinks", "imported.mjs"], {}, `${atTheTarget} true\n`], + ["a worker runs where the link points", ["--preserve-symlinks", "worker.mjs"], {}, `${atTheTarget} false\n`], + [ + "a worker in a linked package finds the packages of the app", + ["--preserve-symlinks", "package-worker.mjs"], + {}, + "bar of app\n", + ], + [ + "with --preserve-symlinks-main an entry point runs where the link is", + ["--preserve-symlinks-main", "imported.mjs"], + {}, + `${atTheLink} true\n`, + ], + [ + "with --preserve-symlinks-main an entry point given without its extension runs where the link is", + ["run", "--preserve-symlinks-main", "./imported"], + {}, + `${atTheLink} true\n`, + ], + [ + "with --preserve-symlinks-main a CommonJS entry point is require.main", + ["--preserve-symlinks-main", "entry.cjs"], + {}, + "app/dep.mjs true\n", + ], + ])("%s", async (_, args, env, stdout) => { + expect(await run(args, env)).toEqual({ stdout, stderr: "", exitCode: 0 }); + }); + + it("bun build bundles an imported link from where the link is", async () => { + const { stdout, exitCode } = await run(["build", "--target=bun", "--preserve-symlinks", "main.mjs"]); + expect(stdout).toContain(`"app/dep.mjs"`); + expect(exitCode).toBe(0); + }); +}); diff --git a/test/js/bun/spawn/spawn.test.ts b/test/js/bun/spawn/spawn.test.ts index da349043f5aa..e30338fc591e 100644 --- a/test/js/bun/spawn/spawn.test.ts +++ b/test/js/bun/spawn/spawn.test.ts @@ -2367,3 +2367,24 @@ describe.skipIf(!isPosix)("a spawn while fd 0, 1 or 2 is closed", () => { }); }); }); + +// An ignored SIGCHLD survives exec. The kernel then reaps a child before its parent can wait for it. +it.skipIf(isWindows)("reports the exit code of a child when the parent process ignores SIGCHLD", async () => { + await using proc = spawn({ + cmd: [ + "sh", + "-c", + 'trap "" CHLD; exec "$0" "$@"', + bunExe(), + "-e", + `console.log(await Bun.spawn(["sh", "-c", "exit 3"]).exited)`, + ], + env: bunEnv, + 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("3\n"); + expect(exitCode).toBe(0); +}); diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 5cf40e2d295d..7a5950c60d0d 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -608,6 +608,28 @@ describe("junit reporter", () => { expect(longPathCase.failure[0]._).toContain(`at fromLongPath (${longPath}:1:`); expect(pathCase.failure[0]._).toContain("at fromPath (generated.js:1:"); }); + + it.each([ + [["--reporter=junit", "--reporter=dots", "--reporter-outfile=junit.xml"], true], + [["--reporter-outfile=junit.xml"], false], + ])("%j writes the report: %p", async (flags, written) => { + await using tmpDir = tempDir("junit-and-dots", { + "package.json": "{}", + "a.test.js": `test("one", () => {});`, + }); + await using proc = spawn([bunExe(), "test", ...flags], { + cwd: String(tmpDir), + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + + expect(stderr).toContain("1 pass"); + if (flags.includes("--reporter=dots")) expect(stderr).not.toContain("(pass)"); + expect(await file(join(String(tmpDir), "junit.xml")).exists()).toBe(written); + expect(exitCode).toBe(0); + }); }); function filterJunitXmlOutput(xmlContent) { diff --git a/test/js/node/process/process.test.js b/test/js/node/process/process.test.js index 15dc93631425..75aa0e03c9c2 100644 --- a/test/js/node/process/process.test.js +++ b/test/js/node/process/process.test.js @@ -2366,6 +2366,22 @@ describe("process.exitCode", () => { ); }); + it.concurrent.each([ + ["and nothing else", ""], + ["and process.exit() in the listener exits with 1", "process.exit();"], + ["and process.exitCode = 0 in the listener still exits with 1", "process.exitCode = 0;"], + ["and process.exitCode = 98 in the listener still exits with 1", "process.exitCode = 98;"], + ])("after an unhandled rejection an exit listener is told 1, %s", async (_, inListener) => { + await runInlineFixture( + ` + process.on("exit", (code) => { console.log("exit", code, process.exitCode); ${inListener} }); + Promise.reject(new Error("oops")); + `, + "exit 1 1\n", + 1, + ); + }); + it("exitsOnExitCodeSet", async () => { await runInlineFixture( ` diff --git a/test/js/web/console/console-log.test.ts b/test/js/web/console/console-log.test.ts index 1152b1cb6cbc..11b203b77f6d 100644 --- a/test/js/web/console/console-log.test.ts +++ b/test/js/web/console/console-log.test.ts @@ -1,6 +1,6 @@ import { file, spawn } from "bun"; import { expect, it } from "bun:test"; -import { bunEnv, bunExe } from "harness"; +import { bunEnv, bunExe, isWindows } from "harness"; import { join } from "node:path"; it("should log to console correctly", async () => { @@ -157,6 +157,35 @@ NamedError: console.error a named error `); }); +it("console.clear() with FORCE_COLOR=1 writes nothing into a pipe", async () => { + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.clear(); console.log('after clear');"], + env: { ...bunEnv, NO_COLOR: undefined, FORCE_COLOR: "1" }, + 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: "after clear\n", stderr: "", exitCode: 0 }); +}); + +// A Windows pseudoconsole repaints its screen instead of passing the bytes on. +it.skipIf(isWindows)("console.clear() clears a terminal", async () => { + let output = ""; + const eof = Promise.withResolvers(); + await using proc = spawn({ + cmd: [bunExe(), "-e", "console.clear()"], + env: { ...bunEnv, NO_COLOR: undefined }, + terminal: { + data: (_terminal, chunk) => void (output += Buffer.from(chunk).toString("latin1")), + exit: () => eof.resolve(), + }, + }); + const exitCode = await proc.exited; + await eof.promise; + expect(output).toBe("\x1b[2J\x1b[3J\x1b[H"); + expect(exitCode).toBe(0); +}); + it("console.log with SharedArrayBuffer", () => { // console.log(x) === Bun.inspect(x) + "\n" written to stdout. expect(Bun.inspect(new ArrayBuffer(0))).toBe("ArrayBuffer(0) []"); From 026a7e0a840afa71175ffaf4c51f5cf3fdf0dac9 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 02:30:00 +0000 Subject: [PATCH 19/24] cli: keep released behavior where the ported changes went past it --- completions/bun.bash | 6 +- docs/runtime/bunfig.mdx | 2 +- src/bunfig/arguments.rs | 18 +- src/dotenv/env_loader.rs | 32 ++- src/install/PackageManager.rs | 18 +- src/js_parser_jsc/Macro.rs | 9 +- src/jsc/VirtualMachine.rs | 8 +- src/resolver/lib.rs | 7 +- src/resolver/resolver.rs | 11 +- src/runtime/cli/Arguments.rs | 12 +- src/runtime/cli/create_command.rs | 6 +- src/runtime/cli/filter_arg.rs | 24 +-- src/runtime/cli/mod.rs | 31 ++- src/runtime/cli/repl.rs | 3 +- src/runtime/cli/run_command.rs | 120 ++++++------ src/runtime/cli/test_command.rs | 23 ++- src/runtime/jsc_hooks.rs | 4 +- src/sys/lib.rs | 15 -- test/bundler/transpiler/macro-test.test.ts | 12 +- test/cli/bun.test.ts | 17 +- test/cli/bunfig-test-options.test.ts | 13 ++ test/cli/install/bun-create.test.ts | 11 +- test/cli/install/bun-patch.test.ts | 51 ----- test/cli/run/filter-workspace.test.ts | 41 ++-- test/cli/run/hide-paths.c | 216 --------------------- test/cli/run/run-shell.test.ts | 74 +++---- test/cli/run/run_command.test.ts | 29 ++- test/cli/test/parallel-signal.test.ts | 87 --------- test/config/bunfig/preload.test.ts | 18 +- test/js/bun/resolve/resolve.test.ts | 12 ++ test/js/junit-reporter/junit.test.js | 2 + 31 files changed, 324 insertions(+), 608 deletions(-) delete mode 100644 test/cli/run/hide-paths.c delete mode 100644 test/cli/test/parallel-signal.test.ts diff --git a/completions/bun.bash b/completions/bun.bash index 7f4898d29757..2072b438b655 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -47,7 +47,7 @@ _read_scripts_in_package_json() { local package_json_compreply; local matched="${BASH_REMATCH[@]:1}"; local scripts="${matched%%\}*}"; - scripts="${scripts//@(\"|\')/}"; + scripts="${scripts//[\"\']/}"; readarray -td, scripts <<<"${scripts}"; for completion in "${scripts[@]}"; do [[ "${completion}" =~ ^[[:space:]]*([[:alnum:]@/:._-]+)[[:space:]]*: ]] && package_json_compreply+=( "${BASH_REMATCH[1]}" ); @@ -58,8 +58,7 @@ _read_scripts_in_package_json() { # when a script is passed as an option, do not show other scripts as part of the completion anymore local re_prev_script="(^| )${prev}($| )"; [[ - ( "${COMPREPLY[*]}" =~ ${re_prev_script} && -n "${COMP_WORDS[2]}" ) || \ - ( "${COMPREPLY[*]}" =~ ${re_comp_word_script} ) + ( "${COMPREPLY[*]}" =~ ${re_prev_script} && -n "${COMP_WORDS[2]}" ) ]] && { local filtered_reply=(); local reply_word script_name keep; @@ -238,7 +237,6 @@ _bun_completions() { "${GLOBAL_OPTIONS[*]}" \ "${GLOBAL_OPTIONS[SHORT_OPTIONS]}" [[ "${cur_word}" != -* ]] && { - _read_scripts_in_package_json; COMPREPLY+=( $(compgen -W "${SUBCOMMANDS}" -- "${cur_word}") ); _file_arguments "${RUNNABLE_FILES}"; } diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 44cc30e9d89d..e82ac6a717d5 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -11,7 +11,7 @@ Bun relies on pre-existing configuration files like `package.json` and `tsconfig Put `bunfig.toml` in your project root, alongside your `package.json`. -`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It does not search parent directories when the working directory is inside `node_modules`. +`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It skips a file in a parent directory that belongs to another user than the working directory, and it does not search parent directories when the working directory is inside `node_modules`. To configure Bun's package manager globally, you can also create a `.bunfig.toml` file at one of the following paths: diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index ba4e71df3a82..8b05ba4f3ee6 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -178,6 +178,20 @@ fn lists_workspace(root: &[u8], dir: &[u8], buf: &mut PathBuffer) -> Option bool { + let Ok(file) = bun_sys::lstat(path) else { + return false; + }; + bun_sys::lstat(&bun_core::ZBox::from_bytes(cwd)).is_ok_and(|dir| dir.st_uid == file.st_uid) +} + +#[cfg(not(unix))] +fn has_owner_of(path: &ZStr, _: &[u8]) -> bool { + bun_sys::exists_z(path) +} + /// The nearest bunfig.toml above `cwd` that is in the same project: not above the nearest package.json, or the workspace root that lists it. fn find_config_above<'buf>(cwd: &[u8], buf: &'buf mut PathBuffer) -> Option<&'buf ZStr> { // A dependency's scripts and a compiled executable are not part of the project they run in. @@ -189,9 +203,7 @@ fn find_config_above<'buf>(cwd: &[u8], buf: &'buf mut PathBuffer) -> Option<&'bu let mut config_dir = cwd; loop { config_dir = bun_core::dirname(config_dir)?; - if bun_sys::exists_z(join_config_path(config_dir, b"bunfig.toml", buf)?) - && !bun_sys::is_shared_directory(config_dir) - { + if has_owner_of(join_config_path(config_dir, b"bunfig.toml", buf)?, cwd) { break; } } diff --git a/src/dotenv/env_loader.rs b/src/dotenv/env_loader.rs index 86152189298a..efb721e58000 100644 --- a/src/dotenv/env_loader.rs +++ b/src/dotenv/env_loader.rs @@ -164,6 +164,9 @@ pub static HAS_NO_CLEAR_SCREEN_CLI_FLAG: OnceLock = OnceLock::new(); static DEFAULT_FILES_DISABLED: AtomicBool = AtomicBool::new(false); +/// Held while a thread copies, adds to or reads a `Loader` that the other threads of a build use too. +static SHARED: bun_core::Mutex<()> = bun_core::Mutex::new(()); + /// `--no-env-file` or bunfig `env = false`: from here on no `Loader` of the process reads a default `.env` file. pub fn disable_default_files() { DEFAULT_FILES_DISABLED.store(true, Ordering::Relaxed); @@ -584,10 +587,9 @@ impl Loader { }) } - /// A copy for a VM on another thread: the map plus the record of what the - /// source already loaded (process env, default and explicit `.env` files). - /// The copy reads only the files the source skipped, into its own map. + /// A copy for a VM on another thread, with the record of what `self` has read, so it reads only the rest. pub fn clone_loaded(&self) -> Result { + let _shared = SHARED.lock(); Ok(Loader { map: self.map.clone_with_allocator()?, default_files_loaded: self.default_files_loaded, @@ -599,6 +601,27 @@ impl Loader { }) } + /// Takes over what `copy`, made by `clone_loaded`, has read from env files since. + pub fn adopt_loaded(&mut self, copy: &Loader) -> Result<(), AllocError> { + let _shared = SHARED.lock(); + for (key, entry) in copy.map.map.iter() { + if self.map.get(key).is_none() { + self.map.put(key, &entry.value)?; + } + } + self.default_files_loaded |= copy.default_files_loaded; + for file in copy.custom_files_loaded.keys() { + self.custom_files_loaded.insert(file)?; + } + Ok(()) + } + + /// `NODE_PATH`, as a copy: the thread of a macro VM may be in `adopt_loaded`. + pub fn node_path(&self) -> Box<[u8]> { + let _shared = SHARED.lock(); + self.get(b"NODE_PATH").map(Box::from).unwrap_or_default() + } + pub fn load_process(&mut self) -> Result<(), AllocError> { if self.did_load_process { return Ok(()); @@ -613,8 +636,7 @@ impl Loader { let key = &env[..i as usize]; let value = &env[i as usize + 1..]; if !key.is_empty() { - // Our inherited IPC channel is not for children spawned from - // this env (Node drops these too); `bun run` re-adds them. + // Not for the children spawned from this env, as in Node. `bun run` adds them again. if is_ipc_channel_key(key) { continue; } diff --git a/src/install/PackageManager.rs b/src/install/PackageManager.rs index 4388b5f86bb9..ce8e603d6514 100644 --- a/src/install/PackageManager.rs +++ b/src/install/PackageManager.rs @@ -2034,7 +2034,7 @@ impl<'a> PackageJsonWalk<'a> { ); if !matches!(&opened, Err(e) if e.get_errno() == bun_sys::E::ENOENT) && dir.len() != self.cwd.len() - && bun_sys::is_shared_directory(dir) + && is_shared_directory(dir) { bun_core::warn!( "ignoring package.json in {}: directory is writable by other users", @@ -2249,6 +2249,22 @@ fn find_workspace_root_above<'a>( Ok(None) } +/// Returns true if `dir` is sticky and group- or world-writable, like `/tmp`. Any local user can +/// create a `package.json` there. +#[cfg(unix)] +fn is_shared_directory(dir: &[u8]) -> bool { + use bun_sys::S; + !bun_sys::stat(&ZBox::from_bytes(dir)).is_ok_and(|st| { + let mode = st.st_mode as bun_sys::Mode; + mode & S::ISVTX == 0 || mode & (S::IWGRP | S::IWOTH) == 0 + }) +} + +#[cfg(not(unix))] +fn is_shared_directory(_: &[u8]) -> bool { + false +} + /// Finds the project that `dir` is in, the way `init` does: the directory of the nearest /// package.json, or the workspace root that this directory belongs to. A package.json that only /// says how to read the files next to it, such as `{ "type": "commonjs" }`, belongs to the project diff --git a/src/js_parser_jsc/Macro.rs b/src/js_parser_jsc/Macro.rs index b7918323713b..6087a7c3f3ef 100644 --- a/src/js_parser_jsc/Macro.rs +++ b/src/js_parser_jsc/Macro.rs @@ -446,8 +446,8 @@ impl Macro { // build thread reads it while this thread parses. This VM's transpiler // loads `.env` files into its loader, so it gets a copy, owned by the VM. // SAFETY: `env` comes from `MacroContext.env`, the transpiler's loader, - // which is live for the whole build. The bundler does not write it - // while parse tasks run. + // which is live for the whole build. While parse tasks run it is written + // only by `adopt_loaded`, under the lock that `clone_loaded` takes. let owned_env_loader = match unsafe { env.as_ref() } { Some(env) => Some(Box::new(env.clone_loaded()?)), None => None, @@ -475,6 +475,11 @@ impl Macro { if is_new_vm { // SAFETY: `vm` is the freshly-allocated per-thread VM. unsafe { (*vm).transpiler.configure_defines()? }; + // The build resolves the imports of this module with what the VM read, a `NODE_PATH` in `.env`. + // SAFETY: as for `clone_loaded` above; the VM's loader is its own. + if let Some(env) = unsafe { env.as_mut() } { + env.adopt_loaded(unsafe { (*vm).transpiler.env() })?; + } } // SAFETY: `vm` is the per-thread VM; uniquely accessed here. diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index f1f31b8802c7..9a0d36c773e4 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -93,10 +93,7 @@ pub struct InitOptions { /// `RuntimeHooks::init_runtime_state` so the high-tier `Transpiler::init` /// reuses the caller's env loader. pub env_loader: Option>, - /// An env loader the VM owns, for a VM whose caller's loader belongs to - /// another thread (a parse-worker macro VM copies the build's loader). - /// The VM's transpiler uses it in place of `env_loader`, and the VM's - /// runtime state frees it on teardown. + /// Used in place of `env_loader` and freed with the VM: a macro VM's copy of the loader of the build. pub owned_env_loader: Option>, pub graph: Option<&'static dyn bun_resolver::StandaloneModuleGraph>, /// `--install=...`; the resolver needs it before `configure_linker()` caches package.json. @@ -4335,8 +4332,7 @@ impl VirtualMachine { self.transpiler_store.enabled = false; } - // Not in `map`: `Loader::load_process` drops it so children don't inherit - // the channel. Main thread only (Node: no process.send() in workers). + // `Loader::load_process` keeps it out of `map` so that children don't inherit the channel. if self.is_main_thread() { if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { let advanced = bun_core::env_var::NODE_CHANNEL_SERIALIZATION_MODE diff --git a/src/resolver/lib.rs b/src/resolver/lib.rs index 466c2aa92824..9b535d3c45be 100644 --- a/src/resolver/lib.rs +++ b/src/resolver/lib.rs @@ -1741,7 +1741,12 @@ pub mod dir_entry_accessor { impl AccessorDirEntry for DirEntryIterResult { fn name_slice(&self) -> &[u8] { - self.entry.base() + // On disks that ignore case the lower-case name opens, and lower-case patterns match it. + if cfg!(any(windows, target_os = "macos")) { + self.entry.base_lowercase() + } else { + self.entry.base() + } } fn kind(&self) -> bun_sys::FileKind { self.kind diff --git a/src/resolver/resolver.rs b/src/resolver/resolver.rs index 65190eec22e8..dc5f1d78fddf 100644 --- a/src/resolver/resolver.rs +++ b/src/resolver/resolver.rs @@ -1643,9 +1643,8 @@ impl<'a> Resolver<'a> { } } - // Imports only, as in Node. For `EntryPointRun` the option holds `--preserve-symlinks-main`. - let preserve_symlinks = - self.opts.preserve_symlinks && kind != ast::ImportKind::EntryPointBuild; + // Imports only, as in Node. An entry point that is a link keeps the link's path in `pretty`. + let preserve_symlinks = self.opts.preserve_symlinks && !kind.is_entry_point(); // Probe the listing in one `entries_mutex` critical section: a // concurrent resolver at a newer generation rewrites this `DirEntry`'s @@ -2873,10 +2872,10 @@ impl<'a> Resolver<'a> { // try resolve from `NODE_PATH` // https://nodejs.org/api/modules.html#loading-from-the-global-folders - let node_path: &[u8] = self + let node_path: Box<[u8]> = self .env_loader() - .and_then(|env| env.get(b"NODE_PATH")) - .unwrap_or(b""); + .map(DotEnv::Loader::node_path) + .unwrap_or_default(); if !node_path.is_empty() { let delim = if cfg!(windows) { b';' } else { b':' }; for path in node_path.split(|&b| b == delim).filter(|s| !s.is_empty()) { diff --git a/src/runtime/cli/Arguments.rs b/src/runtime/cli/Arguments.rs index 77491a4952eb..21db02b7b43d 100644 --- a/src/runtime/cli/Arguments.rs +++ b/src/runtime/cli/Arguments.rs @@ -1820,7 +1820,17 @@ fn parse_test_command_options(args: &clap::Args, ctx: Context<'_>) { ctx.test_options.reporter_outfile = Some(reporter_outfile.into()); } - for &reporter in args.options(b"--reporter") { + if let Some((&reporter, earlier)) = args.options(b"--reporter").split_last() { + // Only the last one can fail the run. + for &reporter in earlier { + match reporter { + b"junit" if ctx.test_options.reporter_outfile.is_some() => { + ctx.test_options.reporters.junit = true; + } + b"dots" | b"dot" => ctx.test_options.reporters.dots = true, + _ => {} + } + } if reporter == b"junit" { // A `--parallel` worker only collects for the coordinator's file. if ctx.test_options.reporter_outfile.is_none() && !args.flag(b"--test-worker") { diff --git a/src/runtime/cli/create_command.rs b/src/runtime/cli/create_command.rs index 8e7663f83db8..a7e9917a334c 100644 --- a/src/runtime/cli/create_command.rs +++ b/src/runtime/cli/create_command.rs @@ -167,10 +167,8 @@ impl CreateOptions { }; let mut positionals = args.positionals().to_vec(); - // What precedes the command is the value of a flag of `bun`. - let is_command = |it: &&[u8]| matches!(*it, b"c" | b"create"); - if let Some(command) = positionals.iter().position(is_command) { - positionals.drain(..=command); + if !positionals.is_empty() && (positionals[0] == b"c" || positionals[0] == b"create") { + positionals.remove(0); } Ok(CreateOptions { diff --git a/src/runtime/cli/filter_arg.rs b/src/runtime/cli/filter_arg.rs index 544c44b9fa6b..6dc8528727cc 100644 --- a/src/runtime/cli/filter_arg.rs +++ b/src/runtime/cli/filter_arg.rs @@ -18,6 +18,8 @@ use bun_resolver::package_json::{IncludeDependencies, IncludeScripts}; use crate::cli::Command; const SKIP_LIST: &[&[u8]] = &[ + // skip hidden directories + b".", // skip node_modules b"node_modules", // skip .git folder @@ -456,24 +458,6 @@ impl<'a> PackageFilterIterator<'a> { }) } - /// The file system looks up an entry without glob syntax, so it decides whether case matters. - fn find_literal(&self) -> Option { - let pattern: &[u8] = &self.patterns[self.pattern_idx]; - if glob::detect_glob_syntax(pattern) - || strings::split_any(pattern, b"/\\").any(glob_ignore_fn) - { - return None; - } - let mut buf = path_buffer_pool::get(); - let path = resolve_path::join_abs_string_buf_z_checked::( - self.root_dir, - &mut buf[..], - &[pattern], - )?; - let stat = bun_sys::stat(path).ok()?; - bun_sys::is_regular_file(stat.st_mode as bun_sys::Mode).then(|| path.as_bytes().into()) - } - /// As in `bun install`, a `!` entry removes a glob match, but never a path that's listed /// literally. fn is_excluded(&self, package_json_path: &[u8]) -> bool { @@ -492,10 +476,6 @@ impl<'a> PackageFilterIterator<'a> { if self.pattern_idx >= self.patterns.len() { return Ok(None); } - if let Some(path) = self.find_literal() { - self.pattern_idx += 1; - return Ok(Some(path)); - } self.active = Some(self.start_walk()?); continue; }; diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index 62dca9231273..0b2a829b914e 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -745,6 +745,7 @@ pub(crate) mod command { // A flag of `bun` can have its value in the next argument, as in `bun --cwd dir run dev`. let mut after_value = false; + let mut in_packages = false; let name = loop { let Some(arg) = iter.next() else { return Tag::AutoCommand; @@ -756,6 +757,8 @@ pub(crate) mod command { if arg[1..].starts_with(b"e") || matches!(flag, b"--eval" | b"--print") { return Tag::AutoCommand; } + in_packages |= + arg == b"--workspaces" || arg.starts_with(b"--filter") || arg.starts_with(b"-F"); // `--interactive` stays on AutoCommand: Arguments.rs parses it and the no-target check // routes to RunCommand::exec_node_repl. An early ReplCommand return here would bypass // that and boot the legacy `bun repl` implementation instead. @@ -779,13 +782,14 @@ pub(crate) mod command { use super::script_or_command::{Runs, is_of_the_project, runs}; let tag = command_named(name); let project_has_it = match tag { - Tag::AutoCommand | Tag::RunCommand => false, + Tag::AutoCommand => false, + // `bun --workspaces --cwd dir run` runs the `run` script of each package. + Tag::RunCommand => after_value && in_packages, Tag::CheckCommand | Tag::LintCommand | Tag::FormatCommand => is_of_the_project(name), + // `bun create` would ignore the `--cwd`, and a reserved word is no command. + Tag::CreateCommand | Tag::ReservedCommand => after_value, // `bun --cwd dir build` is `bun build` only if the project has no `build` to run. Scripts have `bun` in `PATH`. - _ if after_value => { - tag == Tag::ReservedCommand || name == b"bun" || runs(name) != Runs::Command - } - _ => false, + _ => after_value && (name == b"bun" || runs(name) != Runs::Command), }; match project_has_it { true => Tag::AutoCommand, @@ -1353,7 +1357,9 @@ pub(crate) mod command { // InitCommand parses its own argv (no Context). let argv = argv_zslice(); print_help_if_asked_before(&argv, b"init", Tag::InitCommand); - change_directory_before(b"init"); + if let Some(cwd) = lint_command::cwd_before(b"init") { + lint_command::change_directory(cwd); + } super::init_command::InitCommand::exec(after_command(&argv, b"init")) } @@ -1436,13 +1442,6 @@ pub(crate) mod command { &[] } - /// Applies `--cwd` among the flags of `bun`, which precede `command`, for a command that reads argv itself. - fn change_directory_before(command: &[u8]) { - if let Some(cwd) = lint_command::cwd_before(command) { - lint_command::change_directory(cwd); - } - } - /// The flags of `command` that stand before it, as in `bun --check format` or `bun -c format`, and then what follows it. If /// they were dropped, `--check` would write. `knows` tells whether `command` has a flag. The others are for `bun`, or come /// from `BUN_OPTIONS`. @@ -1745,10 +1744,7 @@ pub(crate) mod command { while remainder_i < remainder.len() && positional_i < positionals.len() { let slice = strings::trim(remainder[remainder_i].as_bytes(), b" \t\n"); if !slice.is_empty() { - // What precedes the command is the value of a flag of `bun`. - if !strings::has_prefix(slice, b"--") - && (positional_i > 0 || matches!(slice, b"create" | b"c")) - { + if !strings::has_prefix(slice, b"--") { if positional_i == 1 { template_name_start = remainder_i + 2; } @@ -1776,7 +1772,6 @@ pub(crate) mod command { Global::exit(0); } - change_directory_before(positionals[0]); let template_name = positionals[1]; // if template_name is "react" — deprecated; redirect to react-app/vite. diff --git a/src/runtime/cli/repl.rs b/src/runtime/cli/repl.rs index ca00b44d6226..ecd43b98e0d0 100644 --- a/src/runtime/cli/repl.rs +++ b/src/runtime/cli/repl.rs @@ -2852,8 +2852,7 @@ static SIGINT_ARMED: core::sync::atomic::AtomicBool = core::sync::atomic::Atomic /// A SIGINT arrived during such a wait: stop waiting. Not a VM stop — the VM stays usable. static SIGINT_DURING_WAIT: core::sync::atomic::AtomicBool = core::sync::atomic::AtomicBool::new(false); -/// The loop that wait parks in. The poll retries on EINTR, so the signal does not end it: -/// `sigint_handler` wakes it. +/// The loop that wait parks in. The poll retries on EINTR, so `sigint_handler` wakes it. #[cfg(unix)] static SIGINT_WAKES: core::sync::atomic::AtomicPtr = core::sync::atomic::AtomicPtr::new(core::ptr::null_mut()); diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 83c6496f9e7f..2b5244601443 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -436,8 +436,7 @@ Full documentation is available at https://bun.com/docs/cli/run #[cfg(windows)] let ipc_fd: Option = None; // TODO: implement on Windows - // `Loader::load_process` drops these; a package script inherits the - // channel on purpose (#11132), so restore them alongside `ipc_fd`. + // `Loader::load_process` drops these, but a package script inherits the channel on purpose (#11132). #[cfg(not(windows))] if ipc_fd.is_some() { if let Some(fd_s) = bun_core::env_var::NODE_CHANNEL_FD.get() { @@ -2400,24 +2399,32 @@ impl RunCommand { } } - /// Whether the nearest package.json, in `dir` or above it, has a syntax error. `enclosing_package_json` skips such a file. + /// The file that the error `msg` is located in. + fn file_of_error(msg: &bun_ast::Msg) -> Option<&[u8]> { + let at = msg.data.location.as_ref()?; + (msg.kind == bun_ast::Kind::Err).then_some(&at.file[..]) + } + + /// The nearest package.json, in `dir` or above it, if it has a syntax error. `enclosing_package_json` skips such a file. #[cold] #[inline(never)] - fn nearest_package_json_is_broken(log: &bun_ast::Log, dir: bun_resolver::DirInfoRef) -> bool { + fn nearest_broken_package_json( + log: &bun_ast::Log, + dir: bun_resolver::DirInfoRef, + ) -> Option> { let loaded = dir.enclosing_package_json; let loaded_path_len = loaded.map_or(0, |it| it.source.path.text.len()); // The resolver has read `dir` and its parents only, so a longer path is that of a nearer file. - log.msgs.iter().any(|msg| { - let Some(at) = &msg.data.location else { - return false; - }; - msg.kind == bun_ast::Kind::Err - && at.file.len() > loaded_path_len - && paths::basename(&at.file) == b"package.json" - // A file of nothing but whitespace counts as no file. - && sys::File::read_from(Fd::cwd(), &at.file) + (log.msgs.iter().filter_map(Self::file_of_error)) + .filter(|file| file.len() > loaded_path_len) + .filter(|file| paths::basename(file) == b"package.json") + // A file of nothing but whitespace counts as no file. + .filter(|file| { + sys::File::read_from(Fd::cwd(), file) .is_ok_and(|text| !strings::trim(&text, b" \t\r\n").is_empty()) - }) + }) + .max_by_key(|file| file.len()) + .map(Box::from) } /// Dispatch `bun run `: classify as file path vs. package.json @@ -2539,10 +2546,12 @@ impl RunCommand { // SAFETY: `ctx.log` set in `create_context_data` (single-threaded // CLI startup), process-lifetime. let log = unsafe { ctx.log() }; - let package_json_is_broken = !skip_script_check - && log.has_errors() - && Self::nearest_package_json_is_broken(log, root_dir); - if !skip_script_check && !package_json_is_broken { + let broken_package_json = if !skip_script_check && log.has_errors() { + Self::nearest_broken_package_json(log, root_dir) + } else { + None + }; + if !skip_script_check && broken_package_json.is_none() { if let Some(package_json) = root_dir.enclosing_package_json { if let Some(scripts) = package_json.scripts.as_deref() { if let Some(&script_content) = scripts.get(target_name) { @@ -2701,7 +2710,9 @@ impl RunCommand { bun_core::scoped_log!(RUN_LOG, "Resolved to: `{}`", bstr::BStr::new(path.text)); // borrowck — `boot_and_handle_error` takes // `&mut ctx`; copy `path.text` out of the resolver borrow. - let text: Box<[u8]> = path.text.to_vec().into_boxed_slice(); + let keeps_link = ctx.runtime_options.preserve_symlinks_main && path.is_symlink; + let text = if keeps_link { path.pretty } else { path.text }; + let text: Box<[u8]> = text.to_vec().into_boxed_slice(); return Ok(Self::boot_and_handle_error(ctx, &text, Some(loader))); } else { bun_core::scoped_log!( @@ -2811,7 +2822,7 @@ impl RunCommand { } // ── failure ───────────────────────────────────────────────────────── - if ctx.runtime_options.if_present && !package_json_is_broken { + if ctx.runtime_options.if_present && broken_package_json.is_none() { return Ok(true); } @@ -2875,16 +2886,13 @@ impl RunCommand { return Ok(true); } - if log_errors || package_json_is_broken { - // The resolver records a package.json that fails to load (a - // syntax error, an unreadable file) in `ctx.log` and returns a - // `DirInfo` without it. - // - // SAFETY: `ctx.log` set in `create_context_data` (single-threaded - // CLI startup), process-lifetime. - let log = unsafe { ctx.log() }; - let has_log_errors = log.has_errors(); - if has_log_errors { + if log_errors || broken_package_json.is_some() { + if let Some(path) = &broken_package_json { + // SAFETY: `ctx.log` set in `create_context_data` (single-threaded + // CLI startup), process-lifetime. + let log = unsafe { ctx.log() }; + log.msgs + .retain(|msg| Self::file_of_error(msg) == Some(&path[..])); let _ = log.print(std::ptr::from_mut::( Output::error_writer(), )); @@ -2918,11 +2926,7 @@ impl RunCommand { "error: File not found \"{}\"", bstr::BStr::new(target_name), ); - } else if !(has_log_errors && root_dir.enclosing_package_json.is_none()) { - // With no usable package.json and an error that says - // why, the error is the message. "Script not found" - // would claim the script is absent from a file that - // did not load. + } else { pretty_errorln!( "error: Script not found \"{}\"", bstr::BStr::new(target_name), @@ -3043,26 +3047,26 @@ impl RunCommand { // Re-derive the canonical absolute path from the open fd (resolves // symlinks). `--preserve-symlinks-main` keeps the path as given. - let absolute_script_path: Option> = - if ctx.runtime_options.preserve_symlinks_main_enabled() { - let mut cwd_buf = bun_paths::path_buffer_pool::get(); - let mut joined_buf = bun_paths::path_buffer_pool::get(); - match bun_core::getcwd(&mut cwd_buf) { - Ok(cwd) => { - paths::resolve_path::join_abs_string_buf_checked::( - cwd.as_bytes(), - &mut joined_buf.0, - &[&script_name_buf[..open_len]], - ) - .map(Box::from) - } - Err(_) => None, + let keeps_link = ctx.runtime_options.preserve_symlinks_main; + let absolute_script_path: Option> = if keeps_link { + let mut cwd_buf = bun_paths::path_buffer_pool::get(); + let mut joined_buf = bun_paths::path_buffer_pool::get(); + match bun_core::getcwd(&mut cwd_buf) { + Ok(cwd) => { + paths::resolve_path::join_abs_string_buf_checked::( + cwd.as_bytes(), + &mut joined_buf.0, + &[&script_name_buf[..open_len]], + ) + .map(Box::from) } - } else { - bun_sys::get_fd_path(fd, &mut script_name_buf) - .ok() - .map(|resolved| Box::from(&*resolved)) - }; + Err(_) => None, + } + } else { + bun_sys::get_fd_path(fd, &mut script_name_buf) + .ok() + .map(|resolved| Box::from(&*resolved)) + }; let _ = bun_sys::close(fd); let Some(absolute_script_path) = absolute_script_path else { return false; @@ -3071,9 +3075,7 @@ impl RunCommand { Self::boot_and_handle_error(ctx, &absolute_script_path, None) } - /// `bun run -` / `node -` / `bun <(echo ...)` — read script from `pipe` into - /// `ctx.runtime_options.eval` and boot the VM with the synthetic `[stdin]` - /// path. `name` is the entry point as typed. `false` means `pipe` could not be read. + /// Runs the script read from `pipe` as `[stdin]`; `name` is the entry point as typed. `false`: `pipe` could not be read. fn exec_stdin(ctx: &mut ContextData, pipe: bun_sys::File, name: &[u8]) -> crate::Result { bun_core::scoped_log!(RUN_LOG, "Executing from stdin"); @@ -3215,9 +3217,7 @@ impl RunCommand { Self::exec_as_if_node_missing_script(); } - // `node -` reads the script from stdin. `Arguments::parse` stopped at - // this positional, so everything after the `-` is already in - // `ctx.passthrough`; `exec_stdin` puts the `-` itself back in front. + // `Arguments::parse` stopped at the `-`, so what follows it is already in `ctx.passthrough`. if ctx.positionals[0].as_ref() == b"-" { if Self::exec_stdin(ctx, bun_sys::File::stdin(), b"-")? { return Ok(()); diff --git a/src/runtime/cli/test_command.rs b/src/runtime/cli/test_command.rs index 94eb19cce778..bbae3c97d059 100644 --- a/src/runtime/cli/test_command.rs +++ b/src/runtime/cli/test_command.rs @@ -2478,10 +2478,18 @@ impl TestCommand { if test_files.is_empty() && !pass_with_no_tests_from_filter { failed_to_find_any_tests = true; + let file_patterns = ctx.test_options.file_patterns.join(&b", "[..]); + let file_patterns = bstr::BStr::new(&file_patterns); + // "bun test" - positionals[0] == "test" // Therefore positionals starts at [1]. if ctx.positionals.len() < 2 { - if Output::is_ai_agent() { + if !file_patterns.is_empty() { + pretty_errorln!( + "No tests found!\n\nTests need to match filePatterns in bunfig.toml: {}\n", + file_patterns + ); + } else if Output::is_ai_agent() { // Be very clear to ai. Output::err_generic( "0 test files matching **{{.test,.spec,_test_,_spec_}}.{{js,ts,jsx,tsx}} in --cwd={}", @@ -2526,9 +2534,16 @@ impl TestCommand { Output::print_start_end(ctx.start_time, bun::time::nano_timestamp()); } - pretty_errorln!( - "\n\nnote: Tests need \".test\", \"_test_\", \".spec\" or \"_spec_\" in the filename (ex: \"MyApp.test.ts\")" - ); + if !file_patterns.is_empty() { + pretty_errorln!( + "\n\nnote: Tests need to match filePatterns in bunfig.toml: {}", + file_patterns + ); + } else { + pretty_errorln!( + "\n\nnote: Tests need \".test\", \"_test_\", \".spec\" or \"_spec_\" in the filename (ex: \"MyApp.test.ts\")" + ); + } // print a helpful note if let Some(i) = has_file_like { diff --git a/src/runtime/jsc_hooks.rs b/src/runtime/jsc_hooks.rs index b9ba6310cbbb..c30988a34dd3 100644 --- a/src/runtime/jsc_hooks.rs +++ b/src/runtime/jsc_hooks.rs @@ -84,9 +84,7 @@ pub(crate) struct RuntimeState { /// leaked per-VM (PORTING.md §Forbidden: leaking only for true /// process-lifetime singletons via `OnceLock`, which a per-VM arena is not). pub(crate) transpiler_arena: Box, - /// The env loader `vm.transpiler` points at when the VM owns its own - /// (`InitOptions::owned_env_loader`). `None` when the transpiler shares - /// the caller's loader. Freed with the state, after the VM. + /// `InitOptions::owned_env_loader`, which `vm.transpiler` points at. Freed with the state, after the VM. pub(crate) owned_env_loader: Option>, /// `vm.body_value_pool` — pooled storage for `Body.Value` /// (`Request.body` payloads). diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 71da64bc2a6f..17f6b6f321d6 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9028,21 +9028,6 @@ pub fn exists(path: &[u8]) -> bool { let z = ZStr::from_buf(&buf.0[..], path.len()); exists_z(z) } - -/// Returns true if `dir` is sticky and group- or world-writable, like `/tmp`: any local user can create a file there. -#[cfg(unix)] -pub fn is_shared_directory(dir: &[u8]) -> bool { - !stat(&bun_core::ZBox::from_bytes(dir)).is_ok_and(|st| { - let mode = st.st_mode as Mode; - mode & S::ISVTX == 0 || mode & (S::IWGRP | S::IWOTH) == 0 - }) -} - -#[cfg(not(unix))] -pub fn is_shared_directory(_: &[u8]) -> bool { - false -} - /// `moveFileZ`. Routes through /// [`renameat_concurrently_without_fallback`] (renameat2 NOREPLACE → EXCHANGE → /// delete-tree + rename); on EISDIR removes the dest dir and diff --git a/test/bundler/transpiler/macro-test.test.ts b/test/bundler/transpiler/macro-test.test.ts index 58ee6f1440d6..0c761202e44f 100644 --- a/test/bundler/transpiler/macro-test.test.ts +++ b/test/bundler/transpiler/macro-test.test.ts @@ -628,7 +628,7 @@ describe("--no-macros", () => { // Each parse-worker thread creates its own macro VM at its first macro call. The VM's transpiler // loads the `.env` files of the cwd into the VM's env loader, so the macro's `process.env` sees -// them. That loader used to be the build's own loader, shared by every worker thread. +// them. That loader is a copy of the build's, which every worker thread shares. describe("the macro VM's env loader is its own", () => { const macroModules = 8; const files: Record = { @@ -669,10 +669,10 @@ describe("the macro VM's env loader is its own", () => { } }, 60_000); - // `bun build` without `--env` does not read `.env`. The macro VM reads it for its own - // `process.env`, and that read must not change what the build's resolver sees. - test.concurrent("a macro's .env load does not reach the build's resolver", async () => { - using dir = tempDir("macro-env-loader-isolated", { + // `bun build` without `--env` does not read `.env`. A macro VM does, and the build takes over what + // it read before the imports of the module that calls the macro are resolved. + test.concurrent("a NODE_PATH that only .env sets resolves the imports of a module that calls a macro", async () => { + using dir = tempDir("macro-env-loader-node-path", { "vendor/dep/index.ts": `export const dep = "from NODE_PATH";\n`, "macro.ts": `export function value(name: string) {\n return name;\n}\n`, "with-macro.ts": `import { value } from "./macro.ts" with { type: "macro" };\nimport { dep } from "dep";\nconsole.log(value("a"), dep);\n`, @@ -695,8 +695,8 @@ describe("the macro VM's env loader is its own", () => { }; }; const [withMacro, withoutMacro] = await Promise.all([build("./with-macro.ts"), build("./without-macro.ts")]); + expect(withMacro).toEqual({ resolved: true, unresolved: false, exitCode: 0 }); expect(withoutMacro).toEqual({ resolved: false, unresolved: true, exitCode: 1 }); - expect(withMacro).toEqual(withoutMacro); }); test.concurrent("a macro reads .env through its own loader", async () => { diff --git a/test/cli/bun.test.ts b/test/cli/bun.test.ts index bc068c382765..3a4db607451e 100644 --- a/test/cli/bun.test.ts +++ b/test/cli/bun.test.ts @@ -452,13 +452,13 @@ describe("bun", () => { } const project = { - "package.json": "{}", + "package.json": JSON.stringify({ scripts: { "dev": "echo dev", "test:unit": "echo unit" } }, null, 2), "myscript.ts": "", "foo.txt": "", "src/index.ts": "", "src/other.ts": "", "test/a.test.ts": "", - // bun.fish asks `bun getcompletes` for the scripts and bins. This project has none. + // bun.fish asks `bun getcompletes` for the scripts and bins. This one has no answer. "bin/bun": "#!/bin/sh\n", }; @@ -490,12 +490,14 @@ describe("bun", () => { ? Number(spawnSync({ cmd: ["bash", "-c", "echo ${BASH_VERSINFO[0]}"], env: bunEnv }).stdout.toString()) : 0; - test.concurrent.skipIf(bashVersion < 4)("bash offers files, also after a flag or a script", async () => { + test.concurrent.skipIf(bashVersion < 4)("bash offers files and scripts, also after a flag", async () => { using dir = tempDir("completions-bash", { ...project, "bun.bash": await script("bash") }); const expected = { "bun sr": ["src/"], "bun src/": ["src/index.ts", "src/other.ts"], "bun run src/": ["src/index.ts", "src/other.ts"], + "bun run d": ["dev"], + "bun run te": ["test/", "test:unit"], "bun --hot src/o": ["src/other.ts"], "bun --hot run src/o": ["src/other.ts"], "bun --cwd . run src/o": ["src/other.ts"], @@ -665,6 +667,7 @@ describe("bun", () => { /^ {2}Also add it to the current project's package\.json as a link: dependency\.\n {2}bun link --save $/m, ], ], + ["bun patch --help", ["patch"], [/^ {2}bun patch --commit --patches-dir 'my-patches' 'node_modules\/jquery'$/m]], [ "bun audit --help", ["audit"], @@ -697,7 +700,7 @@ describe("bun", () => { "index.ts": `console.log(process.env.FOO ?? "-", globalThis.PRE ?? "-", typeof X === "undefined" ? "-" : X);`, "with/package.json": JSON.stringify({ name: "with", - scripts: Object.fromEntries(["dev", ...words].map(word => [word, `echo script-${word}`])), + scripts: Object.fromEntries(["dev", "run", ...words].map(word => [word, `echo script-${word}`])), }), "without/package.json": JSON.stringify({ name: "without" }), "without/a.test.ts": `import { test } from "bun:test"; test("in without", () => {});`, @@ -768,6 +771,12 @@ describe("bun", () => { expect(exitCode).toBe(0); }); + test("bun --filter=with --cwd . run dev runs the script that is named run", async () => { + const { stdout, exitCode } = await run(["--filter=with", "--cwd", ".", "run", "dev"]); + expect(stdout).toContain("script-run dev"); + expect(exitCode).toBe(0); + }); + test("bun -p install is bun install --production", async () => { const { stdout, exitCode } = await run(["-p", "install"]); expect(stdout).toContain("bun install"); diff --git a/test/cli/bunfig-test-options.test.ts b/test/cli/bunfig-test-options.test.ts index be4f793cd737..65c25d2c037a 100644 --- a/test/cli/bunfig-test-options.test.ts +++ b/test/cli/bunfig-test-options.test.ts @@ -349,6 +349,19 @@ describe.concurrent("[test] filePatterns", () => { expect(exitCode).toBe(0); }); + test.each( + named([ + { bunfig: `filePatterns = ["*.a.ts", "*.b.ts"]`, args: [] }, + { bunfig: `filePatterns = ["*.a.ts", "*.b.ts"]`, args: ["filter"] }, + ]), + )("$name finds nothing and names the patterns", async ({ bunfig, args }) => { + using dir = tempDir("test-file-patterns", { ...files, "bunfig.toml": `[test]\n${bunfig}\n` }); + const { stderr, exitCode } = await bunTest(String(dir), ...args); + expect(stderr).toContain("Tests need to match filePatterns in bunfig.toml: *.a.ts, *.b.ts\n"); + expect(stderr).not.toContain(`".test"`); + expect(exitCode).toBe(1); + }); + test("a number is an error", async () => { using dir = tempDir("test-file-patterns", { ...files, "bunfig.toml": `[test]\nfilePatterns = 1\n` }); const { stderr, exitCode } = await bunTest(String(dir)); diff --git a/test/cli/install/bun-create.test.ts b/test/cli/install/bun-create.test.ts index 846b2a4d5c89..c9c3ceb1ea13 100644 --- a/test/cli/install/bun-create.test.ts +++ b/test/cli/install/bun-create.test.ts @@ -570,7 +570,10 @@ it("should create template from local folder", async () => { expect(await Bun.file(join(x_dir, testTemplate, "foo", "bar.js")).text()).toBe("hi"); }); -it.concurrent.each([[["--cwd", "proj"]], [["--cwd=proj"]]])("bun %j create works in that folder", async flags => { +it.concurrent.each([ + [["--cwd=proj"], ["dest/tpl.txt"], 0], + [["--cwd", "proj"], [], 1], +])("bun %j create tpl dest writes %j and exits with %d", async (flags, written, expectedExitCode) => { using dir = tempDir("create-after-cwd", { "bun-create/tpl/tpl.txt": "template", "proj/mine.txt": "mine" }); await using proc = spawn({ cmd: [bunExe(), ...flags, "create", "tpl", "dest", "--no-git"], @@ -578,12 +581,12 @@ it.concurrent.each([[["--cwd", "proj"]], [["--cwd=proj"]]])("bun %j create works env: { ...env, BUN_CREATE_DIR: join(String(dir), "bun-create") }, stdin: "ignore", stdout: "ignore", - stderr: "inherit", + stderr: "ignore", }); const exitCode = await proc.exited; - expect(await tree(join(String(dir), "proj"))).toEqual({ "dest/tpl.txt": "template", "mine.txt": "mine" }); - expect(exitCode).toBe(0); + expect(Object.keys(await tree(String(dir)))).toEqual(["bun-create/tpl/tpl.txt", ...written, "proj/mine.txt"]); + expect(exitCode).toBe(expectedExitCode); }); async function tree(dir: string) { diff --git a/test/cli/install/bun-patch.test.ts b/test/cli/install/bun-patch.test.ts index a12b81848d93..10203f1bc209 100644 --- a/test/cli/install/bun-patch.test.ts +++ b/test/cli/install/bun-patch.test.ts @@ -201,57 +201,6 @@ describe.concurrent("error messages", () => { }); }); -// Each example is run as the help prints it, in a project where `jquery` is an alias of a stand-in. -describe.concurrent("the examples in 'bun patch --help'", () => { - test.each([ - ["Generate a patch file for changes made to jquery", "patches"], - ["Generate a patch file in a custom directory for changes made to jquery", "my-patches"], - ])("%s", async (commitExample, patchesDir) => { - await using project = await installedProject({ dependencies: { jquery: "npm:no-deps@1.0.0" } }); - const { dir, env } = project; - const indexJs = join(dir, "node_modules", "jquery", "index.js"); - const help = (await bunOk(dir, env, "patch", "--help")).split(/\r?\n/).map(line => line.trim()); - - /** Runs the command that the help prints under `description`, without the quotes a shell removes. */ - async function runExample(description: string) { - expect(help).toContain(description); - const [bun, ...args] = help[help.indexOf(description) + 1].split(" "); - expect(bun).toBe("bun"); - await bunOk(dir, env, ...args.map(arg => arg.replace(/^'(.*)'$/, "$1"))); - } - - await runExample("Prepare jquery for patching"); - await Bun.write(indexJs, patched); - await runExample(commitExample); - - expect({ - patchedDependencies: (await Bun.file(join(dir, "package.json")).json()).patchedDependencies, - patchFile: existsSync(join(dir, patchesDir, "no-deps@1.0.0.patch")), - indexJs: await Bun.file(indexJs).text(), - }).toEqual({ - patchedDependencies: { "no-deps@1.0.0": `${patchesDir}/no-deps@1.0.0.patch` }, - patchFile: true, - indexJs: patched, - }); - expect(await Bun.file(join(dir, patchesDir, "no-deps@1.0.0.patch")).text()).toMatchInlineSnapshot(` - "diff --git a/index.js b/index.js - index bb9c6f6876154493527577cd78279aa6bb686ebb..486c735f2b33e6d1beb1de227b99684124172cb9 100644 - --- a/index.js - +++ b/index.js - @@ -1,7 +1 @@ - -module.exports = require(\`./package.json\`); - - - -for (const key of [\`dependencies\`, \`devDependencies\`, \`peerDependencies\`]) { - - for (const dep of Object.keys(module.exports[key] || {})) { - - module.exports[key][dep] = require(dep); - - } - -} - +module.exports = 'patched'; - " - `); - }); -}); - // `bun patch` identifies packages by `name@label`, where a tarball package's label is // the spec it was installed from. These labels used to be formatted into 1024 byte // stack buffers (512 bytes in the installer itself), so a long enough spec crashed diff --git a/test/cli/run/filter-workspace.test.ts b/test/cli/run/filter-workspace.test.ts index d0f07d104352..d3574c1f66fa 100644 --- a/test/cli/run/filter-workspace.test.ts +++ b/test/cli/run/filter-workspace.test.ts @@ -827,42 +827,47 @@ describe("bun", () => { "package.json": JSON.stringify({ name, scripts: { build: `echo BUILD-${name}` } }), }); + const upperAndLower = { Core: member("core"), lower: member("lower") }; + const below = ["./packages/**"]; + test.concurrent.each([ { entry: "a glob", workspaces: ["packages/*"], - files: { packages: { Core: member("core"), lower: member("lower") } }, - }, - { - entry: "a literal path", - workspaces: ["packages/Core", "packages/lower"], - files: { packages: { Core: member("core"), lower: member("lower") } }, + files: { packages: upperAndLower }, + paths: below, }, { entry: "a nested glob", workspaces: ["packages/*/*"], files: { packages: { UPPER: { Inner: member("core") }, group: { lower: member("lower") } } }, + paths: below, }, - // The names of these file systems ignore case. + // Where names ignore case, what is written in lower case finds the directory. ...(isMacOS || isWindows ? [ { entry: "a literal path in lower case", workspaces: ["./packages/core", "packages/lower"], - files: { packages: { Core: member("core"), lower: member("lower") } }, + files: { packages: upperAndLower }, + paths: ["./packages/core", "./packages/lower"], }, - ] - : []), - ...(isLinux - ? [ { - entry: "a literal path, next to the same name in lower case", - workspaces: ["packages/Core", "packages/core"], - files: { packages: { Core: member("core"), core: member("lower") } }, + entry: "a glob below a directory in lower case", + workspaces: ["packages/*"], + files: { Packages: upperAndLower }, + paths: below, }, ] - : []), - ])("is found through $entry in workspaces", async ({ workspaces, files }) => { + : [ + { + entry: "a literal path", + workspaces: ["packages/Core", "packages/lower"], + files: { packages: upperAndLower }, + paths: below, + }, + ]), + ])("is found through $entry in workspaces", async ({ workspaces, files, paths }) => { using dir = tempDir("filter-upper-case-dir", { ...files, "package.json": JSON.stringify({ name: "root", private: true, workspaces }), @@ -888,7 +893,7 @@ describe("bun", () => { run("--filter", "*"), run("--workspaces"), run("--filter", "lower"), - run("--filter", "./packages/**"), + run(...paths.flatMap(path => ["--filter", path])), ]); const both = { ran: ["BUILD-core", "BUILD-lower"], errors: [], exitCode: 0 }; expect({ all, everyWorkspace, byName, byPath }).toEqual({ diff --git a/test/cli/run/hide-paths.c b/test/cli/run/hide-paths.c deleted file mode 100644 index ff11b335c710..000000000000 --- a/test/cli/run/hide-paths.c +++ /dev/null @@ -1,216 +0,0 @@ -// Test helper: run a command with some paths made to look absent. -// -// hide-paths /bin/sh /bin/bash -- bun run start -// -// A mount namespace needs privileges that CI does not have. This uses seccomp -// user notification instead: the child's path syscalls stop in the kernel, the -// parent reads the path from the child's memory, and answers ENOENT for a -// hidden path (the path itself or anything below it) or lets the syscall -// continue. Exit code 77 means the kernel or the sandbox does not allow this. -// The path check is racy by design, which does not matter for a test. -#define _GNU_SOURCE -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -#define SKIP 77 - -#if defined(__x86_64__) -#define HP_AUDIT_ARCH AUDIT_ARCH_X86_64 -#elif defined(__aarch64__) -#define HP_AUDIT_ARCH AUDIT_ARCH_AARCH64 -#else -#define HP_AUDIT_ARCH 0 -#endif - -// Linux 5.0 (listener) and 5.5 (continue). Declared here so that older kernel -// headers still compile; an older kernel answers EINVAL and the helper skips. -struct hp_notif { uint64_t id; uint32_t pid; uint32_t flags; struct seccomp_data data; }; -struct hp_notif_resp { uint64_t id; int64_t val; int32_t error; uint32_t flags; }; -struct hp_notif_sizes { uint16_t notif, resp, data; }; -#define HP_NEW_LISTENER (1UL << 3) -#define HP_GET_NOTIF_SIZES 3 -#define HP_CONTINUE (1UL << 0) -#define HP_NOTIF_RECV _IOWR('!', 0, struct hp_notif) -#define HP_NOTIF_SEND _IOWR('!', 1, struct hp_notif_resp) -#ifndef SECCOMP_RET_USER_NOTIF -#define SECCOMP_RET_USER_NOTIF 0x7fc00000U -#endif -#ifndef __NR_faccessat2 -#define __NR_faccessat2 439 -#endif -#ifndef __NR_openat2 -#define __NR_openat2 437 -#endif - -// Syscalls that take the path in args[0], then those that take it in args[1]. -static const int path_arg0[] = { -#ifdef __NR_open - __NR_open, __NR_stat, __NR_lstat, __NR_readlink, __NR_access, -#endif - __NR_execve, -}; -static const int path_arg1[] = { - __NR_openat, __NR_openat2, __NR_newfstatat, __NR_statx, __NR_readlinkat, __NR_faccessat, __NR_faccessat2, __NR_execveat, -}; -#define N0 ((int)(sizeof(path_arg0) / sizeof(path_arg0[0]))) -#define N1 ((int)(sizeof(path_arg1) / sizeof(path_arg1[0]))) - -static int install_filter(void) { - struct sock_filter f[N0 + N1 + 6]; - int n = 0, total = N0 + N1; - f[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)); - f[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, HP_AUDIT_ARCH, 1, 0); - f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); - f[n++] = (struct sock_filter)BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)); - for (int i = 0; i < total; i++) { - int nr = i < N0 ? path_arg0[i] : path_arg1[i - N0]; - // On a match, jump over the remaining compares and the ALLOW to the NOTIF return. - f[n++] = (struct sock_filter)BPF_JUMP(BPF_JMP | BPF_JEQ | BPF_K, nr, total - i, 0); - } - f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW); - f[n++] = (struct sock_filter)BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_USER_NOTIF); - struct sock_fprog prog = {.len = (unsigned short)n, .filter = f}; - return (int)syscall(__NR_seccomp, SECCOMP_SET_MODE_FILTER, HP_NEW_LISTENER, &prog); -} - -static int send_fd(int sock, int fd) { - char control[CMSG_SPACE(sizeof(int))] = {0}, byte = 0; - struct iovec io = {.iov_base = &byte, .iov_len = 1}; - struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)}; - struct cmsghdr *c = CMSG_FIRSTHDR(&msg); - c->cmsg_level = SOL_SOCKET; - c->cmsg_type = SCM_RIGHTS; - c->cmsg_len = CMSG_LEN(sizeof(int)); - memcpy(CMSG_DATA(c), &fd, sizeof(int)); - return sendmsg(sock, &msg, 0) < 0 ? -1 : 0; -} - -static int recv_fd(int sock) { - char control[CMSG_SPACE(sizeof(int))] = {0}, byte; - struct iovec io = {.iov_base = &byte, .iov_len = 1}; - struct msghdr msg = {.msg_iov = &io, .msg_iovlen = 1, .msg_control = control, .msg_controllen = sizeof(control)}; - if (recvmsg(sock, &msg, 0) <= 0) return -1; - struct cmsghdr *c = CMSG_FIRSTHDR(&msg); - if (!c) return -1; - int fd; - memcpy(&fd, CMSG_DATA(c), sizeof(int)); - return fd; -} - -static int is_hidden(const char *path, char **hidden, int count) { - for (int i = 0; i < count; i++) { - size_t len = strlen(hidden[i]); - if (strncmp(path, hidden[i], len) == 0 && (path[len] == 0 || path[len] == '/')) return 1; - } - return 0; -} - -// Reads the NUL-terminated path at `addr` in the child. Returns 0, or -1 if the read is not permitted. -static int read_path(pid_t pid, uint64_t addr, char *out, size_t cap) { - out[0] = 0; - if (!addr) return 0; - // Do not cross into the next page unless the string does: it may be unmapped. - size_t first = 4096 - (size_t)(addr & 4095); - if (first > cap - 1) first = cap - 1; - struct iovec local = {.iov_base = out, .iov_len = first}; - struct iovec remote = {.iov_base = (void *)(uintptr_t)addr, .iov_len = first}; - ssize_t got = process_vm_readv(pid, &local, 1, &remote, 1, 0); - if (got < 0) return errno == EPERM || errno == ENOSYS ? -1 : 0; - if (memchr(out, 0, (size_t)got) == NULL && (size_t)got < cap - 1) { - local.iov_base = out + got; - remote.iov_base = (void *)(uintptr_t)(addr + (uint64_t)got); - local.iov_len = remote.iov_len = cap - 1 - (size_t)got; - ssize_t more = process_vm_readv(pid, &local, 1, &remote, 1, 0); - got += more > 0 ? more : 0; - } - out[got] = 0; - return 0; -} - -int main(int argc, char **argv) { - int sep = 0; - for (int i = 1; i < argc && !sep; i++) - if (strcmp(argv[i], "--") == 0) sep = i; - if (sep < 2 || sep + 1 >= argc) { - fprintf(stderr, "usage: %s ... -- [args...]\n", argv[0]); - return 2; - } - if (HP_AUDIT_ARCH == 0) return SKIP; - - int pair[2]; - if (socketpair(AF_UNIX, SOCK_STREAM, 0, pair) != 0) return SKIP; - pid_t child = fork(); - if (child < 0) return SKIP; - if (child == 0) { - close(pair[0]); - if (prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) != 0) _exit(SKIP); - int listener = install_filter(); - if (listener < 0 || send_fd(pair[1], listener) != 0) _exit(SKIP); - close(listener); - close(pair[1]); - execvp(argv[sep + 1], argv + sep + 1); - perror("execvp"); - _exit(127); - } - close(pair[1]); - int listener = recv_fd(pair[0]); - close(pair[0]); - - struct hp_notif_sizes sizes = {0}; - if (listener < 0 || syscall(__NR_seccomp, HP_GET_NOTIF_SIZES, 0, &sizes) != 0) { - kill(child, SIGKILL); - return SKIP; - } - size_t notif_size = sizes.notif > sizeof(struct hp_notif) ? sizes.notif : sizeof(struct hp_notif); - size_t resp_size = sizes.resp > sizeof(struct hp_notif_resp) ? sizes.resp : sizeof(struct hp_notif_resp); - struct hp_notif *req = malloc(notif_size); - struct hp_notif_resp *resp = malloc(resp_size); - - for (;;) { - struct pollfd pfd = {.fd = listener, .events = POLLIN}; - int ready = poll(&pfd, 1, 100); - if (ready < 0 && errno != EINTR) break; - if (ready <= 0 || !(pfd.revents & POLLIN)) { - int status; - if (waitpid(child, &status, WNOHANG) == child) - return WIFSIGNALED(status) ? 128 + WTERMSIG(status) : WEXITSTATUS(status); - continue; - } - memset(req, 0, notif_size); - if (ioctl(listener, HP_NOTIF_RECV, req) != 0) { - if (errno == EINTR || errno == ENOENT) continue; - break; - } - int index = 1; - for (int i = 0; i < N0; i++) - if (path_arg0[i] == req->data.nr) index = 0; - char path[4096]; - if (read_path((pid_t)req->pid, req->data.args[index], path, sizeof(path)) != 0) break; - - memset(resp, 0, resp_size); - resp->id = req->id; - if (is_hidden(path, argv + 1, sep - 1)) resp->error = -ENOENT; - else resp->flags = HP_CONTINUE; - if (ioctl(listener, HP_NOTIF_SEND, resp) != 0 && errno != ENOENT) break; - } - // The kernel or the sandbox refused a step. Do not report a result. - kill(child, SIGKILL); - waitpid(child, NULL, 0); - return SKIP; -} diff --git a/test/cli/run/run-shell.test.ts b/test/cli/run/run-shell.test.ts index 44214f99e781..34b1163a9ff1 100644 --- a/test/cli/run/run-shell.test.ts +++ b/test/cli/run/run-shell.test.ts @@ -1,8 +1,7 @@ -import { afterAll, describe, expect, test } from "bun:test"; -import { spawnSync } from "child_process"; -import { chmodSync, mkdirSync } from "fs"; +import { describe, expect, test } from "bun:test"; +import { chmodSync, mkdirSync, realpathSync } from "fs"; import { bunEnv, bunExe, isLinux, isWindows, tempDir, tmpdirSync } from "harness"; -import { join } from "path"; +import { dirname, join } from "path"; describe.concurrent("run-shell", () => { test("running a shell script works", async () => { @@ -82,60 +81,39 @@ test.skipIf(isWindows)( }, ); -// hide-paths.c makes chosen paths look absent (seccomp user notification). `bin` is the helper, -// `skip` means this host cannot use it (no cc, missing headers, a kernel or sandbox that refuses -// the filter), `error` is a compile failure of the helper itself. -const hidePaths: { bin: string } | { skip: true } | { error: string } = (() => { - if (!isLinux) return { skip: true }; - const dir = tempDir("hide-paths", {}); - const result = ((): typeof hidePaths => { - const bin = join(String(dir), "hide-paths"); - const compile = spawnSync("cc", ["-O0", "-o", bin, join(import.meta.dir, "hide-paths.c")], { stdio: "pipe" }); - if ((compile.error as NodeJS.ErrnoException | undefined)?.code === "ENOENT") return { skip: true }; - if (compile.status !== 0) { - const stderr = compile.stderr?.toString() ?? ""; - // A missing header or libc means this host cannot build C. Anything else is a bug in the helper. - return stderr.includes("No such file or directory") ? { skip: true } : { error: stderr }; - } - // Probe: the helper runs itself with no arguments, which prints the usage and exits 2. 77 means skip. - return spawnSync(bin, ["/hide-paths-probe", "--", bin], { stdio: "pipe" }).status === 2 ? { bin } : { skip: true }; - })(); - // The tests need the binary until the file ends. Every other outcome is done with the directory now. - if ("bin" in result) afterAll(() => dir[Symbol.dispose]()); - else dir[Symbol.dispose](); - return result; -})(); +// A root directory with no shell in it: a chroot in a private user and mount namespace. +// Skipped where those are disabled, like in most containers. +const canChangeRoot = + isLinux && + Bun.which("unshare") !== null && + Bun.spawnSync({ cmd: ["unshare", "-Urm", "true"], stdout: "ignore", stderr: "ignore" }).exitCode === 0; -// A FROM-scratch or distroless image has no /bin/sh. `--shell=bun` must not need one. Every path -// that the shell lookup probes looks absent, and PATH points at an empty directory. -describe.skipIf("skip" in hidePaths)("bun run on a system with no shell", () => { - // Keep in sync with HARDCODED_POPULAR_ONES in src/runtime/cli/run_command.rs. - const shellPaths = [ - "/bin/bash", - "/usr/bin/bash", - "/usr/local/bin/bash", - "/bin/sh", - "/usr/bin/sh", - "/usr/bin/zsh", - "/usr/local/bin/zsh", - "/system/bin/sh", - ]; +// A FROM-scratch or distroless image has no /bin/sh. `--shell=bun` must not need one. +describe.skipIf(!canChangeRoot)("bun run on a system with no shell", () => { + const changeRoot = `set -e +for dir in /lib /lib64 /usr/lib /proc "$BUN_DIR"; do + [ -d "$dir" ] || continue + mkdir -p "$NEW_ROOT$dir" + mount -o rbind "$dir" "$NEW_ROOT$dir" +done +PATH="$PATH:/usr/sbin:/sbin" +exec chroot "$NEW_ROOT" "$@"`; async function runWithoutShells(files: Record, args: string[]) { using dir = tempDir("run-no-shell", { - "package.json": JSON.stringify({ + "app/package.json": JSON.stringify({ name: "no-shell-fixture", version: "1.0.0", scripts: { prestart: "echo pre", start: "echo started", poststart: "echo post" }, }), - "empty-path/.keep": "", + "home/.keep": "", + "tmp/.keep": "", ...files, }); - if ("error" in hidePaths) throw new Error("failed to compile hide-paths.c:\n" + hidePaths.error); + const bun = realpathSync(bunExe()); await using proc = Bun.spawn({ - cmd: [(hidePaths as { bin: string }).bin, ...shellPaths, "--", bunExe(), "run", ...args], - env: { ...bunEnv, PATH: join(String(dir), "empty-path") }, - cwd: String(dir), + cmd: ["unshare", "-Urm", "/bin/sh", "-c", changeRoot, "sh", bun, "--cwd=/app", "run", ...args], + env: { ...bunEnv, NEW_ROOT: String(dir), BUN_DIR: dirname(bun), HOME: "/home", TMPDIR: "/tmp" }, stdout: "pipe", stderr: "pipe", }); @@ -150,7 +128,7 @@ describe.skipIf("skip" in hidePaths)("bun run on a system with no shell", () => }); test.concurrent('bunfig [run] shell = "bun" runs the scripts', async () => { - const { stdout, exitCode } = await runWithoutShells({ "bunfig.toml": '[run]\nshell = "bun"\n' }, ["start"]); + const { stdout, exitCode } = await runWithoutShells({ "app/bunfig.toml": '[run]\nshell = "bun"\n' }, ["start"]); expect(stdout).toBe("pre\nstarted\npost\n"); expect(exitCode).toBe(0); }); diff --git a/test/cli/run/run_command.test.ts b/test/cli/run/run_command.test.ts index 9bce69a96894..caf5da048765 100644 --- a/test/cli/run/run_command.test.ts +++ b/test/cli/run/run_command.test.ts @@ -41,12 +41,13 @@ describe("bun", () => { expect(exitCode).toBe(1); }); - test("a package.json that fails to parse reports the parse error, not 'Script not found'", () => { + test("the parse error of the nearest package.json is printed before 'Script not found'", () => { using dir = tempDir("broken-package-json", { - "package.json": `{"name": "p", "scripts": {"s": "echo hi"`, + "package.json": `{"name": "top", "private": tru`, + "a/package.json": `{"name": "p", "scripts": {"s": "echo hi"`, }); const { exitCode, stdout, stderr } = spawnSync({ - cwd: String(dir), + cwd: join(String(dir), "a"), cmd: [bunExe(), "run", "s"], env: bunEnv, stdout: "pipe", @@ -55,12 +56,13 @@ describe("bun", () => { expect(stdout.toString()).toBeEmpty(); const err = stderr.toString(); expect(err).toContain(`Expected "}" but found end of file`); - expect(err).toContain(join(String(dir), "package.json") + ":1:40"); - expect(err).not.toContain("Script not found"); + expect(err).toContain(join(String(dir), "a", "package.json") + ":1:40"); + expect(err).not.toContain("Unexpected tru"); + expect(err).toEndWith('error: Script not found "s"\n'); expect(exitCode).toBe(1); }); - test("a package.json that fails to parse in an ancestor is printed before 'Script not found'", () => { + test("a package.json that fails to parse above the nearest one is not reported", () => { using dir = tempDir("broken-ancestor-package-json", { "package.json": `{"name": "top", "private": tru`, "a/b/package.json": JSON.stringify({ name: "deep", scripts: { s: "echo hi" } }), @@ -73,10 +75,7 @@ describe("bun", () => { stderr: "pipe", }); expect(stdout.toString()).toBeEmpty(); - const err = stderr.toString(); - expect(err).toContain("Unexpected tru"); - expect(err).toContain(join(String(dir), "package.json") + ":1:28"); - expect(err).toContain('Script not found "nope"'); + expect(stderr.toString()).toBe('error: Script not found "nope"\n'); expect(exitCode).toBe(1); }); @@ -97,11 +96,11 @@ describe("bun", () => { }; test("a package.json that fails to parse is an error", () => { - expect(run(`{\n<<<<<<< HEAD\n "scripts": { "buildall": "echo a" }\n}`)).toEqual({ - stdout: "", - stderr: expect.stringContaining(join("packages", "a", "package.json") + ":2:1"), - exitCode: 1, - }); + const { stdout, stderr, exitCode } = run(`{\n<<<<<<< HEAD\n "scripts": { "buildall": "echo a" }\n}`); + expect(stdout).toBe(""); + expect(stderr).toContain(join("packages", "a", "package.json") + ":2:1"); + expect(stderr).toEndWith('error: Script not found "buildall"\n'); + expect(exitCode).toBe(1); }); test.each(["\n", "[]"])("a package.json that is %j is no package.json", packageJson => { diff --git a/test/cli/test/parallel-signal.test.ts b/test/cli/test/parallel-signal.test.ts deleted file mode 100644 index 750e484e0492..000000000000 --- a/test/cli/test/parallel-signal.test.ts +++ /dev/null @@ -1,87 +0,0 @@ -// Kept separate from parallel.test.ts for the reason parallel-startup-failure.test.ts gives: -// file-level pass/fail is what the surrounding tooling checks, and that file has cases that -// depend on how fast a debug build starts its workers. - -import { expect, test } from "bun:test"; -import { readFileSync } from "fs"; -import { bunEnv, bunExe, isLinux, isWindows, tempDir } from "harness"; - -// With every worker in the middle of a file, nothing arrives on the coordinator's loop, and the -// poll it waits in is not ended by a signal: it retries on EINTR. A handler that only records -// the signal is then acted on when something else turns the loop, which on Linux shows as the -// coordinator's voluntary context switches. Wait for such a turn to pass, so that the signal -// lands at the start of a quiet period and not just before the next turn. -async function waitForCoordinatorToPark(pid: number) { - const switches = () => - Number(/voluntary_ctxt_switches:\s+(\d+)/.exec(readFileSync(`/proc/${pid}/status`, "utf8"))![1]); - const deadline = Date.now() + 3000; - const before = switches(); - while (switches() === before && Date.now() < deadline) await Bun.sleep(1); - let last = switches(); - let quietSince = Date.now(); - while (Date.now() - quietSince < 100 && Date.now() < deadline) { - await Bun.sleep(1); - const now = switches(); - if (now !== last) { - last = now; - quietSince = Date.now(); - } - } -} - -test.skipIf(isWindows).concurrent.each([ - ["SIGTERM", 143], - ["SIGINT", 130], -] as const)( - "--parallel: the coordinator acts on %s at once while every worker is mid-file", - async (signal, code) => { - const fixture = ` - import { test } from "bun:test"; - import { appendFileSync } from "fs"; - test("slow", async () => { - appendFileSync(process.env.PIDS, process.pid + "\\n"); - await Bun.sleep(60000); - }); - `; - using dir = tempDir("parallel-signal", { "a.test.ts": fixture, "b.test.ts": fixture }); - const pids = String(dir) + "/pids.txt"; - - await using proc = Bun.spawn({ - cmd: [bunExe(), "test", "--parallel=2", "--parallel-delay=0"], - env: { ...bunEnv, PIDS: pids }, - cwd: String(dir), - stdout: "ignore", - stderr: "pipe", - }); - - const workersRunning = () => { - try { - return readFileSync(pids, "utf8").split("\n").filter(Boolean).length; - } catch { - return 0; - } - }; - while (workersRunning() < 2 && proc.exitCode === null) await Bun.sleep(10); - expect(workersRunning()).toBe(2); - if (isLinux) await waitForCoordinatorToPark(proc.pid); - - // "Interrupted" is the first thing the coordinator prints once it has seen the signal. The - // time up to that leaves out how long the processes take to go away. - const stderr = proc.stderr.getReader(); - const decoder = new TextDecoder(); - let seen = ""; - const start = performance.now(); - proc.kill(signal); - while (!seen.includes("Interrupted")) { - const { value, done } = await stderr.read(); - if (done) break; - seen += decoder.decode(value, { stream: true }); - } - const reactedAfterMs = performance.now() - start; - - expect(seen).toContain("Interrupted"); - expect(reactedAfterMs).toBeLessThan(500); - expect(await proc.exited).toBe(code); - }, - 30_000, -); diff --git a/test/config/bunfig/preload.test.ts b/test/config/bunfig/preload.test.ts index 958f75ec4d2a..0c825339ec6d 100644 --- a/test/config/bunfig/preload.test.ts +++ b/test/config/bunfig/preload.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it, test } from "bun:test"; -import { bunEnv, bunExe, normalizeBunSnapshot, tempDir } from "harness"; +import { bunEnv, bunExe, isWindows, normalizeBunSnapshot, tempDir } from "harness"; +import { chmodSync, chownSync } from "node:fs"; import { join, resolve } from "path"; const fixturePath = (...segs: string[]) => resolve(import.meta.dirname, "fixtures", "preload", ...segs); @@ -268,6 +269,21 @@ describe.concurrent("Given a working directory without a `bunfig.toml`", () => { expect(await run("index.ts", { args, cwd: join(String(dir), cwd) })).toEqual([out, "", 0]); }); + it.skipIf(isWindows || process.getuid?.() !== 0)("the one of another user does not count", async () => { + using dir = tempDir("bunfig-above", monorepo); + chownSync(join(String(dir), "bunfig.toml"), 65534, 65534); + expect(await run("index.ts", { cwd: join(String(dir), "packages/a") })).toEqual([none, "", 0]); + }); + + it.skipIf(isWindows).each([ + { name: "group-writable", mode: 0o775 }, + { name: "sticky and world-writable", mode: 0o1777 }, + ])("one's own counts in a $name directory", async ({ mode }) => { + using dir = tempDir("bunfig-above", monorepo); + chmodSync(String(dir), mode); + expect(await run("index.ts", { cwd: join(String(dir), "packages/a") })).toEqual([fromRoot, "", 0]); + }); + it("`bun ` in a workspace fails if the one of the workspace root cannot be parsed", async () => { using dir = tempDir("bunfig-above", { ...monorepo, "bunfig.toml": `[define\n` }); const [out, err, code] = await run("index.ts", { cwd: join(String(dir), "packages/a") }); diff --git a/test/js/bun/resolve/resolve.test.ts b/test/js/bun/resolve/resolve.test.ts index 802c61dfd77e..4903833359d2 100644 --- a/test/js/bun/resolve/resolve.test.ts +++ b/test/js/bun/resolve/resolve.test.ts @@ -2220,6 +2220,18 @@ describe.concurrent("--preserve-symlinks", () => { {}, "app/dep.mjs true\n", ], + [ + "with NODE_PRESERVE_SYMLINKS_MAIN=1 an entry point runs where the link points", + ["imported.mjs"], + { NODE_PRESERVE_SYMLINKS_MAIN: "1" }, + `${atTheTarget} true\n`, + ], + [ + "with NODE_PRESERVE_SYMLINKS_MAIN=1 an entry point given without its extension runs where the link points", + ["run", "./imported"], + { NODE_PRESERVE_SYMLINKS_MAIN: "1" }, + `${atTheTarget} true\n`, + ], ])("%s", async (_, args, env, stdout) => { expect(await run(args, env)).toEqual({ stdout, stderr: "", exitCode: 0 }); }); diff --git a/test/js/junit-reporter/junit.test.js b/test/js/junit-reporter/junit.test.js index 7a5950c60d0d..2cd62cb0a861 100644 --- a/test/js/junit-reporter/junit.test.js +++ b/test/js/junit-reporter/junit.test.js @@ -612,6 +612,8 @@ describe("junit reporter", () => { it.each([ [["--reporter=junit", "--reporter=dots", "--reporter-outfile=junit.xml"], true], [["--reporter-outfile=junit.xml"], false], + [["--reporter=junit", "--reporter=dots"], false], + [["--reporter=bogus", "--reporter=dots"], false], ])("%j writes the report: %p", async (flags, written) => { await using tmpDir = tempDir("junit-and-dots", { "package.json": "{}", From aab462c6f3b23c9d1623832a6b00f2c23168bccf Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 03:14:19 +0000 Subject: [PATCH 20/24] bunfig: compare owners on Windows too before reading a bunfig.toml above the cwd --- docs/runtime/bunfig.mdx | 2 +- src/bunfig/arguments.rs | 64 ++++++++++++++++++++++++++++++++++++-- src/windows_sys/externs.rs | 30 ++++++++++++++++-- 3 files changed, 90 insertions(+), 6 deletions(-) diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index e82ac6a717d5..72244e460641 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -11,7 +11,7 @@ Bun relies on pre-existing configuration files like `package.json` and `tsconfig Put `bunfig.toml` in your project root, alongside your `package.json`. -`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It skips a file in a parent directory that belongs to another user than the working directory, and it does not search parent directories when the working directory is inside `node_modules`. +`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It skips a file in a parent directory that belongs to another user than the working directory (on Windows, also one that is a symbolic link), and it does not search parent directories when the working directory is inside `node_modules`. To configure Bun's package manager globally, you can also create a `.bunfig.toml` file at one of the following paths: diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 8b05ba4f3ee6..8011e93653a9 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -187,9 +187,67 @@ fn has_owner_of(path: &ZStr, cwd: &[u8]) -> bool { bun_sys::lstat(&bun_core::ZBox::from_bytes(cwd)).is_ok_and(|dir| dir.st_uid == file.st_uid) } -#[cfg(not(unix))] -fn has_owner_of(path: &ZStr, _: &[u8]) -> bool { - bun_sys::exists_z(path) +/// The owner of a file or directory. +#[cfg(windows)] +struct Owner { + /// Points into `descriptor`. + sid: *mut core::ffi::c_void, + descriptor: *mut core::ffi::c_void, +} + +#[cfg(windows)] +impl Owner { + fn of(path: &[u8]) -> Option { + use bun_sys::windows::advapi32; + use core::ptr::null_mut; + + let mut wbuf = bun_paths::w_path_buffer_pool::get(); + let wpath = bun_paths::string_paths::to_kernel32_path(&mut wbuf, path); + let mut owner = Owner { + sid: null_mut(), + descriptor: null_mut(), + }; + // SAFETY: `wpath` is NUL-terminated, the two out-pointers are valid, and the others may be null. + let status = unsafe { + advapi32::GetNamedSecurityInfoW( + wpath.as_ptr(), + advapi32::SE_FILE_OBJECT, + advapi32::OWNER_SECURITY_INFORMATION, + &raw mut owner.sid, + null_mut(), + null_mut(), + null_mut(), + &raw mut owner.descriptor, + ) + }; + (status == 0 && !owner.sid.is_null()).then_some(owner) + } +} + +#[cfg(windows)] +impl Drop for Owner { + fn drop(&mut self) { + // SAFETY: `descriptor` is null or what `GetNamedSecurityInfoW` allocated, and nothing else frees it. + unsafe { bun_sys::windows::kernel32::LocalFree(self.descriptor) }; + } +} + +#[cfg(windows)] +fn has_owner_of(path: &ZStr, cwd: &[u8]) -> bool { + // `Owner::of` follows a link, and another user can make one to a file of the owner of `cwd`. + if bun_sys::get_file_attributes(path).is_none_or(|it| it.is_reparse_point) { + return false; + } + let (Some(file), Some(dir)) = (Owner::of(path.as_bytes()), Owner::of(cwd)) else { + return false; + }; + // SAFETY: both are valid SIDs in descriptors that `file` and `dir` keep alive. + unsafe { bun_sys::windows::advapi32::EqualSid(file.sid, dir.sid) != 0 } +} + +#[cfg(not(any(unix, windows)))] +fn has_owner_of(_: &ZStr, _: &[u8]) -> bool { + false } /// The nearest bunfig.toml above `cwd` that is in the same project: not above the nearest package.json, or the workspace root that lists it. diff --git a/src/windows_sys/externs.rs b/src/windows_sys/externs.rs index 990071300015..0da72b6d0bdc 100644 --- a/src/windows_sys/externs.rs +++ b/src/windows_sys/externs.rs @@ -797,8 +797,32 @@ pub use ntdll::NtClose; /// `user32` namespace (subset placeholder; fill in as needed). pub mod user32 {} -/// `advapi32` namespace (subset placeholder; fill in as needed). -pub mod advapi32 {} +/// `advapi32` namespace (subset). +pub mod advapi32 { + use super::*; + + /// `SE_OBJECT_TYPE::SE_FILE_OBJECT` (`accctrl.h`). + pub const SE_FILE_OBJECT: c_int = 1; + /// `OWNER_SECURITY_INFORMATION` (`winnt.h`). + pub const OWNER_SECURITY_INFORMATION: DWORD = 0x0000_0001; + + #[cfg_attr(windows, link(name = "advapi32"))] + unsafe extern "system" { + /// `GetNamedSecurityInfoW` (`aclapi.h`). The SIDs and ACLs point into `*ppSecurityDescriptor`, which the caller `LocalFree`s. + pub fn GetNamedSecurityInfoW( + pObjectName: LPCWSTR, + ObjectType: c_int, + SecurityInfo: DWORD, + ppsidOwner: *mut *mut c_void, + ppsidGroup: *mut *mut c_void, + ppDacl: *mut *mut c_void, + ppSacl: *mut *mut c_void, + ppSecurityDescriptor: *mut *mut c_void, + ) -> DWORD; + /// `EqualSid` (`securitybaseapi.h`). + pub fn EqualSid(pSid1: *mut c_void, pSid2: *mut c_void) -> BOOL; + } +} // `bun.windows.libuv` is exposed from the higher-tier `bun_sys::windows` // module, NOT here — `bun_windows_sys` is the leaf Win32 externs crate and @@ -886,6 +910,8 @@ pub mod kernel32 { pub fn GetExitCodeProcess(hProcess: HANDLE, lpExitCode: *mut DWORD) -> BOOL; /// `FlushFileBuffers` — fsync(2)-equivalent for HANDLE-backed files. pub fn FlushFileBuffers(hFile: HANDLE) -> BOOL; + /// `LocalFree` (`winbase.h`). Ignores null. + pub fn LocalFree(hMem: *mut c_void) -> *mut c_void; /// `lpOverlapped` is required even for synchronous handles: its `Offset` is the range start. pub fn LockFileEx( hFile: HANDLE, From a775e737b954aadb6f0116b4b46bb4a42d84fe3e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 03:32:42 +0000 Subject: [PATCH 21/24] Remove helpers that bun init's dead code was the last user of; pass ConfigFile to Bunfig::parse --- src/ast/expr.rs | 31 ------------ src/bunfig/arguments.rs | 14 +----- src/bunfig/bunfig.rs | 19 +++----- src/collections/bit_set.rs | 88 ----------------------------------- src/collections/hive_array.rs | 6 +-- 5 files changed, 10 insertions(+), 148 deletions(-) diff --git a/src/ast/expr.rs b/src/ast/expr.rs index 056ca92028e8..dc92bf852941 100644 --- a/src/ast/expr.rs +++ b/src/ast/expr.rs @@ -337,37 +337,6 @@ impl Expr { // Expr — property/object/string accessor methods. impl Expr { - pub fn has_any_property_named(&self, names: &'static [&'static [u8]]) -> bool { - if let Data::EObjectJSON(obj) = &self.data { - return obj - .get() - .properties() - .iter() - .any(|p| bun_core::eql_any_comptime(p.key.slice(), names)); - } - let Data::EObject(obj) = &self.data else { - return false; - }; - if obj.properties.len_u32() == 0 { - return false; - } - - for prop in obj.properties.slice() { - if prop.value.is_none() { - continue; - } - let Some(key) = &prop.key else { continue }; - let Data::EString(key_str) = &key.data else { - continue; - }; - if bun_core::eql_any_comptime(&key_str.data, names) { - return true; - } - } - - false - } - // toJS alias deleted — `to_js` lives in `bun_js_parser_jsc::expr_jsc` extension trait. /// Don't use this if you care about performance. diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 8011e93653a9..22986ec269f4 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -76,7 +76,7 @@ fn unreadable_config( } #[derive(Copy, Clone, PartialEq, Eq)] -enum ConfigFile { +pub(crate) enum ConfigFile { /// `$XDG_CONFIG_HOME/.bunfig.toml` or `$HOME/.bunfig.toml` User, /// `./bunfig.toml` @@ -138,17 +138,7 @@ fn load_bunfig( log: log_ptr, level: original_level, }; - let is_above_cwd = file == ConfigFile::ProjectAbove; - let is_project_file = is_above_cwd || file == ConfigFile::Project; - Bunfig::parse( - cmd, - &source, - is_project_file, - is_above_cwd, - ctx, - tool, - counts, - ) + Bunfig::parse(cmd, &source, file, ctx, tool, counts) } /// Whether the package.json in `root` lists `dir`, which is below `root`, as a workspace. `None` if it has no `workspaces`. diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index 0e162de12dc6..f5e902712c56 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -8,6 +8,7 @@ #![allow(clippy::collapsible_if)] +use crate::arguments::ConfigFile; use bun_collections::VecExt; use core::sync::atomic::Ordering; @@ -1245,8 +1246,7 @@ impl Bunfig { pub(crate) fn parse( cmd: CommandTag, source: &bun_ast::Source, - is_project_file: bool, - is_above_cwd: bool, + file: ConfigFile, ctx: &mut ContextData, tool: Option<&mut crate::ToolReader<'_>>, counts: Counts, @@ -1321,8 +1321,8 @@ impl Bunfig { source, ctx, bump: &bump, - is_project_file, - is_above_cwd, + is_project_file: matches!(file, ConfigFile::Project | ConfigFile::ProjectAbove), + is_above_cwd: file == ConfigFile::ProjectAbove, tool, counts, }; @@ -1353,15 +1353,8 @@ impl Bunfig { ..Default::default() }; let cmd = CommandTag::InstallCommand; - Self::parse( - cmd, - source, - true, - false, - &mut ctx, - None, - Counts::OnlyInstall, - )?; + let file = ConfigFile::Project; + Self::parse(cmd, source, file, &mut ctx, None, Counts::OnlyInstall)?; Ok(ctx.install) } } diff --git a/src/collections/bit_set.rs b/src/collections/bit_set.rs index 824ac200118b..c83c0fcde8f5 100644 --- a/src/collections/bit_set.rs +++ b/src/collections/bit_set.rs @@ -146,50 +146,12 @@ impl IntegerBitSet { (self.mask & Self::mask_bit(index)) != 0 } - /// Returns the total number of set bits in this bit set. - pub const fn count(self) -> usize { - self.mask.count_ones() as usize - } - /// Adds a specific bit to the bit set pub fn set(&mut self, index: usize) { debug_assert!(index < Self::BIT_LENGTH); self.mask |= Self::mask_bit(index); } - /// Changes the value of all bits in the specified range to - /// match the passed boolean. - pub fn set_range_value(&mut self, range: Range, value: bool) { - debug_assert!(range.end <= Self::BIT_LENGTH); - debug_assert!(range.start <= range.end); - if range.start == range.end { - return; - } - if SIZE == 0 { - return; - } - - let start_bit = u32::try_from(range.start).expect("int cast"); - - let mut mask = bool_mask_usize(true) << start_bit; - if range.end != Self::BIT_LENGTH { - let end_bit = u32::try_from(range.end).expect("int cast"); - // `~0 >> (usize::BITS - end_bit)` yields the low `end_bit` bits. - mask &= bool_mask_usize(true) >> (usize::BITS - end_bit); - } - // also clear bits above SIZE since the backing `usize` may be wider than SIZE bits - mask &= Self::FULL_MASK; - self.mask &= !mask; - - let mut mask = bool_mask_usize(value) << start_bit; - if range.end != Self::BIT_LENGTH { - let end_bit = u32::try_from(range.end).expect("int cast"); - mask &= bool_mask_usize(value) >> (usize::BITS - end_bit); - } - mask &= Self::FULL_MASK; - self.mask |= mask; - } - /// Removes a specific bit from the bit set pub fn unset(&mut self, index: usize) { debug_assert!(index < Self::BIT_LENGTH); @@ -210,29 +172,6 @@ impl IntegerBitSet { Some(mask.trailing_zeros() as usize) } - /// Iterates through the items in the set, according to the options. - /// The default options (.{}) will iterate indices of set bits in - /// ascending order. Modifications to the underlying bit set may - /// or may not be observed by the iterator. - pub fn iterator( - self, - ) -> SingleWordIterator { - SingleWordIterator { - bits_remain: if KIND_SET { - self.mask - } else { - !self.mask & Self::FULL_MASK - }, - } - } - - /// Iterate indices of set bits in ascending order. - /// Convenience wrapper for `iterator::()`. - #[inline] - pub fn iter_set(self) -> SingleWordIterator { - self.iterator::() - } - #[inline(always)] fn mask_bit(index: usize) -> usize { if SIZE == 0 { @@ -242,33 +181,6 @@ impl IntegerBitSet { } } -/// Iterator over a single-word `IntegerBitSet`. -pub struct SingleWordIterator { - // all bits which have not yet been iterated over - bits_remain: usize, -} - -impl SingleWordIterator { - /// Returns the index of the next unvisited set bit - /// in the bit set, in ascending order. - pub fn next(&mut self) -> Option { - if self.bits_remain == 0 { - return None; - } - - if DIR_FWD { - let next_index = self.bits_remain.trailing_zeros() as usize; - self.bits_remain &= self.bits_remain - 1; - Some(next_index) - } else { - let leading_zeroes = self.bits_remain.leading_zeros(); - let top_bit = (usize::BITS - 1 - leading_zeroes) as usize; - self.bits_remain &= (1usize << top_bit) - 1; - Some(top_bit) - } - } -} - // ───────────────────────────── ArrayBitSet ───────────────────────────── /// Number of `usize` masks needed to hold `bit_length` bits. diff --git a/src/collections/hive_array.rs b/src/collections/hive_array.rs index 51cfe23d8f23..108f0a5a376a 100644 --- a/src/collections/hive_array.rs +++ b/src/collections/hive_array.rs @@ -113,15 +113,13 @@ impl HiveBitSet { None } - /// Forward iterator over set bits. Mirrors `IntegerBitSet::iter_set`. + /// Forward iterator over set bits. #[inline] pub fn iter_set(&self) -> HiveBitSetIter { self.iterator::() } - /// Signature mirrors `IntegerBitSet::iterator` so existing - /// `hive.used.iterator::()` callers compile unchanged. Only - /// the `` combination is implemented (the + /// Only the `` combination is implemented (the /// only one used in-tree); other params assert. #[inline] pub fn iterator(&self) -> HiveBitSetIter { From f8a9f16eaf5306386f70646beeb6253f72e4235e Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 04:03:20 +0000 Subject: [PATCH 22/24] Review fixes: forward --jsx-side-effects to --parallel workers, complete file names with spaces in bash --- completions/bun.bash | 4 +- src/parsers/json.rs | 7 --- src/runtime/cli/test/parallel/runner.rs | 6 +-- test/cli/bun.test.ts | 3 +- test/cli/test/parallel.test.ts | 21 ++++++++ test/js/bun/spawn/bun-ipc-inherit.test.ts | 53 ++++++++++++++++++++- test/js/bun/spawn/ipc-channel-scrub.test.ts | 51 -------------------- 7 files changed, 79 insertions(+), 66 deletions(-) delete mode 100644 test/js/bun/spawn/ipc-channel-scrub.test.ts diff --git a/completions/bun.bash b/completions/bun.bash index 2072b438b655..06d0832f6445 100644 --- a/completions/bun.bash +++ b/completions/bun.bash @@ -3,14 +3,14 @@ _file_arguments() { local pattern="${1}"; local file; - for file in $(compgen -f -- "${cur_word}"); do + while IFS= read -r file; do if [[ -d "${file}" ]]; then # a subcommand goes before a directory of the same name: `bun te` gives `bun test ` [[ " ${COMPREPLY[*]} " == *" ${file} "* ]] || COMPREPLY+=( "${file}/" ); elif [[ "${file}" == ${pattern} ]]; then COMPREPLY+=( "${file}" ); fi - done + done < <(compgen -f -- "${cur_word}") # no space after a directory, so that the next lists what is in it [[ "${#COMPREPLY[@]}" -eq 1 && "${COMPREPLY[0]}" == */ ]] && compopt -o nospace 2>/dev/null; diff --git a/src/parsers/json.rs b/src/parsers/json.rs index 19ec1e1b6bc9..d9c68d2667e1 100644 --- a/src/parsers/json.rs +++ b/src/parsers/json.rs @@ -1942,13 +1942,6 @@ mod tests { root.get_object(b"name").is_some() ) .unwrap(); - writeln!( - out, - "has_any={} {}", - root.has_any_property_named(&[b"zzz", b"private"]), - root.has_any_property_named(&[b"zzz"]) - ) - .unwrap(); let deps = root.get(b"deps").unwrap(); assert!(deps.is_object()); diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 69dbe6aae56a..96201c1bcee9 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -420,9 +420,9 @@ fn build_worker_argv(ctx: &Command::ContextData) -> crate::Result { : 0; test.concurrent.skipIf(bashVersion < 4)("bash offers files and scripts, also after a flag", async () => { - using dir = tempDir("completions-bash", { ...project, "bun.bash": await script("bash") }); + using dir = tempDir("completions-bash", { ...project, "two words.ts": "", "bun.bash": await script("bash") }); const expected = { "bun sr": ["src/"], "bun src/": ["src/index.ts", "src/other.ts"], @@ -503,6 +503,7 @@ describe("bun", () => { "bun --cwd . run src/o": ["src/other.ts"], "bun test te": ["test/"], "bun myscript.ts fo": ["foo.txt"], + "bun tw": ["two words.ts"], "bun --silent add --p": ["--peer", "--production"], // As before. "bun add --p": ["--peer", "--production"], diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index 0a628a4438d5..95ed5a8c2829 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -1251,6 +1251,27 @@ test("--parallel forwards --conditions to workers", async () => { expect(exitCode).toBe(0); }); +test("--parallel forwards --jsx-side-effects to workers", async () => { + const fixture = `import {test,expect} from "bun:test"; let calls = 0; const h = () => void calls++; test("t",()=>{
; expect(calls).toBe(1); });`; + using dir = tempDir("parallel-jsx-side-effects", { + "tsconfig.json": JSON.stringify({ compilerOptions: { jsx: "react", jsxFactory: "h" } }), + "a.test.tsx": fixture, + "b.test.tsx": fixture, + }); + await using proc = Bun.spawn({ + cmd: [bunExe(), "test", "--parallel=2", "--jsx-side-effects"], + env: { ...bunEnv, BUN_TEST_PARALLEL_SCALE_MS: "0" }, + cwd: String(dir), + stderr: "pipe", + stdout: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(stdout).toContain("PARALLEL"); + expect(stderr).toContain("2 pass"); + expect(stderr).toContain("0 fail"); + expect(exitCode).toBe(0); +}); + test("--parallel: workers get the --env-file values and skip the default .env", async () => { const fixture = `import {test,expect} from "bun:test"; test("env", () => { expect(process.env.BUNTEST_CUSTOM).toBe("1"); expect(process.env.BUNTEST_DOTENV).toBeUndefined(); });`; using dir = tempDir("parallel-env-file", { diff --git a/test/js/bun/spawn/bun-ipc-inherit.test.ts b/test/js/bun/spawn/bun-ipc-inherit.test.ts index 00a4ca8988c0..a834434f2274 100644 --- a/test/js/bun/spawn/bun-ipc-inherit.test.ts +++ b/test/js/bun/spawn/bun-ipc-inherit.test.ts @@ -1,6 +1,6 @@ import { spawn } from "bun"; -import { expect, it } from "bun:test"; -import { bunExe, isWindows, tmpdirSync } from "harness"; +import { expect, it, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir, tmpdirSync } from "harness"; import fs from "node:fs/promises"; import path from "path"; @@ -28,3 +28,52 @@ it.todoIf(isWindows)("spawning a bun package script should inherit the ipc fd", await child.exited; expect(testMessage).toBe("hello"); }); + +// A bun CLI process that inherits an IPC channel (NODE_CHANNEL_FD) must not +// forward that channel to the scripts and tools it spawns, the way Node sets +// the channel fd close-on-exec and deletes the variables before it spawns. +// +// The grandchild is a POSIX shell, not bun or node: bun and node remove +// NODE_CHANNEL_FD from `process.env` while they adopt the channel, so only a +// shell reports the raw inherited value. +const SHOW_CHANNEL = [ + "#!/bin/sh", + `printf 'NODE_CHANNEL_FD=[%s]\\n' "\${NODE_CHANNEL_FD:-}"`, + `printf 'NODE_CHANNEL_SERIALIZATION_MODE=[%s]\\n' "\${NODE_CHANNEL_SERIALIZATION_MODE:-}"`, + "", +].join("\n"); + +const NO_CHANNEL = "NODE_CHANNEL_FD=[]\nNODE_CHANNEL_SERIALIZATION_MODE=[]\n"; + +// Spawn `bun ` with an IPC channel (`ipc` makes Bun.spawn create one and +// set NODE_CHANNEL_FD for the direct child) and report what the grandchild saw. +async function runWithChannel(args: string[], cwd: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), ...args], + env: bunEnv, + cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + ipc() {}, + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +test.concurrent.skipIf(isWindows)("bun exec does not forward the inherited IPC channel", async () => { + using dir = tempDir("ipc-scrub-exec", { "show.sh": SHOW_CHANNEL }); + const { stdout, stderr, exitCode } = await runWithChannel(["exec", "sh show.sh"], String(dir)); + expect(stdout).toBe(NO_CHANNEL); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); +}); + +test.concurrent.skipIf(isWindows)("bun run --shell=bun does not forward the inherited IPC channel", async () => { + using dir = tempDir("ipc-scrub-bunshell", { + "package.json": JSON.stringify({ name: "x", version: "1.0.0", scripts: { show: "sh show.sh" } }), + "show.sh": SHOW_CHANNEL, + }); + const { stdout, stderr, exitCode } = await runWithChannel(["run", "--silent", "--shell=bun", "show"], String(dir)); + expect(stdout).toBe(NO_CHANNEL); + expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); +}); diff --git a/test/js/bun/spawn/ipc-channel-scrub.test.ts b/test/js/bun/spawn/ipc-channel-scrub.test.ts deleted file mode 100644 index 962d01247bf7..000000000000 --- a/test/js/bun/spawn/ipc-channel-scrub.test.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { expect, test } from "bun:test"; -import { bunEnv, bunExe, isWindows, tempDir } from "harness"; - -// A bun CLI process that inherits an IPC channel (NODE_CHANNEL_FD) must not -// forward that channel to the scripts and tools it spawns, the way Node sets -// the channel fd close-on-exec and deletes the variables before it spawns. -// -// The grandchild is a POSIX shell, not bun or node: bun and node remove -// NODE_CHANNEL_FD from `process.env` while they adopt the channel, so only a -// shell reports the raw inherited value. -const SHOW_CHANNEL = [ - "#!/bin/sh", - `printf 'NODE_CHANNEL_FD=[%s]\\n' "\${NODE_CHANNEL_FD:-}"`, - `printf 'NODE_CHANNEL_SERIALIZATION_MODE=[%s]\\n' "\${NODE_CHANNEL_SERIALIZATION_MODE:-}"`, - "", -].join("\n"); - -const NO_CHANNEL = "NODE_CHANNEL_FD=[]\nNODE_CHANNEL_SERIALIZATION_MODE=[]\n"; - -// Spawn `bun ` with an IPC channel (`ipc` makes Bun.spawn create one and -// set NODE_CHANNEL_FD for the direct child) and report what the grandchild saw. -async function runWithChannel(args: string[], cwd: string) { - await using proc = Bun.spawn({ - cmd: [bunExe(), ...args], - env: bunEnv, - cwd, - stdin: "ignore", - stdout: "pipe", - stderr: "pipe", - ipc() {}, - }); - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr, exitCode }; -} - -test.concurrent.skipIf(isWindows)("bun exec does not forward the inherited IPC channel", async () => { - using dir = tempDir("ipc-scrub-exec", { "show.sh": SHOW_CHANNEL }); - const { stdout, stderr, exitCode } = await runWithChannel(["exec", "sh show.sh"], String(dir)); - expect(stdout).toBe(NO_CHANNEL); - expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); -}); - -test.concurrent.skipIf(isWindows)("bun run --shell=bun does not forward the inherited IPC channel", async () => { - using dir = tempDir("ipc-scrub-bunshell", { - "package.json": JSON.stringify({ name: "x", version: "1.0.0", scripts: { show: "sh show.sh" } }), - "show.sh": SHOW_CHANNEL, - }); - const { stdout, stderr, exitCode } = await runWithChannel(["run", "--silent", "--shell=bun", "show"], String(dir)); - expect(stdout).toBe(NO_CHANNEL); - expect({ stderr, exitCode }).toEqual({ stderr: "", exitCode: 0 }); -}); From be203b5262a383077f9bb1834380881f68f64d17 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 04:08:48 +0000 Subject: [PATCH 23/24] isolated-install.test.ts: bun can wait for a lifecycle script when SIGCHLD is ignored --- test/cli/install/isolated-install.test.ts | 28 ++++------------------- 1 file changed, 5 insertions(+), 23 deletions(-) diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 91fc99bb6fdb..ac96f9eb21f3 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -5434,23 +5434,6 @@ describe.each(["isolated", "hoisted"] as const)("failed script of an optional de }, ); - // With SIGCHLD ignored, Linux reaps the script itself and bun cannot wait for it. Bun then - // treats a script that exited 0 as failed, through a second exit path. - test.concurrent.skipIf(!isLinux)("workspace: the folder is kept when bun can't wait for the script", async () => { - const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker }, files: workspaceFiles() }); - const verb = linker === "hoisted" ? "deleting" : "skipping"; - - const { stderr, exitCode } = await installCounted(packageDir, { - argv0: ["bash", "-c", 'trap "" CHLD; exec "$0" "$@"'], - args: ["--filter", "y", "--verbose"], - pass: true, - }); - expect(stderr).not.toContain("error:"); - expect(stderr).toContain(`${verb} optional dependency 'x' due to failed 'postinstall' script`); - expect(await workspaceState(packageDir)).toEqual({ runs: ["x"], ...kept }); - expect(exitCode).toBe(0); - }); - test.concurrent("file: folder: the copy is removed, also in a workspace's node_modules", async () => { const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker }, @@ -6121,14 +6104,13 @@ describe.concurrent("bin links of a failed optional dependency (hoisted)", () => expect(trusted.exitCode).toBe(0); }); - // With SIGCHLD ignored, Linux reaps the script itself and bun cannot wait for it. Bun then - // treats a script that exited 0 as failed, through a second exit path. - test.concurrent.skipIf(!isLinux)("a script that bun cannot wait for", async () => { + // If SIGCHLD stayed ignored, Linux would reap the script itself, and bun would take a script that exited 0 for a failed one. + test.concurrent.skipIf(!isLinux)("a script that exits 0 while the parent process ignores SIGCHLD", async () => { const { packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "hoisted" }, files: { "package.json": JSON.stringify({ - name: "optional-bin-cannot-wait", + name: "optional-bin-sigchld-ignored", optionalDependencies: { x: "file:./x" }, trustedDependencies: ["x"], }), @@ -6149,11 +6131,11 @@ describe.concurrent("bin links of a failed optional dependency (hoisted)", () => }); expect(stderr).not.toContain("error:"); - expect(stderr).toContain("deleting optional dependency 'x' due to failed 'postinstall' script"); + expect(stderr).not.toContain("failed 'postinstall' script"); expect({ bins: await linkedBins(join(nodeModules, ".bin")), installed: existsSync(join(nodeModules, "x")), - }).toEqual({ bins: [], installed: false }); + }).toEqual({ bins: ["xbin"], installed: true }); expect(exitCode).toBe(0); }); From 04b8877f6babe4a128d91cb747205d359a0cac13 Mon Sep 17 00:00:00 2001 From: Jarred Sumner Date: Mon, 19 Oct 2026 06:22:25 +0000 Subject: [PATCH 24/24] bunfig: the directory of a bunfig.toml above the cwd must have the cwd's owner too; don't apply its [test] root --- docs/runtime/bunfig.mdx | 6 +++--- src/bun_core/util.rs | 1 + src/bunfig/arguments.rs | 25 ++++++++++++++++--------- src/bunfig/bunfig.rs | 3 ++- test/config/bunfig/preload.test.ts | 24 ++++++++++++++++++++++-- 5 files changed, 44 insertions(+), 15 deletions(-) diff --git a/docs/runtime/bunfig.mdx b/docs/runtime/bunfig.mdx index 72244e460641..49c9a7d07292 100644 --- a/docs/runtime/bunfig.mdx +++ b/docs/runtime/bunfig.mdx @@ -11,7 +11,7 @@ Bun relies on pre-existing configuration files like `package.json` and `tsconfig Put `bunfig.toml` in your project root, alongside your `package.json`. -`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It skips a file in a parent directory that belongs to another user than the working directory (on Windows, also one that is a symbolic link), and it does not search parent directories when the working directory is inside `node_modules`. +`bun `, `bun run`, `bun test`, and `bun build` read the `bunfig.toml` in the working directory. If there is none, they read the nearest one in a parent directory of the same project. The search stops at the directory of the nearest `package.json`, or at the workspace root whose `workspaces` lists that directory. Bun reads only one of these files and does not merge them. It skips a file in a parent directory unless the file and its directory have the same owner as the working directory (on Windows, it also skips a symbolic link), and it does not search parent directories when the working directory is inside `node_modules`. To configure Bun's package manager globally, you can also create a `.bunfig.toml` file at one of the following paths: @@ -38,7 +38,7 @@ An array of scripts/plugins to execute before running a file or script. preload = ["./preload.ts"] ``` -Relative paths are relative to the directory of the `bunfig.toml` that Bun found. With `--config`, they are relative to the working directory. +A relative path in `preload` or [`test.preload`](#test-preload) is relative to the directory of the `bunfig.toml` that Bun found. With `--config`, it is relative to the working directory. In a `bunfig.toml` that Bun found in a parent directory, only these two and the paths in [`[install]`](#package-manager) are relative to that file's directory, and [`test.root`](#test-root) is not applied. ### `jsx` @@ -192,7 +192,7 @@ The `[test]` section of `bunfig.toml` configures the test runner. ### `test.root` -The root directory to run tests from, relative to the working directory. Default `.`. +The root directory to run tests from, relative to the working directory. Default `.`. Bun ignores it in a `bunfig.toml` that it found in a parent directory. ```toml title="bunfig.toml" icon="settings" [test] diff --git a/src/bun_core/util.rs b/src/bun_core/util.rs index 540147bc13ca..42e0c150d10f 100644 --- a/src/bun_core/util.rs +++ b/src/bun_core/util.rs @@ -4578,6 +4578,7 @@ pub fn keep_children_waitable() { && current.sa_sigaction == libc::SIG_IGN { current.sa_sigaction = libc::SIG_DFL; + current.sa_flags = 0; libc::sigaction(libc::SIGCHLD, &raw const current, core::ptr::null_mut()); } } diff --git a/src/bunfig/arguments.rs b/src/bunfig/arguments.rs index 22986ec269f4..9cdb42c68b6e 100644 --- a/src/bunfig/arguments.rs +++ b/src/bunfig/arguments.rs @@ -168,13 +168,15 @@ fn lists_workspace(root: &[u8], dir: &[u8], buf: &mut PathBuffer) -> Option bool { +fn has_owner_of(path: &ZStr, dir: &[u8], cwd: &[u8]) -> bool { let Ok(file) = bun_sys::lstat(path) else { return false; }; - bun_sys::lstat(&bun_core::ZBox::from_bytes(cwd)).is_ok_and(|dir| dir.st_uid == file.st_uid) + [dir, cwd].into_iter().all(|it| { + bun_sys::lstat(&bun_core::ZBox::from_bytes(it)).is_ok_and(|it| it.st_uid == file.st_uid) + }) } /// The owner of a file or directory. @@ -223,20 +225,24 @@ impl Drop for Owner { } #[cfg(windows)] -fn has_owner_of(path: &ZStr, cwd: &[u8]) -> bool { +fn has_owner_of(path: &ZStr, dir: &[u8], cwd: &[u8]) -> bool { + use bun_sys::windows::advapi32::EqualSid; + // `Owner::of` follows a link, and another user can make one to a file of the owner of `cwd`. if bun_sys::get_file_attributes(path).is_none_or(|it| it.is_reparse_point) { return false; } - let (Some(file), Some(dir)) = (Owner::of(path.as_bytes()), Owner::of(cwd)) else { + let (Some(file), Some(dir), Some(cwd)) = + (Owner::of(path.as_bytes()), Owner::of(dir), Owner::of(cwd)) + else { return false; }; - // SAFETY: both are valid SIDs in descriptors that `file` and `dir` keep alive. - unsafe { bun_sys::windows::advapi32::EqualSid(file.sid, dir.sid) != 0 } + // SAFETY: all are valid SIDs in descriptors that `file`, `dir` and `cwd` keep alive. + unsafe { EqualSid(file.sid, cwd.sid) != 0 && EqualSid(dir.sid, cwd.sid) != 0 } } #[cfg(not(any(unix, windows)))] -fn has_owner_of(_: &ZStr, _: &[u8]) -> bool { +fn has_owner_of(_: &ZStr, _: &[u8], _: &[u8]) -> bool { false } @@ -251,7 +257,8 @@ fn find_config_above<'buf>(cwd: &[u8], buf: &'buf mut PathBuffer) -> Option<&'bu let mut config_dir = cwd; loop { config_dir = bun_core::dirname(config_dir)?; - if has_owner_of(join_config_path(config_dir, b"bunfig.toml", buf)?, cwd) { + let path = join_config_path(config_dir, b"bunfig.toml", buf)?; + if has_owner_of(path, config_dir, cwd) { break; } } diff --git a/src/bunfig/bunfig.rs b/src/bunfig/bunfig.rs index f5e902712c56..bd3ebf947787 100644 --- a/src/bunfig/bunfig.rs +++ b/src/bunfig/bunfig.rs @@ -469,7 +469,8 @@ impl<'a> Parser<'a, '_> { // The flags of `bun test` are applied already, and they win over these keys. if cmd == CommandTag::TestCommand { if let Some(test) = json.get(b"test") { - if let Some(root) = test.get(b"root") { + // `root` is relative to the working directory, not to the directory of a file found above it. + if let Some(root) = test.get(b"root").filter(|_| !self.is_above_cwd) { self.ctx.debug.test_directory = root.as_string(self.bump).unwrap_or(b"").into(); } diff --git a/test/config/bunfig/preload.test.ts b/test/config/bunfig/preload.test.ts index 0c825339ec6d..a6bedf881736 100644 --- a/test/config/bunfig/preload.test.ts +++ b/test/config/bunfig/preload.test.ts @@ -269,9 +269,12 @@ describe.concurrent("Given a working directory without a `bunfig.toml`", () => { expect(await run("index.ts", { args, cwd: join(String(dir), cwd) })).toEqual([out, "", 0]); }); - it.skipIf(isWindows || process.getuid?.() !== 0)("the one of another user does not count", async () => { + it.skipIf(isWindows || process.getuid?.() !== 0).each([ + { name: "the one of another user does not count", path: "bunfig.toml" }, + { name: "one's own in a directory of another user does not count", path: "." }, + ])("$name", async ({ path }) => { using dir = tempDir("bunfig-above", monorepo); - chownSync(join(String(dir), "bunfig.toml"), 65534, 65534); + chownSync(join(String(dir), path), 65534, 65534); expect(await run("index.ts", { cwd: join(String(dir), "packages/a") })).toEqual([none, "", 0]); }); @@ -299,6 +302,23 @@ describe.concurrent("Given a working directory without a `bunfig.toml`", () => { expect(code).toBe(0); }); + it.each([ + { name: "`bun test` in a workspace", args: [], cwd: "packages/a" }, + { name: "`bun run --filter a test`", args: ["run", "--filter", "a"], cwd: "." }, + ])( + "$name runs the workspace's tests when the one of the workspace root sets `[test] root`", + async ({ args, cwd }) => { + using dir = tempDir("bunfig-above", { + ...monorepo, + "bunfig.toml": rootBunfig + `root = "./tests"\n`, + "packages/a/package.json": JSON.stringify({ name: "a", scripts: { test: `${bunExe()} test` } }), + }); + const [out, , code] = await run("test", { args, cwd: join(String(dir), cwd) }); + expect(out).toContain("number undefined"); + expect(code).toBe(0); + }, + ); + it("`bun build` in a workspace reads the one of the workspace root", async () => { using dir = tempDir("bunfig-above", monorepo); const [out, err, code] = await run("index.ts", { args: ["build"], cwd: join(String(dir), "packages/a") });