Skip to content
Closed
Show file tree
Hide file tree
Changes from 3 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
47 changes: 27 additions & 20 deletions src/runtime/bake/production.rs
Original file line number Diff line number Diff line change
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 @@ -211,28 +211,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(())
if vm.exit_handler.exit_code == 0 {
vm.exit_handler.exit_code = 1;
}
vm.on_exit();
vm.global_exit()
}

/// Ported inline from `bun.bun_js.failWithBuildError` to avoid the
Expand Down Expand Up @@ -646,7 +643,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
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,
/// Already reported; `bake::production::build_command` exits 1 through the build VM.
#[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
67 changes: 64 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 } 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,67 @@ export default function Docs() {
}
});

// The failure is reported with the directory's path and, like a build whose page throws, the
// process exits through the build VM: the config's 'exit' handler runs and, with
// BUN_DESTRUCT_VM_ON_EXIT set, the VM is torn down before the exit code is returned.
describe.concurrent("output directory that cannot be opened", () => {
const app = {
"src/index.tsx": `
process.on("exit", code => console.log("exit event: " + code));
export default { app: { framework: "react" } };
`,
"pages/index.tsx": `export default function IndexPage() { return <p>index</p>; }`,
};

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

// Each case bundles 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);
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);
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("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