Skip to content

bake: fail bun build --app before prerendering when an output file cannot be written - #47194

Open
robobun wants to merge 2 commits into
mainfrom
robobun/c7658eed/bake-fail-unwritable-output
Open

robobun wants to merge 2 commits into
mainfrom
robobun/c7658eed/bake-fail-unwritable-output

Conversation

@robobun

@robobun robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator

Related to #39295, #39488

Problem

  • bun build --app exits 0 when it cannot write a file under dist/_bun/. It prints ENOTDIR: Failed to write "_bun/<hash>.js" to output directory, prerenders anyway and prints done.
  • Cause: the three write_to_disk call sites in build_with_vm (src/runtime/bake/production/mod.rs:720, :731, :812) print the error and continue.

Fix

  • One OutputDir::write replaces the three error arms. It reports a failed write and counts it. Before the prerender, the build exits 1 if the count is not zero.
  • It attempts the runtime chunk once. Main can write that chunk twice.
  • Verified: test/bake/dev/production.test.ts, block an output file that cannot be written fails the build. 7 of 7 tests fail on main.
  • Self-reviewed: 14 concerns raised, 13 addressed (Notes).

Background

Downsides

  • A build that printed Failed to write and exited 0 now exits 1. This includes a rebuild over read-only chunks with the same content.
  • A successful build runs about 22 more instructions per written file. Syscalls and allocations: +0.
  • Not fixed: a { pages } route still exits 0 when a page cannot be written (bake: fail the build when a component throws during static prerender #33216).
Notes

Source and reach. A read of build_with_vm found this during the work on #47146, another exit status bug of the same command. No user reported it, and no issue quotes the message. --app needs a canary or debug build, or BUN_FEATURE_FLAG_EXPERIMENTAL_BAKE=1. The symptom needs a path under dist/_bun/ that cannot be written while dist/ can. Main already exits 1 in three related cases: dist is a regular file, dist/ is read-only, or a page of a static route cannot be written.

History. #39295 fixed this on 2026-08-16. Its first revision is this change: the OutputDir recorder and a Global::crash() gate. Review on #39302 asked that bake return its failures: "we don't get to decide if the process should exit here". The second revision of #39295 then returned Error::BakeBuildFailed. It was closed into the consolidation PR #39488. That PR carries the same recorder with that return, and it is not on main. Since then #46021 added if route_scan.invalid_routes > 0 { Global::crash(); } to build_with_vm. This PR uses that form because main has no BakeBuildFailed.

Questions for a maintainer.

  • Should the gate keep the in-place exit until the exit model of bake: consolidate the open robobun dev-server, HMR runtime, production build and router fixes #39488 lands? The other choice is to add Error::BakeBuildFailed and a build_command arm in this PR. The in-place exit runs no process.on("exit") listener and ignores process.exitCode, like the invalid_routes gate. I checked with a listener and process.exitCode = 3 in the config, and a regular file at dist/_bun. Main runs no listener and exits 0. This build runs no listener and exits 1. A failed page write exits 3 and runs the listener on both.
  • bun bake build: build the app into dist/server.js #46018 was closed "while the Bake design is reworked". Does build_with_vm stay in that design? If it does not, close this PR and keep its test cases for the new build.

Repro. Four files, no dependencies:

app.ts           export default { app: { framework: { fileSystemRouterTypes: [{ root: "routes", style: "nextjs-pages", serverEntryPoint: "./server.ts", clientEntryPoint: "./client.ts" }] } } };
server.ts        export function render(req, meta) { return new Response(String(meta.pageModule.default())); }
                 export async function prerender(meta) { return { files: { "/index.html": String(meta.pageModule.default(meta.params)) } }; }
client.ts        console.log("client");
routes/index.ts  export default () => "home";
$ rm -rf dist && mkdir dist && : > dist/_bun     # a regular file where the _bun directory goes
$ bun build --app ./app.ts
Loading configuration
Bundling routes
Rendering routes
done
ENOTDIR: Failed to write "_bun/nzkg0hkq.js" to output directory
$ echo $?
0

Main 11c3671 (a release build, and 1.4.4-canary.1+11c367163) writes dist/index.html and exits 0. With this PR: the same Failed to write line, no done, no dist/index.html, exit 1.

Not fixed here.

The runtime chunk. Main can write it twice: the loop over the output files writes it, and the step after the loop writes it again. This happens always when only the client graph uses a runtime helper, because the chunk is then a client file. It happens with --debug-dump-server-files when the chunk is a server file. A failed write was then reported twice. With the gate alone, a build also failed when only the second open failed, with a complete dist/_bun. I saw that as a non-root user with umask 0222. OutputDir::write now skips the second attempt. In that umask case main prints EACCES and exits 0, and this build prints no error and exits 0. #46272 deletes the step after the loop. The skip is then dead code.

Measurements. Two release builds (LTO) in one build directory: main 11c3671, and the same tree with this change. strace, perf, valgrind and bloaty are not installed on my machine. The numbers come from llvm-size -A, llvm-nm -S and gdb.

  • Release binary: .text 108,646,665 to 108,646,409 bytes, .rodata +0. build_with_vm 12,911 to 12,731 bytes. OutputDir::write is a separate 109-byte function, and its drop glue is 40 bytes. The stripped bun is 138,197,144 bytes in both.
  • Successful build, from the open of dist to the call to PerThread::init. Counted with gdb nexti on the main thread, identical in 3 of 3 runs. One written file: 449 to 453 instructions in build_with_vm, plus 19 in OutputDir::write (+23). Two written files, one of them the runtime chunk: 584 to 586, plus 41 (+43).
  • Same section: syscalls 4 to 4 (one file) and 8 to 8 (two files), calls into the allocator 4 to 4. dist/ is byte-identical, stdout is done, exit 0.
  • Runtime helper only in the client graph: write_to_disk calls 2 to 1 for 1 file, syscalls in the section 8 to 4. With --debug-dump-server-files: calls 5 to 4 for 4 files. dist/ is byte-identical in both cases.
  • Same two cases with a regular file at dist/_bun: failure lines 2 to 1, and 5 to 4. No path is reported twice.
  • Regular file at dist/_bun, two outputs: both are attempted and reported (main: same). Exit 1 (main: 0). Main thread syscalls after the first failed write: 22 to 6. Pages written: 1 to 0.
  • Also exit 0 to exit 1: a failed --debug-dump-server-files dump (a regular file at dist/_bun/routes).
  • Also exit 0 to exit 1: a rebuild as a non-root user over a 0444 chunk with the same name and bytes (EACCES).
  • Global::crash() sites in production/mod.rs: 10 to 11.
  • Diff: mod.rs +40/-26, production.test.ts +110/-3.

Tests. All in test/bake/dev/production.test.ts, block an output file that cannot be written fails the build. Each test is one bun build --app child process on a framework with no dependencies. Each asserts the exact list of reported files, exit code 1, empty stdout (no done) and no dist/index.html.

  • every failed write is reported: a regular file at dist/_bun. The stylesheet and the client entry point are both reported. No debug flag.
  • a directory where a chunk goes: a directory at the path of the client chunk. The name comes from a control build in beforeAll. The control also asserts that the fixture builds (done, exit 0).
  • a runtime chunk in a client file is written once: only the client graph imports a CommonJS module. Main reports the chunk twice. No debug flag.
  • a client file, the runtime chunk, a server file: one test for each of the three call sites. --debug-no-minify names each output after its source, so a regular file where one directory goes blocks one output.
  • a runtime chunk in the dump of the server files is written once: the same for --debug-dump-server-files. Main reports the chunk twice.
  • A build that is neither canary nor debug has no --debug flags and skips the last four.
  • 7 of 7 fail on a release build of main 11c3671 and on 1.4.4-canary.1+11c367163. Each fails at the one toEqual, with exit code 0, done and the page written. The two written once tests also list the chunk twice there.
  • 7 of 7 pass on a debug build (ASAN) and on a release build of this branch.
  • Whole file: 28 pass, 0 fail on the release build, and on the debug build with --timeout 240000. The React cases need more than the default 5 s on a debug build, on main too.
  • Release build of this branch: dev-and-prod.test.ts 12 pass, dev/ssg-pages-router.test.ts 10 pass, app-options.test.ts 13 pass.
  • Not run on my machine: Windows. The tests do not assert an errno name.

Self-review. 14 concerns raised, 13 addressed. One concern was rejected:

The runtime chunk took two attempts. A first commit skipped the second write with a flag in build_with_vm, for the --debug-dump-server-files case only. I dropped it because it did not cover a runtime chunk in a client file. Review on this PR then showed the umask case above. The skip is now in OutputDir::write and covers both cases.


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/bake/dev/production.test.ts

build_with_vm writes the client files, the server files under
--debug-dump-server-files and the runtime chunk to dist/. Each of the
three call sites printed 'Failed to write ... to output directory' and
continued, so the build prerendered the pages, printed 'done' and
exited 0.

The three sites now go through one OutputDir, which reports each
failed write as before and counts it. After the last write and before
the prerender, the build exits 1 when the count is not zero. Every
failed write is still reported in one run.

This is the first version of #39295, ported to current main.
@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review. The diff is green on every lane that ran its tests.

  • Build 134537 (e870296) is red only on the test-aot-drawn lanes. They fail in test/aot/* shards, which this PR does not touch. The same lanes have failed jobs on main: builds 134067, 134092 and 134576.
  • test/bake/dev/production.test.ts with the 7 new tests passed on Linux, on Linux with ASAN, and on both Windows lanes. The macOS test jobs had not finished when I wrote this.
  • One question is open for a maintainer (PR notes): keep the in-place Global::crash() at the gate, or add Error::BakeBuildFailed in this PR.

How I reproduced it: four files with no dependencies (the PR body has them), and a regular file at dist/_bun. On main 11c3671 (a release build, and 1.4.4-canary.1+11c367163), bun build --app ./app.ts prints ENOTDIR: Failed to write "_bun/nzkg0hkq.js" to output directory, writes dist/index.html, prints done and exits 0. With this branch the same command prints the same line, writes no page and exits 1.

PR: #47194

@robobun

robobun commented Oct 28, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 2:53 PM PT - Oct 28th, 2026

❌ @robobun, your commit e870296 has 6 failures in Build #134537 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 47194

That installs a local version of the PR into your bun-47194 executable, so you can run:

bun-47194 --bun

@coderabbitai

coderabbitai Bot commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
Commits

Reviewing files that changed from the base of the PR and between bcbb6cb and e870296.

Files selected for processing: 2
  • src/runtime/bake/production/mod.rs
  • test/bake/dev/production.test.ts

Included review availability: This review used your included allowance. Your plan provides up to 10 included reviews per hour; 2 remain after this review.

Walkthrough

Production builds now track output write failures and stop before prerendering if any write fails. The runtime chunk is attempted at most once. Tests cover blocked output destinations and verify reported paths, exit codes, and prerender output.

Changes

Production output handling

Layer / File(s) Summary
Track production output write failures
src/runtime/bake/production/mod.rs
OutputDir tracks failed writes and whether it has attempted the runtime chunk. Client outputs, debug-dumped server outputs, and the runtime output use its writer.
Stop builds after failed writes
src/runtime/bake/production/mod.rs, test/bake/dev/production.test.ts
The build stops when output writes fail. Tests check reported paths, exit codes, runtime write deduplication, and whether prerendering produced dist/index.html.

Suggested reviewers: alii, jarred-sumner, dylan-conway

Priority: Normal

Merge Risk: Minimal · up to e8702

Failed tracked output writes stop the build before prerendering. No merge-blocking issue was established.

Pre-merge checks: Passed 4
Check name Status Explanation
Title check Passed The title clearly and concisely states the primary change: failed output writes now stop bun build --app before prerendering.
Description check Passed The description explains the problem, cause, fix, verification, scope, limitations, and test results. It does not use the template headings verbatim, but it provides the required information in equiva…
Linked Issues check Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check Passed Check skipped because no linked issues were found for this pull request.
Full details: Description check

Explanation

The description explains the problem, cause, fix, verification, scope, limitations, and test results. It does not use the template headings verbatim, but it provides the required information in equivalent sections.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, I also checked for write sites the new counter could miss: the three former write_to_disk arms are the only ones in build_with_vm and all now go through OutputDir::write, and write_sourcemap_to_disk only records an index into the source-map table despite its name, so it has no failure path to count. The gate sits before PerThread::init, so no prerender happens after a failed write.

Extended reasoning...

The change replaces three copy-pasted error arms in src/runtime/bake/production/mod.rs with an OutputDir helper that counts failed writes and adds a Global::crash() gate before prerendering, plus a new describe block in test/bake/dev/production.test.ts. It touches no security-sensitive surface. Two confirmed inline findings (the double runtime-chunk write making a complete dist exit 1 under some umasks, and crash() bypassing process.on("exit")/exitCode unlike the sibling build_command arm) already signal that a human should look.

Comment thread src/runtime/bake/production/mod.rs
Comment thread src/runtime/bake/production/mod.rs
The loop over the output files writes the runtime chunk when it is a
client file, or a server file under --debug-dump-server-files. The
step after the loop then wrote it again. A failed write was reported
twice. When only the second open failed, the build failed with a
complete dist/_bun.

OutputDir::write now attempts the runtime chunk once.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The follow-up push (e870296) addresses the runtime-chunk double-write from my earlier review, and I found no new bugs; a maintainer should still weigh in because the PR description explicitly asks whether the Global::crash() gate should stay until the returned-error exit model lands.

What was reviewed:

  • OutputDir::write dedupe: bake_is_runtime is debug-asserted to be set on exactly one chunk, so the single runtime_chunk_attempted flag correctly covers the Client-side loop write, the --debug-dump-server-files write, and the post-loop write without skipping a first attempt.
  • Refactor vs the three removed arms: handle_error_return_trace, the Output::err message, and write order are unchanged; the ? sites in the loop still return Err before the new gate as before.
  • Tests: the two new "written once" cases assert the exact failed-write list via toEqual, so they fail if the chunk is attempted twice; the --debug matrix is skipIf-gated with a stated reason.
Extended reasoning...

The change touches src/runtime/bake/production/mod.rs (the static build path of bun build --app) and test/bake/dev/production.test.ts, replacing three copy-pasted write_to_disk error arms with one OutputDir::write that counts failures and aborts via Global::crash() before prerendering. It touches no security-sensitive surface. The second commit resolves the double-write of the runtime chunk I raised earlier, and the refactor is behavior-preserving on the success path. Deferral rather than approval is because the exit mechanism (in-place Global::crash() like the existing invalid_routes gate, versus returning a typed error so process.on("exit") and process.exitCode are honoured) is an open design question the author has put to maintainers, and the bake subsystem's direction is itself under rework per the description.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant