diff --git a/test/regression/issue/26360.test.ts b/test/regression/issue/26360.test.ts index fd4fc4789c4f..9eef66113b89 100644 --- a/test/regression/issue/26360.test.ts +++ b/test/regression/issue/26360.test.ts @@ -1,33 +1,23 @@ import { expect, test } from "bun:test"; -import { bunEnv, bunExe, tempDir } from "harness"; +import { bunEnv, bunExe, normalizeBunSnapshot, 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"); +// Bun.build called from a macro that runs during another Bun.build used to deadlock. +// Every Bun.build call runs on one singleton bundler thread. The outer build blocks that +// thread while it waits for the macro, and the macro's inner Bun.build waits for the same +// thread. The fix makes Bun.build throw when it is called in macro mode. + +// Shared by the two child-process tests. The macro catches the inner Bun.build error and +// returns its message, so the bundled output shows exactly what the macro saw. +const fixture = { + "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" }; + "macro.ts": `import browserCode from "./browser" with { type: "file" }; let errorMessage = "no error"; try { - const built = await Bun.build({ + await Bun.build({ entrypoints: [browserCode], format: "esm", }); @@ -36,13 +26,29 @@ try { } export const getErrorMessage = (): string => errorMessage; `, - - // File that imports from the macro - "index.ts": `import { getErrorMessage } from "./macro" with { type: "macro" }; + "index.ts": `import { getErrorMessage } from "./macro" with { type: "macro" }; console.log("ERROR_MSG:", getErrorMessage()); `, +}; + +// Debug builds print "[macro] call " to stdout when a macro runs. +function withoutMacroDebugLines(stdout: string) { + return stdout + .split("\n") + .filter(line => !line.startsWith("[macro]")) + .join("\n"); +} - // Build script that uses Bun.build API (this would hang before the fix) +// Kill switch for the child processes: a deadlock hangs the child forever. Kill it so the +// assertions fail with a clear message instead of the whole file hanging. +const killSwitch = { timeout: 60_000, killSignal: "SIGKILL" } as const; +const hungMessage = "the child hung and was killed after 60s"; + +// The outer Bun.build runs in a child process: before the fix it deadlocked the bundler +// thread, and an in-process deadlock would take the test runner down with it. +test.concurrent("Bun.build from macro during bundling throws instead of hanging", async () => { + using dir = tempDir("issue-26360", { + ...fixture, "build_script.ts": `const result = await Bun.build({ entrypoints: ["./index.ts"], }); @@ -54,77 +60,71 @@ if (!result.success) { } } 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); + console.log(await result.outputs[0].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", + ...killSwitch, }); - 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"); -}); + expect(proc.signalCode, hungMessage).toBeNull(); + expect(normalizeBunSnapshot(withoutMacroDebugLines(stdout), String(dir))).toMatchInlineSnapshot(` + "BUILD_SUCCESS + // index.ts + console.log("ERROR_MSG:", \`CAUGHT: Bun.build cannot be called from within a macro during bundling. -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 ""; -`, + 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. - // 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; -`, + 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"]);\`);" + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); +}); - "index.ts": `import { getErrorMessage } from "./macro" with { type: "macro" }; -console.log("ERROR_MSG:", getErrorMessage()); -`, - }); +test.concurrent("CLI bun build with macro that calls Bun.build also throws", async () => { + using dir = tempDir("issue-26360-cli", fixture); - // Run via CLI await using proc = Bun.spawn({ cmd: [bunExe(), "build", "index.ts", "--target=node"], cwd: String(dir), env: bunEnv, stdout: "pipe", stderr: "pipe", + ...killSwitch, }); - 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"); + expect(proc.signalCode, hungMessage).toBeNull(); + // With no --outdir the CLI writes the bundle to stdout and prints no summary. + expect(normalizeBunSnapshot(withoutMacroDebugLines(stdout), String(dir))).toMatchInlineSnapshot(` + "// index.ts + console.log("ERROR_MSG:", \`CAUGHT: 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"]);\`);" + `); + expect(stderr).toBe(""); + expect(exitCode).toBe(0); }); -test("regular Bun.build (not in macro) still works", async () => { +test.concurrent("regular Bun.build (not in macro) still works", async () => { using dir = tempDir("issue-26360-normal", { - "entry.ts": ` - console.log("hello world"); - export default ""; - `, + "entry.ts": `console.log("hello world"); +export default ""; +`, }); const result = await Bun.build({ @@ -132,8 +132,20 @@ test("regular Bun.build (not in macro) still works", async () => { 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"); + expect({ success: result.success, logs: result.logs, outputs: result.outputs.length }).toEqual({ + success: true, + logs: [], + outputs: 1, + }); + // The "// " comment on the first line is relative to the test runner's cwd, so it + // changes from machine to machine. The rest of the bundle is exact. + const text = normalizeBunSnapshot(await result.outputs[0].text()).replace(/^\/\/ .*\/entry\.ts$/m, "// entry.ts"); + expect(text).toMatchInlineSnapshot(` + "// entry.ts + console.log("hello world"); + var entry_default = ""; + export { + entry_default as default + };" + `); });