From 83e406e6de126f429d3a7e1cbdc6e52a7cb61354 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:03:16 +0000 Subject: [PATCH 1/5] install: use the cached unpatched folder for patched dependencies under 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. --- src/install/isolated_install.rs | 82 +++++++++----- test/cli/install/isolated-install.test.ts | 127 ++++++++++++++++++++++ 2 files changed, 180 insertions(+), 29 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 35e5ea78bcfc..3b67a43210bf 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -32,7 +32,7 @@ use bun_collections::{ ArrayHashMap, DynamicBitSet, DynamicBitSetList, DynamicBitSetUnmanaged, HashMap, LinearFifo, StringArrayHashMap, }; -use bun_core::{Environment, Global, Output, fast_random, fmt as bun_fmt}; +use bun_core::{Environment, Global, Output, fast_random, fmt as bun_fmt, strings}; use bun_paths::path_options::AssumeOk as _; use bun_paths::{self as paths, AutoAbsPath as AbsPath, AutoRelPath, PathBuffer}; use bun_semver as semver; @@ -2356,37 +2356,61 @@ pub(crate) fn install_isolated_packages( { install::PreinstallState::Done => false, _ => 'missing_from_cache: { - if matches!(patch_info, installer::PatchInfo::None) { - let exists = match pkg_res_tag { - ResolutionTag::Npm => { - // Reshaped for borrowck — capture length - // instead of `save()` so the path stays unborrowed. - let cache_dir_path_save = pkg_cache_dir_subpath.len(); - pkg_cache_dir_subpath.append(b"package.json").assume_ok(); - let exists = sys::exists_at( - cache_dir, - pkg_cache_dir_subpath.slice_z(), - ); - pkg_cache_dir_subpath.set_length(cache_dir_path_save); - exists - } - _ => sys::directory_exists_at( - cache_dir, - pkg_cache_dir_subpath.slice_z(), + // For a patched dependency the subpath ends in + // `_patch_hash=`, but downloads only ever + // extract the unpatched folder; the patched folder + // is derived from it by `apply_package_patch` + // below. Check for the unpatched folder here (like + // the hoisted installer's + // `package_missing_from_cache`): when the resolve + // phase already downloaded this tarball, + // re-enqueueing it would push this entry onto that + // completed task's already-drained callback list + // and hang the install. + let full_len = pkg_cache_dir_subpath.len(); + if matches!(patch_info, installer::PatchInfo::Patch(_)) { + let idx = strings::last_index_of( + pkg_cache_dir_subpath.slice(), + b"_patch_hash=", + ) + .unwrap_or_else(|| { + panic!( + "Patched dependency cache dir subpath does not have the \ + \"_patch_hash=HASH\" suffix. This is a bug, please file \ + a GitHub issue." ) - .unwrap_or(false), - }; - if exists { - installer.manager_mut().set_preinstall_state( - pkg_id, - install::PreinstallState::Done, - ); + }); + pkg_cache_dir_subpath.set_length(idx); + } + let exists = match pkg_res_tag { + // `Remove` also lands here: its subpath is + // already the unpatched folder + // (`contents_hash()` is None). + ResolutionTag::Npm + if !matches!(patch_info, installer::PatchInfo::Patch(_)) => + { + // Reshaped for borrowck — capture length + // instead of `save()` so the path stays unborrowed. + let cache_dir_path_save = pkg_cache_dir_subpath.len(); + pkg_cache_dir_subpath.append(b"package.json").assume_ok(); + let exists = + sys::exists_at(cache_dir, pkg_cache_dir_subpath.slice_z()); + pkg_cache_dir_subpath.set_length(cache_dir_path_save); + exists } - break 'missing_from_cache !exists; + _ => sys::directory_exists_at( + cache_dir, + pkg_cache_dir_subpath.slice_z(), + ) + .unwrap_or(false), + }; + pkg_cache_dir_subpath.set_length(full_len); + if exists { + installer + .manager_mut() + .set_preinstall_state(pkg_id, install::PreinstallState::Done); } - - // TODO: why does this look like it will never work? - break 'missing_from_cache true; + break 'missing_from_cache !exists; } }; diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 3d3b6a5428ad..15b58298548b 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -724,6 +724,133 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e await checkInstall(); }); +// Adding a patchedDependencies entry for a github: dependency of a workspace +// member deadlocked `bun install` forever with the isolated linker: +// re-resolution re-downloaded the github tarball, and the install phase then +// re-enqueued the same tarball task and parked the store entry on the +// completed task's already-drained callback list, so the pending-task count +// never reached zero. +test( + "adding and removing a patch for a github dependency in a workspace completes", + async () => { + const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + + // Minimal gzipped tarball shaped like a github codeload tarball: a single + // root directory wrapping the package contents. + function tarHeader(name: string, size: number, isDir: boolean): Uint8Array { + const header = new Uint8Array(512); + const encoder = new TextEncoder(); + header.set(encoder.encode(name), 0); + header.set(encoder.encode(isDir ? "0000755 " : "0000644 "), 100); + header.set(encoder.encode("0000000 "), 108); + header.set(encoder.encode("0000000 "), 116); + header.set(encoder.encode(size.toString(8).padStart(11, "0") + " "), 124); + header.set(encoder.encode("00000000000 "), 136); + header.set(encoder.encode(" "), 148); + header[156] = (isDir ? "5" : "0").charCodeAt(0); + header.set(encoder.encode("ustar"), 257); + header.set(encoder.encode("00"), 263); + let checksum = 0; + for (const byte of header) checksum += byte; + header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148); + return header; + } + const blocks: Uint8Array[] = []; + blocks.push(tarHeader("testowner-testrepo-aaaaaaa/", 0, true)); + for (const [name, contents] of [ + ["package.json", JSON.stringify({ name: "gh-dep", version: "1.0.0" })], + ["index.js", 'console.log("original");\n'], + ]) { + const bytes = new TextEncoder().encode(contents); + blocks.push(tarHeader(`testowner-testrepo-aaaaaaa/${name}`, bytes.length, false)); + blocks.push(bytes); + if (bytes.length % 512 !== 0) blocks.push(new Uint8Array(512 - (bytes.length % 512))); + } + blocks.push(new Uint8Array(1024)); + const tarball = Bun.gzipSync(Buffer.concat(blocks)); + + using server = Bun.serve({ + port: 0, + fetch: () => new Response(tarball, { headers: { "Content-Type": "application/gzip" } }), + }); + + const env = { + ...bunEnv, + GITHUB_API_URL: `http://localhost:${server.port}`, + // CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache state is + // its own. + BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), + }; + + async function install() { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const rootPackageJson = { + name: "patched-github-workspace", + workspaces: ["packages/*"], + }; + await write(packageJson, JSON.stringify(rootPackageJson)); + await write( + join(packageDir, "packages", "member", "package.json"), + JSON.stringify({ + name: "member", + version: "1.0.0", + dependencies: { + "gh-dep": "github:testowner/testrepo#aaaaaaa", + }, + }), + ); + await write( + join(packageDir, "patches", "gh-dep.patch"), + `diff --git a/index.js b/index.js +index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1d 100644 +--- a/index.js ++++ b/index.js +@@ -1 +1 @@ +-console.log("original"); ++console.log("patched"); +`, + ); + + const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "gh-dep", "index.js")); + + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); + + // Adding the patch triggers a re-resolution; this install hung forever + // before the fix. + await write( + packageJson, + JSON.stringify({ + ...rootPackageJson, + patchedDependencies: { + "gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch", + }, + }), + ); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + + // Removing the patch re-resolves again and rebuilds the store entry from + // the unpatched cache folder (the PatchInfo::Remove path, which hung the + // same way). + await write(packageJson, JSON.stringify(rootPackageJson)); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); + }, + 90_000, +); + for (const backend of ["clonefile", "hardlink", "copyfile"]) { test(`isolated install with backend: ${backend}`, async () => { const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); From e15ea1d89f18d0ac5e0191b83d1148abdf792ae6 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:05:53 +0000 Subject: [PATCH 2/5] [autofix.ci] apply automated fixes --- test/cli/install/isolated-install.test.ts | 206 +++++++++++----------- 1 file changed, 101 insertions(+), 105 deletions(-) diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 15b58298548b..b929ad99214e 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -730,89 +730,87 @@ index 0000000000000000000000000000000000000000..3b18e512dba79e4c8300dd08aeb37f8e // re-enqueued the same tarball task and parked the store entry on the // completed task's already-drained callback list, so the pending-task count // never reached zero. -test( - "adding and removing a patch for a github dependency in a workspace completes", - async () => { - const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); +test("adding and removing a patch for a github dependency in a workspace completes", async () => { + const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); - // Minimal gzipped tarball shaped like a github codeload tarball: a single - // root directory wrapping the package contents. - function tarHeader(name: string, size: number, isDir: boolean): Uint8Array { - const header = new Uint8Array(512); - const encoder = new TextEncoder(); - header.set(encoder.encode(name), 0); - header.set(encoder.encode(isDir ? "0000755 " : "0000644 "), 100); - header.set(encoder.encode("0000000 "), 108); - header.set(encoder.encode("0000000 "), 116); - header.set(encoder.encode(size.toString(8).padStart(11, "0") + " "), 124); - header.set(encoder.encode("00000000000 "), 136); - header.set(encoder.encode(" "), 148); - header[156] = (isDir ? "5" : "0").charCodeAt(0); - header.set(encoder.encode("ustar"), 257); - header.set(encoder.encode("00"), 263); - let checksum = 0; - for (const byte of header) checksum += byte; - header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148); - return header; - } - const blocks: Uint8Array[] = []; - blocks.push(tarHeader("testowner-testrepo-aaaaaaa/", 0, true)); - for (const [name, contents] of [ - ["package.json", JSON.stringify({ name: "gh-dep", version: "1.0.0" })], - ["index.js", 'console.log("original");\n'], - ]) { - const bytes = new TextEncoder().encode(contents); - blocks.push(tarHeader(`testowner-testrepo-aaaaaaa/${name}`, bytes.length, false)); - blocks.push(bytes); - if (bytes.length % 512 !== 0) blocks.push(new Uint8Array(512 - (bytes.length % 512))); - } - blocks.push(new Uint8Array(1024)); - const tarball = Bun.gzipSync(Buffer.concat(blocks)); + // Minimal gzipped tarball shaped like a github codeload tarball: a single + // root directory wrapping the package contents. + function tarHeader(name: string, size: number, isDir: boolean): Uint8Array { + const header = new Uint8Array(512); + const encoder = new TextEncoder(); + header.set(encoder.encode(name), 0); + header.set(encoder.encode(isDir ? "0000755 " : "0000644 "), 100); + header.set(encoder.encode("0000000 "), 108); + header.set(encoder.encode("0000000 "), 116); + header.set(encoder.encode(size.toString(8).padStart(11, "0") + " "), 124); + header.set(encoder.encode("00000000000 "), 136); + header.set(encoder.encode(" "), 148); + header[156] = (isDir ? "5" : "0").charCodeAt(0); + header.set(encoder.encode("ustar"), 257); + header.set(encoder.encode("00"), 263); + let checksum = 0; + for (const byte of header) checksum += byte; + header.set(encoder.encode(checksum.toString(8).padStart(6, "0") + "\0 "), 148); + return header; + } + const blocks: Uint8Array[] = []; + blocks.push(tarHeader("testowner-testrepo-aaaaaaa/", 0, true)); + for (const [name, contents] of [ + ["package.json", JSON.stringify({ name: "gh-dep", version: "1.0.0" })], + ["index.js", 'console.log("original");\n'], + ]) { + const bytes = new TextEncoder().encode(contents); + blocks.push(tarHeader(`testowner-testrepo-aaaaaaa/${name}`, bytes.length, false)); + blocks.push(bytes); + if (bytes.length % 512 !== 0) blocks.push(new Uint8Array(512 - (bytes.length % 512))); + } + blocks.push(new Uint8Array(1024)); + const tarball = Bun.gzipSync(Buffer.concat(blocks)); - using server = Bun.serve({ - port: 0, - fetch: () => new Response(tarball, { headers: { "Content-Type": "application/gzip" } }), - }); + using server = Bun.serve({ + port: 0, + fetch: () => new Response(tarball, { headers: { "Content-Type": "application/gzip" } }), + }); - const env = { - ...bunEnv, - GITHUB_API_URL: `http://localhost:${server.port}`, - // CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache state is - // its own. - BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), - }; + const env = { + ...bunEnv, + GITHUB_API_URL: `http://localhost:${server.port}`, + // CI exports BUN_INSTALL_CACHE_DIR; pin it so this test's cache state is + // its own. + BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), + }; - async function install() { - await using proc = spawn({ - cmd: [bunExe(), "install"], - cwd: packageDir, - env, - stdout: "pipe", - stderr: "pipe", - }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); - expect(err).not.toContain("error:"); - expect(exitCode).toBe(0); - } + async function install() { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } - const rootPackageJson = { - name: "patched-github-workspace", - workspaces: ["packages/*"], - }; - await write(packageJson, JSON.stringify(rootPackageJson)); - await write( - join(packageDir, "packages", "member", "package.json"), - JSON.stringify({ - name: "member", - version: "1.0.0", - dependencies: { - "gh-dep": "github:testowner/testrepo#aaaaaaa", - }, - }), - ); - await write( - join(packageDir, "patches", "gh-dep.patch"), - `diff --git a/index.js b/index.js + const rootPackageJson = { + name: "patched-github-workspace", + workspaces: ["packages/*"], + }; + await write(packageJson, JSON.stringify(rootPackageJson)); + await write( + join(packageDir, "packages", "member", "package.json"), + JSON.stringify({ + name: "member", + version: "1.0.0", + dependencies: { + "gh-dep": "github:testowner/testrepo#aaaaaaa", + }, + }), + ); + await write( + join(packageDir, "patches", "gh-dep.patch"), + `diff --git a/index.js b/index.js index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1d 100644 --- a/index.js +++ b/index.js @@ -820,36 +818,34 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f -console.log("original"); +console.log("patched"); `, - ); + ); - const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "gh-dep", "index.js")); + const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "gh-dep", "index.js")); - await install(); - expect(await installedIndexJs.text()).toBe('console.log("original");\n'); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); - // Adding the patch triggers a re-resolution; this install hung forever - // before the fix. - await write( - packageJson, - JSON.stringify({ - ...rootPackageJson, - patchedDependencies: { - "gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch", - }, - }), - ); - await install(); - expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); - - // Removing the patch re-resolves again and rebuilds the store entry from - // the unpatched cache folder (the PatchInfo::Remove path, which hung the - // same way). - await write(packageJson, JSON.stringify(rootPackageJson)); - await install(); - expect(await installedIndexJs.text()).toBe('console.log("original");\n'); - }, - 90_000, -); + // Adding the patch triggers a re-resolution; this install hung forever + // before the fix. + await write( + packageJson, + JSON.stringify({ + ...rootPackageJson, + patchedDependencies: { + "gh-dep@github:testowner/testrepo#aaaaaaa": "patches/gh-dep.patch", + }, + }), + ); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + + // Removing the patch re-resolves again and rebuilds the store entry from + // the unpatched cache folder (the PatchInfo::Remove path, which hung the + // same way). + await write(packageJson, JSON.stringify(rootPackageJson)); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); +}, 90_000); for (const backend of ["clonefile", "hardlink", "copyfile"]) { test(`isolated install with backend: ${backend}`, async () => { From d0ae4be95bd97da2eb62c312a0a5c445fc28da1b Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:53:07 +0000 Subject: [PATCH 3/5] Address review: drop dead subpath restore, tighten comments, cover git 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. --- src/install/isolated_install.rs | 22 +--- test/cli/install/isolated-install.test.ts | 134 +++++++++++++++++++++- 2 files changed, 138 insertions(+), 18 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 3b67a43210bf..068e827b1262 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2356,18 +2356,11 @@ pub(crate) fn install_isolated_packages( { install::PreinstallState::Done => false, _ => 'missing_from_cache: { - // For a patched dependency the subpath ends in - // `_patch_hash=`, but downloads only ever - // extract the unpatched folder; the patched folder - // is derived from it by `apply_package_patch` - // below. Check for the unpatched folder here (like - // the hoisted installer's - // `package_missing_from_cache`): when the resolve - // phase already downloaded this tarball, - // re-enqueueing it would push this entry onto that - // completed task's already-drained callback list - // and hang the install. - let full_len = pkg_cache_dir_subpath.len(); + // Downloads only produce the unpatched folder + // (`apply_package_patch` derives the `_patch_hash=` + // one), so check for that. Re-enqueueing a tarball + // the resolve phase already extracted deadlocks the + // install (#37136). if matches!(patch_info, installer::PatchInfo::Patch(_)) { let idx = strings::last_index_of( pkg_cache_dir_subpath.slice(), @@ -2383,9 +2376,7 @@ pub(crate) fn install_isolated_packages( pkg_cache_dir_subpath.set_length(idx); } let exists = match pkg_res_tag { - // `Remove` also lands here: its subpath is - // already the unpatched folder - // (`contents_hash()` is None). + // `Remove`'s subpath is already unpatched. ResolutionTag::Npm if !matches!(patch_info, installer::PatchInfo::Patch(_)) => { @@ -2404,7 +2395,6 @@ pub(crate) fn install_isolated_packages( ) .unwrap_or(false), }; - pkg_cache_dir_subpath.set_length(full_len); if exists { installer .manager_mut() diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index b929ad99214e..acd7ddfe8234 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -788,7 +788,7 @@ test("adding and removing a patch for a github dependency in a workspace complet stdout: "pipe", stderr: "pipe", }); - const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); expect(err).not.toContain("error:"); expect(exitCode).toBe(0); } @@ -839,13 +839,143 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f await install(); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + // Cold cache with the patch still in the lockfile: the install phase itself + // downloads the tarball and applies the patch after extraction. + await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true }); + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true }); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + // Removing the patch re-resolves again and rebuilds the store entry from // the unpatched cache folder (the PatchInfo::Remove path, which hung the // same way). await write(packageJson, JSON.stringify(rootPackageJson)); await install(); expect(await installedIndexJs.text()).toBe('console.log("original");\n'); -}, 90_000); +}); + +// Same deadlock through the git: task-id space (clone + checkout tasks +// instead of a tarball download). The repo is served over git's dumb HTTP +// protocol: after `git update-server-info`, a bare repo is plain static +// files. +test("adding and removing a patch for a git dependency in a workspace completes", async () => { + const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); + + const srcDir = join(packageDir, "git-src"); + const bareDir = join(packageDir, "repo.git"); + // Isolate git from system/global config (e.g. core.autocrlf on Windows + // would rewrite the checked-out file contents this test asserts on). + const gitConfigEnv = { + GIT_CONFIG_NOSYSTEM: "1", + GIT_CONFIG_GLOBAL: join(packageDir, "gitconfig"), + }; + const gitEnv = { + ...bunEnv, + ...gitConfigEnv, + GIT_AUTHOR_NAME: "bun-test", + GIT_AUTHOR_EMAIL: "test@bun.sh", + GIT_COMMITTER_NAME: "bun-test", + GIT_COMMITTER_EMAIL: "test@bun.sh", + }; + async function git(args: string[], cwd: string): Promise { + await using proc = spawn({ cmd: ["git", ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" }); + const [out, err, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + expect(err).not.toContain("fatal:"); + expect(exitCode).toBe(0); + return out; + } + + await write(join(packageDir, "gitconfig"), "[core]\n\tautocrlf = false\n"); + await write(join(srcDir, "package.json"), JSON.stringify({ name: "git-dep", version: "1.0.0" })); + await write(join(srcDir, "index.js"), 'console.log("original");\n'); + await git(["init", "-q"], srcDir); + await git(["add", "-A"], srcDir); + await git(["commit", "-qm", "init"], srcDir); + const sha = (await git(["rev-parse", "HEAD"], srcDir)).trim(); + await git(["clone", "-q", "--bare", srcDir, bareDir], packageDir); + await git(["update-server-info"], bareDir); + + using server = Bun.serve({ + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + if (!pathname.startsWith("/repo.git/")) return new Response("not found", { status: 404 }); + const f = file(join(bareDir, pathname.slice("/repo.git/".length))); + return (await f.exists()) ? new Response(f) : new Response("not found", { status: 404 }); + }, + }); + const repoUrl = `git+http://127.0.0.1:${server.port}/repo.git`; + + const env = { + ...bunEnv, + ...gitConfigEnv, + BUN_INSTALL_CACHE_DIR: join(packageDir, ".bun-cache"), + }; + + async function install() { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd: packageDir, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [err, exitCode] = await Promise.all([proc.stderr.text(), proc.exited, proc.stdout.text()]); + expect(err).not.toContain("error:"); + expect(exitCode).toBe(0); + } + + const rootPackageJson = { + name: "patched-git-workspace", + workspaces: ["packages/*"], + }; + await write(packageJson, JSON.stringify(rootPackageJson)); + await write( + join(packageDir, "packages", "member", "package.json"), + JSON.stringify({ + name: "member", + version: "1.0.0", + dependencies: { + "git-dep": repoUrl, + }, + }), + ); + await write( + join(packageDir, "patches", "git-dep.patch"), + `diff --git a/index.js b/index.js +index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1d 100644 +--- a/index.js ++++ b/index.js +@@ -1 +1 @@ +-console.log("original"); ++console.log("patched"); +`, + ); + + const installedIndexJs = file(join(packageDir, "packages", "member", "node_modules", "git-dep", "index.js")); + + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); + + // The patchedDependencies key must carry the resolved commit; a key without + // it is silently ignored, which the patched-content assertion would catch. + await write( + packageJson, + JSON.stringify({ + ...rootPackageJson, + patchedDependencies: { + [`git-dep@${repoUrl}#${sha}`]: "patches/git-dep.patch", + }, + }), + ); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + + await write(packageJson, JSON.stringify(rootPackageJson)); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("original");\n'); +}); for (const backend of ["clonefile", "hardlink", "copyfile"]) { test(`isolated install with backend: ${backend}`, async () => { From 9c5d22c88665e91b46c5a0b3d3314c87617b717e Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 13:55:31 +0000 Subject: [PATCH 4/5] Shorten the cache-check comment --- src/install/isolated_install.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 068e827b1262..26ce4756ebeb 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2356,11 +2356,9 @@ pub(crate) fn install_isolated_packages( { install::PreinstallState::Done => false, _ => 'missing_from_cache: { - // Downloads only produce the unpatched folder - // (`apply_package_patch` derives the `_patch_hash=` - // one), so check for that. Re-enqueueing a tarball - // the resolve phase already extracted deadlocks the - // install (#37136). + // Downloads only produce the unpatched folder; + // re-enqueueing one the resolve phase already + // extracted deadlocks the install (#37136). if matches!(patch_info, installer::PatchInfo::Patch(_)) { let idx = strings::last_index_of( pkg_cache_dir_subpath.slice(), From a1c251cba5a0555363d9a70585d6e7ac4b177f87 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 14:00:04 +0000 Subject: [PATCH 5/5] Add cold-cache leg to the git test and skip it when git is unavailable --- test/cli/install/isolated-install.test.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index acd7ddfe8234..36ad0aea3c20 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -858,8 +858,9 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f // Same deadlock through the git: task-id space (clone + checkout tasks // instead of a tarball download). The repo is served over git's dumb HTTP // protocol: after `git update-server-info`, a bare repo is plain static -// files. -test("adding and removing a patch for a git dependency in a workspace completes", async () => { +// files. Requires the git executable to build the fixture repository. +const gitExecutable = Bun.which("git"); +test.skipIf(!gitExecutable)("adding and removing a patch for a git dependency in a workspace completes", async () => { const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); const srcDir = join(packageDir, "git-src"); @@ -879,7 +880,7 @@ test("adding and removing a patch for a git dependency in a workspace completes" GIT_COMMITTER_EMAIL: "test@bun.sh", }; async function git(args: string[], cwd: string): Promise { - await using proc = spawn({ cmd: ["git", ...args], cwd, env: gitEnv, stdout: "pipe", stderr: "pipe" }); + 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]); expect(err).not.toContain("fatal:"); expect(exitCode).toBe(0); @@ -972,6 +973,14 @@ index 1f0e8b9f1f9a56799cdbc1a5a2f8cf9f9a3b2f1c..2f0e8b9f1f9a56799cdbc1a5a2f8cf9f await install(); expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + // Cold cache with the patch still in the lockfile: the install phase + // clones and checks out itself, applying the patch after the checkout. + await rm(join(packageDir, ".bun-cache"), { recursive: true, force: true }); + await rm(join(packageDir, "node_modules"), { recursive: true, force: true }); + await rm(join(packageDir, "packages", "member", "node_modules"), { recursive: true, force: true }); + await install(); + expect(await installedIndexJs.text()).toBe('console.log("patched");\n'); + await write(packageJson, JSON.stringify(rootPackageJson)); await install(); expect(await installedIndexJs.text()).toBe('console.log("original");\n');