install: use one errno predicate so the macOS fallbacks fire for ENOTSUP

RO
18d ago
+310-19

Land before #45761, #45572, #45929 (Notes).

Problem

  • On macOS, bun install exits 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>, or ENOTSUP: 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, but EOPNOTSUPP is 102 in src/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 .bin symlink check, the fcopyfile check. Outcomes are unchanged: hoisted copies, isolated hard-links then copies.
  • Last commit, separable: #[deprecated] on Darwin's EOPNOTSUPP makes 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_modules reach 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, linkermainthis branch
HFS+, hoistedUnexpected: failed copying files from cache to destination for package baz, exit 1exit 0, a copy (nlink 1)
HFS+, isolatedENOTSUP: Operation not supported: failed to link package: baz@./baz-0.0.3.tgz (clonefile), exit 1exit 0, a hard link (nlink 2)
exFAT, hoistedas HFS+ hoisted, exit 1exit 0, a copy (nlink 1)
exFAT, isolatedas HFS+ isolated, exit 1ENOTSUP: 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):

callAPFSHFS+exFAT
clonefile, clonefileat04545
link0045
symlink000
renameatx_np(RENAME_EXCL), free name0045
renameatx_np(RENAME_EXCL), taken name171717
renameatx_np(RENAME_SWAP)04545
renameatx_np(RENAME_SWAP), missing target222
fcopyfile from a pipe454545

When the cache is on the project's volume

  • The user puts it there: BUN_INSTALL_CACHE_DIR, --cache-dir, install.cache.dir, or BUN_INSTALL, XDG_CACHE_HOME or HOME on that volume.
  • Bun puts it there: [install.cache] disable = true or a cache directory that cannot be opened gives node_modules/.cache. No HOME, XDG_CACHE_HOME or BUN_INSTALL gives node_modules/.bun-cache (src/install/PackageManager/PackageManagerDirectories.rs).
  • With install.globalStore the 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

gatehistoryevidence
install_with_clonefile (src/install/PackageInstall.rs)1.3.14 arm, dead since 1.4.0hoisted install exits 1 on HFS+ and exFAT
install_with_clonefile_each_dir (same file)1.3.14 arm, dead since 1.4.0clonefileat 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.0isolated 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.045 for a pipe source. No caller passes one today
.bin symlink gate (src/install/bin.rs)new in #44968, in no releaseinterposer 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 names EOPNOTSUPP. It is inert on macOS: RENAME_EXCL answers EEXIST for a taken name before the file system is asked, so ENOTSUP means the target is missing, and RENAME_SWAP then answers ENOENT. The delete-then-rename path runs in both cases.
  • Bun.write has its own fcopyfile gate 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),
  • .bin gate with #45761: its hunk adds | sys::Errno::ENOSYS inside the matches!. && !bun_errno::is_not_supported(errno) stays.
  • link_refusal in #45761: _ if bun_errno::is_not_supported(errno) => ...
  • fcopyfile gate 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_install text 3,029,269 to 3,029,255 bytes (-14, all in rename_no_replace). bun_sys is identical.
  • is_not_supported leaves no symbol: it is inlined.
  • From each of the 5 clonefileat calls to its success branch the instructions are the same. So is fcopyfile.
  • The .bin gate's errno check takes 9 instructions for EEXIST on darwin arm64, 7 on main. Linux is identical.
  • Allocations added: 0.

Checks

  • cargo check --workspace for 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 clippy for 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