output: report a failed write to stdout instead of claiming success
Adopts #38807 by @alii. His 16 commits are rebased onto main, he remains their author. The last five commits are mine, from review findings: install: the remaining log prints to stderr are best-effort too, bun build: write a single bundle to stdout unbuffered so a failed write is reported, the two restore the formatter indent on every exit of write_format commits (BuildArtifact, Blob), and install: an allocation failure while printing the summary tree is still an OOM. Supersedes #38807.
Problem
- A write to stdout through
Output::writer()cannot fail.fd_write_all_quiet(src/sys/lib.rs:9255) drops the error andadapter_write_all(src/sys/lib.rs:9301) returnsOk(())to the caller. - So
bun pm hash,bun pm hash-stringand a single filebun buildto stdout with a closed or read only stdout print nothing and exit 0. Bun 1.3 exits 1 withWriteFailedin this case. - The quiet path for
bun bun.lockb | head(issue #5828) matchesinstall::Error::WriteFailed(src/install/lockfile.rs:1655). The#[from]conversion wrapped the core error asCore(WriteFailed), so the arm did not match. Nobody could see this while writes never failed. - The console formatter calls
.expect("unreachable")on writes of Map, Set, Request and long string values (src/jsc/ConsoleObject.rs, src/runtime/webcore/Request.rs, src/runtime/api/JSBundler.rs). Once a write can fail,console.logof a large Map into a closed pipe aborts inside a C callback.
Fix
fd_write_all_quietreturns the write error. A zero length write isEIO.adapter_write_allandadapter_flushmap it tobun_core::Error::WriteFailed. The buffer position is reset before the error returns, so a later write does not drain stale bytes.From<bun_core::Error> for install::ErrormapsWriteFailedtoinstall::Error::WriteFailed, so the quiet arm in lockfile.rs matches again. A broken pipe stays quiet and exits 0.EIO,ENOSPCand a bad fd exit 1 withWriteFailed, as in 1.3.- The console formatter records a failed write in
Formatter::failedand stops printing that value. This is what the other formatter paths already do.RequestandBuildArtifactreturn the error to their caller.BuildArtifact::write_formatandBlob::write_formatnow hold their indent in anIndentScope, asRequestdoes, so an early return leaves the formatter at its original depth. - Prints in the middle of real work are best effort now: the
bun --filterchild output and exit line (multi_run.rs), the coverage table (test_command.rs), thebun buildsummary (build_command.rs), the install log and summary, and five more log prints (print_fetch_failures,root_package_json_source,pm view,update -i, thebun bun.lockbprinter). The work after each print (dependents, lcov and the exit code, output files, lockfile,Global::crash()) always happens. - Where the output is the result, the error is kept:
pm hash,pm hash-string, and a single filebun buildto stdout. The build path now flushes and writes the bundle through the unbufferedOutput::writer(). Before, a bundle under 4 KB sat in the stdout buffer andOutput::flush()dropped the error, so only bundles of 4 KB or more reported the failure. - Verified: the six changed test files and test/regression/issue/05828.test.ts pass on a debug build of the current head (after each rebase). The two
bun pm hashtests fail on main (stderr empty, exit 0). The other new tests pass on main, where no write fails. They guard the sites that a failing write reaches after this change. Details in the notes.
Background
Output::writer()is the buffered stdout writer the CLI uses. It is abun_core::io::Writervtable.src/sys/lib.rssupplies the implementation (SysQuietWriterAdapter): small writes go into a buffer, a large write or a flush callswrite(2)on the fd.bun_core::Error::WriteFailedis the one error aWritercan return. Each crate has its ownErrorenum and converts the core error withFrom. A?on a write insidebun_installproduces aninstall::Error.- The console formatter (
Formatterin src/jsc/ConsoleObject.rs) prints one value at a time. It has afailedflag. Iteration over a Map or a Set runs inside a C++ callback, so an error cannot be returned from there. The flag is how the formatter stops. console.logwrites into a 4 KB buffer. A value that formats to more than 4 KB makes the formatter write to the fd while it is still printing. This is why the console test prints 200 entries per value.- The tests make stdout fail by passing a read only descriptor of
/dev/nullas the child's stdout.write(2)on it fails withEBADFat once. This does not work on Windows, so the tests are skipped there.
Notes
Rebases:
- First rebase: one conflict, in the imports of test/bundler/cli.test.ts (2a707c0dda added
normalizeBunSnapshot). The new callers on main (bun pm diffusesOutput::print_bytes, which ignores the error) need no change. - Second rebase: c6a05c1014 removed
install::Error::BrokenPipe. The quiet arm in lockfile.rs matchesWriteFailedonly, which is the variant the newFromimpl produces. 05828.test.ts still passes. - Third rebase: #39770 (3d3016e329) turned the const generics of
print_code_coverageinto runtime bools and apretty_lit!macro. This conflicted with the coverage commit. The resolution keeps main's spelling and this PR's shape: every table write islet _ =, none of them returns early any more. The resolved commit is still authored by @alii. - Fourth rebase: #39962 (b4684c80cb) added
normalizeBunSnapshotto the imports of test/cli/install/bun-pm.test.ts, next to the imports this PR adds. Union of both, nothing else conflicted. The dead code removals in #39582 and #39732 do not touch anything this PR uses (disable_buffering_scope,written_slice,InvalidCharacterare all still there). - Fifth rebase: no conflicts. It picks up #40039, which fixes test/cli/install/bun-audit.test.ts on main. That test was red on every lane of the previous CI run (build 103036) because the bump to 1.4.1 made
normalizeBunSnapshotrewrite the literal<1.4.1inside the audit snapshot. This PR does not touch it. - Sixth rebase: #39934 added a test at the end of test/cli/test/coverage.test.ts, where this PR adds one too. Both are kept. Four pm ls tests in test/cli/install/bun-pm.test.ts fail on main itself since #38952 changed the header to
(N installed)without updating the inline snapshots that #39962 added (node_modules (5)). This PR does not touch pm ls. - Seventh rebase: no conflicts. It picks up #40062, which fixes those pm ls snapshots and the prune summary expectation. bun-pm (24), bun-prune (110, 1 skipped) and bun-audit (182) pass on the debug build of this head, together with the files listed above.
- Eighth rebase: #40201 added the
BuiltinBytecodeandBytecodeStringTablearms to the twomatch f.output_kindexpressions of the build summary, which this PR moved intoprint_output_file_line. The arms are in the moved function. bundler cli (35, 2 skipped), bun-build-api (56, 1 skipped), bun-pm, console-log, coverage, filter-workspace, bun-update-transitive and 05828 pass on the debug build of this head. - Ninth rebase: same spot. #40509 added the
ModuleInfoStringTablearm, which is inprint_output_file_linenow. The same files pass on the debug build of this head. Two new bytecode tests in bun-build-api.test.ts from main hit the 5 s limit on this machine (a debug build, 14 s and 31 s when run alone with a longer timeout) and are unrelated to this PR.
Two review comments outside the diff: print_summary_tree discarded every error of Tree::print, including Alloc. The last commit sends Alloc to bun_core::out_of_memory(), as the lockfile printer does, and keeps only WriteFailed best-effort (bun-add 70 and bun-update-transitive 175 pass). The Bun.sleep(100) in the --parallel merges line coverage across workers test belongs to #39934 on main, not to this PR.
The install commit: four of the five prints sit right before Global::crash() or Global::exit(1), where a failed write replaced the intended exit with a WriteFailed error. In print_fetch_failures a failed write made bun outdated exit 1 for an optional dependency's warning and skipped the log reset. print_fetch_failures returns the bool and root_package_json_source returns the Source, since neither has an error path left. The new test in bun-update-transitive.test.ts exits 1 with that site reverted and passes with it.
The bun build commit: Output::writer() is the unbuffered adapter, Output::writer_buffered() the 4 KB one, and Output::flush() drops the flush error. The single bundle path wrote through the buffered one, so a small bundle to a read only stdout exited 0 with nothing written while a large one exited 1. Probed by hand on the debug build: a 25 KB and a 20 byte bundle to a read only stdout both exit 1 with WriteFailed now, a normal stdout still prints the bundle, and bun build a.js | head -c 1 exits 1 as it did for large bundles before this commit. The new test in test/bundler/cli.test.ts fails on main and on the head before this commit (empty stderr, exit 0). The same commit removes a leftover inner block in print_exit_status.
Test runs on the debug build after the fifth rebase and the bun build commit: bun-pm (24, this now includes the pm ls tests from #39962), filter-workspace (80, 2 skipped on linux), console-log (5), bundler cli (35, 2 skipped on linux), coverage (13), bun-update-transitive (175), 05828 (1), bun-audit (182). After the third rebase also the coverage consumers 29925, 30205, 31503. Before the rebases also bun-info, update_interactive_install and the pnpm-lock-v9 manifest block. One run of bundler cli.test.ts had the three concurrent --compile tests at about 4.5 s each on this machine and one of them hit the 5 s limit once. It passed alone and in the next full run. This PR does not touch that path.
Fail-before on the release build of main (8326d1bd3): bun pm hash and bun pm hash-string with a read only stdout print nothing to stderr and exit 0. The tests expect WriteFailed and exit 1.
no test proof · iteration 8 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/web/console/console-log.test.ts, test/cli/test/coverage.test.ts, test/cli/run/filter-workspace.test.ts, test/cli/install/bun-update-transitive.test.ts, test/cli/install/bun-pm.test.ts, test/bundler/cli.test.ts