From 12bc50a5d4dcb41fd1c6dc86ae05bb721395c0c8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:48:06 +0000 Subject: [PATCH 1/9] install: keep existing cache entries instead of replacing them on rename collision Concurrent bun install processes sharing BUN_INSTALL_CACHE_DIR raced when the filesystem does not support renameat2(RENAME_EXCHANGE) (NFS, FUSE): losing the RENAME_NOREPLACE race fell back to delete_tree + rename, which unlinked the winner's cache entry files while another process was copying them into node_modules, failing with ENOENT. Cache entries only appear via an atomic rename of a complete tree, so on a rename collision keep the existing entry and discard the freshly extracted copy. An entry that exists but is missing package.json (a crashed copy) is still replaced, since replacement is its only repair path. The generic renameat_concurrently fallback also no longer deletes the live destination in place: it moves the destination aside, renames the source into place, then deletes the old tree under its aside name, restoring it if the rename fails. Fixes #36227 --- src/install/extract_tarball.rs | 88 +++++-- src/install/patch_install.rs | 45 +++- src/sys/lib.rs | 104 ++++++-- .../install/bun-install-cache-race.test.ts | 242 ++++++++++++++++++ 4 files changed, 426 insertions(+), 53 deletions(-) create mode 100644 test/cli/install/bun-install-cache-race.test.ts diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index 0746103c5485..10a39fa6b82b 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -678,14 +678,17 @@ impl ExtractTarball { } #[cfg(not(windows))] { - // Attempt to gracefully handle duplicate concurrent `bun install` calls - // - // By: - // 1. Rename from temporary directory to cache directory and fail if it already exists - // 2a. If the rename fails, swap the cache directory with the temporary directory version - // 2b. Delete the temporary directory version ONLY if we're not using a provided temporary directory - // 3. If rename still fails, fallback to racily deleting the cache directory version and then renaming the temporary directory version again. + // Gracefully handle duplicate concurrent `bun install` calls: // + // 1. Rename from the temporary directory into the cache, failing if + // the destination already exists. + // 2. If it already exists, a concurrent `bun install` put a complete + // entry there (entries only appear via an atomic rename of a fully + // extracted tree), so keep it and discard ours. Replacing it would + // unlink files while another process copies them out of the cache. + // 3. If the existing entry is incomplete (e.g. a crashed copy left it + // without package.json), replace it: that is the only repair path + // for a corrupt entry. if create_subdir { if let Some(folder) = bun_paths::Dirname::dirname(folder_name) { @@ -693,27 +696,62 @@ impl ExtractTarball { } } - if let Err(err) = sys::renameat_concurrently_a( + let mut folder_name_z_buf = PathBuffer::uninit(); + folder_name_z_buf[0..folder_name.len()].copy_from_slice(folder_name); + folder_name_z_buf[folder_name.len()] = 0; + let folder_name_z = ZStr::from_buf(&folder_name_z_buf, folder_name.len()); + + let renamed = sys::renameat2( tmpdir.fd(), - tmpname.as_bytes(), + tmpname, cache_dir.fd(), - folder_name, - sys::RenameatConcurrentlyOptions { - move_fallback: true, + folder_name_z, + sys::Renameat2Flags { + exclude: true, + ..Default::default() }, - ) { - log.add_error_fmt( - None, - bun_ast::Loc::EMPTY, - format_args!( - "moving \"{}\" to cache dir failed: {}\n From: {}\n To: {}", - bun_fmt::s(name), - err, - bun_fmt::s(tmpname.as_bytes()), - bun_fmt::s(folder_name), - ), - ); - return Err(crate::Error::InstallFailed); + ) + .is_ok(); + + let keep_existing = !renamed + && match self.resolution.tag { + ResolutionTag::Npm => { + let mut pkg_json_buf = PathBuffer::uninit(); + let pkg_json = path::resolve_path::join_z_buf::( + &mut pkg_json_buf.0, + &[folder_name, b"package.json"], + ); + sys::exists_at(cache_dir.fd(), pkg_json) + } + _ => sys::directory_exists_at(cache_dir.fd(), folder_name_z) + .unwrap_or(false), + }; + + if keep_existing { + let _ = tmpdir.delete_tree(tmpname.as_bytes()); + } else if !renamed { + if let Err(err) = sys::renameat_concurrently_a( + tmpdir.fd(), + tmpname.as_bytes(), + cache_dir.fd(), + folder_name, + sys::RenameatConcurrentlyOptions { + move_fallback: true, + }, + ) { + log.add_error_fmt( + None, + bun_ast::Loc::EMPTY, + format_args!( + "moving \"{}\" to cache dir failed: {}\n From: {}\n To: {}", + bun_fmt::s(name), + err, + bun_fmt::s(tmpname.as_bytes()), + bun_fmt::s(folder_name), + ), + ); + return Err(crate::Error::InstallFailed); + } } } diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index e756598377dd..fc2ff347ff8c 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -599,24 +599,45 @@ impl PatchTask { ); let cache_dir_subpath_z: &ZStr = patch.cache_dir_subpath.as_zstr(); - if let Err(e) = sys::renameat_concurrently( + let renamed = sys::renameat2( system_tmpdir, path_in_tmpdir, patch.cache_dir, cache_dir_subpath_z, - sys::RenameOptions { - move_fallback: true, + sys::Renameat2Flags { + exclude: true, ..Default::default() }, - ) { - log.add_error_fmt_opts( - format_args!( - "renaming changes to cache dir: {}", - e.with_path(cache_dir_subpath_z.as_bytes()) - ), - Default::default(), - ); - return Ok(()); + ) + .is_ok(); + if !renamed + && sys::directory_exists_at(patch.cache_dir, cache_dir_subpath_z).unwrap_or(false) + { + // A concurrent `bun install` created the same entry; its name embeds + // the patch hash, so the contents are equivalent. Keep theirs and + // discard ours: replacing it would unlink files while another + // process copies them out of the cache. + let _ = sys::Dir::borrow(&system_tmpdir).delete_tree(path_in_tmpdir.as_bytes()); + } else if !renamed { + if let Err(e) = sys::renameat_concurrently( + system_tmpdir, + path_in_tmpdir, + patch.cache_dir, + cache_dir_subpath_z, + sys::RenameOptions { + move_fallback: true, + ..Default::default() + }, + ) { + log.add_error_fmt_opts( + format_args!( + "renaming changes to cache dir: {}", + e.with_path(cache_dir_subpath_z.as_bytes()) + ), + Default::default(), + ); + return Ok(()); + } } Ok(()) } diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 90d4cdbac33f..b908c1d08c2c 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9074,7 +9074,7 @@ pub fn exists(path: &[u8]) -> bool { } /// `moveFileZ`. Routes through /// [`renameat_concurrently_without_fallback`] (renameat2 NOREPLACE → EXCHANGE → -/// delete-tree + rename); on EISDIR removes the dest dir and +/// rename-aside + rename); on EISDIR removes the dest dir and /// retries; on EXDEV falls back to the slow open+copy path. Only opens the /// source inside the EXDEV branch. pub fn move_file_z(from_dir: Fd, filename: &ZStr, to_dir: Fd, destination: &ZStr) -> Maybe<()> { @@ -9172,8 +9172,9 @@ pub fn move_file_z_slow_maybe( } /// `renameatConcurrently`. Tries an atomic NOREPLACE rename, -/// then EXCHANGE, then a racy delete-tree + rename. With `move_fallback` set, -/// an EXDEV result falls through to a slow open/copy. +/// then EXCHANGE, then moving the destination aside before renaming into +/// place. With `move_fallback` set, an EXDEV result falls through to a slow +/// open/copy. pub fn renameat_concurrently( from_dir_fd: Fd, from: &ZStr, @@ -9253,21 +9254,92 @@ pub fn renameat_concurrently_without_fallback( } } - // sad path: let's try to delete the folder and then rename it - if to_dir_fd.is_valid() { - let _ = Dir::borrow(&to_dir_fd).delete_tree(to.as_bytes()); - } else { - let _ = delete_tree_absolute(to.as_bytes()); - } - match renameat(from_dir_fd, from, to_dir_fd, to) { - Err(err) => return Err(err), - Ok(()) => {} + // Sad path: no atomic exchange (NFS/FUSE, Windows). Deleting the + // destination in place would let a concurrent reader open the + // directory and then hit ENOENT on its files mid-delete, so move the + // destination aside first, rename the source into place, and only + // then delete the old tree under its aside name. + let delete_tree_at = |path: &[u8]| { + if to_dir_fd.is_valid() { + let _ = Dir::borrow(&to_dir_fd).delete_tree(path); + } else { + let _ = delete_tree_absolute(path); + } + }; + let mut aside_buf = bun_paths::path_buffer_pool::get(); + let mut attempts_left: u32 = 8; + loop { + let Some(aside) = rename_aside_name(to, &mut aside_buf) else { + // No room to append a suffix; fall back to the racy + // in-place delete. + delete_tree_at(to.as_bytes()); + renameat(from_dir_fd, from, to_dir_fd, to)?; + break; + }; + let moved_aside = match renameat(to_dir_fd, to, to_dir_fd, aside) { + Ok(()) => true, + // The destination vanished (e.g. a concurrent process moved + // it aside itself); go straight to the rename. + Err(err) if err.get_errno() == E::ENOENT => false, + Err(err) => return Err(err), + }; + match renameat(from_dir_fd, from, to_dir_fd, to) { + Ok(()) => { + if moved_aside { + delete_tree_at(aside.as_bytes()); + } + break; + } + Err(err) + if matches!(err.get_errno(), E::EEXIST | E::ENOTEMPTY) + && attempts_left > 0 => + { + // A concurrent process recreated the destination between + // the two renames; retry against the new one. + if moved_aside { + delete_tree_at(aside.as_bytes()); + } + attempts_left -= 1; + } + Err(err) => { + // Best-effort restore of the displaced destination. + if moved_aside { + let _ = renameat(to_dir_fd, aside, to_dir_fd, to); + } + return Err(err); + } + } } } Ok(()) } +/// Builds `{to}.{16 random hex chars}.tmp\0` into `buf` for the rename-aside +/// fallback in [`renameat_concurrently_without_fallback`]. Returns `None` +/// when the result would not fit. +fn rename_aside_name<'a>(to: &ZStr, buf: &'a mut bun_paths::PathBuffer) -> Option<&'a ZStr> { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let to_bytes = to.as_bytes(); + // "." + 16 hex + ".tmp" + NUL + if to_bytes.len() + 22 > buf.0.len() { + return None; + } + buf.0[..to_bytes.len()].copy_from_slice(to_bytes); + let mut pos = to_bytes.len(); + buf.0[pos] = b'.'; + pos += 1; + let r = bun_core::fast_random(); + for i in 0..16 { + buf.0[pos + i] = HEX[((r >> ((15 - i) * 4)) & 0xf) as usize]; + } + pos += 16; + buf.0[pos..pos + 4].copy_from_slice(b".tmp"); + pos += 4; + buf.0[pos] = 0; + Some(ZStr::from_buf(&buf.0[..], pos)) +} + /// `eventfd(initval, flags)` — kernel notification fd. Linux native (Android /// included since API 8); FreeBSD 13+ gained a Linux-compatible `eventfd(2)` /// via the `libc` shim. @@ -9563,10 +9635,10 @@ bun_core::link_impl_OutputSink! { mod owned_handle_tests { use super::*; - /// `renameat_concurrently_without_fallback` falls back to `delete_tree` + - /// retry when the destination exists. The `delete_tree` is run via a `Dir` - /// borrowed from the caller's `to_dir_fd`; if it took ownership instead, - /// `to_dir_fd` would be closed out from under the caller. + /// `renameat_concurrently_without_fallback` falls back to rename-aside + + /// `delete_tree` when the destination exists. The `delete_tree` is run via + /// a `Dir` borrowed from the caller's `to_dir_fd`; if it took ownership + /// instead, `to_dir_fd` would be closed out from under the caller. #[test] fn renameat_concurrently_does_not_close_caller_fd() { let _g = crate::file::tests::FD_TEST_LOCK.lock(); diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts new file mode 100644 index 000000000000..b0ce7efebb82 --- /dev/null +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -0,0 +1,242 @@ +// Concurrent `bun install` processes sharing BUN_INSTALL_CACHE_DIR on a +// filesystem without renameat2(RENAME_EXCHANGE) (NFS, FUSE) raced: when a +// process lost the RENAME_NOREPLACE race for a cache entry, the fallback +// deleted the existing entry in place before renaming its own copy over it. +// A concurrent reader copying that entry into node_modules would open the +// directory fine and then hit ENOENT on files mid-copy (issue #36227). +// +// An LD_PRELOAD shim makes RENAME_EXCHANGE fail with EOPNOTSUPP the way NFS +// does; everything else runs against the real (local) filesystem. +import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { createHash } from "node:crypto"; +import { createServer, type Server } from "node:http"; +import { join } from "node:path"; +import { gzipSync } from "node:zlib"; + +setDefaultTimeout(1000 * 60 * 5); + +const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); + +// BUN_TEST_FAIL_RENAME_EXCHANGE=1: renameat2 with RENAME_EXCHANGE fails with +// EOPNOTSUPP (NFS behavior). Covers both the glibc wrapper and the raw +// syscall() path bun uses on Linux. +const SHIM_C = /* c */ ` +#define _GNU_SOURCE +#include +#include +#include +#include +#include + +#define RENAME_EXCHANGE_FLAG (1 << 1) + +static long (*real_syscall)(long, ...); +static int fail_exchange = -1; + +static int should_fail(void) { + if (fail_exchange < 0) fail_exchange = getenv("BUN_TEST_FAIL_RENAME_EXCHANGE") != NULL; + return fail_exchange; +} + +long syscall(long number, ...) { + if (!real_syscall) real_syscall = (long (*)(long, ...))dlsym(RTLD_NEXT, "syscall"); + va_list ap; + va_start(ap, number); + long a = va_arg(ap, long); + long b = va_arg(ap, long); + long c = va_arg(ap, long); + long d = va_arg(ap, long); + long e = va_arg(ap, long); + long f = va_arg(ap, long); + va_end(ap); + if (number == SYS_renameat2 && (e & RENAME_EXCHANGE_FLAG) && should_fail()) { + errno = EOPNOTSUPP; + return -1; + } + return real_syscall(number, a, b, c, d, e, f); +} + +int renameat2(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, unsigned int flags) { + if ((flags & RENAME_EXCHANGE_FLAG) && should_fail()) { + errno = EOPNOTSUPP; + return -1; + } + return (int)syscall(SYS_renameat2, (long)olddirfd, (long)oldpath, (long)newdirfd, (long)newpath, (long)flags); +} +`; + +// ------------------------------------------------------------------- +// Tarball construction. Built in-process so the package can have many +// files (a wide delete/copy window) without committing a fixture. +// ------------------------------------------------------------------- + +function octal(n: number, width: number): string { + return n.toString(8).padStart(width - 1, "0") + "\0"; +} + +function tarHeader(name: string, size: number): Buffer { + const buf = Buffer.alloc(512, 0); + buf.write(name, 0, 100, "utf8"); + buf.write(octal(0o644, 8), 100); // mode + buf.write(octal(0, 8), 108); // uid + buf.write(octal(0, 8), 116); // gid + buf.write(octal(size, 12), 124); // size + buf.write(octal(0, 12), 136); // mtime + buf.fill(" ", 148, 156); // checksum placeholder + buf.write("0", 156); // regular file + buf.write("ustar\0", 257); + buf.write("00", 263); + let sum = 0; + for (let i = 0; i < 512; i++) sum += buf[i]; + buf.write(octal(sum, 8), 148); + return buf; +} + +function pad512(len: number): Buffer { + const pad = (512 - (len % 512)) % 512; + return Buffer.alloc(pad, 0); +} + +const PKG_NAME = "cache-race-pkg"; +const FILE_COUNT = 600; + +function buildTarball(): { tgz: Buffer; shasum: string; integrity: string } { + const blocks: Buffer[] = []; + const push = (path: string, body: Buffer) => { + blocks.push(tarHeader(`package/${path}`, body.length), body, pad512(body.length)); + }; + push("package.json", Buffer.from(JSON.stringify({ name: PKG_NAME, version: "1.0.0", main: "index.js" }) + "\n")); + push("index.js", Buffer.from("module.exports = 'ok';\n")); + for (let i = 0; i < FILE_COUNT; i++) { + push(`files/f-${i}.txt`, Buffer.from(`file ${i}\n`)); + } + blocks.push(Buffer.alloc(1024, 0)); // end-of-archive + const tgz = gzipSync(Buffer.concat(blocks)); + return { + tgz, + shasum: createHash("sha1").update(tgz).digest("hex"), + integrity: "sha512-" + createHash("sha512").update(tgz).digest("base64"), + }; +} + +async function makeRegistry(tgz: Buffer, shasum: string, integrity: string) { + const server: Server = createServer((req, res) => { + const url = new URL(req.url!, "http://x"); + if (url.pathname === `/${PKG_NAME}`) { + const body = JSON.stringify({ + name: PKG_NAME, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: PKG_NAME, + version: "1.0.0", + dist: { + shasum, + integrity, + tarball: `http://127.0.0.1:${port}/${PKG_NAME}/-/${PKG_NAME}-1.0.0.tgz`, + }, + }, + }, + }); + res.setHeader("content-type", "application/json"); + res.end(body); + return; + } + if (url.pathname.endsWith(".tgz")) { + res.setHeader("content-type", "application/octet-stream"); + res.end(tgz); + return; + } + res.statusCode = 404; + res.end("not found"); + }); + await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); + const port = (server.address() as { port: number }).port; + return { + url: `http://127.0.0.1:${port}/`, + [Symbol.asyncDispose]: () => new Promise(resolve => server.close(() => resolve())), + }; +} + +let shimPath: string; +let shimDir: ReturnType | undefined; + +beforeAll(async () => { + if (!isLinux || !cc) return; + shimDir = tempDir("cache-race-shim", { "shim.c": SHIM_C }); + shimPath = join(String(shimDir), "shim.so"); + await using ccProc = Bun.spawn({ + cmd: [cc, "-shared", "-fPIC", "-o", shimPath, join(String(shimDir), "shim.c"), "-ldl"], + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [ccOut, ccErr, ccExit] = await Promise.all([ccProc.stdout.text(), ccProc.stderr.text(), ccProc.exited]); + if (ccExit !== 0) { + throw new Error(`shim compile failed: ${ccErr || ccOut}`); + } +}); + +afterAll(() => { + shimDir?.[Symbol.dispose](); +}); + +test.skipIf(!isLinux || !cc)( + "concurrent installs sharing a cache survive a filesystem without RENAME_EXCHANGE", + async () => { + const { tgz, shasum, integrity } = buildTarball(); + await using registry = await makeRegistry(tgz, shasum, integrity); + + const PROCS = 4; + const ITERATIONS = 3; + const existingPreload = bunEnv.LD_PRELOAD; + + for (let iter = 0; iter < ITERATIONS; iter++) { + const projects: Record = {}; + for (let i = 0; i < PROCS; i++) { + projects[`proj-${i}/package.json`] = JSON.stringify({ + name: `proj-${i}`, + version: "1.0.0", + dependencies: { [PKG_NAME]: "1.0.0" }, + }); + projects[`proj-${i}/bunfig.toml`] = `[install]\nregistry = "${registry.url}"\n`; + } + using dir = tempDir(`cache-race-${iter}`, projects); + const cacheDir = join(String(dir), ".shared-cache"); + + const procs = Array.from({ length: PROCS }, (_, i) => + Bun.spawn({ + cmd: [bunExe(), "install", "--backend=copyfile", "--linker=hoisted", "--no-progress"], + cwd: join(String(dir), `proj-${i}`), + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: cacheDir, + LD_PRELOAD: existingPreload ? `${shimPath}:${existingPreload}` : shimPath, + BUN_TEST_FAIL_RENAME_EXCHANGE: "1", + }, + stdout: "pipe", + stderr: "pipe", + }), + ); + + const results = await Promise.all( + procs.map(async proc => { + const [stdout, stderr, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + return { stdout, stderr, exitCode }; + }), + ); + + for (let i = 0; i < results.length; i++) { + const { stdout, stderr, exitCode } = results[i]; + const output = `iteration ${iter} proj-${i}:\n${stdout}\n${stderr}`; + expect(output).not.toContain("ENOENT"); + expect(exitCode).toBe(0); + } + } + }, +); From 32f70283d65a446ee88dd728148c694096a02743 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:50:39 +0000 Subject: [PATCH 2/9] [autofix.ci] apply automated fixes --- src/sys/lib.rs | 3 +-- test/cli/install/bun-install-cache-race.test.ts | 6 +----- 2 files changed, 2 insertions(+), 7 deletions(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index b908c1d08c2c..81b569ca0f16 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9291,8 +9291,7 @@ pub fn renameat_concurrently_without_fallback( break; } Err(err) - if matches!(err.get_errno(), E::EEXIST | E::ENOTEMPTY) - && attempts_left > 0 => + if matches!(err.get_errno(), E::EEXIST | E::ENOTEMPTY) && attempts_left > 0 => { // A concurrent process recreated the destination between // the two renames; retry against the new one. diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index b0ce7efebb82..dd10aff1103d 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -222,11 +222,7 @@ test.skipIf(!isLinux || !cc)( const results = await Promise.all( procs.map(async proc => { - const [stdout, stderr, exitCode] = await Promise.all([ - proc.stdout.text(), - proc.stderr.text(), - proc.exited, - ]); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); return { stdout, stderr, exitCode }; }), ); From 8468732d7c77d14900e57641d9ae52bcb29d5fe0 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:51:57 +0000 Subject: [PATCH 3/9] Tighten comments --- src/install/extract_tarball.rs | 16 +++++----------- src/install/patch_install.rs | 7 +++---- src/sys/lib.rs | 14 +++++--------- 3 files changed, 13 insertions(+), 24 deletions(-) diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index 10a39fa6b82b..57e264c70cb1 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -678,17 +678,11 @@ impl ExtractTarball { } #[cfg(not(windows))] { - // Gracefully handle duplicate concurrent `bun install` calls: - // - // 1. Rename from the temporary directory into the cache, failing if - // the destination already exists. - // 2. If it already exists, a concurrent `bun install` put a complete - // entry there (entries only appear via an atomic rename of a fully - // extracted tree), so keep it and discard ours. Replacing it would - // unlink files while another process copies them out of the cache. - // 3. If the existing entry is incomplete (e.g. a crashed copy left it - // without package.json), replace it: that is the only repair path - // for a corrupt entry. + // An existing destination is a complete entry from a concurrent + // `bun install` (entries only appear via atomic rename): keep it, + // since replacing it unlinks files another process may be copying + // out of the cache (#36227). Only an incomplete entry (no + // package.json, e.g. a crashed copy) is replaced. if create_subdir { if let Some(folder) = bun_paths::Dirname::dirname(folder_name) { diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index fc2ff347ff8c..f8007dc2b491 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -613,10 +613,9 @@ impl PatchTask { if !renamed && sys::directory_exists_at(patch.cache_dir, cache_dir_subpath_z).unwrap_or(false) { - // A concurrent `bun install` created the same entry; its name embeds - // the patch hash, so the contents are equivalent. Keep theirs and - // discard ours: replacing it would unlink files while another - // process copies them out of the cache. + // A concurrent `bun install` created the same entry (its name embeds + // the patch hash, so contents are equivalent): keep it, since + // replacing it unlinks files another process may be copying out. let _ = sys::Dir::borrow(&system_tmpdir).delete_tree(path_in_tmpdir.as_bytes()); } else if !renamed { if let Err(e) = sys::renameat_concurrently( diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 81b569ca0f16..490fe4d24925 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9254,11 +9254,9 @@ pub fn renameat_concurrently_without_fallback( } } - // Sad path: no atomic exchange (NFS/FUSE, Windows). Deleting the - // destination in place would let a concurrent reader open the - // directory and then hit ENOENT on its files mid-delete, so move the - // destination aside first, rename the source into place, and only - // then delete the old tree under its aside name. + // Sad path (no atomic exchange, e.g. NFS/FUSE or Windows): move the + // destination aside, rename the source into place, then delete the old + // tree. An in-place delete_tree(dest) would ENOENT concurrent readers. let delete_tree_at = |path: &[u8]| { if to_dir_fd.is_valid() { let _ = Dir::borrow(&to_dir_fd).delete_tree(path); @@ -9270,16 +9268,14 @@ pub fn renameat_concurrently_without_fallback( let mut attempts_left: u32 = 8; loop { let Some(aside) = rename_aside_name(to, &mut aside_buf) else { - // No room to append a suffix; fall back to the racy - // in-place delete. + // No room to append a suffix; fall back to the in-place delete. delete_tree_at(to.as_bytes()); renameat(from_dir_fd, from, to_dir_fd, to)?; break; }; let moved_aside = match renameat(to_dir_fd, to, to_dir_fd, aside) { Ok(()) => true, - // The destination vanished (e.g. a concurrent process moved - // it aside itself); go straight to the rename. + // The destination vanished; go straight to the rename. Err(err) if err.get_errno() == E::ENOENT => false, Err(err) => return Err(err), }; From 8869e195543e7355004b2ffb1014a3a134e7f245 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:00:42 +0000 Subject: [PATCH 4/9] Check patch tag for entry completeness, delete aside tree on failed restore, use Bun.serve in test --- src/install/patch_install.rs | 41 +++++++----- src/sys/lib.rs | 8 ++- .../install/bun-install-cache-race.test.ts | 62 +++++++++---------- 3 files changed, 58 insertions(+), 53 deletions(-) diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index f8007dc2b491..e0e864977889 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -533,6 +533,19 @@ impl PatchTask { } } + // `.bun-tag-`: written into the tree before the rename publishes + // it, so its presence marks a complete entry. + let mut buntagbuf: BuntagHashBuf = [0; MAX_BUNTAG_HASH_BUF_LEN]; + let buntag_len = { + use std::io::Write as _; + buntagbuf[..bun_hash_tag.len()].copy_from_slice(bun_hash_tag); + let mut cursor = &mut buntagbuf[bun_hash_tag.len()..]; + let before = cursor.len(); + write!(&mut cursor, "{:x}", patch.patch_hash).expect("unreachable"); + bun_hash_tag.len() + (before - cursor.len()) + }; + buntagbuf[buntag_len] = 0; + { let patch_pkg_dir = match sys::openat( system_tmpdir, @@ -564,18 +577,7 @@ impl PatchTask { } // 5. Add bun tag - let bun_tag_prefix = bun_hash_tag; - let mut buntagbuf: BuntagHashBuf = [0; MAX_BUNTAG_HASH_BUF_LEN]; - buntagbuf[..bun_tag_prefix.len()].copy_from_slice(bun_tag_prefix); - let hashlen = { - use std::io::Write as _; - let mut cursor = &mut buntagbuf[bun_tag_prefix.len()..]; - let before = cursor.len(); - write!(&mut cursor, "{:x}", patch.patch_hash).expect("unreachable"); - before - cursor.len() - }; - buntagbuf[bun_tag_prefix.len() + hashlen] = 0; - let buntag_zstr = ZStr::from_buf(&buntagbuf, bun_tag_prefix.len() + hashlen); + let buntag_zstr = ZStr::from_buf(&buntagbuf, buntag_len); if let Err(e) = sys::File::write_file(patch_pkg_dir, buntag_zstr, b"") { log.add_error_fmt_opts( format_args!( @@ -610,10 +612,17 @@ impl PatchTask { }, ) .is_ok(); - if !renamed - && sys::directory_exists_at(patch.cache_dir, cache_dir_subpath_z).unwrap_or(false) - { - // A concurrent `bun install` created the same entry (its name embeds + let keep_existing = !renamed && { + let mut tag_path_buf = PathBuffer::uninit(); + let tag_path = path::resolve_path::join_z_buf::( + &mut tag_path_buf.0, + &[cache_dir_subpath_z.as_bytes(), &buntagbuf[..buntag_len]], + ); + sys::exists_at(patch.cache_dir, tag_path) + }; + if keep_existing { + // A concurrent `bun install` created the same complete entry (the + // tag is written before the rename publishes it, and the name embeds // the patch hash, so contents are equivalent): keep it, since // replacing it unlinks files another process may be copying out. let _ = sys::Dir::borrow(&system_tmpdir).delete_tree(path_in_tmpdir.as_bytes()); diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 490fe4d24925..9a3959de61ec 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9297,9 +9297,11 @@ pub fn renameat_concurrently_without_fallback( attempts_left -= 1; } Err(err) => { - // Best-effort restore of the displaced destination. - if moved_aside { - let _ = renameat(to_dir_fd, aside, to_dir_fd, to); + // Best-effort restore of the displaced destination; if it + // was recreated meanwhile, drop the displaced tree instead + // of leaking it. + if moved_aside && renameat(to_dir_fd, aside, to_dir_fd, to).is_err() { + delete_tree_at(aside.as_bytes()); } return Err(err); } diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index dd10aff1103d..9706bbd57440 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -10,7 +10,6 @@ import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; import { bunEnv, bunExe, isLinux, tempDir } from "harness"; import { createHash } from "node:crypto"; -import { createServer, type Server } from "node:http"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; @@ -120,42 +119,37 @@ function buildTarball(): { tgz: Buffer; shasum: string; integrity: string } { }; } -async function makeRegistry(tgz: Buffer, shasum: string, integrity: string) { - const server: Server = createServer((req, res) => { - const url = new URL(req.url!, "http://x"); - if (url.pathname === `/${PKG_NAME}`) { - const body = JSON.stringify({ - name: PKG_NAME, - "dist-tags": { latest: "1.0.0" }, - versions: { - "1.0.0": { - name: PKG_NAME, - version: "1.0.0", - dist: { - shasum, - integrity, - tarball: `http://127.0.0.1:${port}/${PKG_NAME}/-/${PKG_NAME}-1.0.0.tgz`, +function makeRegistry(tgz: Buffer, shasum: string, integrity: string) { + const server = Bun.serve({ + port: 0, + fetch(req) { + const url = new URL(req.url); + if (url.pathname === `/${PKG_NAME}`) { + return Response.json({ + name: PKG_NAME, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: PKG_NAME, + version: "1.0.0", + dist: { + shasum, + integrity, + tarball: `http://127.0.0.1:${server.port}/${PKG_NAME}/-/${PKG_NAME}-1.0.0.tgz`, + }, }, }, - }, - }); - res.setHeader("content-type", "application/json"); - res.end(body); - return; - } - if (url.pathname.endsWith(".tgz")) { - res.setHeader("content-type", "application/octet-stream"); - res.end(tgz); - return; - } - res.statusCode = 404; - res.end("not found"); + }); + } + if (url.pathname.endsWith(".tgz")) { + return new Response(tgz, { headers: { "content-type": "application/octet-stream" } }); + } + return new Response("not found", { status: 404 }); + }, }); - await new Promise(resolve => server.listen(0, "127.0.0.1", resolve)); - const port = (server.address() as { port: number }).port; return { - url: `http://127.0.0.1:${port}/`, - [Symbol.asyncDispose]: () => new Promise(resolve => server.close(() => resolve())), + url: `http://127.0.0.1:${server.port}/`, + [Symbol.asyncDispose]: () => server.stop(true), }; } @@ -186,7 +180,7 @@ test.skipIf(!isLinux || !cc)( "concurrent installs sharing a cache survive a filesystem without RENAME_EXCHANGE", async () => { const { tgz, shasum, integrity } = buildTarball(); - await using registry = await makeRegistry(tgz, shasum, integrity); + await using registry = makeRegistry(tgz, shasum, integrity); const PROCS = 4; const ITERATIONS = 3; From a1c01465a7703aa6b1a2bb6595dbb6b6c3c227b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:09:53 +0000 Subject: [PATCH 5/9] Guard aside name against NAME_MAX, skip shim test on musl --- src/sys/lib.rs | 10 +++++++--- test/cli/install/bun-install-cache-race.test.ts | 9 ++++++--- 2 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/sys/lib.rs b/src/sys/lib.rs index 9a3959de61ec..36c382a2e580 100644 --- a/src/sys/lib.rs +++ b/src/sys/lib.rs @@ -9314,12 +9314,16 @@ pub fn renameat_concurrently_without_fallback( /// Builds `{to}.{16 random hex chars}.tmp\0` into `buf` for the rename-aside /// fallback in [`renameat_concurrently_without_fallback`]. Returns `None` -/// when the result would not fit. +/// when the result would not fit in the buffer or would push the filename +/// component past NAME_MAX. fn rename_aside_name<'a>(to: &ZStr, buf: &'a mut bun_paths::PathBuffer) -> Option<&'a ZStr> { const HEX: &[u8; 16] = b"0123456789abcdef"; + const SUFFIX_LEN: usize = 21; // "." + 16 hex + ".tmp" + const NAME_MAX: usize = 255; let to_bytes = to.as_bytes(); - // "." + 16 hex + ".tmp" + NUL - if to_bytes.len() + 22 > buf.0.len() { + if to_bytes.len() + SUFFIX_LEN + 1 > buf.0.len() + || bun_paths::resolve_path::basename(to_bytes).len() + SUFFIX_LEN > NAME_MAX + { return None; } buf.0[..to_bytes.len()].copy_from_slice(to_bytes); diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index 9706bbd57440..c20800e90d29 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -8,7 +8,7 @@ // An LD_PRELOAD shim makes RENAME_EXCHANGE fail with EOPNOTSUPP the way NFS // does; everything else runs against the real (local) filesystem. import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; -import { bunEnv, bunExe, isLinux, tempDir } from "harness"; +import { bunEnv, bunExe, isLinux, isMusl, tempDir } from "harness"; import { createHash } from "node:crypto"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; @@ -17,6 +17,9 @@ setDefaultTimeout(1000 * 60 * 5); const cc = Bun.which("cc") || Bun.which("gcc") || Bun.which("clang"); +// bun-musl is statically linked, so LD_PRELOAD cannot load the shim. +const canShim = isLinux && !isMusl && !!cc; + // BUN_TEST_FAIL_RENAME_EXCHANGE=1: renameat2 with RENAME_EXCHANGE fails with // EOPNOTSUPP (NFS behavior). Covers both the glibc wrapper and the raw // syscall() path bun uses on Linux. @@ -157,7 +160,7 @@ let shimPath: string; let shimDir: ReturnType | undefined; beforeAll(async () => { - if (!isLinux || !cc) return; + if (!canShim) return; shimDir = tempDir("cache-race-shim", { "shim.c": SHIM_C }); shimPath = join(String(shimDir), "shim.so"); await using ccProc = Bun.spawn({ @@ -176,7 +179,7 @@ afterAll(() => { shimDir?.[Symbol.dispose](); }); -test.skipIf(!isLinux || !cc)( +test.skipIf(!canShim)( "concurrent installs sharing a cache survive a filesystem without RENAME_EXCHANGE", async () => { const { tgz, shasum, integrity } = buildTarball(); From 90ac8522aac95696906c722bd224ceb729ce54d5 Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:11:36 +0000 Subject: [PATCH 6/9] [autofix.ci] apply automated fixes --- .../install/bun-install-cache-race.test.ts | 103 +++++++++--------- 1 file changed, 50 insertions(+), 53 deletions(-) diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index c20800e90d29..a2a3d67af1e9 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -179,57 +179,54 @@ afterAll(() => { shimDir?.[Symbol.dispose](); }); -test.skipIf(!canShim)( - "concurrent installs sharing a cache survive a filesystem without RENAME_EXCHANGE", - async () => { - const { tgz, shasum, integrity } = buildTarball(); - await using registry = makeRegistry(tgz, shasum, integrity); - - const PROCS = 4; - const ITERATIONS = 3; - const existingPreload = bunEnv.LD_PRELOAD; - - for (let iter = 0; iter < ITERATIONS; iter++) { - const projects: Record = {}; - for (let i = 0; i < PROCS; i++) { - projects[`proj-${i}/package.json`] = JSON.stringify({ - name: `proj-${i}`, - version: "1.0.0", - dependencies: { [PKG_NAME]: "1.0.0" }, - }); - projects[`proj-${i}/bunfig.toml`] = `[install]\nregistry = "${registry.url}"\n`; - } - using dir = tempDir(`cache-race-${iter}`, projects); - const cacheDir = join(String(dir), ".shared-cache"); - - const procs = Array.from({ length: PROCS }, (_, i) => - Bun.spawn({ - cmd: [bunExe(), "install", "--backend=copyfile", "--linker=hoisted", "--no-progress"], - cwd: join(String(dir), `proj-${i}`), - env: { - ...bunEnv, - BUN_INSTALL_CACHE_DIR: cacheDir, - LD_PRELOAD: existingPreload ? `${shimPath}:${existingPreload}` : shimPath, - BUN_TEST_FAIL_RENAME_EXCHANGE: "1", - }, - stdout: "pipe", - stderr: "pipe", - }), - ); - - const results = await Promise.all( - procs.map(async proc => { - const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); - return { stdout, stderr, exitCode }; - }), - ); - - for (let i = 0; i < results.length; i++) { - const { stdout, stderr, exitCode } = results[i]; - const output = `iteration ${iter} proj-${i}:\n${stdout}\n${stderr}`; - expect(output).not.toContain("ENOENT"); - expect(exitCode).toBe(0); - } +test.skipIf(!canShim)("concurrent installs sharing a cache survive a filesystem without RENAME_EXCHANGE", async () => { + const { tgz, shasum, integrity } = buildTarball(); + await using registry = makeRegistry(tgz, shasum, integrity); + + const PROCS = 4; + const ITERATIONS = 3; + const existingPreload = bunEnv.LD_PRELOAD; + + for (let iter = 0; iter < ITERATIONS; iter++) { + const projects: Record = {}; + for (let i = 0; i < PROCS; i++) { + projects[`proj-${i}/package.json`] = JSON.stringify({ + name: `proj-${i}`, + version: "1.0.0", + dependencies: { [PKG_NAME]: "1.0.0" }, + }); + projects[`proj-${i}/bunfig.toml`] = `[install]\nregistry = "${registry.url}"\n`; } - }, -); + using dir = tempDir(`cache-race-${iter}`, projects); + const cacheDir = join(String(dir), ".shared-cache"); + + const procs = Array.from({ length: PROCS }, (_, i) => + Bun.spawn({ + cmd: [bunExe(), "install", "--backend=copyfile", "--linker=hoisted", "--no-progress"], + cwd: join(String(dir), `proj-${i}`), + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: cacheDir, + LD_PRELOAD: existingPreload ? `${shimPath}:${existingPreload}` : shimPath, + BUN_TEST_FAIL_RENAME_EXCHANGE: "1", + }, + stdout: "pipe", + stderr: "pipe", + }), + ); + + const results = await Promise.all( + procs.map(async proc => { + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }), + ); + + for (let i = 0; i < results.length; i++) { + const { stdout, stderr, exitCode } = results[i]; + const output = `iteration ${iter} proj-${i}:\n${stdout}\n${stderr}`; + expect(output).not.toContain("ENOENT"); + expect(exitCode).toBe(0); + } + } +}); From eaaf149608020639130ffbd27dcebbb2d8c32e07 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:18:26 +0000 Subject: [PATCH 7/9] Add repair test for incomplete cache entries --- .../install/bun-install-cache-race.test.ts | 52 +++++++++++++++++++ 1 file changed, 52 insertions(+) diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index a2a3d67af1e9..79393036a03c 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -10,6 +10,7 @@ import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; import { bunEnv, bunExe, isLinux, isMusl, tempDir } from "harness"; import { createHash } from "node:crypto"; +import { existsSync, readdirSync, rmSync } from "node:fs"; import { join } from "node:path"; import { gzipSync } from "node:zlib"; @@ -230,3 +231,54 @@ test.skipIf(!canShim)("concurrent installs sharing a cache survive a filesystem } } }); + +// An entry without package.json is incomplete and must be replaced by a fresh +// extraction (exercising the rename-aside fallback, since EXCHANGE fails under +// the shim), not kept as an equivalent existing entry. +test.skipIf(!canShim)("repairs an incomplete cache entry instead of keeping it", async () => { + const { tgz, shasum, integrity } = buildTarball(); + await using registry = makeRegistry(tgz, shasum, integrity); + + using dir = tempDir("cache-repair", { + "proj/package.json": JSON.stringify({ + name: "proj", + version: "1.0.0", + dependencies: { [PKG_NAME]: "1.0.0" }, + }), + "proj/bunfig.toml": `[install]\nregistry = "${registry.url}"\n`, + }); + const cacheDir = join(String(dir), ".cache"); + const existingPreload = bunEnv.LD_PRELOAD; + + const run = async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install", "--backend=copyfile", "--linker=hoisted", "--no-progress"], + cwd: join(String(dir), "proj"), + env: { + ...bunEnv, + BUN_INSTALL_CACHE_DIR: cacheDir, + LD_PRELOAD: existingPreload ? `${shimPath}:${existingPreload}` : shimPath, + BUN_TEST_FAIL_RENAME_EXCHANGE: "1", + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + }; + + const first = await run(); + expect(`${first.stdout}\n${first.stderr}`).not.toContain("error:"); + expect(first.exitCode).toBe(0); + + const entry = readdirSync(cacheDir).find(name => name.startsWith(`${PKG_NAME}@`)); + expect(entry).toBeDefined(); + rmSync(join(cacheDir, entry!, "package.json")); + rmSync(join(String(dir), "proj", "node_modules"), { recursive: true, force: true }); + + const second = await run(); + expect(`${second.stdout}\n${second.stderr}`).not.toContain("error:"); + expect(second.exitCode).toBe(0); + expect(existsSync(join(cacheDir, entry!, "package.json"))).toBe(true); + expect(existsSync(join(String(dir), "proj", "node_modules", PKG_NAME, "package.json"))).toBe(true); +}); From 5b1d90a0ecf002838a5cef874896acc2d3a5d398 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:22:48 +0000 Subject: [PATCH 8/9] Assert shim injection via marker file in repair test --- .../install/bun-install-cache-race.test.ts | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/test/cli/install/bun-install-cache-race.test.ts b/test/cli/install/bun-install-cache-race.test.ts index 79393036a03c..6500a3af78f2 100644 --- a/test/cli/install/bun-install-cache-race.test.ts +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -28,9 +28,11 @@ const SHIM_C = /* c */ ` #define _GNU_SOURCE #include #include +#include #include #include #include +#include #define RENAME_EXCHANGE_FLAG (1 << 1) @@ -42,6 +44,16 @@ static int should_fail(void) { return fail_exchange; } +// Touches BUN_TEST_RENAME_EXCHANGE_MARKER so the test can prove an +// EOPNOTSUPP was actually injected (i.e. the shim loaded). +static void mark_injected(void) { + const char *path = getenv("BUN_TEST_RENAME_EXCHANGE_MARKER"); + if (path) { + int fd = open(path, O_CREAT | O_WRONLY | O_CLOEXEC, 0644); + if (fd >= 0) close(fd); + } +} + long syscall(long number, ...) { if (!real_syscall) real_syscall = (long (*)(long, ...))dlsym(RTLD_NEXT, "syscall"); va_list ap; @@ -54,6 +66,7 @@ long syscall(long number, ...) { long f = va_arg(ap, long); va_end(ap); if (number == SYS_renameat2 && (e & RENAME_EXCHANGE_FLAG) && should_fail()) { + mark_injected(); errno = EOPNOTSUPP; return -1; } @@ -62,6 +75,7 @@ long syscall(long number, ...) { int renameat2(int olddirfd, const char *oldpath, int newdirfd, const char *newpath, unsigned int flags) { if ((flags & RENAME_EXCHANGE_FLAG) && should_fail()) { + mark_injected(); errno = EOPNOTSUPP; return -1; } @@ -248,6 +262,7 @@ test.skipIf(!canShim)("repairs an incomplete cache entry instead of keeping it", "proj/bunfig.toml": `[install]\nregistry = "${registry.url}"\n`, }); const cacheDir = join(String(dir), ".cache"); + const markerPath = join(String(dir), "exchange-injected"); const existingPreload = bunEnv.LD_PRELOAD; const run = async () => { @@ -259,6 +274,7 @@ test.skipIf(!canShim)("repairs an incomplete cache entry instead of keeping it", BUN_INSTALL_CACHE_DIR: cacheDir, LD_PRELOAD: existingPreload ? `${shimPath}:${existingPreload}` : shimPath, BUN_TEST_FAIL_RENAME_EXCHANGE: "1", + BUN_TEST_RENAME_EXCHANGE_MARKER: markerPath, }, stdout: "pipe", stderr: "pipe", @@ -281,4 +297,7 @@ test.skipIf(!canShim)("repairs an incomplete cache entry instead of keeping it", expect(second.exitCode).toBe(0); expect(existsSync(join(cacheDir, entry!, "package.json"))).toBe(true); expect(existsSync(join(String(dir), "proj", "node_modules", PKG_NAME, "package.json"))).toBe(true); + // The repair rename attempted RENAME_EXCHANGE and the shim injected + // EOPNOTSUPP, proving the shim loaded and the fallback path ran. + expect(existsSync(markerPath)).toBe(true); }); From 2f400c3f24eab48601b519f60db2a9c7d381d0c4 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Tue, 28 Jul 2026 22:06:56 +0000 Subject: [PATCH 9/9] ci: retrigger