install: fall back to copyfile when linkat fails with EMLINK, ENOTSUP or ENOSYS
Problem
bun installexits 1 whenlink(2)is refused with EMLINK, ENOTSUP or ENOSYS:EMLINK: failed copying files from cache to destination for package p(hoisted),EMLINK: Too many links: failed to link package: p@./p.tgz (link)(isolated).- Cause:
map_linkat_err(src/install/PackageInstall.rs) andneeds_copy(src/install/isolated_install/Installer.rs) copy only on EXDEV, EACCES and EPERM. - Regression since #44968: a hoisted
file:../xfolder on a file system without hard links installs on 1.4.3.
Fix
- Both linkers ask one classifier,
link_refusal. ENOTSUP and ENOSYS act like EXDEV: copy the package, then start the next ones with copyfile. EMLINK copies the package and keeps the hardlink backend. - The isolated linker first removes its partial links (
Hardlinker::delete_dest): its copy opens existing files, and a linked file is the cache file. - The
.binsymlink fallback also skips bin links on ENOSYS. Windowslink_wreportsERROR_TOO_MANY_LINKSas EMLINK. - Verified:
test/cli/install/bun-install-hardlink-fallback.test.ts(15 of 30 fail on main). Self-reviewed: 8 concerns raised, 8 addressed.
Background
- EMLINK: a file is at its link limit (177 on AWS EFS, 1023 on NTFS, 65,000 on ext4). Each install adds one link to every file of a cached package, so they reach the limit together.
- Considered copying only the refused file: the same tree for a cache that installs filled, at +514 -260 lines.
Downsides
- An isolated
file:folder still fails on the new errnos until #45569 lands: its copy writes into the files of the last install (#45194). - Every later install copies a package at the link limit again, without a message.
.text+256 bytes, stripped binary +0. Per linked file: 29 instructions hoisted (main 28), 112 isolated (main 112). A refused isolated package pays one moredelete_tree.
Notes
Prior art
pnpm (pnpm/pnpm#14778) and uv (astral-sh/uv#17699) have the same fallback at the link limit. The uv change names the 177 links of AWS EFS.
The two questions of the request
- Should EMLINK copy only the file that is full and keep linking the rest? It makes no difference for a cache that installs filled. Each install adds one link to every file of a cached package, so all of its files reach the limit together. Measured on ext4 with all 23 files of a cache entry at 65,000 links: the package is copied whole either way. What matters is the next package, so EMLINK does not store the copyfile backend.
- Does the wider list belong in #40628? That PR is closed (#44968 took it in), so this is a PR of its own.
Repro
An LD_PRELOAD shim makes linkat fail with a chosen errno (the test file has one). Package p is a tarball dependency. x86_64 Linux.
linkat fails with | main | this branch |
|---|---|---|
| EMLINK, ENOTSUP, ENOSYS | exit 1 on both linkers | exit 0 |
| EACCES, EPERM, EXDEV | exit 0 | exit 0 |
| ENOSPC, EDQUOT, EIO, EROFS, ENOMEM | exit 1 | exit 1 |
No shim: on ext4, link every file of a cache entry until each has 65,000 links, then install. Main exits 1 on both linkers: EMLINK: failed copying files from cache to destination for package p, EMLINK: Too many links: failed to link package: p@./p.tgz (link). This branch exits 0 and the package is a copy.
The regression since #44968: a hoisted file:../x folder with every linkat refused with ENOTSUP or ENOSYS. 1.4.3 installs it with symlinks and never calls linkat. Main exits 1: ENOTSUP: failed copying files from cache to destination for package x. This branch copies the folder. A folder inside the project (file:./x) fails on 1.4.3 too.
A refusal after some files are linked
A file can reach the limit alone, for example when a project deleted or replaced its copy of another file. Then the link loop stops in the middle of a package.
- Hoisted: the copy creates each file with
O_EXCLand unlinks a file that is there (on WindowsCopyFileWand a delete). This is the code of main. - Isolated: the copy (
FileCopier) opens a file that is there and writes into it. On POSIX a linked file stayed a hard link and the copy wrote the cache file onto itself, which changed its mtime. On WindowsCopyFileWcannot overwrite a hard link of its source, so the package failed.Hardlinker::delete_destremoves the package directory before the copy. That directory is the staging directory of this install, or the package directory in the store when the linker works in place. It also runs for EXDEV, EACCES and EPERM, where main has the same partial state. - The removal is best effort, like the other
delete_treecalls of the file. A link that stays gives the result of the last point. A strict error would fail an install that works on main: when the linker works in place, the package directory could not be moved away or deleted before, and the copy into it still works.
Left out on purpose
- The isolated
file:folder (link_folder). On main its copy fallback goes toFileCopier, which opens the files that are there withoutO_EXCLorO_TRUNC. With ENOTSUP on this gate: the first install works, a source file then shrinks, and the second install leaves the new text followed by the old tail. That is issue #45194, which exists today for EXDEV, EACCES and EPERM. #45569 makes that copy replace the file. After it lands,link_foldercan asklink_refusaltoo. A folder is linked into a directory that stays between installs, so this PR does not delete it. - The macOS gates that compare with EOPNOTSUPP only (clonefile,
fcopyfile, a rename fallback, and the Darwin value in the.bingate). Darwin reports ENOTSUP (45). #46022 has them. - Windows
ERROR_INVALID_FUNCTION, the answer of a volume without hard links. The Win32 table turns it into EISDIR, which is not on the list. This is from reading the code, not from a run: CI has no such volume. On FAT32 and exFAT the isolated layout also needs junctions or symlinks, which those file systems do not have, so a row for this code alone would not make the install pass there. - Linux errno 524 (ENOTSUPP, which NFS can let out).
get_errno()decodes it asEUNKNOWN. It still fails. Say if it should copy too. - A fresh inode for a cache file at the limit, so that later installs link again. uv does that (astral-sh/uv#17699). A comment on this PR reports the probes and why it is not built for now.
- A copy of only the refused file inside the link loop. The branch
robobun/0d97fe3b/install-link-or-copy-per-filehas it, with its measurements. It needs two decisions that this patch does not: a copy on every errno that is not listed, and a package that is part links and part copies.
Checks
bun-install-hardlink-fallback.test.tson the debug build, Linux: 30 pass on this branch. With thesrc/installof main, 15 fail: the six EMLINK, ENOTSUP and ENOSYS rows of the errno grid, the four folder tests with ENOTSUP and ENOSYS, the two link limit tests, the two partial link tests and the ENOSYS row of the.bintests.- The link limit tests pin the EMLINK rule. With a build that stores the copyfile backend for EMLINK, both fail (6 of 6 runs).
- The isolated partial link test pins
delete_dest. With a build that skips the call, the two files that were linked before the refusal keep 2 links and the test fails. - Real ext4, no shim, all 23 files of a cache entry at 65,000 links: the patch exits 0 on both linkers and the package is a copy. Main exits 1.
- Windows: two tests reach the real NTFS limit, with every file of the package at the limit and with only
package.jsonat the limit. NTFS lists a directory by name, so in the second test the other files are linked before the refusal. Both pass on Windows x64 and aarch64 in CI, the only place where they run. - macOS: four tests refuse
linkatwith ENOTSUP (45) and EOPNOTSUPP (102) through aDYLD_INSERT_LIBRARIESinterposer. They run only in CI. - Size and instructions were measured at 4c06fb25b4 against main 91e7e3b140, before
delete_destwas added. Size, release builds of main and of main with thesrc/installpart of the patch (the rest is Windows code):.textis 81,665,765 and 81,666,021 bytes,.rodatais the same, and the strippedbunis 107,243,232 bytes on both. Instructions per linked file (ptrace single-step of the walk, packages of 1,003 and 3,003 files): hoisted 29.00, main 28.00 (the same code with one more padding NOP), isolated 112.00 on both.delete_destruns only after a link is refused. cargo check -p bun_installfor Linux, macOS (aarch64) and Windows (x64).cargo clippy -p bun_install.- Suites on the debug build, all pass:
bun-install-backends(28),bun-link(57),isolated-relink(6),isolated-install(283),bun-install(771). The last three ran with--timeout 120000: on this machine some of their tests pass the 5 s default only when they run alone.
Self-review
A review of a first design (a per-file link-or-copy walk) asked for this gate patch instead. A second review, of this patch, raised 8 points. All are in the diff or in this text: link_folder stays on its own list until #45569, the partial link test, ENOSYS in the .bin gate, the macOS rows, the NTFS precondition, the cost of a package at the limit with the limits and the prior art, the Windows code that is left out, and two wording fixes. The review on the PR then found the isolated copy over the partial links. delete_dest and its tests answer it.
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-hardlink-fallback.test.ts