install: use one errno predicate so the macOS fallbacks fire for ENOTSUP
Land before #45761, #45572, #45929 (Notes).
Problem
- On macOS,
bun installexits 1 when the cache and the project share a volume that cannot clone (HFS+, exFAT):Unexpected: failed copying files from cache to destination for package <name>, orENOTSUP: Operation not supported: failed to link package: <pkg> (clonefile)(isolated). 1.3.14 fell back. - The kernel reports ENOTSUP (45) there. Zig named 45
OPNOTSUPP. The Rust port kept the name, butEOPNOTSUPPis 102 insrc/errno/darwin_errno.rs, so five checks compare 45 with 102.
Fix
bun_errno::is_not_supported(E)is true for both values. Five checks call it:install_with_clonefile,install_with_clonefile_each_dir, the isolated clonefile arm, the.binsymlink check, thefcopyfilecheck. Outcomes are unchanged: hoisted copies, isolated hard-links then copies.- Last commit, separable:
#[deprecated]on Darwin'sEOPNOTSUPPmakes a bare use fail the macOS build. - Verified on both darwin lanes:
test/cli/install/bun-install-backends.test.ts(6 tests, dyld interposer), HFS+ and exFAT disk images. - Self-reviewed: 7 concerns raised, 7 addressed.
Background
- Elsewhere the two names are one value, so a pattern with both does not compile. The predicate uses equality.
- Considered both names inline at each check (two spellings) and a regex lint (cannot read
cfg).
Downsides
- Isolated installs on exFAT (no hard links) still fail, at
(link), until #45761. - Open question (Notes): hard link or copy in the isolated linker. A hard link lets an edit in
node_modulesreach the cache. - Darwin arm64, per-crate objects: text +128 B, and 2 more instructions per existing bin link on a reinstall. Linux x64 -14 B. Linked binaries not measured (Notes).
Notes
Before and after (darwin arm64 and x64, same results on both)
Before is build 128736: main plus tests only, macOS 26.4 arm64 and macOS 14 x64. After is build 129314: commit 23328ad8ee, macOS 15 arm64 and macOS 14 x64.
Cache and project on one HFS+ or exFAT disk image (hdiutil), one tarball dependency:
| volume, linker | main | this branch |
|---|---|---|
| HFS+, hoisted | Unexpected: failed copying files from cache to destination for package baz, exit 1 | exit 0, a copy (nlink 1) |
| HFS+, isolated | ENOTSUP: Operation not supported: failed to link package: baz@./baz-0.0.3.tgz (clonefile), exit 1 | exit 0, a hard link (nlink 2) |
| exFAT, hoisted | as HFS+ hoisted, exit 1 | exit 0, a copy (nlink 1) |
| exFAT, isolated | as HFS+ isolated, exit 1 | ENOTSUP: Operation not supported: failed to link package: baz@./baz.tgz (link), exit 1 |
With the cache on APFS and the project on the image, both linkers exit 0 on main: clonefileat reports EXDEV first, and that check works. --backend=copyfile is the workaround for 1.4.0 to 1.4.3.
test/cli/install/bun-install-backends.test.ts on the darwin lanes: 31 pass and 4 fail on main (the 4 tests of the first push). 37 pass, 0 fail and 4 skip on this branch (the skips are the Windows tests).
clonefileat calls of one install, counted with the interposer on APFS (build 129314):
- 1 package: 1 refused call, then the fallback.
- 20 packages, hoisted: 3 refused (arm64) and 7 (x64), then the copyfile backend for the rest. Each thread that starts before the first refusal is stored is refused once.
- 20 packages, isolated: 3 refused (arm64) and 11 (x64), then the hardlink backend.
- Nothing refused: 20 successful clones for 20 packages.
The runs on disk images used a temporary probe test. It is not in the branch any more (removed in 2728dababa).
Errno of each call (0 is success):
| call | APFS | HFS+ | exFAT |
|---|---|---|---|
clonefile, clonefileat | 0 | 45 | 45 |
link | 0 | 0 | 45 |
symlink | 0 | 0 | 0 |
renameatx_np(RENAME_EXCL), free name | 0 | 0 | 45 |
renameatx_np(RENAME_EXCL), taken name | 17 | 17 | 17 |
renameatx_np(RENAME_SWAP) | 0 | 45 | 45 |
renameatx_np(RENAME_SWAP), missing target | 2 | 2 | 2 |
fcopyfile from a pipe | 45 | 45 | 45 |
When the cache is on the project's volume
- The user puts it there:
BUN_INSTALL_CACHE_DIR,--cache-dir,install.cache.dir, orBUN_INSTALL,XDG_CACHE_HOMEorHOMEon that volume. - Bun puts it there:
[install.cache] disable = trueor a cache directory that cannot be opened givesnode_modules/.cache. NoHOME,XDG_CACHE_HOMEorBUN_INSTALLgivesnode_modules/.bun-cache(src/install/PackageManager/PackageManagerDirectories.rs). - With
install.globalStorethe isolated linker clones into<cache>/links, so the volume of the cache alone decides.
A project on an external drive with the cache in the home directory never failed.
The five gates
| gate | history | evidence |
|---|---|---|
install_with_clonefile (src/install/PackageInstall.rs) | 1.3.14 arm, dead since 1.4.0 | hoisted install exits 1 on HFS+ and exFAT |
install_with_clonefile_each_dir (same file) | 1.3.14 arm, dead since 1.4.0 | clonefileat on a file is 45 on HFS+ and exFAT |
isolated clonefile arm (src/install/isolated_install/Installer.rs) | 1.3.14 arm, dead since 1.4.0 | isolated install exits 1 on HFS+ and exFAT |
fcopyfile in copy_file_with_state (src/sys/copy_file.rs) | 1.3.14 arm, dead since 1.4.0 | 45 for a pipe source. No caller passes one today |
.bin symlink gate (src/install/bin.rs) | new in #44968, in no release | interposer only. HFS+ and exFAT have symlinks. Apple's webdavfs has no symlink operation (read from its source, not run) |
Where the regression comes from: before the port bun.sys.E was Zig's std.posix.E, whose Darwin enum has OPNOTSUPP = 45 and nothing at 102. The port (#30412) made E the same type as SystemErrno, where EOPNOTSUPP = 102, and carried the arms over by name. It corrected the manifest save in src/install/npm.rs by hand in the same commit.
Left alone on purpose
renameat_concurrently_without_fallback(src/sys/lib.rs) still namesEOPNOTSUPP. It is inert on macOS:RENAME_EXCLanswers EEXIST for a taken name before the file system is asked, so ENOTSUP means the target is missing, andRENAME_SWAPthen answers ENOENT. The delete-then-rename path runs in both cases.Bun.writehas its ownfcopyfilegate that falls back on EBADF only (src/runtime/webcore/blob/copy_file.rs). #36692 has that one.- The hardlink gates (
map_linkat_err,needs_copy): #45761 has them.
Landing order
#45572, #45761 and #45929 each rewrite a line that this PR changes and type EOPNOTSUPP again there. Their hunks apply on main and not on this branch. With the last commit of this PR, such a line no longer builds for macOS. The resolved lines:
- Isolated clonefile arm with #45572:
errno if bun_errno::is_not_supported(errno) => self.set_backend(InstallMethod::Hardlink), .bingate with #45761: its hunk adds| sys::Errno::ENOSYSinside thematches!.&& !bun_errno::is_not_supported(errno)stays.link_refusalin #45761:_ if bun_errno::is_not_supported(errno) => ...fcopyfilegate with #45929:Err(err) if bun_errno::is_not_supported(err.get_errno()) => {}
Open question: hard link or copy in the isolated linker
1.2.19 to 1.3.14 went from a refused clone to the hardlink backend in the isolated linker (#21122) and to a copy in the hoisted linker. This PR restores both. If a copy is wanted for both linkers, the isolated arm becomes InstallMethod::Copyfile, the isolated test expects nlink 1, and exFAT then works without #45761. The docs name only the copy fallback. I left them as they are until this is answered. If the hard link stays, docs/pm/isolated-installs.mdx, docs/pm/cli/install.mdx and docs/pm/global-cache.mdx each need one sentence.
The last commit
#[deprecated] on Darwin's EOPNOTSUPP = 102 makes rustc reject a bare use in code compiled for macOS (the workspace denies warnings). bun run rust:check-all shows it from Linux. Five places keep the name with an allow: the errno table, the predicate, the two socket gates that want 102 (open(2) in src/dotenv/env_loader.rs, listen(2) in src/runtime/socket/Listener.rs) and the rename gate. The manifest save and rename_no_replace already compared both values and now call the predicate, because the guard requires it. Checked by putting E::EOPNOTSUPP back at the fcopyfile gate: the darwin check fails with the note, the Linux check passes.
Measurements
CI artifacts are on a host that the machine that made this PR cannot reach, and it has no macOS SDK. So these are release objects of single crates (cargo rustc --release --emit=obj with the release flags, no LTO), main against this branch, compared with llvm-size and a per-function llvm-objdump diff.
- darwin arm64,
bun_install__TEXT,__text: 2,327,924 to 2,328,052 bytes (+128).Task::run(isolated) +22 instructions of 3,331,install_into+5,install_with_clonefile_each_dir+2,link_bin_or_create_shim+1,rename_no_replace-3. - darwin arm64,
bun_sys__TEXT,__text: 71,076 bytes on both.copy_file_with_state+5 instructions. - Linux x64:
bun_installtext 3,029,269 to 3,029,255 bytes (-14, all inrename_no_replace).bun_sysis identical. is_not_supportedleaves no symbol: it is inlined.- From each of the 5
clonefileatcalls to its success branch the instructions are the same. So isfcopyfile. - The
.bingate's errno check takes 9 instructions for EEXIST on darwin arm64, 7 on main. Linux is identical. - Allocations added: 0.
Checks
cargo check --workspacefor aarch64-apple-darwin, x86_64-apple-darwin, x86_64-unknown-linux-gnu, x86_64-pc-windows-msvc and x86_64-unknown-freebsd, after the merge of main at 963452075e.cargo clippyfor the touched crates on Linux and darwin.- On the Linux debug build:
bun-install-backends,bun-install-hardlink-fallback,dt-unknown-readdir,bun-link,bun-install-staging,isolated-relink,bun-pm-cache-pack,test/cli/run/env.test.ts. - The interposer (
test/fixtures/refuse-syscalls-shim.c) looks the names up before it refuses, as the kernel does. Its logic ran on Linux in a unit harness.
Prior work
The predicate and the guard arms come from commit 579f46cbdb3 on the branch robobun/0d97fe3b/install-link-or-copy-per-file, which was never opened as a PR.
no test proof · iteration 1 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bun-install-backends.test.ts