Skip to content

install: replace an empty directory in an isolated link slot with the link - #41453

Open
robobun wants to merge 6 commits into
mainfrom
robobun/89f0a791/isolated-empty-dir-relink
Open

robobun wants to merge 6 commits into
mainfrom
robobun/89f0a791/isolated-empty-dir-relink

Conversation

@robobun

@robobun robobun commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • With linker = "isolated", when node_modules/<pkg> is an empty real directory instead of the symlink into node_modules/.bun/<pkg>@<ver>/, bun install and bun install --frozen-lockfile report (no changes) and leave it as is. node_modules/<pkg>/package.json never exists. Deleting the link, or deleting the store entry, is repaired. Only the "empty directory in the link's place" case is missed.
  • Cause: Symlinker::ensure_symlink (src/install/isolated_install/Symlinker.rs:102) keeps any real directory found at a link path. install: global virtual store for isolated linker (7x faster warm installs) #29489 added that to protect a bun patch <pkg> workspace, which is a real directory at the same path. An empty directory matches the same check.

Fix

  • When readlink says the link path is not a symlink, probe <dest>/package.json with one syscall. If it exists, keep the directory (the patch workspace). If not, delete_tree removes whatever is there (a directory or a regular file) and the link is written. The separate lstat that classified the path is gone.
  • Correct because a bun patch workspace is a copy of the package, so it always has a package.json. A directory without one is not a package. The hoisted linker uses the same signal (PackageInstall::verify_package_json_name_and_version) to decide whether an install is present.
  • The same function writes the dependency links inside a store entry and the .bun/node_modules/<pkg> links, so those slots heal too.
  • Verified: six new tests in test/cli/install/isolated-relink.test.ts. Five fail on the current release and pass with this change (the root slot, a store entry slot, the .bun/node_modules and scoped slots, a workspace package slot, and a new dependency whose directory exists before the install). The sixth pins that a regular file in the slot is replaced. isolated-install.test.ts, isolated-relink.test.ts, and the isolated tests in bun-install-patch.test.ts pass with a debug build.

Background

  • Isolated linker: each package is unpacked once into node_modules/.bun/<pkg>@<ver>/node_modules/<pkg>. node_modules/<pkg> at the project root, and each dependency slot inside a store entry, is a symlink into that store.
  • ensure_symlink with Strategy::ExpectExisting reads the link at the destination. Right target: nothing to do. Nothing there: create it. Anything else is in the way. Only the "a directory is in the way" arm changes here.
  • bun patch <pkg> replaces node_modules/<pkg> with a real directory that holds a copy of the package for the user to edit until bun patch --commit. Install must not delete it.

Supersedes #37757, which removes the directory with rmdir (so only an empty one). This change also repairs a leftover directory that has stray contents but no package.json. The tests from #37757 (the .bun/node_modules and scoped slots, a workspace package slot, and the bun bd case where ninja creates node_modules/<new dep>/ before bun install runs) are folded into this branch. The summary line still says (no changes) when only a root link is rewritten. That is pre-existing: root and workspace entries are always counted as skipped in Installer::on_task_complete, also when the root link was deleted outright.


no test proof · iteration 2 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/cli/install/isolated-relink.test.ts

@coderabbitai

coderabbitai Bot commented Sep 5, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 181ad4f8-0634-4f56-9fd8-d16adcba15ee

📥 Commits

Reviewing files that changed from the base of the PR and between f42e980 and 429c9b1.

📒 Files selected for processing (2)
  • src/install/isolated_install/Symlinker.rs
  • test/cli/install/isolated-relink.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 3 remain after this review.


Walkthrough

ensure_symlink now preserves only real directories containing package.json. Other directories are removed before symlink creation. Tests cover root and store-entry dependency slots.

Changes

Isolated symlink replacement

Layer / File(s) Summary
Symlink replacement behavior
src/install/isolated_install/Symlinker.rs
ensure_symlink preserves directories with package.json, removes other directories, and unlinks non-directories before recreating the symlink.
Replacement scenario tests
test/cli/install/isolated-relink.test.ts
Tests verify root and store-entry directories become symlinks, restore package metadata, report changes, and remain unchanged on a later install.

Suggested reviewers: jarred-sumner

Merge Risk: ⚪ Minimal · up to 429c9

Isolated installs now restore dependency symlinks when an empty or unrelated directory occupies the link path, while preserving patch workspaces. The covered scenarios indicate no remaining merge-blocking risk.

🚥 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 and concisely describes the main change: replacing an empty directory at an isolated dependency link path with the expected link.
Description check ✅ Passed The description explains the problem, cause, fix, scope, preservation of bun patch workspaces, and verification results. It does not use the template headings exactly, but it contains the required cha…

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

@github-actions github-actions Bot added the claude label Sep 5, 2026
@robobun

robobun commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:58 AM PT - Sep 6th, 2026

❌ @robobun, your commit 4b57000 has 3 failures in Build #110765 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 41453

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

bun-41453 --bun

@robobun

robobun commented Sep 5, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fixed, ready for review.

Reproduced on the current release in an isolated-linker project: rm node_modules/<pkg> && mkdir node_modules/<pkg>, then bun install. The link is not restored and node_modules/<pkg>/package.json does not exist. The tests in test/cli/install/isolated-relink.test.ts that cover this fail on the release and pass with this branch.

CI: every job that exercises the install tests is green. The red lanes (test/js/bun/http/async-iterator-stream.test.ts, test/js/node/test/parallel/test-cluster-primary-error.js, the napi test_object worker crash) do not touch the isolated linker and are reported separately.

@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.

LGTM — narrow refinement of the existing "real directory at link slot" guard, with matching tests.

What was reviewed:

  • Path::save() returns a ResetScope that truncates on Drop, and Path::append auto-inserts the separator, so the probe is <dest>/package.json and self.dest is restored before delete_tree.
  • Discarding the delete_tree result matches the existing ExpectMissing/EEXIST arm; a failure surfaces via the subsequent symlink().
  • Considered the "patch workspace with package.json deleted" case — the hoisted linker already treats absence of package.json as not-installed, so this is consistent, and a patch copy always starts with one.
  • New tests reuse the file's unlink+mkdir and isSymbolicLink() patterns already exercised cross-platform by the neighbouring tests.
Extended reasoning...

Overview

The PR tightens one arm of Symlinker::ensure_symlink in the isolated-install linker. Previously, when readlink on a link slot failed and the slot was a real directory, the directory was unconditionally preserved (to protect a bun patch workspace). Now it is only preserved if it contains a package.json; otherwise it is removed with delete_tree and the symlink is re-created. Two new test.concurrent cases in test/cli/install/isolated-relink.test.ts cover the root-dependency slot and the store-entry nested-dependency slot.

Security risks

None. The path being deleted is one the installer itself owns (node_modules/<pkg> or a store-entry dependency slot) and only when it is a real directory lacking package.json. No user-controlled input reaches a new path-construction site; the probe path is built via the pooled Path::append, which inserts the separator and is length-checked. No network, auth, or crypto surface is touched.

Level of scrutiny

Low-to-moderate. The change is ~15 lines in a rarely-reached recovery arm (readlink failed, dest is a real non-reparse directory), so it is neither a hot path nor a syscall-count concern. I verified the RAII behaviour: save() yields a ResetScope whose Drop restores the buffer length, so self.dest is back to the directory path before delete_tree and symlink() run. Path::append computes needs_sep and inserts the platform separator, so the probe is correctly <dest>/package.json on both POSIX and Windows. The let _ = delete_tree(...) discard mirrors the existing Strategy::ExpectMissing EEXIST arm in the same function — if removal fails, the following symlink() returns the error.

Other factors

Tests follow the file's established conventions exactly (VerdaccioRegistry, test.concurrent, installOk, unlink+mkdir on a link slot, lstat().isSymbolicLink()), all of which are already exercised cross-platform by neighbouring pre-existing tests. No CODEOWNERS entry covers src/install/ or the test file. The bug hunt exited on dry_streak with no findings. The PR conversation contains no outstanding third-party objections. The one design tradeoff — deleting any package.json-less directory rather than only empty ones — is deliberate, explained in the description, and matches the hoisted linker's verify_package_json_name_and_version signal.

Comment thread src/install/isolated_install/Symlinker.rs Outdated
Comment thread src/install/isolated_install/Symlinker.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.

Code review found no issues

No high-confidence issues detected in this change.

@Jarred-Sumner Jarred-Sumner left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Instead, can we check if the package.json exists in one syscall?

@robobun

robobun commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator Author

Done in 2d13f73. When readlink fails with "not a symlink", the arm now probes <dest>/package.json directly (one access on POSIX, one GetFileAttributesW on Windows). The lstat / GetFileAttributes on dest is gone. If the probe fails, delete_tree removes whatever is there (it handles a regular file too), then the link is written. Verified on the debug build: a regular file and a non-empty directory without package.json in the slot are both replaced, and the isolated bun patch tests still pass.

@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.

Code review found no issues

No high-confidence issues detected in this change.

… link

The isolated linker kept any real directory found where a dependency
symlink belongs, to protect a bun patch workspace. An empty directory
with no package.json is not a package. Delete it and write the link.
@robobun
robobun force-pushed the robobun/89f0a791/isolated-empty-dir-relink branch from 2d13f73 to 4b57000 Compare September 6, 2026 06:32

@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.

Code review found no issues

No high-confidence issues detected in this change.

This branch has not been deployed

No deployments
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.

3 participants