Skip to content

install: stage git cache folders, require completion markers on hit, fix bun patch for non-npm deps and isolated hang - #38269

Merged
Jarred-Sumner merged 4 commits into
mainfrom
claude/bun-patch-fixes
Aug 14, 2026
Merged

install: stage git cache folders, require completion markers on hit, fix bun patch for non-npm deps and isolated hang#38269
Jarred-Sumner merged 4 commits into
mainfrom
claude/bun-patch-fixes

Conversation

@Jarred-Sumner

@Jarred-Sumner Jarred-Sumner commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator

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

…solated-linker patch hang

`bun patch --commit` failed with "Could not access .../@gh@@@@1" for git,
github and tarball dependencies: it loaded its own lockfile but computed the
cache path against the empty manager lockfile. Move the loaded lockfile into
the manager before computing the path. Patch filenames also escape the
NTFS-reserved characters these resolutions contain.

Under the isolated linker, installing with a patchedDependencies entry for a
git/github dependency in a workspace hung forever: the installer treated every
patched package as missing and re-enqueued a download the resolve phase had
already completed, parking the entry on a drained task. Probe the unpatched
cache folder the same way unpatched packages do.
@Jarred-Sumner
Jarred-Sumner force-pushed the claude/bun-patch-fixes branch from e64fe1f to 1df1053 Compare August 13, 2026 23:02
@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator
Updated 10:05 PM PT - Aug 13th, 2026

@Jarred-Sumner, your commit 25855c0 has some failures in Build #95196 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38269

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

bun-38269 --bun

@Jarred-Sumner Jarred-Sumner changed the title install: fix bun patch for git/github/tarball deps, isolated-linker hang, patch hash install: fix bun patch --commit for git/github/tarball deps and isolated hang Aug 13, 2026
@coderabbitai

coderabbitai Bot commented Aug 13, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

You’ve reached a temporary PR review limit under our Fair Usage Limits Policy.

Your recent review volume is higher than typical usage, so adaptive limits are currently applied.

Next review available in: 43 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: 398beb7c-728d-478c-af98-c72b42b9e855

📥 Commits

Reviewing files that changed from the base of the PR and between 508ee60 and 25855c0.

📒 Files selected for processing (10)
  • src/install/PackageInstall.rs
  • src/install/PackageManager/PackageManagerDirectories.rs
  • src/install/PackageManager/PackageManagerLifecycle.rs
  • src/install/PackageManager/patchPackage.rs
  • src/install/isolated_install.rs
  • src/install/isolated_install/Installer.rs
  • src/install/repository.rs
  • test/cli/install/bun-install.test.ts
  • test/cli/install/bun-patch.test.ts
  • test/cli/install/isolated-install.test.ts

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

Comment on lines +1177 to +1180
]) {
await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: gitEnv, stderr: "pipe" });
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);
expect(exitCode, `git ${args.join(" ")} failed: ${stderr}`).toBe(0);

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 git-setup loop spawns with stderr: "pipe" but omits stdout, so it defaults to "pipe" — yet only proc.stderr.text() and proc.exited are awaited. These git commands (init -q, config, add -A, commit -q, update-server-info) produce essentially no stdout so it won't deadlock in practice, but the sibling git() helper this PR adds in isolated-install.test.ts does drain all three, so the two are inconsistent — either add stdout: "ignore" here or drain proc.stdout.text() alongside stderr.

Extended reasoning...

What the issue is

At test/cli/install/bun-patch.test.ts:1177-1180, the git-setup loop in the new "git dependency" test spawns each git command with:

await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: gitEnv, stderr: "pipe" });
const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]);

stdout is unspecified, so per Bun.spawn's documented default (packages/bun-types/bun.d.ts, @default "pipe") it is a pipe — but nothing ever reads it. REVIEW.md's "Subprocess tests: drain pipes concurrently" rule requires Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]) so an unread pipe cannot fill the ~64 KB OS buffer and deadlock the child.

Step-by-step

  1. Bun.spawn({ ..., stderr: "pipe" })stdout omitted → defaults to "pipe".
  2. The kernel allocates a stdout pipe with a fixed buffer (~64 KB on Linux).
  3. The loop awaits Promise.all([proc.stderr.text(), proc.exited]) — stdout is never drained.
  4. If a git command wrote > 64 KB to stdout, it would block on write(1, …) while the test blocks on proc.exited → deadlock.
  5. In practice the five commands here (init -q, config core.autocrlf false, add -A, commit -q -m init, update-server-info) all write zero or a few bytes to stdout, so the pipe never fills and the test does not hang.

Why nothing prevents it

There is no stdout: "ignore" and no proc.stdout.text() in the Promise.all. The await using cleanup runs only after the awaited promise settles, so it cannot rescue a deadlock.

Inconsistency within the diff

The same PR adds a git() helper in test/cli/install/isolated-install.test.ts that does follow the convention:

await using proc = spawn({ cmd: [gitExecutable!, ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" });
const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

So the two new git helpers introduced by this PR are inconsistent with each other, and this one diverges from the pattern REVIEW.md and the surrounding file (runBun at the top of the same describe block drains all three) both use.

Impact

None in practice — the specific git invocations produce negligible stdout, so this cannot deadlock as written. It is a harness-convention nit and a within-diff consistency issue, not a functional bug; merging as-is would not break anything.

Fix

Either add stdout: "ignore" to the spawn options, or drain it:

await using proc = Bun.spawn({ cmd: ["git", ...args], cwd: repo, env: gitEnv, stdout: "ignore", stderr: "pipe" });

or

const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

…as the cache-hit marker

Git checkouts (@g@<sha>) and bare mirrors (<hash>.git) were cloned straight
into their final cache name, so an install killed mid-way left a folder that
every later install trusted: an empty checkout resolved as an empty package
and a half-cloned mirror failed every later fetch. Both are now built under a
temporary sibling in the cache dir and renamed into place once complete, the
same way tarball extraction already works.

Cache-hit probes for unpatched entries go through one helper: npm folders
must contain package.json, git checkouts the .bun-tag written last. This
replaces the bare directory probes in determine_preinstall_state and the
hoisted/isolated installers (and the hoisted installer's in-place mutation of
the shared folder-name buffer). Since the tag is now the marker, checkout()
replaces anything a repository checked in under that name and fails instead
of publishing a folder it could never hit.
Comment on lines +1200 to +1202
const env = { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") };

const patchKey = await expectPatchFlowWorks(project, env, "node_modules/pkg-to-patch");

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 env passed to expectPatchFlowWorks here is only { ...bunEnv, BUN_INSTALL_CACHE_DIR: ... }, so the git clone/git checkout that bun install runs for this dependency inherits the machine's global/system git config — including core.autocrlf on Windows. The sibling test this PR adds in isolated-install.test.ts spreads gitConfigEnv (GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL) into the bun install env for exactly this reason (with a comment naming the hazard); doing the same here — e.g. const env = { ...bunEnv, GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(String(dir), "no-gitconfig"), BUN_INSTALL_CACHE_DIR: ... } — would make this test hermetic and consistent with its sibling.

Extended reasoning...

What the issue is

The new "git dependency" test in test/cli/install/bun-patch.test.ts builds a gitEnv with GIT_CONFIG_NOSYSTEM: "1" and GIT_CONFIG_GLOBAL: <nonexistent> and uses it for the fixture-creating git init/commit/update-server-info commands — but the env it then passes to expectPatchFlowWorks (which drives bun install, bun patch, and bun patch --commit) is only { ...bunEnv, BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache") }. bunEnv (test/harness.ts) spreads process.env and does not set GIT_CONFIG_NOSYSTEM or GIT_CONFIG_GLOBAL.

Bun's git-dependency checkout (src/install/repository.rs) spawns git clone -c core.longpaths=true --no-checkout <url> <dir> followed by git -C <dir> checkout --quiet <sha> with the inherited process env. It does not pass -c core.autocrlf=… and does not isolate git from the machine's config. The fixture repo's local git config core.autocrlf false is written to the source repo's .git/config, which does not transfer over the dumb-HTTP clone; the clone consults the machine's system/global config.

Step-by-step on a Windows runner with global core.autocrlf=true

  1. The fixture repo commits index.js containing LF-only bytes (module.exports = "original";\n) — the fixture's local core.autocrlf=false and GIT_CONFIG_NOSYSTEM in gitEnv keep these bytes as-is in the blob.
  2. expectPatchFlowWorks runs bun install with env = { ...bunEnv, BUN_INSTALL_CACHE_DIR: ... }. Inside, Bun spawns git clone --no-checkout then git checkout <sha> with that env.
  3. Because GIT_CONFIG_NOSYSTEM/GIT_CONFIG_GLOBAL are not set, git reads the runner's global core.autocrlf=true and the checkout writes index.js to the cache with CRLF line endings.
  4. bun patch copies the cache folder into node_modules/pkg-to-patch; the test then Bun.writes an LF-only index.js over it.
  5. bun patch --commit diffs LF against the CRLF cache copy; the reinstall re-checks-out with CRLF and applies the patch on top.
  6. The final assertion is expect(await Bun.file(...).text()).toBe('module.exports = "patched";\n') — an exact .toBe on a \n-terminated string. With CRLF in the checkout/reapply path, the file on disk (and/or the diff's - line matching) becomes environment-dependent, so the test's outcome depends on the runner's git config rather than the code under test.

Why nothing prevents it

gitEnv is only used for the loop that builds the fixture repo. The env object handed to runBun is a separate literal that does not spread gitEnv's config-isolation keys. Bun's own SharedEnv::get() in repository.rs clones the process env and only injects GIT_ASKPASS/GIT_SSH_COMMAND; it does not force autocrlf off.

Within-diff inconsistency

The sibling test this same PR adds in test/cli/install/isolated-install.test.ts explicitly builds:

const gitConfigEnv = { GIT_CONFIG_NOSYSTEM: "1", GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig") };
...
const env = { ...bunEnv, ...gitConfigEnv, BUN_INSTALL_CACHE_DIR: ... };

and comments it as "Isolate git from system/global config (e.g. core.autocrlf on Windows would rewrite the checked-out file contents this test asserts on)". The bun-patch.test.ts version omits the same spread, so the two new tests disagree on how to isolate the same hazard. REVIEW.md: "Tests must be hermetic" and "deduplicate within your own diff".

Impact

Nit. It may well pass on the current CI Windows image (depends on whether that image sets core.autocrlf=true globally), and it does not affect the shipped runtime fix. It is a hermeticity gap and a within-diff inconsistency, not a functional bug.

Fix

Spread the same config-isolation keys into the env passed to expectPatchFlowWorks, matching the isolated-install.test.ts approach:

const env = {
  ...bunEnv,
  GIT_CONFIG_NOSYSTEM: "1",
  GIT_CONFIG_GLOBAL: join(String(dir), "no-gitconfig"),
  BUN_INSTALL_CACHE_DIR: join(String(dir), ".bun-cache"),
};

(or equivalently ...gitEnv in place of ...bunEnv since gitEnv already spreads bunEnv).

Adds a re-add leg to the github and git workspace patch tests and an
add/remove/re-add cycle for an npm dependency. The suspected stale
.bun-tag-<hash> skip on re-add did not reproduce; these pin the cycle.

No-Verification-Needed: test-only change
@Jarred-Sumner Jarred-Sumner changed the title install: fix bun patch --commit for git/github/tarball deps and isolated hang install: stage git cache folders, require completion markers on hit, fix bun patch for non-npm deps and isolated hang Aug 13, 2026
Comment thread src/install/repository.rs
…ed-in .bun-tag directory

The hardlink and copyfile backends overlay files onto an existing store
entry, so removing a patch (or changing it) kept every file the previous
patched build had added, along with its .bun-tag-<hash> marker; clonefile
happened to replace the tree, which is why this only showed on Linux.
Delete the previous project-local package tree before rebuilding an entry.

Repository::checkout used delete_file_z to clear a checked-in .bun-tag,
which fails on a directory and then made the O_EXCL create fail; use
delete_tree so any checked-in file, symlink or directory is replaced.

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

Re-ran on 25855c0 — the .bun-tag-as-directory case is fixed (delete_tree + the new "replaces a .bun-tag directory" test), and I found no issues in the added isolated-store delete_tree(previous) rebuild path. The two earlier test nits are non-blocking. Given the scope (cache-hit marker semantics across three call sites, CacheStaging publish/discard on every git error path, the manager.lockfile = lockfile ownership move in do_patch_commit, and the store-entry wipe in the isolated installer), a human pass is still worthwhile.

Checked in this run:

  • checkout() now handles a checked-in .bun-tag file, symlink, and directory before the O_EXCL create; every early-return path calls staging.discard().
  • is_package_in_cache_at is used consistently by the hoisted installer, isolated installer, and determine_preinstall_state; the patched branch still probes the unpatched folder.
  • Installer.rs delete_tree targets the project-local final store path (not the global cache) and only runs on the rebuild branch.
Extended reasoning...

Overview

Consolidated install-robustness PR touching src/install/{repository.rs, PackageInstall.rs, isolated_install.rs, isolated_install/Installer.rs, PackageManager/{PackageManagerDirectories.rs, PackageManagerLifecycle.rs, patchPackage.rs}} plus three test files. Four independent fixes: (1) git cache folders are built in a staging sibling and renamed atomically via CacheStaging, (2) cache hits now require a completion marker (package.json for npm, .bun-tag for git) via a shared is_package_in_cache_at helper, (3) bun patch --commit for git/github/tarball deps moves the loaded lockfile into manager.lockfile before computing the cache path so resolution strings resolve against the correct buffer, and (4) the isolated linker no longer hangs on patched git/github deps and now wipes the previous project-local store entry before rebuilding so removed-patch files don't linger under hardlink/copyfile.

Follow-up commit

25855c0 addressed my earlier finding: delete_file_z(".bun-tag")delete_tree(b".bun-tag") so a checked-in .bun-tag/ directory is replaced instead of hard-failing the install on O_EXCLEEXIST. A test with a nested .bun-tag/nested.txt was added alongside the existing symlink test. The same commit added the Fd::cwd().delete_tree(previous.slice()) call in Installer.rs to fix the Linux hardlink-backend staleness the PR description documents; I confirmed append_real_store_path(..., Which::Final) targets the project-local .bun/ store entry, not the shared global cache, and sits inside the needs-rebuild branch so warm installs skip it.

Security risks

None identified. The .bun-tag write path already went through is_safe_resolved_tag before this PR; the new O_EXCL|O_NOFOLLOW open plus the preceding delete_tree is strictly safer than the old O_TRUNC write (which could have followed a checked-in symlink on Windows where NOFOLLOW is 0 — now the link is removed first). escape_patch_filename gains NTFS-reserved characters, which only widens the escape set. CacheStaging::publish uses renameat_concurrently within the same directory (same filesystem), matching the existing tarball-extract ladder.

Level of scrutiny

High. This is production-critical bun install behavior: changing what counts as a cache hit affects every install, and the manager.lockfile = lockfile move in do_patch_commit changes ownership flow that the surrounding code (and the later install_with_manager reload noted in the PR description) depends on. The isolated-store delete_tree is a new destructive step. All four fixes are well-tested (staging-dir-only-on-success, tag-required-for-hit, patch-commit for github/git/tarball, add/remove/re-add cycles for npm/github/git under isolated), but the interaction surface is large enough that a maintainer should sign off.

Other factors

Two open 🟡 nits from earlier runs remain (stdout not drained in the bun-patch.test.ts git-setup loop; gitConfigEnv not spread into the bun install env for the git patch test). Both are test-hermeticity concerns with no practical failure mode as written and do not block. CI on 826113f is still building per the robobun comment; Windows rename/escaping legs are explicitly deferred to CI per the PR description.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

segfault when attempting to patch package Crash on patch commit

2 participants