Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
156 changes: 84 additions & 72 deletions test/regression/issue/26360.test.ts
Original file line number Diff line number Diff line change
@@ -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",
});
Expand All @@ -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 <name>" 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"],
});
Expand All @@ -54,86 +60,92 @@ 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,
});
Comment thread
robobun marked this conversation as resolved.
Resolved

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({
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");
expect({ success: result.success, logs: result.logs, outputs: result.outputs.length }).toEqual({
success: true,
logs: [],
outputs: 1,
});
// The "// <path>" 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
};"
`);
});