Skip to content

install: fix isolated-linker deadlock installing patched git/github dependencies in workspaces - #37136

Closed
robobun wants to merge 5 commits into
mainfrom
farm/30ad4a5e/isolated-patched-github-hang
Closed

install: fix isolated-linker deadlock installing patched git/github dependencies in workspaces#37136
robobun wants to merge 5 commits into
mainfrom
farm/30ad4a5e/isolated-patched-github-hang

Conversation

@robobun

@robobun robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator

Symptom

bun install hangs forever (no output after Resolved, downloaded and extracted [N], main thread asleep in the event loop) when all of the following hold:

  • the project is a workspace,
  • patchedDependencies has an entry for a github: (or git:) dependency of a workspace member,
  • the isolated linker is in use (the configVersion: 1 default).

No bun patch commands are involved; a plain bun install after adding the patchedDependencies entry deadlocks. The same project completes with --linker hoisted. Reproduced on 1.4.0-canary.1 (0ffabf64d) for both the github: and git: protocols. This is the hang documented as a known limitation in #37124 (the internal reinstall run by bun patch --commit reaches it the same way).

Repro (offline, fake GitHub API):

  1. Serve any tgz with a single root folder (package.json name gh-dep) from a local HTTP server, set GITHUB_API_URL=http://localhost:PORT and a fresh BUN_INSTALL_CACHE_DIR.
  2. Workspace root {"name":"ws-root","workspaces":["packages/*"]}; member depends on "gh-dep": "github:testowner/testrepo#aaaaaaa". bun install --linker isolated succeeds.
  3. Add "patchedDependencies": {"gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch"} to the root and run bun install --linker isolated again: hangs forever. Removing an existing entry hangs the same way.

Cause

Adding (or removing) the patch entry triggers a re-resolution. The member's github dependency misses lockfile.get_package_id during re-resolution (the re-parsed version's package_name is still empty, so the lookup uses the wrong name hash) and the resolve phase re-downloads the tarball, registering it in task_queue and network_dedupe_map under Task::Id::for_tarball(url). When the extract completes in the resolve phase its callback list is drained, but the (now empty) task_queue entry and the dedupe entry stay behind. The extract appends a transient duplicate package and marks that id Done, while the dependency ends up resolved to the original package, whose preinstall state stays Unknown through the lockfile clean. (The duplicate itself is in-memory only; the clean prunes it, so bun.lock stays correct. The redundant re-download on re-resolution is an upstream inefficiency tracked separately.)

The isolated install loop then hits this in install_isolated_packages (src/install/isolated_install.rs):

// TODO: why does this look like it will never work?
break 'missing_from_cache true;

Every patched package whose preinstall state is not Done was unconditionally treated as missing from the cache, so the installer called enqueue_tarball_for_download for the same URL the resolve phase already downloaded. task_queue.get_or_put found the stale entry (found_existing), pushed the store entry's install context onto the already-drained callback list, and returned without scheduling anything. That context is never drained, the entry's pending-task slot never releases, and Wait::is_done spins on pending_task_count() > 0 forever.

git: dependencies deadlock the same way through their own task-id space: the isolated installer enqueues them via enqueue_git_for_checkout, which parks the entry context on the resolve phase's completed checkout task and returns.

The hoisted installer is structurally immune: its package_missing_from_cache checks the cache for the unpatched folder (the patched _patch_hash=<hash> folder is always derived locally from it), so it never re-requests a tarball the resolve phase already extracted.

Fix

Give the isolated loop the same cache check the hoisted installer has, replacing the break true:

  • PatchInfo::Patch: strip the _patch_hash=<hash> suffix and check for the unpatched cache folder. If present, mark the package Done and fall through to the existing path that runs apply_package_patch (which builds the patched folder from the unpatched one) and starts the store task. Only enqueue a download when the unpatched folder is genuinely absent, in which case no stale resolve-phase task can exist for that URL (a failed resolve-phase download removes its task_queue entry and marks the dedupe entry failed, which surfaces as an install error rather than a hang).
  • PatchInfo::Remove: its computed subpath is already the unpatched folder (contents_hash() is None), so it now uses the same existence check as unpatched packages instead of always re-downloading. This also fixes the identical hang when removing a patch entry.

This also stops isolated installs from re-downloading patched npm tarballs that are already extracted in the cache.

Verification

Two new tests in test/cli/install/isolated-install.test.ts, both asserting patched/unpatched file contents across install -> add patch -> install -> remove patch -> install:

  • adding and removing a patch for a github dependency in a workspace completes: fake GitHub API via Bun.serve with a hand-built tarball. Includes a cold-cache leg while the patch is active (cache and node_modules wiped, patch still in the lockfile), which exercises the other half of the fix: the install phase downloads the tarball itself and applies the patch after extraction.
  • adding and removing a patch for a git dependency in a workspace completes: a real local repository served over git's dumb HTTP protocol (git update-server-info + static file serving), covering the git: clone/checkout task path.

On unfixed bun each variant deadlocks at the add-patch install until the test times out; with the fix both run in under a second.

  • bun bd test test/cli/install/isolated-install.test.ts: 64 pass
  • bun bd test test/cli/install/bun-install-patch.test.ts: 18 pass
  • bun bd test test/cli/install/bun-patch.test.ts: 31 pass

Also verified manually against the shell repros: add/remove/re-add patch, cold-cache install from a patched lockfile, and warm-cache reinstall all complete on isolated and hoisted, with patched content where expected.

Two related pre-existing issues reproduce on released bun without this change and are tracked separately: the re-resolution re-download of git/github dependencies described above, and an isolated-linker staleness where re-adding a previously removed patch is skipped as "no changes" because a stale .bun-tag-<hash> marker survives the store entry rebuild.


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

…er the isolated linker

The isolated install loop treated every patched dependency whose
preinstall state was not Done as missing from the cache and re-enqueued
its tarball download. When the resolve phase had already downloaded that
same tarball (a github dependency of a workspace member misses
get_package_id during re-resolution because the parsed version's
package_name is empty), the install-phase context was pushed onto the
completed task's already-drained callback list, nothing was scheduled,
and the pending-task wait spun forever.

Check for the unpatched cache folder instead, the same way the hoisted
installer's package_missing_from_cache does: strip the _patch_hash=
suffix for PatchInfo::Patch, treat PatchInfo::Remove like an unpatched
package, and only enqueue a download when the folder is genuinely
absent. When it is present, apply_package_patch derives the patched
folder from it and the store task starts immediately.
@coderabbitai

coderabbitai Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 9 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 9d498a64-f4c4-4054-bdc2-b7d077c534b0

📥 Commits

Reviewing files that changed from the base of the PR and between d0ae4be and a1c251c.

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

Walkthrough

Isolated installs now detect unpatched cache entries for patched packages. Cache checks support npm, patched, removed, and non-npm entries. Regression tests cover GitHub tarballs and Git-over-HTTP dependencies across patch transitions.

Changes

Isolated install cache handling

Layer / File(s) Summary
Unpatched cache detection
src/install/isolated_install.rs
Patched paths temporarily remove the _patch_hash= suffix during cache checks. Existing entries update preinstall state and avoid duplicate downloads.
Patch transition regression coverage
test/cli/install/isolated-install.test.ts
Tests verify patched and unpatched content across repeated installs for GitHub tarballs and Git-over-HTTP dependencies, including cold-cache installation.

Possibly related PRs

  • oven-sh/bun#35105: Both changes adjust isolated-install cache detection for patched and unpatched packages.
  • oven-sh/bun#36229: Both changes address isolated-install cache handling for patched packages.
  • oven-sh/bun#37137: Both changes cover isolated-linker patch removal and reapplication workflows.

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 and concisely identifies the isolated-linker deadlock and the affected patched Git and GitHub dependencies.
Description check ✅ Passed The description explains the symptom, cause, fix, reproduction steps, verification, test results, and related issues in sufficient detail.

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install.rs

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/isolated-install.test.ts`:
- Around line 803-809: Extend the isolated-install workspace dependency tests
around the existing `github:` fixture to also exercise the `git:` variant. Add
equivalent patch-add and patch-remove scenarios using a git workspace
dependency, covering both cache-folder and checkout behavior implemented by
ResolutionTag::Git while preserving the existing GitHub coverage.
- Around line 784-790: Update the install subprocess flow around spawn to
consume proc.stdout concurrently with proc.stderr and proc.exited, such as by
awaiting all three operations together. Preserve the piped configuration and
existing assertions while ensuring neither output stream can block the child
process.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 01f02a60-b8bf-4edf-b2c2-b1f9e1a72d1b

📥 Commits

Reviewing files that changed from the base of the PR and between 45ee955 and e15ea1d.

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

Comment thread test/cli/install/isolated-install.test.ts
Comment thread test/cli/install/isolated-install.test.ts
Comment thread test/cli/install/isolated-install.test.ts Outdated
Comment thread test/cli/install/isolated-install.test.ts Outdated
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install.rs Outdated
…t protocol and cold cache

- Remove the set_length(full_len) restore: nothing reads the buffer after
  the block, and in the Patch case slice_z had already written a NUL at
  the truncation point, so the restore produced a corrupted view.
- Drain stdout in the test's install helper and drop the per-test
  timeout.
- Add a git: variant of the regression test (local repo served over
  git's dumb HTTP protocol) and a cold-cache leg to the github test.
Comment thread src/install/isolated_install.rs Outdated
Comment thread src/install/isolated_install.rs

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

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/install/isolated-install.test.ts`:
- Around line 972-977: Add a cold-cache reinstall step to the git: test after
the patched-content assertion, clearing .bun-cache and both relevant
node_modules directories while retaining the patched lockfile, then run
install() and assert the patched content remains correct. Mirror the existing
github: test coverage without changing the subsequent original-content
restoration flow.
- Around line 881-887: Update the isolated-install test setup around the git
helper to detect availability with Bun.which("git"), define the executable
prerequisite, and apply test.skipIf(!gitExecutable) to skip when git is
unavailable. Add a brief comment documenting that git is required.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d62683d2-357b-4aed-b057-2b5bb15e3988

📥 Commits

Reviewing files that changed from the base of the PR and between e15ea1d and d0ae4be.

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

Comment thread test/cli/install/isolated-install.test.ts
Comment thread test/cli/install/isolated-install.test.ts

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

No bugs found this pass, and all four of my earlier inline nits (dead set_length restore, undrained stdout pipe, per-test timeout, comment length) have been addressed. Deferring to a human because this changes when the isolated installer transitions preinstall state to Done and skips download enqueueing — core bun install state-machine logic — which is worth a maintainer's eyes even though the change mirrors the hoisted path.

What was reviewed:

  • Verified the PatchInfo::None path is behavior-identical to the pre-change code (same Npm package.json check, same _ directory check, same set_preinstall_state).
  • Compared the new Patch/Remove logic against PackageInstall::package_missing_from_cache (src/install/PackageInstall.rs:2305-2380) — same last_index_of(b"_patch_hash=") truncation, same panic message, same directory_exists_at on the unpatched folder.
  • Confirmed pkg_cache_dir_subpath is dead after line 2392, so the in-place truncation is safe (nothing downstream reads the patched path).
Extended reasoning...

Overview

Two files: src/install/isolated_install.rs (~50 lines replacing a // TODO: why does this look like it will never work? + unconditional break 'missing_from_cache true with a real cache check for patched packages) and test/cli/install/isolated-install.test.ts (~260 lines: two end-to-end tests for github: and git: dependencies, each running install → add patch → install → cold-cache install → remove patch → install and asserting file contents at each step).

Security risks

None. No untrusted-input parsing, no auth/crypto, no path handling beyond the existing _patch_hash= suffix strip already used verbatim by the hoisted installer.

Level of scrutiny

Medium-high. This is bun install under the default (configVersion: 1) isolated linker, and the change alters when set_preinstall_state(pkg_id, Done) fires and when a tarball/git download is enqueued vs skipped. I traced the PatchInfo::None case through both old and new code and it is byte-for-byte behavior-preserving — only Patch(_) and Remove(_) change (from always-redownload to check-unpatched-cache-first), which is exactly what PackageInstall::package_missing_from_cache already does for the hoisted linker. The panic on missing _patch_hash= suffix is copy-pasted from the hoisted version, and put_patch_hash in PackageManagerDirectories.rs guarantees the suffix is present whenever contents_hash() is Some (which Patch(_) implies). Still, someone who owns the install subsystem's task-queue/preinstall-state invariants should confirm the interaction with apply_package_patch and the cold-cache download path.

Other factors

  • All four of my earlier inline findings and both CodeRabbit nits (skipIf on missing git, cold-cache leg for the git test) are resolved in the current diff.
  • The last comment-cop flag on the 3-line comment at 2359-2361 was reasonably pushed back on by the author — the comment carries non-local info (why the unpatched folder is checked) plus the PR link, and further shortening loses that.
  • No CODEOWNERS entry for src/install/.
  • The PR description notes two pre-existing issues in this area (redundant re-resolution download; stale .bun-tag marker on re-add) that reproduce on released bun and are explicitly out of scope here.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

Folded into #38269 together with the other two bun patch fixes (rebased; the fix is reshaped there — see that PR's description).

Jarred-Sumner added a commit that referenced this pull request Aug 14, 2026
…fix bun patch for non-npm deps and isolated hang (#38269)

### What does this PR do?

Install-cache and `bun patch` robustness, consolidated from #37124,
#37136 and #37145 (rebased and reshaped; #32749 from the same batch
landed separately).

**Git dependency cache folders are built in a staging dir and renamed on
success.** `Repository::checkout` cloned straight into
`<cache>/@g@<sha>` and checked out in place; `Repository::download`
cloned the bare mirror straight into `<cache>/<hash>.git`. An install
(or its git child — seen OOM-killed in CI) dying between steps left a
folder at the trusted name: an empty `@G@` folder resolves as an *empty
package* through the "git dependency without package.json" path (exit 0,
`bun.lock` name falls back to the URL basename), and a half-cloned
mirror fails every later `git fetch`. Both now build under a temporary
sibling inside the cache dir (`CacheStaging`, same-filesystem so it's
the same `renameat_concurrently` ladder tarball extraction uses) and are
renamed into place only when complete; failures remove the staging dir.

**Cache hits require the entry's completion marker.** One helper,
`is_package_in_cache_at(cache_dir, folder, tag)`: npm folders must
contain `package.json`, git checkouts must contain the `.bun-tag`
written last, everything else stays a directory probe. Used by
`checkout()`'s resolve-time hit, `determine_preinstall_state`, and the
hoisted and isolated installers — previously every git hit was a bare
directory probe (`.bun-tag` in the cache was written but never read),
and `determine_preinstall_state` didn't probe `package.json` for npm
either. Folders left by older versions are re-cloned instead of
installed. Deletes the hoisted installer's unsafe in-place edit of the
shared folder-name buffer and the isolated installer's append/truncate
copy. Since the tag is now the marker, `checkout()` unlinks anything a
repo checked in as `.bun-tag`, creates it `O_EXCL|O_NOFOLLOW`, and fails
the checkout rather than publish an untaggable folder (a repo shipping a
symlink named `.bun-tag` now gets a real tag; its target is still never
written — test updated). Existing bare mirrors are not validated
structurally; there's no marker for them.

**`bun patch --commit` on git, github and tarball dependencies** failed
with `Could not access '.../@gh@@@@1'` and wrote nothing (#18792,
#17945): it loads its own lockfile but computed the cache path against
the empty `manager.lockfile`. The loaded lockfile is moved into the
manager before the path is computed (`install_with_manager` reloads it
afterwards, as it already does for `bun update`; the double parse is
left alone). Patch filenames additionally escape NTFS-reserved
characters, which only these resolutions contain. Fixes #18792, fixes
#17945.

**Isolated linker hang** — a plain `bun install` in a workspace with a
`patchedDependencies` entry for a git/github dependency hung forever:
the installer treated every patched package as missing and re-enqueued a
download the resolve phase had already completed, parking on a drained
task list. It now probes the unpatched folder (computed with no patch
hash) like everything else; also fixes removing an entry.

**Tests:** github/git/tarball `patch --commit` flows; add → cold cache →
remove → re-add of a patch under isolated for github, git and npm (the
suspected stale-`.bun-tag-<hash>` skip on re-add did not reproduce, so
these just pin the cycle); git checkout failure leaves only the mirror
in the cache and an empty folder at the cache name is re-cloned; a
pre-existing test pinned to the bogus `--commit` cache path now asserts
the step that genuinely fails.

### How did you verify your code works?

`bun-install` + `bun-install-registry` (427), `isolated-install` (65),
`bun-install-patch`, `bun-patch` all pass locally; new tests fail on
release bun (half-built `@G@` folder left behind; `--commit` error;
isolated hang). Each fix was also driven by hand with the debug binary:
`patch --commit` on a `file:` tarball fails on main's binary and
succeeds here; a workspace with a patched local git dep installs,
requires as patched, survives remove and re-add, and re-clones an
emptied cache folder, with no `.tmp` residue. Windows rename/escaping
legs are left to CI. Local gotcha: these files need `HOME` pointed at an
empty dir if `~/.npmrc` sets `install-strategy=hoisted`.


---

**Added after CI** (`25855c0fe97`): the npm add/remove/re-add test
failed on Linux — the hardlink and copyfile backends overlay files onto
an existing isolated store entry, so removing a patch kept every file
the patched build had *added* (and its `.bun-tag-<hash>`); clonefile
replaces the tree, which is why macOS passed and why the earlier "did
not reproduce" was wrong — this is the staleness #37136 mentioned. The
task now deletes the previous project-local package tree before
rebuilding an entry (only reached when the entry needs a rebuild, so
warm installs don't pay for it); the npm cycle test pins the hardlink
backend. Also from review: `checkout()` uses `delete_tree` for a
checked-in `.bun-tag`, so a directory under that name is replaced
instead of failing the install (test added).
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