Make concurrent bun test and bun install processes safe on shared files

RO
1mo ago
+1491-290

Problem

  • bun test and the package.json editors write files in place. Two writers of one .snap leave a mixed file. A killed bun add left package.json at 0 bytes in 31 of 240 runs.
  • bun add and bun remove started together drop each other's edit and exit 0. Concurrent installs of one project fail with Failed to link x: EEXIST.

Fix

  • File::write_file_atomically (src/sys/file.rs) writes a temporary file and renames it over the target. The .snap, inline snapshot and package.json writers use it.
  • A command that edits a project takes a flock under the install cache (PackageManager::lock_project). A second process waits.
  • A broken .snap is parsed once and reported by name. A failed .snap write sets the exit code.
  • Verified: concurrent-processes.test.ts, new-snapshot.test.ts, and ulimit -f tests that fail the write of each converted writer.

Background

  • A test run reads the .snap into a buffer and writes it back at exit.
  • init finds the project root before a command reads a file it edits, so init takes the lock. A flock lock dies with its process.

Downsides

  • Syscalls: a package.json write goes from 3 to 12, a locking command adds 4, a .snap that gains an entry adds 6.
  • Two bun commands in one project now run in turn. Hard links to the old file keep the old text.
  • A kill between create and rename leaves a .tmp file (4 of 480 kills). The copy needs room for the whole file.
Notes

Moved here from the summary: where the directory takes no temporary file, or the volume no rename over the target, write_file_atomically writes in place, as every one of these writers did before (the cases are listed below). The new file gets the mode and owner of the old one. The waiting process prints Waiting for another bun process to finish in <dir>; the lock file is <install cache>/.locks/<hash of the root>, lifecycle scripts inherit the lock through BUN_INTERNAL_INSTALL_LOCK_DIR, the file is never deleted, and on Windows, where locks are mandatory, it holds no data. bin.rs keeps a .bin link that already has the right target, and ROOT_PACKAGE_JSON_PATH is built from the project root. Inline snapshots are spliced into the test source at exit, and lock_project drops the package.json files read on the way up once it holds the lock. Concurrent installs also ran lifecycle scripts once per process. The replaced file is a new inode, so a watcher on the inode sees a rename, not a write.

Rebased onto main 9ed8d11b96 on 2026-10-09 (the branch was 994 commits behind), as two commits: this PR's 17 commits squashed, then #39701. Conflicts: the imports of updatePackageJSONAndInstall.rs, the path buffer in update_interactive_command.rs (main's pool buffer kept), three hunks of snapshot.rs (this branch's side), and src/sys/lib.rs (Tag::flock is 109 now, after main's uv_os_setpriority; FileLockMode sits before main's RenameMode). Folded in from #39701: bun init writes an existing package.json through write_file_atomically (and loses the Windows save and restore of the file position that only the in-place write needed); a failed .snap write at the end of a serial run sets the exit code instead of leaving main as an internal error (the --parallel worker still drops that result, which needs a worker to coordinator message and is tracked separately); withFileSizeLimit in test/harness.ts and the tests that use it. New with the rebase: bun remove and bun patch --commit print the errno and the path when the write of package.json fails (it was An internal error occurred (EFBIG)), and write-failure tests for bun add and bun remove.

Measurements on the rebased head (debug build) against the installed 1.4.3-canary.1+367d939d9 (release, the old writers), Linux x64:

  • Syscalls, traced with a small ptrace tracer (strace is not installable here). bun pm pkg set, package.json two directories deep: before openat(O_TRUNC), write, close; after readlink x3 (realpath, one per path component), openat(O_WRONLY), fstat, close, openat(tmp, O_EXCL), write, fchown, fchmod, renameat, close. bun remove: 4 before (openat, pwrite64, ftruncate, close), the same 12 after. The project lock: openat(.locks), openat(<hash>.lock, O_CREAT), close, flock. .snap, a run that adds nothing: mkdir, openat, fstat, pread64, write, close before; the same without the write and with a second pread64 after. A run that creates the file: 5 before, 11 after.
  • Write made to fail. ulimit -f 1, bun add ./dep with a 3 KB package.json: before, package.json is the first 512 bytes of the new text and no longer parses; after, it is unchanged and the command exits 1 with failed to write package.json. Real disk full (tmpfs, 0 bytes free), bun test -u on a 260,511 byte file with 400 inline snapshots: before, the source is left at 262,144 bytes and no longer parses; after, it is unchanged, and the run after space is freed writes all 400. With 100 KB free the old code succeeds in place and this branch fails with ENOSPC and leaves the file: the copy needs the full 277 KB.
  • ftruncate made to fail with EIO (an LD_PRELOAD shim; Landlock is not available on this kernel): bun remove before exits 0 and leaves the new JSON followed by the old tail; after, it exits 0 and the file is valid JSON, because no truncation is made.
  • Signals. bun add ../dep, a watcher that spins on bun.lock appearing and then sends SIGTERM, SIGINT or SIGKILL, 80 tries each: before, package.json is empty in 31 of 240; after, in 0 of 480 (two sweeps of the delay, since the debug build is slower), with a temporary file left behind in 4. SIGKILL spread over bun test -u with 40 snapshots: before, a 0 byte .snap in 10 of 120; after, the old or the new file in 120 of 120, with one temporary file left.
  • Binary size: taken from the binary-size step of CI for this head.

Suites run on the rebased head with the debug build (with --timeout, the debug binary takes 0.3 s to start on this machine): the eight test files this PR changes, bun-add, bun-remove, bun-update, bun-add-filter, bun-patch, bun-pm-version, init, bunx, migration/pnpm-migration, bun-install-lifecycle-scripts -t trust, test/js/bun/test/snapshot-tests/, rerun-each, ci-restrictions, parallel -t snapshot, regression 14029 and test/internal/source-lints/. Two failures, both debug-only and both on main as well: the error return trace in bun-link.test.ts and the npm_config_user_agent version suffix in bunx.test.ts. cargo check of bun_sys, bun_install and bun_runtime for x86_64-pc-windows-msvc, aarch64-apple-darwin and x86_64-unknown-linux-musl, cargo clippy on the three crates and cargo fmt --all. Nothing was run on Windows or macOS after the rebase; the Windows runs below are from the August base.

The account below is from before the rebase.

Fuzz ledger entries covered (ledger numbers, not GitHub issue numbers): torn .snap (16211), corrupted inline source (16212), memory on a malformed .snap (16213), add/remove lost update (16214), install and bunx loser diagnostics (16215), and 16573: eight concurrent bun patch of different packages in one project leave packages hard linked to the cache, so the user's edits land in the cache and other projects install them, and eight concurrent bun patch --commit keep one or two of the eight entries; also eight concurrent bun link registrations of one package, some of which fail with EEXIST. Patch, PatchCommit and Link were in the locked set already; concurrent-processes.test.ts now has both shapes. On the bun without this branch the patch test fails in 6 runs of 6 (in one run: 5 of 8 edits in the cache, 1 of 8 entries kept, 3 patch files), and the link test fails in 6 of 6 with the EEXIST error; both pass on this branch on Linux (debug, 4 runs of the file) and on Windows (debug build of 023d98c, 8 pass and the bunx skip).

Reproductions on the baked 1.4.0-canary.1 (release): .snap with 8 runners of 300 tests torn in 3 of 6 rounds (360 or 603 keys for 300 tests); inline source corrupted in 3 of 8 rounds; a .snap with one trailing exports[\torn` line: 300 tests 234 MB, 600 tests 1466 MB, intact 29 to 32 MB. With this branch (debug build): 0 of 6, 0 of 6, and the torn run uses 6 MB more than the intact one.

Tests that fail on the baked bun: the two held tests in concurrent-processes.test.ts (a bun remove, which is also the project's first install, held in its postinstall script, a bun add arriving; on the baked bun the add goes through at once and the remove then writes its copy over it, so is-number is missing from package.json, both in the root and in a workspace package), usually the two simultaneous add+remove tests and the four-installs test (timing), the two-writer .snap test, the unchanged .snap test (the file was rewritten), the unparseable .snap test (message and memory), and the repeat-install bin link test. The inline source test and the bunx test pass or fail by timing on the old code. The two-writer .snap test starts the long process only after the short one reports that it has loaded the .snap file (a marker file written by its second test); without that, a slow short process loaded the file the long one had already written and failed its comparisons (3 runs in 10 on a loaded debug machine). The mode and owner tests in bun-pm-pkg.test.ts and the long name test in new-snapshot.test.ts pass on the old code too: they pin behavior the in-place writes had. All processes in concurrent-processes.test.ts share one package cache that is filled before the tests run: the CI runner points every process of a test file at one cache, and two projects filling the same cache entry at the same time is a different race (#33884 fixes it on Windows); the project lock does not serialize different projects on purpose.

package.json writers converted: write_target (add, update, link, install pkg, audit fix, -g), update_package_json_and_install_with_manager_with_updates (remove, patch, patch --commit), bun pm trust (which now also reads package.json by path after locking), bun pm pkg, bun pm version, bun update -i, pnpm migration (migrate_pnpm_lockfile lost its unused dir parameter), bunx's {} reset of its install dir's package.json. attempt_to_create_package_json_and_open creates with O_EXCL and opens the file when another process created it first. bun init is converted too: it wrote the new text over an existing package.json and truncated afterwards. bun create still writes the package.json it has just copied in place.

Which commands lock: Install, Add, Remove, Update (including -i), Link, Patch, PatchCommit, Dedupe, Prune through Subcommand::always_edits_project in init, unless --dry-run; bun audit fix, bun pm trust and bun pm migrate lock after init. lock_project clears workspace_package_json_cache once it holds the lock, so every one of these commands re-reads the package.json files that the walk up in init read before the wait (the explicit callers missed that at first; the held workspace test exercises the path). bun pm version and bun pm pkg have read-only forms and do not lock; their writes are atomic. bun pm version also runs git with the process environment, so a git hook that runs bun install in the same project would wait for a lock its parent holds. The lock directory comes from fetch_cache_directory_path, so BUN_INSTALL_CACHE_DIR, bunfig and the default all work, also when the package cache is disabled. The lock file is opened read-only so a shared cache directory works for every user. bun_sys::flock takes FileLockMode::{Shared, Exclusive}; only Exclusive is used here.

write_file_atomically resolves the path with realpath first so a symlinked package.json (a global package.json kept in dotfiles, for example) is replaced at its target, and opens the existing file for writing before it writes anything, which keeps test/cli/install/bun-add-filter.test.ts ("an unwritable target is reported by name") passing and gives the mode and owner to copy. The owner matters for bun add as root in a bind mount of a user's project, which used to leave package.json owned by the user (bun-pm-pkg.test.ts checks the mode, and the owner when the tests run as root). The temporary file is named by FileSystem::tmpname, so a .snap file whose name is already 255 bytes long still gets written (new-snapshot.test.ts has that case). When the directory refuses the temporary file (EACCES, EPERM), or the rename over the target fails with EACCES, EPERM, EINVAL, ENOTSUP, EBUSY or ETXTBSY, the helper writes the target in place, which is what every one of these writers did before: this covers Windows volumes without FileRenameInformationEx (EINVAL, #10169, which #33029 fixes in the rename itself for the lockfile), a target that another program holds open without FILE_SHARE_DELETE (EBUSY or EPERM), and a writable file in a directory the user may not write to (bun-pm-pkg.test.ts has that case; it is skipped as root, and checked by hand as a non-root user here). Every other error, including a write into the temporary file that fails, is returned with the old file as it was: under ulimit -f 0, bun pm pkg set prints Failed to write package.json: EFBIG, exits 1, and leaves no temporary file. fchown and fchmod on the new file are best effort: a user who may write a file owned by someone else cannot give it back, and the edit used to work for that user. Checked as a non-root user: a read-only .snap that gains an entry fails with EACCES: ... Failed to write snapshot file: <path>, and a read-only .snap that gains nothing passes (it used to fail at open). Hard links to the old file keep the old contents. The helper does not fsync: the writers it replaces did not either, and the problem here is what other processes see, not what survives a power loss.

root_package_json_file is removed from PackageManager: bun pm trust was its only reader. init still opens the file during the walk up, as the check that it can be written to, and closes it right after (the Windows rewind of that file, which served the pm trust read, is gone too). ROOT_PACKAGE_JSON_PATH is built from top_level_dir and package.json (the shape #39361 uses, for its own reason) instead of get_fd_path of that open file, which on Linux reports package.json (deleted) once another process has renamed a new file over it, for example while this process waits for the lock.

Related open PRs. #39403 folds the open install PRs into one branch and touches 15 of the 20 source files changed here; of the changes folded there, #37851 carries the same sys::flock as this branch and #39361 builds ROOT_PACKAGE_JSON_PATH the same way (those hunks resolve to either side), and #38745 also removes root_package_json_file, so it overlaps with this branch in init and in pm_trusted_command.rs; the project lock, the atomic package.json writes and the snapshot changes exist only here. #36701 makes the same change to bin.rs as this branch for the main link site (this branch covers the three sites, for EEXIST); its EPERM/EACCES arms for a read-only .bin (#14736) are a two-line addition on top of either. #39701 (bun init, the exit code for a failed .snap write, the tests of writes that fail) is folded into this branch and closed; #39666 is the companion for an empty package.json. #37851 adds a bunx-level lock with a completion handshake for dist-tag installs, which pass --force and can still reinstall under a bunx that already runs the tree; this branch serializes the children and keeps the bin links, and its sys::flock has the same signature as the one in #37851. #33992 adds a third flock wrapper for a JS API. #38974 and #36679 change the .snap write in snapshot.rs too (write back and ftruncate); the rename here covers their O_TRUNC concern, so whichever lands second keeps its per-file and bail changes only. #37384 fixes the debug-only node shim directory race that makes node -p should work in postinstall scripts and ensureTempNodeGypScript works fail in debug builds on main; those, the npm_config_user_agent debug version suffix in bunx.test.ts, the debug error return trace in bun-link.test.ts, and the color-dependent error snapshots test fail here without this branch as well.

Other suites run with the debug build, under test/cli/install/ unless noted: bun-add.test.ts, bun-remove.test.ts, bun-add-filter.test.ts, bun-update.test.ts, bun-link.test.ts, bun-lock.test.ts, bun-install-registry.test.ts, bun-workspaces.test.ts, bad-workspace.test.ts, bunx.test.ts, bun-pm-pkg.test.ts, bun-pm-version.test.ts, bun-add-catalog.test.ts, bun-install-lifecycle-scripts.test.ts, bun-patch.test.ts, bun-install-patch.test.ts, registry/ (248 tests), migration/pnpm-migration.test.ts, isolated-install.test.ts -t "concurrent|global store", test/js/bun/test/snapshot-tests/ (all files), and test/internal/source-lints/. cargo check of bun_sys, bun_install and bun_runtime for aarch64-apple-darwin, x86_64-pc-windows-msvc and x86_64-unknown-linux-musl; clippy clean on the touched files.