diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index 0746103c5485..57e264c70cb1 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -678,14 +678,11 @@ 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. - // + // 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) { @@ -693,27 +690,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..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!( @@ -599,24 +601,51 @@ 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(), + ) + .is_ok(); + 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]], ); - return Ok(()); + 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()); + } 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..36c382a2e580 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,93 @@ 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, 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); + } 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 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; 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 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); + } + } } } 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 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(); + 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); + 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 +9636,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..6500a3af78f2 --- /dev/null +++ b/test/cli/install/bun-install-cache-race.test.ts @@ -0,0 +1,303 @@ +// 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, 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"; + +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. +const SHIM_C = /* c */ ` +#define _GNU_SOURCE +#include +#include +#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; +} + +// 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; + 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()) { + mark_injected(); + 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()) { + mark_injected(); + 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"), + }; +} + +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`, + }, + }, + }, + }); + } + if (url.pathname.endsWith(".tgz")) { + return new Response(tgz, { headers: { "content-type": "application/octet-stream" } }); + } + return new Response("not found", { status: 404 }); + }, + }); + return { + url: `http://127.0.0.1:${server.port}/`, + [Symbol.asyncDispose]: () => server.stop(true), + }; +} + +let shimPath: string; +let shimDir: ReturnType | undefined; + +beforeAll(async () => { + if (!canShim) 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(!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); + } + } +}); + +// 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 markerPath = join(String(dir), "exchange-injected"); + 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", + BUN_TEST_RENAME_EXCHANGE_MARKER: markerPath, + }, + 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); + // 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); +});