From c65112a324db1073ccbb1e846bcc4bf5c2f06ee6 Mon Sep 17 00:00:00 2001 From: Jeff Noel Date: Sun, 16 Aug 2026 22:06:05 -0400 Subject: [PATCH 1/4] Fix bunx fork-bombing itself when BUN_OPTIONS is set BUN_OPTIONS tokens are spliced into argv right after argv[0], which shifted the "add" the parent bunx spawned its install child with out of the BUN_INTERNAL_BUNX_INSTALL escape hatch's positional check; the child re-entered bunx mode and re-spawned itself forever. Fixes #39377 --- src/runtime/cli/mod.rs | 6 +++++- test/cli/install/bunx.test.ts | 39 +++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/src/runtime/cli/mod.rs b/src/runtime/cli/mod.rs index f609dfdd4a0b..489d5231c03e 100644 --- a/src/runtime/cli/mod.rs +++ b/src/runtime/cli/mod.rs @@ -913,7 +913,11 @@ pub mod command { }; if is_bun_x(argv0) { - if let Some(next) = argv.get(1) { + // BUN_OPTIONS tokens are spliced in right after argv[0], so the + // "add"/"exec" the parent bunx spawned its install child with sits + // at `1 + bun_options_argc()` — checking argv[1] made the escape + // hatch below miss it and bunx re-spawn itself forever (#39377). + if let Some(next) = argv.get(1 + bun::bun_options_argc()) { let next_bytes = next.as_bytes(); if next_bytes == b"add" && bun_core::env_var::feature_flag::BUN_INTERNAL_BUNX_INSTALL.get() diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index ec0a9fb5a508..44c851f43e0e 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -511,6 +511,45 @@ it.concurrent("should handle postinstall scripts correctly with symlinked bunx", expect(exited).toBe(0); }); +// Regression test for #39377: BUN_OPTIONS tokens spliced after argv[0] used to +// defeat the BUN_INTERNAL_BUNX_INSTALL escape hatch's positional check, so the +// install child re-entered bunx mode and re-spawned itself forever. Before the +// fix this test hangs instead of completing. +it.concurrent( + "bunx does not fork-bomb when BUN_OPTIONS is set", + async () => { + const { x_dir, env } = setup(); + // The bug only triggers when argv[0] is "bunx": the `bun x` spelling + // dispatches through the generic flag-skipping path and is immune. + copyFileSync(bunExe(), join(x_dir, isWindows ? "bun.exe" : "bun")); + copyFileSync(bunExe(), join(x_dir, isWindows ? "bunx.exe" : "bunx")); + + const subprocess = spawn({ + cmd: ["bunx", "esbuild@latest", "--version"], + cwd: x_dir, + stdout: "pipe", + stdin: "inherit", + stderr: "pipe", + env: { + ...env, + BUN_OPTIONS: "--smol", + PATH: `${x_dir}${isWindows ? ";" : ":"}${env.PATH || ""}`, + }, + }); + + const [err, out, exited] = await Promise.all([ + subprocess.stderr.text(), + subprocess.stdout.text(), + subprocess.exited, + ]); + + expect(err).not.toContain("error:"); + expect(out.trim()).not.toContain(Bun.version); + expect(exited).toBe(0); + }, + 1000 * 60 * 2, +); + // Pinned to 20: its engines are "^20.19.0 || ^22.12.0 || >=24.0.0", so the node-24 // requirement this test exercises holds no matter what Node.js version Bun reports. // @latest tracks Angular's engines upward and breaks whenever they outrun us. From 7e5c01062a34ca35f75ebf4cdca7aa258b9e6ceb Mon Sep 17 00:00:00 2001 From: Jeff Noel Date: Sun, 16 Aug 2026 22:35:10 -0400 Subject: [PATCH 2/4] Assert esbuild's version output in the BUN_OPTIONS regression test Review follow-up: the success path now checks stdout looks like a semver, not just that it isn't Bun's own version. --- test/cli/install/bunx.test.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 44c851f43e0e..014f1ca18cd2 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -545,6 +545,7 @@ it.concurrent( expect(err).not.toContain("error:"); expect(out.trim()).not.toContain(Bun.version); + expect(out.trim()).toMatch(/^\d+\.\d+\.\d+/); expect(exited).toBe(0); }, 1000 * 60 * 2, From 4b14dc9b9bfceaf6dcb306645aff1f51918705c5 Mon Sep 17 00:00:00 2001 From: Jeff Noel Date: Mon, 17 Aug 2026 09:05:57 -0400 Subject: [PATCH 3/4] Add a decoy-based escape hatch test alongside the e2e regression test Suggested in review: exercises the hatch directly with decoy add/exec executables on PATH, so a regression fails as a bounded wrong-output assertion (~300 ms, no network) instead of recursing. Also covers the exec arm and a two-token BUN_OPTIONS value. Co-authored-by: robobun --- test/cli/install/bunx.test.ts | 54 ++++++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 014f1ca18cd2..1dc5f45c3614 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -2,7 +2,7 @@ import { spawn } from "bun"; import { afterAll, beforeAll, beforeEach, describe, expect, it, setDefaultTimeout } from "bun:test"; import { mkdir, rm, writeFile } from "fs/promises"; import { bunEnv, bunExe, isWindows, readdirSorted, tmpdirSync } from "harness"; -import { chmodSync, copyFileSync, readdirSync, symlinkSync } from "node:fs"; +import { chmodSync, copyFileSync, readdirSync, symlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "os"; import { delimiter, join, resolve } from "path"; import { dummyAfterAll, dummyBeforeAll, dummyBeforeEach, dummyRegistry, getPort, setHandler } from "./dummy.registry"; @@ -551,6 +551,58 @@ it.concurrent( 1000 * 60 * 2, ); +// Companion to the end-to-end test above: exercises the escape hatch directly, +// so a regression fails as a bounded wrong-output assertion in under a second +// (no network, no process chain). Covers the exec arm and a two-token +// BUN_OPTIONS value the e2e path doesn't reach. +it.concurrent("BUN_OPTIONS does not break the internal bunx install escape hatch", async () => { + const { env } = setup(); + // Dispatch keys off argv[0] ending in "bunx". + const bunxDir = tmpdirSync(); + const bunxPath = join(bunxDir, isWindows ? "bunx.exe" : "bunx"); + if (isWindows) copyFileSync(bunExe(), bunxPath); + else symlinkSync(bunExe(), bunxPath); + + // If the escape hatch misses, BunxCommand resolves "add"/"exec" as the + // package to run and finds these on PATH, so the failure is bounded + // wrong output instead of an unbounded chain of installs. + const decoyDir = tmpdirSync(); + for (const name of ["add", "exec"]) { + if (isWindows) { + writeFileSync(join(decoyDir, `${name}.cmd`), `@echo MISDISPATCHED_${name.toUpperCase()}\r\n`); + } else { + writeFileSync(join(decoyDir, name), `#!/bin/sh\necho MISDISPATCHED_${name.toUpperCase()}\n`, { mode: 0o755 }); + } + } + + // One injected token and two, so skipping a fixed count instead of + // bun_options_argc() still fails. + for (const bunOptions of ["--smol", "--smol --silent"]) { + const childEnv = { + ...env, + BUN_OPTIONS: bunOptions, + BUN_INTERNAL_BUNX_INSTALL: "true", + PATH: `${decoyDir}${delimiter}${env.PATH || ""}`, + }; + + { + await using proc = spawn({ cmd: [bunxPath, "add", "--help"], env: childEnv, stdout: "pipe", stderr: "pipe" }); + const [out, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(out).not.toContain("MISDISPATCHED_ADD"); + expect(out).toContain("bun add"); + expect(exitCode).toBe(0); + } + + { + await using proc = spawn({ cmd: [bunxPath, "exec", "echo hatch-ok"], env: childEnv, stdout: "pipe", stderr: "pipe" }); + const [out, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(out).not.toContain("MISDISPATCHED_EXEC"); + expect(out.trim()).toBe("hatch-ok"); + expect(exitCode).toBe(0); + } + } +}); + // Pinned to 20: its engines are "^20.19.0 || ^22.12.0 || >=24.0.0", so the node-24 // requirement this test exercises holds no matter what Node.js version Bun reports. // @latest tracks Angular's engines upward and breaks whenever they outrun us. From 4c32c98f6a5892f8d3f1ad26c10fa99cb8d31a1e Mon Sep 17 00:00:00 2001 From: Jeff Noel Date: Mon, 17 Aug 2026 09:19:39 -0400 Subject: [PATCH 4/4] Drain stderr in the escape hatch test Review follow-up: both children piped stderr without consuming it, which can block a chatty child before exited resolves; drain it and assert the misdispatch signal there too. --- test/cli/install/bunx.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/test/cli/install/bunx.test.ts b/test/cli/install/bunx.test.ts index 1dc5f45c3614..f8e706abd8b4 100644 --- a/test/cli/install/bunx.test.ts +++ b/test/cli/install/bunx.test.ts @@ -587,16 +587,18 @@ it.concurrent("BUN_OPTIONS does not break the internal bunx install escape hatch { await using proc = spawn({ cmd: [bunxPath, "add", "--help"], env: childEnv, stdout: "pipe", stderr: "pipe" }); - const [out, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [out, errOut, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(out).not.toContain("MISDISPATCHED_ADD"); + expect(errOut).not.toContain("MISDISPATCHED_ADD"); expect(out).toContain("bun add"); expect(exitCode).toBe(0); } { await using proc = spawn({ cmd: [bunxPath, "exec", "echo hatch-ok"], env: childEnv, stdout: "pipe", stderr: "pipe" }); - const [out, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + const [out, errOut, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); expect(out).not.toContain("MISDISPATCHED_EXEC"); + expect(errOut).not.toContain("MISDISPATCHED_EXEC"); expect(out.trim()).toBe("hatch-ok"); expect(exitCode).toBe(0); }