install: stop `bun prune` and `bun update` removing packages behind a symlinked workspace node_modules
Fixes #44669
Problem
bun pruneremoves what is behind a workspacenode_modulesthat is a symlink. So dobun update,bun dedupeandbun audit fixwith the hoisted linker. A link to the root's costs the root its packages.- Cause:
src/install/prune.rsopens<workspace>/node_modulesat six places withDir::open, which follows the link.
Fix
open_workspace_folderopens every workspacenode_modules, for both linkers, and refuses a link.bun prunethen prunes the rest and exits 1.bun updatewarns.Plan::spare_link_targetsdrops the removals in the folder the link leads to, where an install writes the workspace's entries.- With the isolated linker, such a run removes nothing from
node_modules/.bun. - Verified: 21 new tests, 20 fail on main. 18 reverted clauses each fail a test. Self-reviewed: 29 concerns raised, 18 addressed, 9 moot, 2 not adopted.
Background
bun pruneremoves whatbun.lockdoes not place. After a hoistedbun update,remove_collapsed_copiesremoves copies a folder above provides. Both walk each workspace'snode_modules.- This guards a link the user made, not a security boundary. A root
node_modulesornode_modules/.bunthat is a link is still followed (#44671). - Cut after review: a new folder model that removes copies 1.4.2 keeps (#44670).
Downsides
bun pruneexits 1 for a project with a linked workspacenode_modules, andbun updateno longer cleans behind the link. 1.4.2 cleans a link to the workspace's own storage.bun installstill writes through the link (#42039).- +0 syscalls and +0 allocator calls on
bun install, no-opbun updateand no-opbun prune. +7,680 bytes of text.
Notes
Where it came from. A review of #44406 found it. No user reported it. src/install/prune.rs is the same in bun 1.4.2, and the repro of #44669 gives the same result there.
What the user sees. From bun prune, exit code 1:
error: packages/a/node_modules is a symlink, so Bun did not prune it
note: to have Bun prune that folder, remove the link and run 'bun install'
From bun update, bun dedupe and bun audit fix with the hoisted linker, exit code unchanged:
warn: packages/a/node_modules is a symlink, so Bun did not look behind it
note: to have Bun clean that folder, remove the link and run 'bun install'
bun prune fails because the user asked for that folder to be pruned. The pass after bun update is a cleanup the user did not ask for, so it only warns.
Cases, each one a test in test/cli/install/bun-prune.test.ts (the last two are in bun-workspaces.test.ts)
| Setup | main | this PR |
|---|---|---|
the link leads to the root's node_modules, hoisted and isolated | removes the root's packages (isolated: the root's link to its dependency) | error, removes nothing |
| the link leads to a folder elsewhere, both linkers | removes the directories in that folder | error, removes nothing |
| the workspace has its own copies with a nested tree below them, the link leads to its storage | prunes behind the link | one error, leaves it |
| two workspaces behind links | no message | two errors in sorted order, one note |
strays in the root and in another workspace, one workspace behind a link, both linkers, --dry-run then a real run | also prunes behind the link | removes the two strays, leaves the folder behind the link, exits 1 both times |
a foreign folder behind the link holds a link that looks like one of the isolated linker's, hoisted install, --linker isolated | takes the layout for mixed, runs the isolated planner, removes the root's a-dep | hoisted planner, removes nothing |
isolated, a dev dependency behind the link, --production | removes the store entry and the link | keeps both |
isolated, bun.lock dropped a dependency whose link is still behind the refused folder | removes the store entry and the link | keeps both |
| isolated, the link leads to the root, and an install wrote the workspace's link into the root | removes it from the root | keeps it, and a scoped stray beside it |
| hoisted, the link leads to a sibling's folder, and an install wrote the workspace's copy there | removes it | keeps it |
| the same with a copy nested below a package of the sibling | removes it | keeps it |
| hoisted, the link leads to the root, a stray in another workspace's real folder | removes the root's packages | removes only that stray |
| hoisted, the link leads to the root, the only entry to remove is below a root package | removes it | keeps it |
| hoisted, the link leads to a scope folder of the root | removes the entries of that scope folder | keeps them |
a regular file at packages/a/node_modules | no message | no message, exit 0 |
--silent | prints nothing, exit 0 | prints nothing, exit 1 |
bun update, hoisted, the link leads to the root | removes the root's packages, prints (no changes) | warning, removes nothing |
bun update, hoisted, the link leads to a sibling's folder that holds the workspace's copy | removes that copy | warning, keeps it |
The six opens. detect_layout, the workspace loop of plan_hoisted, the workspace arm of open_tree_folder, the workspace loop of the pass (plan_and_remove_collapsed_copies), the workspace arm of plan_isolated, and the reopen in housekeeping.
How the link is detected. On POSIX the folder is opened with O_NOFOLLOW | O_DIRECTORY, which fails for a link, and only a failed open other than ENOENT is followed by one lstat. A real folder and a missing folder cost what they cost on main. On Windows O_NOFOLLOW opens the link itself, so the lstat comes first: one more call per workspace folder there. That cost is not measured, no Windows machine was at hand.
spare_link_targets. It runs only when a link was refused. It takes the device and inode of the directory each refused link leads to, and compares them with the folders of the plan: the open Dir when the plan kept one, else stat of the folder's path. A folder that matches is taken out of the plan. So is a folder inside it, when every component on the way down is a real directory (lstat). The tree of a workspace has the workspace's link in the root on that way, so it is not inside the root and is pruned as before.
The store rule. With the isolated linker a workspace's node_modules holds links into node_modules/.bun. When that folder is refused its links stay, and one of them can lead to any store entry, also to one that bun.lock dropped. So a run that refuses a folder removes no store entry. The check for a linked workspace runs only when a store entry is about to be removed.
Each clause against a revert, on a debug build. With the clause reverted, the named tests fail:
| Reverted | Tests that fail |
|---|---|
the guard in the plan_hoisted workspace loop | 5 hoisted link tests |
the guard in plan_isolated | 4 isolated link tests |
the guard in open_tree_folder | "own copies", "two workspaces" |
| the guard in the pass | the first bun update test |
the guard in detect_layout | "the layout is not read" |
the lstat after a failed open | "a file" |
| the sort, the dedup of the messages | "two workspaces", "own copies" |
the check for --silent | "--silent" |
| exit 1 after removals | "--silent", "a run that removes entries" (2) |
| the store rule | "--production", "an entry bun.lock dropped" |
spare_link_targets in bun prune, in the pass | 2 "through a link" tests, the second bun update test |
| the scope folder in the match, in the filter | "a scope folder of the root", "the root keeps" |
the walk down with lstat | "does not stop the pruning of a sibling" |
| the folders inside a spared one | "below a sibling's package" |
| the identity from the path | "below a root package" |
The reopen in housekeeping has no such test. It sees a folder only after plan_isolated opened it in the same run.
Measurements. Release builds of main 4b02e1031d and of this branch at 8ca1184f50, linux x64. Filesystem syscalls are counted with a ptrace counter over every thread, allocator calls with gdb breakpoints on mimalloc's entry points. The project has 21 registry packages in the root and one nested copy per workspace.
| Run | main | this PR |
|---|---|---|
no-op bun install, hoisted, 0 / 5 / 50 workspaces, syscalls | 162 / 250 / 970 | 162 / 250 / 970 |
no-op bun install, hoisted, 5 / 50 workspaces, allocator calls | 1,001 / 4,358 | 1,001 / 4,358 |
bun install after one package is deleted, 0 / 5 / 50 workspaces, syscalls | 173 / 261 / 981 | 173 / 261 / 981 |
no-op bun update, 5 / 50 workspaces, syscalls and allocator calls | 334 / 1,414 and 2,511 / 7,387 | the same |
no-op bun prune, hoisted, 5 / 50 workspaces, syscalls and allocator calls | 289 / 1,324 and 963 / 4,362 | the same |
no-op bun prune, isolated, 5 / 50 workspaces, syscalls and allocator calls | 246 / 1,236 and 1,264 / 5,443 | the same |
no-op bun install, isolated, 5 / 50 workspaces, syscalls | 256 / 1,024 | 256 / 1,024 |
bun update with the linked workspace folder, syscalls | 636, removes the root's packages | 235, removes none |
| text size | 80,678,993 | 80,686,673 |
One of four runs of the isolated no-op install at 5 workspaces counted 259 for this PR (one more openat, read, close). bun install does not reach prune.rs, and a second run of main showed the same three calls in another row, so this is noise.
Not fixed here
bun installfollows a linked workspacenode_modulesand writes behind it. #42039 changes the installer.- A root
node_modulesornode_modules/.bunthat is a link is followed, and a root that links into a workspace's folder makes that folder count twice: #44671. - A workspace that a self-contained workspace depends on has two trees for one folder, and
bun pruneandbun updateremove whatbun.lockplaces there: #44670. - The pass after
bun updateruns before a failed run exits, and its removals do not show in the summary or the exit code: #44672. - With
--filter, a workspace that is not selected keeps the behaviour of main: the store is pruned frombun.lock, and a stale link in that workspace can dangle, linked folder or not.
The two self-reviews. The first version also changed the folder model for a workspace with two trees, moved the pass after the failed-run exit and kept unsaved links. The first review found three layouts where the model removes a copy 1.4.2 keeps, and that the guard missed the isolated linker. It offered to grow the PR or to carve out the guard at all six opens. This PR is the carve-out. The second review cut a filter for a root that links into a workspace's folder, because it missed nested folders (that case is #44671). It asked for the remedy in the message, the test for detect_layout, exact counts in the tests, a run that removes entries, and the issues linked above. The 9 moot concerns were about the parts that left this PR. The two concerns not adopted: the removal count in the summary of bun update (#44672 tracks it), and pub(crate) on open_workspace_folder for a caller that is not on main.
After the self-reviews, from the review bots on this PR. Exit 1 from bun prune when a folder was refused. No store entry removed in such a run. spare_link_targets, which starts from a link that was refused: it is a different rule from the filter the second self-review cut. Each of these has tests in the table above.
A question for a maintainer. Three rules for a linked node_modules are in flight: refuse (this PR, for a workspace's folder), replace the link with a folder (#42039, for bun install), follow it (1.4.2). #44671 asks for one.
Suites run on the debug build at the head: bun-prune (140 pass), bun-workspaces (84), bun-update (159), bun-dedupe (82), bun-audit (182), bun-workspaces-self-contained (24). cargo clippy -p bun_install is clean. bun run rust:check-all: 12 of 12 targets.
no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/bun-workspaces.test.ts, test/cli/install/bun-prune.test.ts