From f88f60af5a50cdcb7bd430f4d1d92e32fe270f5a Mon Sep 17 00:00:00 2001 From: robobun Date: Fri, 23 Jan 2026 20:24:12 -0800 Subject: [PATCH] fix(bundler): throw error when Bun.build is called from macro during bundling (#26361) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary - Fixes #26360 - Detects when `Bun.build` is called from within macro mode during bundling and throws a clear error instead of hanging indefinitely ## Problem When `Bun.build` API is called to bundle a file that imports from a macro which itself uses `Bun.build`, the process would hang indefinitely due to a deadlock: 1. The bundler uses a singleton thread for processing `Bun.build` calls 2. During parsing, when a macro is encountered, it's evaluated on that thread 3. If the macro calls `Bun.build`, it tries to enqueue to the same singleton thread 4. The singleton is blocked waiting for macro completion → deadlock ## Solution Added a check in `Bun.build` that detects when it's called from macro mode (`vm.macro_mode`) and throws a clear error with guidance: ``` Bun.build cannot be called from within a macro during bundling. This would cause a deadlock because the bundler is waiting for the macro to complete, but the macro's Bun.build call is waiting for the bundler. To bundle code at compile time in a macro, use Bun.spawnSync to invoke the CLI: const result = Bun.spawnSync(["bun", "build", entrypoint, "--format=esm"]); ``` ## Test plan - [x] Added regression test in `test/regression/issue/26360.test.ts` - [x] Verified test hangs/fails with system Bun (the bug exists) - [x] Verified test passes with the fix applied - [x] Verified regular `Bun.build` (not in macro context) still works 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Bot Co-authored-by: Claude Opus 4.5 Co-authored-by: Jarred Sumner --- src/bun.js/api/JSBundler.zig | 24 ++++- test/regression/issue/26360.test.ts | 139 ++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+), 1 deletion(-) create mode 100644 test/regression/issue/26360.test.ts diff --git a/src/bun.js/api/JSBundler.zig b/src/bun.js/api/JSBundler.zig index 57a8fe763a..3338c97c18 100644 --- a/src/bun.js/api/JSBundler.zig +++ b/src/bun.js/api/JSBundler.zig @@ -1081,6 +1081,28 @@ pub const JSBundler = struct { return globalThis.throwInvalidArguments("Expected a config object to be passed to Bun.build", .{}); } + const vm = globalThis.bunVM(); + + // Detect and prevent calling Bun.build from within a macro during bundling. + // This would cause a deadlock because: + // 1. The bundler thread (singleton) is processing the outer Bun.build + // 2. During parsing, it encounters a macro and evaluates it + // 3. The macro calls Bun.build, which tries to enqueue to the same singleton thread + // 4. The singleton thread is blocked waiting for the macro to complete -> deadlock + if (vm.macro_mode) { + return globalThis.throw( + \\Bun.build cannot be called from within a macro during bundling. + \\ + \\This would cause a deadlock because the bundler is waiting for the macro to complete, + \\but the macro's Bun.build call is waiting for the bundler. + \\ + \\To bundle code at compile time in a macro, use Bun.spawnSync to invoke the CLI: + \\ const result = Bun.spawnSync(["bun", "build", entrypoint, "--format=esm"]); + , + .{}, + ); + } + var plugins: ?*Plugin = null; const config = try Config.fromJS(globalThis, arguments[0], &plugins, bun.default_allocator); @@ -1088,7 +1110,7 @@ pub const JSBundler = struct { config, plugins, globalThis, - globalThis.bunVM().eventLoop(), + vm.eventLoop(), bun.default_allocator, ); } diff --git a/test/regression/issue/26360.test.ts b/test/regression/issue/26360.test.ts new file mode 100644 index 0000000000..fd4fc4789c --- /dev/null +++ b/test/regression/issue/26360.test.ts @@ -0,0 +1,139 @@ +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; + +// https://github.com/oven-sh/bun/issues/26360 +// Bug: Bun.build API hangs indefinitely when called from within a macro that is +// evaluated during another Bun.build call. The CLI `bun build` works correctly. +// +// Root cause: The bundler uses a singleton thread for processing Bun.build calls. +// When a macro is evaluated during bundling and that macro calls Bun.build: +// 1. The singleton bundler thread is processing the outer Bun.build +// 2. The macro runs on the bundler thread and calls Bun.build +// 3. The inner Bun.build tries to enqueue to the same singleton thread +// 4. The singleton thread is blocked waiting for the macro to complete -> deadlock +// +// Fix: Detect when Bun.build is called from within macro mode and throw a clear error. + +test("Bun.build from macro during bundling throws instead of hanging", async () => { + using dir = tempDir("issue-26360", { + // A simple file that will be bundled by the macro + "browser.ts": `console.log("browser code"); +export default ""; +`, + + // A macro that calls Bun.build and catches the error + // The error should indicate that Bun.build cannot be called from macro context + "macro.ts": `import browserCode from "./browser" with { type: "file" }; + +let errorMessage = "no error"; +try { + const built = await Bun.build({ + entrypoints: [browserCode], + format: "esm", + }); +} catch (e) { + errorMessage = "CAUGHT: " + e.message; +} +export const getErrorMessage = (): string => errorMessage; +`, + + // File that imports from the macro + "index.ts": `import { getErrorMessage } from "./macro" with { type: "macro" }; +console.log("ERROR_MSG:", getErrorMessage()); +`, + + // Build script that uses Bun.build API (this would hang before the fix) + "build_script.ts": `const result = await Bun.build({ + entrypoints: ["./index.ts"], +}); + +if (!result.success) { + console.log("BUILD_ERROR"); + for (const log of result.logs) { + console.log(log.message); + } +} else { + console.log("BUILD_SUCCESS"); + // Print the output to verify the macro caught the error + const text = await result.outputs[0].text(); + console.log(text); +} +`, + }); + + // Run the build script - should complete (not hang) and the macro should have caught the error + await using proc = Bun.spawn({ + cmd: [bunExe(), "build_script.ts"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The build should succeed (the macro catches the error) + expect(stdout).toContain("BUILD_SUCCESS"); + // The macro should have received the error message about Bun.build not being allowed + expect(stdout).toContain("Bun.build cannot be called from within a macro"); +}); + +test("CLI bun build with macro that calls Bun.build also throws", async () => { + using dir = tempDir("issue-26360-cli", { + "browser.ts": `console.log("browser code"); +export default ""; +`, + + // A macro that calls Bun.build and catches the error + "macro.ts": `import browserCode from "./browser" with { type: "file" }; + +let errorMessage = ""; +try { + const built = await Bun.build({ + entrypoints: [browserCode], + format: "esm", + }); +} catch (e) { + errorMessage = e.message; +} +export const getErrorMessage = (): string => errorMessage; +`, + + "index.ts": `import { getErrorMessage } from "./macro" with { type: "macro" }; +console.log("ERROR_MSG:", getErrorMessage()); +`, + }); + + // Run via CLI + await using proc = Bun.spawn({ + cmd: [bunExe(), "build", "index.ts", "--target=node"], + cwd: String(dir), + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + // The CLI build should also work and show the error message was caught + expect(stdout).toContain("Bun.build cannot be called from within a macro"); +}); + +test("regular Bun.build (not in macro) still works", async () => { + using dir = tempDir("issue-26360-normal", { + "entry.ts": ` + console.log("hello world"); + export default ""; + `, + }); + + const result = await Bun.build({ + entrypoints: [`${dir}/entry.ts`], + format: "esm", + }); + + expect(result.success).toBe(true); + expect(result.outputs.length).toBeGreaterThan(0); + const text = await result.outputs[0].text(); + expect(text).toContain("hello world"); +});