Skip to content

install: remove files deleted from a folder dependency on reinstall with the isolated linker - #37138

Open
robobun wants to merge 4 commits into
mainfrom
farm/fc17bd6d/isolated-folder-dep-prune
Open

install: remove files deleted from a folder dependency on reinstall with the isolated linker#37138
robobun wants to merge 4 commits into
mainfrom
farm/fc17bd6d/isolated-folder-dep-prune

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Repro

$ cat package.json
{"dependencies":{"local":"file:./local-pkg"}}
$ cat bunfig.toml
[install]
linker = "isolated"
$ ls local-pkg
foo.js  index.js  package.json
$ bun install && ls node_modules/local
foo.js  index.js  package.json
$ rm local-pkg/foo.js
$ bun install && ls node_modules/local
foo.js  index.js  package.json   # foo.js is back from the dead, on every install

With linker = "hoisted" the second install correctly removes node_modules/local/foo.js.

Cause

The isolated linker relinks folder (file:) dependencies on every install to keep them up to date. The LinkPackage step's Folder | Root arm (src/install/isolated_install/Installer.rs) rebuilds the store entry by walking the source folder with Hardlinker/FileCopier, which only visits names present in the source: existing destination files are overwritten, but files that exist only in the destination are never touched. So the rebuild merges over the previous build, and a file deleted from the dependency's folder survives in node_modules/.bun/<name>@file+<path>/node_modules/<name> forever.

The hoisted linker runs uninstall_before_install (delete, then copy) for the same case, so the two linkers diverged.

Fix

Delete the store entry's package directory before the walk, once per relink, after the source folder has been opened (a missing source still fails without destroying the previous build). Only the package directory inside the entry is removed; the entry's node_modules siblings (dependency symlinks) are left for the later steps, which re-run on every install for folder entries.

This matches hoisted semantics for lifecycle scripts too: both linkers re-run a folder dependency's scripts on every install, and hoisted already wipes the destination first, so artifacts scripts write into the package directory are recreated the same way under both linkers.

Root resolutions reach this arm only as a dependency on the root package ("x": "file:."), where append_store_path yields the store entry's package dir. The delete is additionally scoped by resolution tag, the same way on_task_fail guards its cleanup delete: a Root entry that is not a dependency on the root package (whose store path is the project dir itself) never reaches it, in release builds too.

Same defect class as #37137 (npm/git/tarball arm of the same step); the two changes are in different arms and independent.

Verification

Tests in test/cli/install/isolated-install.test.ts cover a deleted file, a deleted nested directory, a modified file, and that the entry's dependency symlinks still resolve after the rebuild; the root-as-dependency case ("self": "file:.") reinstalling over an existing build with a file deleted from the project (and the project's own files untouched); and that a folder dependency whose source folder disappeared fails the install while the previous build and the node_modules symlink survive. It fails on bun 1.4.0-canary.1 (extra.js still exists after reinstall) and passes with this change. All 63 tests in the file pass, as does bun-install-hardlink-fallback.test.ts (exercises the copyfile fallback in the same arm).


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/isolated-install.test.ts

…ed linker relinks its store entry

The isolated linker rebuilds a folder (file:) dependency's store entry on
every install by walking the source folder and hardlinking/copying each
entry. The walk only visits names present in the source, so the rebuild
merged over the previous contents and a file deleted from the dependency's
folder survived every subsequent install. The hoisted linker removes the
destination before reinstalling, so the two linkers diverged.

Delete the store entry's package directory before the walk. Lifecycle
scripts re-run on every install for folder entries under both linkers, so
artifacts they write into the package directory are recreated, matching
hoisted behavior.
@github-actions github-actions Bot added the claude label Aug 7, 2026
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The installer now removes existing store entries before rebuilding folder and root packages. A regression test verifies that reinstalling a folder dependency removes deleted files, updates modified files, and preserves dependency links.

Changes

Isolated install cleanup

Layer / File(s) Summary
Store entry cleanup and rebuild
src/install/isolated_install/Installer.rs, test/cli/install/isolated-install.test.ts
The installer deletes existing store paths before rebuilding folder and root package entries. Cleanup failures return TaskError::LinkPackage. The regression test verifies stale-file removal, updated files, valid store symlinks, and transitive dependencies.

Possibly related PRs

  • oven-sh/bun#35105: Both changes modify isolated installer cache handling.
  • oven-sh/bun#36853: Both changes modify isolated installer package-linking behavior.
  • oven-sh/bun#37137: Both changes address cleanup of isolated-linker store entries during rebuilds.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly summarizes the primary fix for deleted files in folder dependencies during isolated-linker reinstall.
Description check ✅ Passed The description explains the defect, cause, fix, and verification results, although it uses different headings from the template.

Comment @coderabbitai help to get the list of available commands.

Comment thread src/install/isolated_install/Installer.rs Outdated
…se builds too

A scheduling invariant in isolated_install.rs is what keeps the root
project entry out of LinkPackage; if that ever broke, the debug_assert
would compile out and the delete would remove the project directory.
Assert on the delete's precondition directly (the path extended past the
top-level dir) so release builds fail loudly instead.
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs Outdated
Comment thread src/install/isolated_install/Installer.rs
Comment thread src/install/isolated_install/Installer.rs Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My earlier concern (debug_assert → release assert) was addressed in 38d7270. I didn't find further issues, but this adds a delete_tree call to the package manager's install path, so a maintainer familiar with the isolated linker should sign off.

Checked: delete_tree returns Ok on ENOENT so first-install is unaffected; the delete runs only after the source folder opens, so a missing source doesn't wipe the previous build; append_store_path for Root with a valid dep_id and for Folder both extend into node_modules/.bun/..., so the release assert covers the only unextended case. The hardlink→copyfile fallback loop runs after the single delete, so a mid-walk EXDEV retry doesn't re-delete.

Extended reasoning...

Overview

The PR adds a delete_tree of the store entry's package directory before the isolated linker's Folder | Root rebuild walk (src/install/isolated_install/Installer.rs:905-919), so files removed from a file: dependency no longer survive reinstalls. A regression test in test/cli/install/isolated-install.test.ts covers a deleted file, a deleted nested directory, a modified file, and that the entry's dependency symlinks (no-deps) still resolve after rebuild.

Security risks

The new call recursively deletes a directory computed from init_top_level_dir() + append_store_path. The degenerate case where append_store_path appends nothing (Root entry with invalid_dependency_id) would target the project root itself. That path is unreachable via task scheduling today (isolated_install.rs sets the root entry's step to SymlinkDependencies), and the release-mode assert!(prev_build.len() > top_level_len) now guards it at the delete site. I traced append_store_path (Installer.rs:2702-2742): for Root with a valid dep_id and for every non-root tag it appends node_modules/.bun/<entry>/node_modules/<name>, so the assert holds on every reachable path. No injection or auth surface is touched.

Level of scrutiny

High. This is a destructive filesystem operation inside bun install, on a path derived from lockfile/store state. The blast radius of a wrong path is user data loss. The change is small and mirrors the hoisted linker's uninstall_before_install, but destructive changes to the package manager warrant a maintainer's eyes even when the diff looks correct.

Other factors

  • delete_tree_open_initial_subpath (src/sys/dir.rs:256) returns Ok(None) on ENOENT, so the first install (no prior store entry) is not broken.
  • The delete is placed after open_dir_for_iteration on the source succeeds, so a missing/renamed source folder fails the task without destroying the previous build — matches the PR description's claim.
  • The delete sits before the 'backend: loop, so the EXDEV/EACCES → Copyfile fallback does not re-delete a partially-hardlinked destination.
  • The test follows existing patterns in the file (readlinkSync + join for symlink targets, registry.createTestDir, runBunInstall), and the sibling test "can install folder dependencies on root package" already exercises the Root-with-valid-dep_id arm.
  • The comment-cop bot re-flagged the trimmed comments; the author pushed back with a reasonable justification and the threads are resolved. That's a style call for a human, not a correctness issue.

…t-as-dep and missing-source relinks

Replace the length assert with the explicit condition it encoded, matching
the tag guard on_task_fail uses for its cleanup delete: only Folder entries
and Root entries that are a dependency on the root package get the delete;
the plain root entry's store path is the project dir itself.

Extend the root-as-dep test with a reinstall after deleting a project file,
so the Root half of the arm runs against an existing build in CI, and pin
the open-before-delete ordering: a folder dependency whose source folder
disappeared fails the install without destroying the previous build.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Self-review on the last push surfaced three gaps, addressed in 2f50fa4:

  • The Root half of the Folder | Root arm ("self": "file:.") never ran the new delete against an existing build in any test. The root-as-dep test now reinstalls after deleting a project file and checks the store copy pruned it while the project's own files and the node_modules/self symlink are untouched.
  • The guard against the delete resolving to the project dir was a release assert; it is now the explicit tag condition, mirroring the guard on_task_fail already uses for its cleanup delete in the same file.
  • The open-before-delete ordering (a missing source folder fails the install without destroying the previous build) was claimed in the description but not pinned; there is now a test for it.

Full suite: 64 pass.

Comment thread src/install/isolated_install/Installer.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The earlier concern about delete_tree reaching the project root is now addressed — the explicit Folder || dep_id != invalid guard fails closed and matches exactly when append_store_path produces a store subpath. I didn't find anything else wrong, but this adds a recursive delete to the install path, so it's worth a maintainer's eyes before merging.

What was reviewed:

  • Traced append_store_path for both Folder and Root tags — the guard covers every case where it would return the top-level dir.
  • delete_tree treats ENOENT as success, so first install (no prior build) is unaffected.
  • Delete is placed after the source-folder open and before the 'backend loop, so a missing source preserves the old build and the hardlink→copyfile fallback doesn't re-delete.
Extended reasoning...

Overview

Adds a delete_tree of the isolated-linker store entry's package directory before the hardlink/copy walk in the Folder | Root arm of Step::LinkPackage, so files removed from a file: dependency are pruned on reinstall instead of surviving forever. ~20 lines of production code in src/install/isolated_install/Installer.rs plus three new/extended tests in test/cli/install/isolated-install.test.ts.

Security risks

The destructive operation is a recursive delete. The path is derived from lockfile resolution data via append_store_path, which for Folder and Root-with-valid-dep_id always yields <top_level>/node_modules/.bun/<entry>/node_modules/<name>. The one degenerate case (Root with invalid_dependency_id, whose store path is the project dir itself) is now excluded by an explicit condition rather than the earlier debug_assert! I flagged — so it fails closed in release builds. Fd::cwd().delete_tree(abs_path) is the same pattern already used at six other sites in this file. No injection, auth, or network surface.

Level of scrutiny

High — this is a recursive filesystem delete inside the package manager, where a wrong path is unrecoverable data loss. The change itself is small and the guard is provably correct against the current append_store_path implementation, but the invariant it depends on (task scheduling never routes the root-project entry through LinkPackage) lives in a different file, and adding a new destructive call to that arm is the kind of thing a maintainer familiar with the isolated linker's scheduling should confirm.

Other factors

My earlier review comment was addressed across three follow-up commits, ending with the explicit tag-based guard (better than the release assert! I'd suggested — it skips rather than panics). Test coverage is thorough: deleted file, deleted nested dir, modified file, dependency symlinks preserved, root-as-dep ("self": "file:.") reinstall with the project's own files untouched, and the open-before-delete ordering pinned by a missing-source test. The one remaining unresolved inline comment is the automated comment-cop linter re-firing on the explanatory comment; the author already trimmed twice and the current comment is genuinely load-bearing (it explains why the delete must exist), so I don't consider it blocking.

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 8:34 AM PT - Aug 7th, 2026

@robobun, your commit 2f50fa46716610324830f560b1f3d86a9e10fe3a passed in Build #90139! 🎉


🧪   To try this PR locally:

bunx bun-pr 37138

That installs a local version of the PR into your bun-37138 executable, so you can run:

bun-37138 --bun

@dylan-conway

Copy link
Copy Markdown
Member

does this mean we delete the entire folder before each install if it's in the store? i'm not sure we can do that

@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Yes, with tight scope: on every install, for folder (file:) dependencies and root-as-dependency entries only, the package directory inside the project-local store entry (node_modules/.bun/<name>@file+<path>/node_modules/<name>) is deleted and rebuilt from the source folder. It never touches the shared global store (entry_uses_global_store is always false for folder/root entries, so these live only under the project's node_modules/.bun), it leaves the entry's node_modules siblings alone (the dependency symlinks), and npm/git/tarball entries are not affected (their arm is gated on needs_install).

What made me judge the delete acceptable:

  • These entries were already rewritten on every install before this change. The scheduling arm starts LinkPackage unconditionally for folder entries ("folders are always hardlinked to keep them up-to-date"), and on the default hardlink backend every existing destination file hits EEXIST, gets unlinked, and is relinked on each pass. The tree delete keeps that same O(files) per-install cost and additionally removes names that left the source, which is the bug.
  • The hoisted linker already does exactly this for the same case: install() runs uninstall_before_install (full delete of the destination) before copying, on every install of a folder dep. Lifecycle scripts re-run per install for folder entries under both linkers, so artifacts they write into the package dir are recreated; the divergence this PR removes was only the stale-file leak.

If the full delete is still not acceptable (for example the transient window where the whole directory is empty mid-install, which hoisted also has), the alternative is an incremental prune: after the link walk, walk the destination and remove only names that no longer exist in the source. That keeps unchanged files in place and closes the window, at the cost of a second directory walk and more code. Happy to rework it that way if you prefer.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants