Skip to content
Open
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion src/crash_handler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1275,7 +1275,7 @@ mod draft {
super::out_of_memory();
} else if matches!(
name,
b"InvalidArgument" | b"Invalid Bunfig" | b"InstallFailed"
b"InvalidArgument" | b"Invalid Bunfig" | b"InstallFailed" | b"BakeBuildFailed"
) {
if !show_trace {
Global::exit(1);
Expand Down
94 changes: 44 additions & 50 deletions src/runtime/bake/production.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,8 @@ use bun_bundler::options::{self as bundler_options, OutputFile, SourceMapOption}
use bun_bundler::output_file::Index as OutputFileIndex;

use bun_collections::{AutoBitSet, StringArrayHashMap};
use bun_core::Output;
use bun_core::String as BunString;
use bun_core::{Global, Output};
use bun_dotenv as dotenv;
use bun_jsc::js_promise::{UnwrapMode, Unwrapped};
use bun_jsc::virtual_machine::VirtualMachine;
Expand Down Expand Up @@ -75,20 +75,20 @@ pub fn build_command(ctx: Context) -> crate::Result<()> {

if ctx.args.entry_points.len() > 1 {
bun_core::err_generic!("bun build --app only accepts one entrypoint");
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}

if ctx.debug.hot_reload != HotReload::None {
bun_core::err_generic!("Instead of using --watch, use 'bun run'");
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}

let mut cwd_buf = PathBuffer::uninit();
let cwd = match bun_core::getcwd(&mut cwd_buf) {
Ok(cwd) => cwd.as_bytes(),
Err(err) => {
Output::err(err, "Could not query current working directory", ());
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}
};
// Note: reshaped for borrowck — clone the cwd slice so the PathBuffer
Expand Down Expand Up @@ -116,7 +116,7 @@ pub fn build_command(ctx: Context) -> crate::Result<()> {
// Note: pass `vm_ptr` by value into the guard so the drop closure does
// not borrow the local (`defer!` would capture `&vm_ptr`, which under
// edition-2024 disjoint-capture rules collides with the `&mut *vm_ptr`
// re-borrows on the JSError path).
// re-borrows below).
let _vm_guard = scopeguard::guard(vm_ptr, |p| {
// SAFETY: p is the unique live VM on this thread; its loop is alive, so
// queued work is released here rather than by a thread teardown.
Expand Down Expand Up @@ -187,9 +187,10 @@ pub fn build_command(ctx: Context) -> crate::Result<()> {
}
MacroOptions::Unspecified => {}
}
if vm.transpiler.configure_defines().is_err() {
fail_with_build_error(vm);
}
// The errors are in `ctx.log`, which `Cli::start` prints.
vm.transpiler
.configure_defines()
.map_err(|_| crate::Error::BakeBuildFailed)?;
// `vm.log` was set from `ctx.log` above (non-null, process-lifetime);
// `log_mut()` is the safe accessor encapsulating the NonNull deref.
bun_http::async_http::load_env(vm.log_mut().unwrap(), vm.env_loader());
Expand All @@ -211,42 +212,25 @@ pub fn build_command(ctx: Context) -> crate::Result<()> {
// LIFO order — under the API lock, before the VM is destroyed.
let mut pt = PerThread::placeholder(vm_ptr);

match build_with_vm(ctx, &cwd, &mut pt) {
Ok(()) => {}
let result = build_with_vm(ctx, &cwd, &mut pt);
// SAFETY: `build_with_vm` has returned, so its reborrows through `pt.vm` are
// dead; the VM is live until `global_exit` (or `_vm_guard`) destroys it.
let vm = unsafe { &mut *vm_ptr };
match result {
Ok(()) => return Ok(()),
Err(crate::Error::JSError) => {
// SAFETY: vm.global is live for VM lifetime.
let global = unsafe { &*(*vm_ptr).global };
let err_value = global.take_exception(jsc::JsError::Thrown);
// SAFETY: see above.
unsafe {
(*vm_ptr)
.print_error_like_object_to_console(err_value.to_error().unwrap_or(err_value))
};
// SAFETY: see above.
let vm = unsafe { &mut *vm_ptr };
if vm.exit_handler.exit_code == 0 {
vm.exit_handler.exit_code = 1;
}
vm.on_exit();
vm.global_exit();
let err_value = vm.global().take_exception(jsc::JsError::Thrown);
vm.print_error_like_object_to_console(err_value.to_error().unwrap_or(err_value));
}
// Already reported by `build_with_vm`.
Err(crate::Error::BakeBuildFailed) => {}
Err(e) => return Err(e),
}
Ok(())
}

/// Ported inline from `bun.bun_js.failWithBuildError` to avoid the
/// `bun_runtime → bun (binary)` dep cycle (PORTING.md §Forbidden: dep-cycle
/// fixes via fn-ptr hooks — move/port the code instead).
#[cold]
#[inline(never)]
fn fail_with_build_error(vm: &mut VirtualMachine) -> ! {
// `vm.log` is the process-lifetime ctx.log set in build_command;
// `log_ref()` is the safe accessor encapsulating the NonNull deref.
if let Some(log) = vm.log_ref() {
let _ = log.print(std::ptr::from_mut(Output::error_writer()));
if vm.exit_handler.exit_code == 0 {
vm.exit_handler.exit_code = 1;
}
Global::exit(1);
vm.on_exit();
vm.global_exit()
}

fn write_sourcemap_to_disk(
Expand Down Expand Up @@ -319,7 +303,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
TODO: insert a link to `bun.com/docs`",
(),
);
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}
}

Expand All @@ -328,7 +312,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
"could not resolve application config file '{}'",
(BStr::new(&unresolved_config_entry_point),),
);
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}
};

Expand Down Expand Up @@ -517,7 +501,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
let _ = server_transpiler
.log()
.print(std::ptr::from_mut(Output::error_writer()));
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}
};

Expand Down Expand Up @@ -646,7 +630,17 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
Output::flush();

// mkdir -p + open.
let root_dir = bun_sys::Dir::cwd().make_open_path(b"dist", Default::default())?;
let root_dir = match bun_sys::Dir::cwd().make_open_path(b"dist", Default::default()) {
Ok(dir) => dir,
Err(err) => {
Output::err(
err,
"could not open output directory {}",
(bun_core::fmt::quote(&root_dir_path),),
);
return Err(crate::Error::BakeBuildFailed);
}
};

let mut maybe_runtime_file_index: Option<u32> = None;

Expand Down Expand Up @@ -862,7 +856,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
pt.input_file(server_file).abs_path()
))
);
Global::crash();
return Err(crate::Error::BakeBuildFailed);
};

let server_param_func = if router.dynamic_routes.count() > 0 {
Expand All @@ -888,7 +882,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
pt.input_file(server_file).abs_path()
))
);
Global::crash();
return Err(crate::Error::BakeBuildFailed);
}
}
} else {
Expand Down Expand Up @@ -1197,8 +1191,7 @@ fn build_with_vm(ctx: Context, cwd: &[u8], pt: &mut PerThread) -> crate::Result<
Ok(())
}

/// unsafe function, must be run outside of the event loop
/// quits the process on exception
/// Must be run outside of the event loop; failures are returned as `JSError`.
fn load_module(
vm: *mut VirtualMachine,
global: &JSGlobalObject,
Expand All @@ -1225,9 +1218,10 @@ fn load_module(
// TODO: Specially draining microtasks here because `waitForPromise` has a
// bug which forgets to do it, but I don't want to fix it right now as it
// could affect a lot of the codebase. This should be removed.
if vm_ref.event_loop_mut().drain_microtasks().is_err() {
Global::crash();
}
vm_ref
.event_loop_mut()
.drain_microtasks()
.map_err(|stopped| js_err(stopped.throw(vm_ref.global())))?;
let jsc_vm = vm_ref.as_mut().jsc_vm_mut();
match jsc::JSInternalPromise::opaque_mut(promise).unwrap(jsc_vm, UnwrapMode::MarkHandled) {
Unwrapped::Pending => unreachable!(),
Expand Down
4 changes: 4 additions & 0 deletions src/runtime/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,9 @@ pub enum Error {
MissingPackageJSON,
#[error("InstallFailed")]
InstallFailed,
/// `bun build --app` already reported the failure; like `InstallFailed`, the handler only exits 1.
#[error("BakeBuildFailed")]
BakeBuildFailed,
#[error("InvalidPackageJSON")]
InvalidPackageJSON,
#[error("HTTPForbidden")]
Expand Down Expand Up @@ -679,6 +682,7 @@ impl Error {
Self::FailedToBindPipe => "FailedToBindPipe",
Self::MissingPackageJSON => "MissingPackageJSON",
Self::InstallFailed => "InstallFailed",
Self::BakeBuildFailed => "BakeBuildFailed",
Self::InvalidPackageJSON => "InvalidPackageJSON",
Self::HTTPForbidden => "HTTPForbidden",
Self::ExampleNotFound => "ExampleNotFound",
Expand Down
89 changes: 86 additions & 3 deletions test/bake/dev/production.test.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import { describe, expect, test } from "bun:test";
import { existsSync } from "fs";
import { bunEnv, bunExe } from "harness";
import { existsSync, symlinkSync } from "fs";
import { bunEnv, bunExe, isWindows, tempDir } from "harness";
import path from "path";
import { tempDirWithBakeDeps } from "../bake-harness";
import { tempDirWithBakeDeps, WAIT_MULTIPLIER } from "../bake-harness";

const normalizePath = (path: string) => (process.platform === "win32" ? path.replaceAll("\\", "/") : path);
const platformPath = (path: string) => (process.platform === "win32" ? path.replaceAll("/", "\\") : path);
Expand Down Expand Up @@ -394,6 +394,89 @@ export default function Docs() {
}
});

// A failure the build reports itself is returned rather than exited on: once the config has been
// loaded, build_command exits through the build VM like it does for a build that throws (the
// config's 'exit' handler runs; with BUN_DESTRUCT_VM_ON_EXIT set the VM is torn down first), and
// before that the error goes back to the CLI, which must not print it as an internal error.
describe.concurrent("failures reported by the build", () => {
const config = `
process.on("exit", code => console.log("exit event: " + code));
export default { app: { framework: "react" } };
`;
const app = {
"src/index.tsx": config,
"pages/index.tsx": `export default function IndexPage() { return <p>index</p>; }`,
};

async function build(dir: string, ...entryPoints: string[]) {
const { exitCode, stdout, stderr } = await Bun.$`${bunExe()} build --app ${entryPoints}`
.cwd(dir)
.env({ ...bunEnv, BUN_DESTRUCT_VM_ON_EXIT: "1" })
.quiet()
.throws(false);
return { exitCode, stdout: stdout.toString(), stderr: stderr.toString() };
}

// The dist cases bundle a react app, which on a debug build takes most of the default timeout;
// this is the budget bake-harness gives its production builds.
const timeout = 30_000 * WAIT_MULTIPLIER;

test(
"a file at dist",
async () => {
const dir = await tempDirWithBakeDeps("bake-production-dist-is-a-file", {
...app,
"dist": "a file in the way of the output directory",
});

const { exitCode, stdout, stderr } = await build(dir, "./src/index.tsx");
expect(stderr).toContain(
`ENOTDIR: Not a directory: could not open output directory "${path.join(dir, "dist")}"`,
);
expect(stderr).not.toContain("An internal error occurred");
expect(stdout).toBe("exit event: 1\n");
expect(exitCode).toBe(1);
},
timeout,
);

test.skipIf(isWindows)(
"a dangling symlink at dist",
async () => {
const dir = await tempDirWithBakeDeps("bake-production-dist-is-a-dangling-symlink", app);
symlinkSync("does-not-exist", path.join(dir, "dist"));

const { exitCode, stdout, stderr } = await build(dir, "./src/index.tsx");
expect(stderr).toContain(
`ENOENT: No such file or directory: could not open output directory "${path.join(dir, "dist")}"`,
);
expect(stdout).toBe("exit event: 1\n");
expect(exitCode).toBe(1);
},
timeout,
);

test("framework imports that do not resolve", async () => {
// No react packages are installed here.
using dir = tempDir("bake-production-framework-unresolved", { "app.ts": config });

const { exitCode, stdout, stderr } = await build(String(dir), "./app.ts");
expect(stderr).toContain("error: Failed to resolve all imports required by the framework");
expect(stdout).toBe("exit event: 1\n");
expect(exitCode).toBe(1);
});

test("more than one entry point", async () => {
using dir = tempDir("bake-production-two-entry-points", { "app.ts": config, "other.ts": config });

const { exitCode, stdout, stderr } = await build(String(dir), "./app.ts", "./other.ts");
expect(stderr).toContain("error: bun build --app only accepts one entrypoint");
expect(stderr).not.toContain("BakeBuildFailed");
expect(stdout).toBe("");
expect(exitCode).toBe(1);
});
});

test("client-side component with default import should work", async () => {
const dir = await tempDirWithBakeDeps("bake-production-client-import", {
"src/index.tsx": `export default { app: { framework: "react" } };`,
Expand Down
Loading