bake: return bun build --app failures instead of exiting; name the output directory it cannot open - #39302
bake: return bun build --app failures instead of exiting; name the output directory it cannot open#39302robobun wants to merge 4 commits into
Conversation
When dist/ cannot be opened or created, build_with_vm propagated the bun_sys error with ? and the CLI printed it as "An internal error occurred (ENOTDIR)" (or the generic ENOENT line), without the path. Print the errno and the output directory's path like the other user-facing failures in this function, then exit 1.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan includes up to 5 reviews per rolling hour; 0 remain after this review. WalkthroughChangesProduction bake error handling
Possibly related PRs
Suggested reviewers: 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
Comment |
|
Status: reproduced and fixed; reworked twice after review.
|
There was a problem hiding this comment.
LGTM — replaces a bare ? with the same Output::err + Global::crash() pattern used for every other user-facing failure in build_with_vm, reusing the exact wording from build_command.rs.
What was reviewed:
root_dir_pathis already computed a few lines above and used later forBakeRenderRoutesForProdStatic, so quoting it here is free and correct.- Checked
make_open_pathinsrc/sys/dir.rs: file-at-path yields ENOTDIR directly, dangling symlink yields ENOENT → mkdir no-ops → reopen fails, matching the two test cases. - Tests follow the file's conventions (
tempDirWithBakeDeps,bunEnv,describe.concurrent), assert stderr before exit code, and the symlink case is correctly gated off Windows.
Extended reasoning...
Overview
The PR changes one call site in src/runtime/bake/production.rs: bun_sys::Dir::cwd().make_open_path(b"dist", ..) previously propagated its bun_sys::Error via ?, which reached Cli::start's fallback and printed only An internal error occurred (ENOTDIR) or the generic ENOENT line. Now it matches on the result and, on Err, calls Output::err(err, "could not open output directory {}", quote(&root_dir_path)) followed by Global::crash(). Two tests are added under a describe.concurrent block in test/bake/dev/production.test.ts.
Security risks
None. This is an error-message-only change on a CLI failure path in an experimental feature (bake::print_warning() runs at the top of the command). No new I/O, no user-controlled input reaching new code, no change to the success path.
Level of scrutiny
Low. The Rust change is 11 lines and is a byte-for-byte copy of the pattern used in the same function for the config-resolution error (Output::err(err, "could not resolve...", ...); Global::crash();) and of the wording in src/runtime/cli/build_command.rs:818 for --outdir. root_dir_path is a Box<[u8]> computed via resolve_path::join_abs_string_buf::<platform::Auto>(cwd, ..., &[b"dist"]) a few lines earlier and is already consumed later by BakeRenderRoutesForProdStatic, so it is guaranteed to be in scope and to hold the absolute path the user needs to see. bun_core::fmt::quote is used exactly as the neighboring write_to_disk error paths use it.
Other factors
I read make_open_path in src/sys/dir.rs to confirm the two test scenarios exercise distinct paths through it: a regular file at dist fails the initial open_dir_at with ENOTDIR (returned directly), and a dangling symlink fails with ENOENT, falls through to mkdir_recursive_at (no-op on the existing symlink), then fails the reopen — so both new tests are non-vacuous. The tests assert the exact errno prefix for the file case, the quoted absolute path in both, that the old fallback strings no longer appear, and exitCode === 1. They use tempDirWithBakeDeps/bunEnv/bunExe per the file's conventions, run concurrently, and skip the symlink case on Windows. The PR description confirms both fail on the unfixed canary and pass with bun bd. No prior human reviews or outstanding comments.
…pened build_with_vm reports the failure and returns Error::BakeBuildFailed; build_command sets the exit code and leaves through on_exit and global_exit, the same way it already does for a build that throws, instead of exiting in place from build_with_vm.
There was a problem hiding this comment.
LGTM — focused error-message fix with a clean exit path, reviewer feedback addressed.
What was reviewed:
- The restructured
build_commandmatch:Ok/JSError/Err(e)paths are behavior-preserving;global_exit() -> !coerces as the tail expression. vm.global()returns&'staticso thetake_exception+print_error_like_object_to_console(&mut self)sequence has no borrow conflict; the fresh&mut *vm_ptrreborrow afterbuild_with_vmmatches the SAFETY comment.- Error wording matches
build_command.rs's--outdirmessage;WAIT_MULTIPLIERand the 30s budget match bake-harness's production-build timeout.
Extended reasoning...
Overview
The PR replaces a bare ? on Dir::cwd().make_open_path(b"dist", ..) in src/runtime/bake/production.rs with an explicit Output::err naming the absolute output-directory path, then returns a new Error::BakeBuildFailed variant. build_command is restructured so JSError and BakeBuildFailed share the same exit tail (exit_code = 1 if unset → vm.on_exit() → vm.global_exit()), which runs the config's process.on('exit') handlers and, under BUN_DESTRUCT_VM_ON_EXIT, tears the VM down. Two subprocess tests assert the errno-prefixed message with the quoted path, the exit event: 1 line on stdout, and exit code 1.
Security risks
None. The change is error-message plumbing in the bake production build's CLI path; no new inputs are parsed, no privilege or trust boundaries are crossed.
Level of scrutiny
Low-to-moderate. The bake production build is experimental (bake::print_warning()), and the change is a diagnostic improvement plus a small control-flow refactor of the caller's match. I traced each arm of the restructured match against the old code: Ok(()) now returns early instead of falling through to a trailing Ok(()) (equivalent, _vm_guard still drops); JSError still takes the exception, prints it, sets exit code 1 and exits through the VM (now via a shared tail instead of inline); Err(e) still returns. global_exit() returns !, so using it as the tail expression of a Result<()> function is sound. The new vm.global()-based JSError arm is equivalent to the old raw-pointer version because global() returns &'static, decoupled from the &mut vm needed by print_error_like_object_to_console.
Other factors
Both prior review comments are resolved: alii's "do something better than crash" led to the BakeBuildFailed return (mirroring InstallFailed's pattern), and the comment-cop note on error.rs was addressed by trimming the doc comment to one line. The error wording is copied from build_command.rs:818 (bun build --outdir's equivalent), and bun_core::fmt::quote is used per REVIEW.md. The tests spread bunEnv, use describe.concurrent for two independent subprocess spawns, assert stderr before exit code, and use bake-harness's own 30_000 * WAIT_MULTIPLIER production-build budget rather than raising a global timeout. The PR description confirms both tests fail on the unfixed canary and that the throwing-page test (which exercises the restructured JSError arm) still passes.
…iting in place Every site in production.rs that printed a message and then exited now returns Error::BakeBuildFailed (or JSError for a stopped microtask drain). build_command turns the ones raised while the build VM is up into an exit through the VM; the ones raised before that reach handle_root_error, which treats BakeBuildFailed like InstallFailed and only exits 1. fail_with_build_error is gone: the configure_defines errors are in ctx.log, which Cli::start prints.
There was a problem hiding this comment.
I reviewed this PR and didn't find any bugs. Since alii has been actively shaping the design across two rounds and the final commit (e062717) broadens the change to every Global::crash() site plus deletes fail_with_build_error, a human sign-off on the final shape would be worthwhile.
What was reviewed:
build_commandresult restructuring —Ok(())still returns through_vm_guard;JSError/BakeBuildFailedfall through toon_exit+global_exit(-> !), matching the pre-existingJSErrorexit;Err(e)still propagates.configure_definespath —ctx.logis printed byCli::startbeforehandle_root_error, so the deletedfail_with_build_errorprint is still covered.handle_root_error—BakeBuildFailedslots next toInstallFailedfor pre-VM failures; the post-VM ones never reach it.- Tests cover one site of each handling path (VM-exit and CLI-exit) and assert the
exitevent fires underBUN_DESTRUCT_VM_ON_EXIT.
Extended reasoning...
Overview
Converts bun build --app (bake production) error handling from in-place Global::crash() to returned errors. Adds Error::BakeBuildFailed (analogous to InstallFailed), wires it into handle_root_error, restructures build_command's post-build_with_vm match so JSError and BakeBuildFailed share the on_exit()/global_exit() tail, adds a proper error message naming the output directory when make_open_path fails, converts every remaining Global::crash() site in production.rs to return BakeBuildFailed, converts the drain_microtasks failure in load_module to JSError, and deletes the now-dead fail_with_build_error. Four new tests in test/bake/dev/production.test.ts cover the file-at-dist, dangling-symlink, unresolved-framework, and two-entry-point cases.
Security risks
None. This is CLI error-path plumbing; no untrusted input parsing, auth, or crypto.
Level of scrutiny
Medium-high. The change is a control-flow refactor across ~10 error sites in code with unsafe VM pointer reborrows and a scopeguard whose interaction with global_exit() (-> !) matters. I traced each path: Ok returns and the guard drops the VM (unchanged); JSError/BakeBuildFailed call global_exit() which never returns, so the guard/api-lock/pt never drop — same as the pre-existing JSError arm; other Err(e) returns and the guard fires (unchanged). The pre-VM BakeBuildFailed returns (entry-point count, --watch, getcwd, configure_defines) reach Cli::start, which prints ctx.log before handle_root_error exits 1 — so the configure_defines diagnostics still surface despite fail_with_build_error being deleted.
Other factors
A human reviewer (alii) drove the design here in two rounds, and the final commit is the largest, directly responding to their second request to convert every crash site. All threads are marked resolved, but there's no explicit approval on the final shape. Given the scope grew from "fix one error message" to "remove every process-exit from the file" and touches unsafe VM-lifetime code, deferring for alii to confirm the final revision matches their intent seems right rather than auto-approving over an in-progress human review.
Problem
bun build --appcannot open or create its output directory (a regular file nameddistin the project, for example), the build ends witherror: An internal error occurred (ENOTDIR); whendistis a dangling symlink it ends withENOENT: Bun could not find a file, and the code that produces this error is missing a better error.Neither line names the directory. The exit code is already 1.distpre-created as a file, and again withdistas a dangling symlink.build_with_vminsrc/runtime/bake/production.rsopens the directory withDir::cwd().make_open_path(b"dist", ..)?. The?converts thebun_sys::Errorinto the crate error, which only keeps the errno, and returns it all the way toCli::start, whose fallback handler prints the lines above.Global::crash()like the other failure sites in the file) asked that bake's code not exit the process at all and leave that to the handler, so the PR also converts the existing sites.Fix
ENOTDIR: Not a directory: could not open output directory "/home/me/app/dist" (open), and returned as a newError::BakeBuildFailed. The wording is the onebun builduses for its--outdirinbuild_command.rs; the path isroot_dir_path, the absolute path the prerender step writes into (the user never spelleddist, the production build hardcodes it).Error::BakeBuildFailedmeans "bake already printed why; just exit 1". Every site inproduction.rsthat printed a message and then exited (Global::crash()or the localfail_with_build_error) now returns it instead: entry point count,--watch, getcwd,configure_defines, config file not found, framework imports that do not resolve, the output directory, and the missingprerender/getParamsexports. The stopped microtask drain inload_modulereturnsJSErrorthe way thewait_for_promisecall above it already does.production.rsno longer importsGlobal.build_with_vm(the build VM is up and the config has run):build_commandhandles it next to itsJSErrorarm. Both set the exit code to 1 unless the build set one and leave throughvm.on_exit()+vm.global_exit(), so the config'sprocess.on("exit")handlers run and, underBUN_DESTRUCT_VM_ON_EXIT, the VM is torn down;Global::crash()skipped both. TheJSErrorarm additionally prints the exception, as before; the success path and theErr(e)return for anything else are unchanged.build_commandbefore that (nothing to exit through yet): it returns to the CLI, andhandle_root_error(src/crash_handler/lib.rs) now listsBakeBuildFailedwithInstallFailedand the other already-reported names, so it exits 1 without the internal error line. This is thebun installarrangement: the inner function reports, the command returns a marker error, the root exits.fail_with_build_erroris deleted: theconfigure_defineserrors are inctx.log, whichCli::startprints for any error a command returns, so the site only returns the marker.failures reported by the buildblock intest/bake/dev/production.test.ts, every case with anexithandler in the config andBUN_DESTRUCT_VM_ON_EXIT=1:distas a regular file, and on POSIXdistas a dangling symlink (open fails with ENOENT, the mkdir is a no-op on the symlink, the reopen fails again): the errno line with the directory's path on stderr,exit event: 1on stdout, exit 1. Both fail on the unfixed canary (the two lines quoted above, empty stdout).build_with_vmsites: the existing message plusexit event: 1, exit 1. Fails on the canary (empty stdout), and the fixture needs no packages so it runs in under half a second on the debug build.build_commandsites: the existing message, noBakeBuildFailedon stderr, empty stdout, exit 1. This one also passes on the canary; it guards thehandle_root_errorentry, without which it would printAn internal error occurred (BakeBuildFailed).--watch, a missing config file with and without an explicit path, and all of the above with and withoutBUN_DESTRUCT_VM_ON_EXIT. Theconfigure_definesandprerender/getParamssites are the same one line change but have no test; a bad--defineor a.envdirectory did not makeconfigure_definesfail, and the export sites need a custom framework.--timeout 120000(16/16, including the throwing-page test that goes through the restructuredJSErrorarm); the dist cases get the timeout bake-harness gives its production builds (30_000 * WAIT_MULTIPLIER) because a react bundling takes 2 to 4 s on the debug ASAN build and six of the file's existing tests already hit the 5 s default there.production.test.tsis the only test ofbun build --app.cargo clippy -p bun_runtime -p bun_crash_handlerand rustfmt are clean.Background
bun build --appis bake's static production build (production.rs).build_commandvalidates the arguments, creates a dedicated JS VM and callsbuild_with_vm, which evaluates the app config in it, bundles the app, opens<cwd>/dist, writes the client output files into it and prerenders every route into it.Output::err(err, fmt, args)prints an error line prefixed with the error's name; for abun_sys::Errorit also adds the strerror text and the syscall that failed.Global::crash()isexit(1)after flushing the output buffers.VirtualMachine::on_exitemitsprocess'sexitevent;global_exitexits the process withexit_handler.exit_code(the field behindprocess.exitCode) and, whenBUN_DESTRUCT_VM_ON_EXITis set (CI's ASAN lane sets it for leak checking), destroys the VM first.Cli::startreceives whatever error a command returns, prints the accumulated log (ctx.log) and hands the error tohandle_root_error, which printsAn internal error occurred (<name>)unless the name is one it knows; the known already-reported names (InvalidArgument,InstallFailed, ...) just exit 1.Earlier versions
c08b2d7 printed the message and called
Global::crash()at the site, matching the other failure sites inbuild_with_vm. 0773af5 replaced that one crash with theBakeBuildFailedreturn and thebuild_commandarm. e062717 converted the remaining sites, added thehandle_root_errorentry for the pre-VM ones and the two extra tests.