diff --git a/src/bun_core/env_var.rs b/src/bun_core/env_var.rs index eb14e27b6a99..716f8e0807c0 100644 --- a/src/bun_core/env_var.rs +++ b/src/bun_core/env_var.rs @@ -105,6 +105,11 @@ new!(pub BUN_INSTALL_STREAMING_MIN_SIZE: unsigned, "BUN_INSTALL_STREAMING_MIN_SI // thread schedules a drain; collapses the per-chunk thread-pool futex wake // into roughly one per `threshold` bytes. new!(pub BUN_INSTALL_STREAMING_DRAIN_THRESHOLD: unsigned, "BUN_INSTALL_STREAMING_DRAIN_THRESHOLD", { default: 256 * 1024 }); +// How long `bun install` retries a cache-publish rename that Windows fails +// because a scanner has a file in the directory open (`bun_install::cache_rename`). +// 5s outlasts a real-time scan of a multi-MB binary (SQLite retries 1.4s, +// graceful-fs 60s) and is also the per-package cost of a permanent failure. +new!(pub BUN_INSTALL_WINDOWS_RENAME_RETRY_MS: unsigned, "BUN_INSTALL_WINDOWS_RENAME_RETRY_MS", { default: 5_000 }); new!(pub BUN_NEEDS_PROC_SELF_WORKAROUND: boolean, "BUN_NEEDS_PROC_SELF_WORKAROUND", { default: false }); new!(pub BUN_OPTIONS: string, "BUN_OPTIONS", {}); new!(pub BUN_POSTGRES_SOCKET_MONITOR: string, "BUN_POSTGRES_SOCKET_MONITOR", {}); diff --git a/src/install/cache_rename.rs b/src/install/cache_rename.rs new file mode 100644 index 000000000000..6986ae6aa449 --- /dev/null +++ b/src/install/cache_rename.rs @@ -0,0 +1,83 @@ +//! Retry budget for the renames that publish a directory into the install +//! cache (extracted tarball, patched package, global virtual store entry). +//! +//! On Windows a directory rename fails with `STATUS_ACCESS_DENIED` or +//! `STATUS_SHARING_VIOLATION` while any process holds a handle without +//! `FILE_SHARE_DELETE` on a file inside it, which is how antivirus and the +//! Search Indexer open freshly written files. Nothing is retried on POSIX, +//! where open handles do not block renames and `EPERM` is a real failure. + +use core::fmt; +use core::time::Duration; +use std::time::Instant; + +use bun_core::env_var::BUN_INSTALL_WINDOWS_RENAME_RETRY_MS; +use bun_sys as sys; + +pub(crate) struct RenameRetry { + started: Instant, + budget: Duration, + /// graceful-fs schedule: +10ms per attempt, capped at 100ms. + next_backoff: Duration, + exhausted: bool, +} + +impl RenameRetry { + pub(crate) fn start() -> Self { + Self { + started: Instant::now(), + budget: Duration::from_millis(BUN_INSTALL_WINDOWS_RENAME_RETRY_MS.get().unwrap()), + next_backoff: Duration::ZERO, + exhausted: false, + } + } + + pub(crate) fn is_transient(err: &sys::Error) -> bool { + cfg!(windows) + && matches!( + err.get_errno(), + sys::Errno::EPERM | sys::Errno::EACCES | sys::Errno::EBUSY + ) + } + + /// Sleeps and returns `true` if another attempt fits in the budget. + pub(crate) fn wait(&mut self) -> bool { + if self.started.elapsed() >= self.budget { + self.exhausted = true; + return false; + } + self.next_backoff = + (self.next_backoff + Duration::from_millis(10)).min(Duration::from_millis(100)); + std::thread::sleep(self.next_backoff); + true + } + + pub(crate) fn exhausted(&self) -> bool { + self.exhausted + } + + /// Error-message suffix; displays as nothing unless the budget ran out. + pub(crate) fn exhausted_hint(&self) -> ExhaustedHint { + ExhaustedHint { + waited: self.exhausted.then(|| self.started.elapsed()), + } + } +} + +pub(crate) struct ExhaustedHint { + waited: Option, +} + +impl fmt::Display for ExhaustedHint { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.waited { + Some(waited) => write!( + f, + " (gave up after retrying for {}ms; usually another process such as antivirus has a file in the directory open. Set {} to wait longer)", + waited.as_millis(), + bstr::BStr::new(BUN_INSTALL_WINDOWS_RENAME_RETRY_MS.key().as_bytes()), + ), + None => Ok(()), + } + } +} diff --git a/src/install/extract_tarball.rs b/src/install/extract_tarball.rs index b8d3a8b091b5..4b0690c6d515 100644 --- a/src/install/extract_tarball.rs +++ b/src/install/extract_tarball.rs @@ -21,6 +21,9 @@ use bun_libarchive::{ArchiveAppender, ExtractOptions}; use bun_resolver::fs::FileSystem; #[cfg(windows)] use bun_sys::FdDirExt; + +#[cfg(windows)] +use crate::cache_rename::RenameRetry; type Error = crate::Error; pub struct ExtractTarball { @@ -522,12 +525,11 @@ impl ExtractTarball { // Now that we've extracted the archive, we rename. #[cfg(windows)] { - // Windows EBUSY/SHARING_VIOLATION on `NtSetInformationFile` is - // transient when a concurrent process (another `bun install` - // sharing the cache, AV, the Search Indexer) is closing its - // handle to the destination. Back off briefly between retries. - const MAX_RETRIES: u32 = 4; - let mut retries: u32 = 0; + // Transient on Windows while another process holds a handle + // in either directory: a concurrent `bun install` sharing the + // cache (EXIST/NOTEMPTY, or PERM for a directory destination) + // or a scanner reading what we just extracted (PERM/BUSY). + let mut retry = RenameRetry::start(); let mut path2_buf = WPathBuffer::uninit(); let path2 = strings::to_wpath_normalized(&mut path2_buf, folder_name); if create_subdir { @@ -573,65 +575,51 @@ impl ExtractTarball { true, ) { bun_sys::Result::Err(err) => { - if retries < MAX_RETRIES { - match err.get_errno() { - sys::Errno::NOTEMPTY - | sys::Errno::PERM - | sys::Errno::BUSY - | sys::Errno::EXIST => { - // before we attempt to delete the destination, let's close the source dir. - let _ = sys::close(dir_to_move); - - // We tried to move the folder over - // but it didn't work! - // so instead of just simply deleting the folder - // we rename it back into the temp dir - // and then delete that temp dir - // The goal is to make it more difficult for an application to reach this folder - let mut tempdest_buf = PathBuffer::uninit(); - tempdest_buf[0..tmpname.len()] - .copy_from_slice(tmpname.as_bytes()); - tempdest_buf[tmpname.len()..][0..4] - .copy_from_slice(&[b't', b'm', b'p', 0]); - let tempdest = - ZStr::from_buf(&tempdest_buf, tmpname.len() + 3); - 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()); - match sys::renameat( - Fd::from_std_dir(cache_dir), - folder_name_z, - Fd::from_std_dir(tmpdir), - tempdest, - ) { - bun_sys::Result::Err(_) => {} - bun_sys::Result::Ok(_) => { - let _ = tmpdir.delete_tree(tempdest.as_bytes()); - } - } - retries += 1; - // 10ms, 20ms, 40ms, 80ms — long enough - // for a concurrent close to land, - // short enough to not slow a legit - // failure noticeably. - std::thread::sleep(std::time::Duration::from_millis( - 10u64 << (retries - 1), - )); - continue; + // before we attempt to delete the destination, let's close the source dir. + let _ = sys::close(dir_to_move); + + let retryable = RenameRetry::is_transient(&err) + || matches!( + err.get_errno(), + sys::Errno::NOTEMPTY | sys::Errno::EXIST + ); + if retryable && retry.wait() { + // We tried to move the folder over + // but it didn't work! + // so instead of just simply deleting the folder + // we rename it back into the temp dir + // and then delete that temp dir + // The goal is to make it more difficult for an application to reach this folder + let mut tempdest_buf = PathBuffer::uninit(); + tempdest_buf[0..tmpname.len()].copy_from_slice(tmpname.as_bytes()); + tempdest_buf[tmpname.len()..][0..4] + .copy_from_slice(&[b't', b'm', b'p', 0]); + let tempdest = ZStr::from_buf(&tempdest_buf, tmpname.len() + 3); + 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()); + match sys::renameat( + Fd::from_std_dir(cache_dir), + folder_name_z, + Fd::from_std_dir(tmpdir), + tempdest, + ) { + bun_sys::Result::Err(_) => {} + bun_sys::Result::Ok(_) => { + let _ = tmpdir.delete_tree(tempdest.as_bytes()); } - _ => {} } + continue; } - let _ = sys::close(dir_to_move); log.add_error_fmt( None, bun_ast::Loc::EMPTY, format_args!( - "moving \"{}\" to cache dir failed\n{}\n From: {}\n To: {}", + "moving \"{}\" to cache dir failed{}\n{}\n From: {}\n To: {}", bun_fmt::s(name), + retry.exhausted_hint(), err, bun_fmt::s(tmpname.as_bytes()), bun_fmt::s(folder_name), diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index 1984c05fb3a7..eb05f25c5cfb 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -13,6 +13,7 @@ use bun_semver::String as SemverString; use bun_sys::{FdDirExt as _, FdExt as _}; use crate::bin_real; +use crate::cache_rename::RenameRetry; use crate::lockfile::package; use crate::lockfile_real::PackageIDSlice; use crate::package_install::{Method as InstallMethod, Summary as InstallSummary}; @@ -2446,61 +2447,84 @@ impl<'a> Installer<'a> { let mut final_ = AutoAbsPath::init(); self.append_global_store_entry_path(&mut final_, entry_id, Which::Final); - match sys::renameat(Fd::cwd(), staging.slice_z(), Fd::cwd(), final_.slice_z()) { - sys::Result::Ok(()) => sys::Result::Ok(()), - sys::Result::Err(err) => { - if !is_rename_collision(&err) { - let _ = Fd::cwd().delete_tree(staging.slice()); - return sys::Result::Err(err); + let mut retry = RenameRetry::start(); + loop { + let err = match sys::renameat(Fd::cwd(), staging.slice_z(), Fd::cwd(), final_.slice_z()) + { + sys::Result::Ok(()) => return sys::Result::Ok(()), + sys::Result::Err(err) => err, + }; + if is_rename_collision(&err, final_.slice_z()) { + break; + } + if RenameRetry::is_transient(&err) && retry.wait() { + continue; + } + let _ = Fd::cwd().delete_tree(staging.slice()); + report_exhausted_publish(&retry, &final_); + return sys::Result::Err(err); + } + + // Under --force, the existing entry may be the corrupt one + // we were asked to replace. Swap it aside (atomic from a + // reader's POV: `final` is always either the old or the new + // tree, never missing), publish staging, then GC the old + // tree. Without --force, the existing entry came from a + // concurrent install and is content-identical — keep it and + // discard ours. + if self.manager().options.enable.force_install() { + let mut old = AutoAbsPath::init(); + let _ = old.append(self.global_store_path.as_ref().unwrap().as_bytes()); // OOM/capacity: fire-and-forget + // OOM/capacity: fire-and-forget + let _ = old.append_fmt(format_args!( + "{}.old-{:x}", + store::entry::fmt_global_store_path(entry_id, self.store, self.lockfile()), + bun_core::fast_random(), + )); + while let Some(swap_err) = + sys::renameat(Fd::cwd(), final_.slice_z(), Fd::cwd(), old.slice_z()).err() + { + if RenameRetry::is_transient(&swap_err) && retry.wait() { + continue; } - // Under --force, the existing entry may be the corrupt one - // we were asked to replace. Swap it aside (atomic from a - // reader's POV: `final` is always either the old or the new - // tree, never missing), publish staging, then GC the old - // tree. Without --force, the existing entry came from a - // concurrent install and is content-identical — keep it and - // discard ours. - if self.manager().options.enable.force_install() { - let mut old = AutoAbsPath::init(); - let _ = old.append(self.global_store_path.as_ref().unwrap().as_bytes()); // OOM/capacity: fire-and-forget - // OOM/capacity: fire-and-forget - let _ = old.append_fmt(format_args!( - "{}.old-{:x}", - store::entry::fmt_global_store_path(entry_id, self.store, self.lockfile()), - bun_core::fast_random(), - )); - if let Some(swap_err) = - sys::renameat(Fd::cwd(), final_.slice_z(), Fd::cwd(), old.slice_z()).err() - { - let _ = Fd::cwd().delete_tree(staging.slice()); - return sys::Result::Err(swap_err); - } - match sys::renameat(Fd::cwd(), staging.slice_z(), Fd::cwd(), final_.slice_z()) { - sys::Result::Ok(()) => { - let _ = Fd::cwd().delete_tree(old.slice()); - return sys::Result::Ok(()); - } - sys::Result::Err(publish_err) => { - // Another --force install raced us in the window - // between swap-out and publish. Theirs is fresh - // too; clean up both temp trees. - let _ = Fd::cwd().delete_tree(staging.slice()); - let _ = Fd::cwd().delete_tree(old.slice()); - return if is_rename_collision(&publish_err) { - sys::Result::Ok(()) - } else { - sys::Result::Err(publish_err) - }; - } + let _ = Fd::cwd().delete_tree(staging.slice()); + report_exhausted_publish(&retry, &final_); + return sys::Result::Err(swap_err); + } + loop { + let publish_err = match sys::renameat( + Fd::cwd(), + staging.slice_z(), + Fd::cwd(), + final_.slice_z(), + ) { + sys::Result::Ok(()) => { + let _ = Fd::cwd().delete_tree(old.slice()); + return sys::Result::Ok(()); } + sys::Result::Err(err) => err, + }; + let raced = is_rename_collision(&publish_err, final_.slice_z()); + if !raced && RenameRetry::is_transient(&publish_err) && retry.wait() { + continue; } + // Another --force install raced us in the window + // between swap-out and publish. Theirs is fresh + // too; clean up both temp trees. let _ = Fd::cwd().delete_tree(staging.slice()); - // A concurrent install renamed first; both writers produced - // the same content-addressed bytes, so theirs is as good as - // ours. - sys::Result::Ok(()) + let _ = Fd::cwd().delete_tree(old.slice()); + if raced { + return sys::Result::Ok(()); + } + report_exhausted_publish(&retry, &final_); + return sys::Result::Err(publish_err); } } + let _ = Fd::cwd().delete_tree(staging.slice()); + // A concurrent install renamed first; both writers produced + // the same content-addressed bytes, so theirs is as good as + // ours. + sys::Result::Ok(()) } /// Project-local path `node_modules/.bun/` (the symlink that @@ -2795,13 +2819,23 @@ pub enum Which { Staging, } -fn is_rename_collision(err: &sys::Error) -> bool { +fn is_rename_collision(err: &sys::Error, final_: &ZStr) -> bool { match err.get_errno() { sys::Errno::EEXIST | sys::Errno::ENOTEMPTY => true, - // Windows maps a rename onto an in-use directory to - // ERROR_ACCESS_DENIED; on POSIX PERM/ACCES are real - // permission failures and must propagate. - sys::Errno::EPERM | sys::Errno::EACCES => cfg!(windows), + // Windows reports both "destination directory exists" and "a scanner + // has one of our staged files open" as ERROR_ACCESS_DENIED; only the + // former is a collision. On POSIX these are real permission failures. + sys::Errno::EPERM | sys::Errno::EACCES => cfg!(windows) && sys::exists_z(final_), _ => false, } } + +fn report_exhausted_publish(retry: &RenameRetry, final_: &AutoAbsPath) { + if retry.exhausted() { + bun_core::pretty_errorln!( + "error: publishing {} to the global store failed{}", + bstr::BStr::new(final_.slice()), + retry.exhausted_hint(), + ); + } +} diff --git a/src/install/lib.rs b/src/install/lib.rs index 11258ad52a84..a8fa9a0aa220 100644 --- a/src/install/lib.rs +++ b/src/install/lib.rs @@ -74,6 +74,7 @@ pub mod resolution; // Legacy alias kept while callers migrate from the stub/real split. pub use resolution as resolution_real; pub mod auto_installer; +pub(crate) mod cache_rename; #[path = "ConfigVersion.rs"] pub mod config_version; pub mod dependency; diff --git a/src/install/patch_install.rs b/src/install/patch_install.rs index 2b5addb0149b..afa3553014ed 100644 --- a/src/install/patch_install.rs +++ b/src/install/patch_install.rs @@ -14,6 +14,7 @@ use bun_threading::IntrusiveWorkTask as _; use bun_threading::thread_pool::{Batch, Node as ThreadPoolNode, Task as ThreadPoolTask}; use bun_wyhash::Wyhash11; +use crate::cache_rename::RenameRetry; use crate::package_install::PackageInstall; use crate::package_manager; use crate::{ @@ -578,26 +579,33 @@ impl PatchTask { ); let cache_dir_subpath_z: &ZStr = patch.cache_dir_subpath.as_zstr(); - 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() - }, - ) { + let mut retry = RenameRetry::start(); + loop { + 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() + }, + ) else { + return Ok(()); + }; + if RenameRetry::is_transient(&e) && retry.wait() { + continue; + } log.add_error_fmt_opts( format_args!( - "renaming changes to cache dir: {}", + "renaming changes to cache dir{}: {}", + retry.exhausted_hint(), e.with_path(cache_dir_subpath_z.as_bytes()) ), Default::default(), ); return Ok(()); } - Ok(()) } pub(crate) fn calc_hash(&mut self) -> Option { diff --git a/test/cli/install/bun-install-windows-rename-retry-fixture.ts b/test/cli/install/bun-install-windows-rename-retry-fixture.ts new file mode 100644 index 000000000000..580683cb59df --- /dev/null +++ b/test/cli/install/bun-install-windows-rename-retry-fixture.ts @@ -0,0 +1,112 @@ +// Simulates an antivirus / search-indexer process scanning files that +// `bun install` has just written. On NTFS, an open handle that lacks +// FILE_SHARE_DELETE on any file inside a directory makes a rename of that +// directory fail with STATUS_ACCESS_DENIED. +// +// argv: +// +// Spin-polls watchDir until a subdirectory whose name contains subdirFilter +// ("" matches any) contains a regular file, opens that file via CreateFileW +// with dwShareMode = FILE_SHARE_READ | FILE_SHARE_WRITE (no DELETE), prints +// "HELD ", keeps the handle open for holdMs, closes it, prints +// "RELEASED" and exits 0. Prints "MISSED" and exits 0 if nothing shows up +// within 15s. + +import { dlopen, FFIType, ptr } from "bun:ffi"; +import { readdirSync } from "node:fs"; +import { join } from "node:path"; + +if (process.platform !== "win32") { + console.log("MISSED"); + process.exit(0); +} + +const [, , watchDir, subdirFilter, holdMsStr] = process.argv; +const holdMs = Number(holdMsStr); + +const { symbols } = dlopen("kernel32.dll", { + CreateFileW: { + args: [FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr, FFIType.u32, FFIType.u32, FFIType.ptr], + returns: FFIType.u64, + }, + CloseHandle: { args: [FFIType.u64], returns: FFIType.i32 }, +}); + +const GENERIC_READ = 0x80000000; +const FILE_SHARE_READ = 0x00000001; +const FILE_SHARE_WRITE = 0x00000002; +// Deliberately omitting FILE_SHARE_DELETE (0x00000004). +const OPEN_EXISTING = 3; +const FILE_ATTRIBUTE_NORMAL = 0x80; +const INVALID_HANDLE_VALUE = 0xffffffffffffffffn; + +function toWide(s: string): Uint8Array { + const buf = Buffer.alloc((s.length + 1) * 2); + for (let i = 0; i < s.length; i++) buf.writeUInt16LE(s.charCodeAt(i), i * 2); + return buf; +} + +function tryOpenNoShareDelete(path: string): bigint { + return symbols.CreateFileW( + ptr(toWide(path)), + GENERIC_READ, + FILE_SHARE_READ | FILE_SHARE_WRITE, + null, + OPEN_EXISTING, + FILE_ATTRIBUTE_NORMAL, + 0n, + ) as bigint; +} + +// Returns a handle to the first regular file found at most `depth` levels +// below `dir`, or INVALID_HANDLE_VALUE. Directories fail to open with +// FILE_ATTRIBUTE_NORMAL, which is what lets this tell them apart. +function grabFileBelow(dir: string, depth: number): [bigint, string] { + let entries; + try { + entries = readdirSync(dir, { withFileTypes: true }); + } catch { + return [INVALID_HANDLE_VALUE, ""]; + } + for (const entry of entries) { + const path = join(dir, entry.name); + if (entry.isFile()) { + const h = tryOpenNoShareDelete(path); + if (h !== INVALID_HANDLE_VALUE && h !== 0n) return [h, path]; + } else if (entry.isDirectory() && depth > 0) { + const found = grabFileBelow(path, depth - 1); + if (found[0] !== INVALID_HANDLE_VALUE) return found; + } + } + return [INVALID_HANDLE_VALUE, ""]; +} + +console.log("READY"); + +const deadline = Date.now() + 15_000; +let handle: bigint = INVALID_HANDLE_VALUE; +let heldPath = ""; +outer: while (Date.now() < deadline) { + let names: string[]; + try { + names = readdirSync(watchDir); + } catch { + continue; + } + for (const name of names) { + if (!name.includes(subdirFilter)) continue; + [handle, heldPath] = grabFileBelow(join(watchDir, name), 4); + if (handle !== INVALID_HANDLE_VALUE) break outer; + } +} + +if (handle === INVALID_HANDLE_VALUE) { + console.log("MISSED"); + process.exit(0); +} + +console.log("HELD " + heldPath); +await Bun.sleep(holdMs); +symbols.CloseHandle(handle); +console.log("RELEASED"); +process.exit(0); diff --git a/test/cli/install/bun-install-windows-rename-retry.test.ts b/test/cli/install/bun-install-windows-rename-retry.test.ts new file mode 100644 index 000000000000..fc53707c2729 --- /dev/null +++ b/test/cli/install/bun-install-windows-rename-retry.test.ts @@ -0,0 +1,272 @@ +// https://github.com/oven-sh/bun/issues/11250 +// +// `bun install` publishes directories into the cache by renaming them into +// place: the temp dir a tarball was extracted into, the temp dir a patch was +// applied in, and a global virtual store entry's staging dir. On Windows that +// rename fails with STATUS_ACCESS_DENIED (EPERM) for as long as any other +// process holds a handle without FILE_SHARE_DELETE on a file inside the +// directory, which is what antivirus / Search Indexer / MDM agents do to +// freshly written files. The fixture spawned below is such a process. +// +// Each publish path is exercised twice: with the default retry budget the +// install must outlast a 2s hold, and with BUN_INSTALL_WINDOWS_RENAME_RETRY_MS=0 +// it must fail immediately and name the variable (which also proves the held +// handle is what blocks the rename). + +import { afterAll, beforeAll, describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isWindows, tempDir } from "harness"; +import { createHash, randomBytes } from "node:crypto"; +import { existsSync, mkdirSync, readFileSync, readlinkSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const PKG = "av-test-pkg"; +const ENV = "BUN_INSTALL_WINDOWS_RENAME_RETRY_MS"; +const HOLD_MS = 2000; + +const patch = `diff --git a/index.js b/index.js +--- a/index.js ++++ b/index.js +@@ -1 +1 @@ +-module.exports = "unpatched"; ++module.exports = "patched"; +`; + +let pkgDir: ReturnType | undefined; +let tgzBytes: Buffer; +let tgzSha1: string; + +beforeAll(async () => { + if (!isWindows) return; + const files: Record = { + "package/package.json": JSON.stringify({ name: PKG, version: "1.0.0" }), + "package/index.js": `module.exports = "unpatched";\n`, + // A blob that takes a moment to extract, plus enough files that copying + // or hardlinking the package into a staging dir is a window the fixture + // reliably lands in. + "package/bin.exe": randomBytes(2 * 1024 * 1024), + }; + for (let i = 0; i < 300; i++) files[`package/files/${i}.txt`] = `${i}\n`; + pkgDir = tempDir("rename-retry-pkg", files); + const tgz = join(String(pkgDir), `${PKG}-1.0.0.tgz`); + await Bun.$`tar -czf ${tgz} -C ${String(pkgDir)} package`.quiet(); + tgzBytes = readFileSync(tgz); + tgzSha1 = createHash("sha1").update(tgzBytes).digest("hex"); +}); + +afterAll(() => { + pkgDir?.[Symbol.dispose](); +}); + +function serveRegistry(stallTarballUntil?: Promise) { + const server = Bun.serve({ + port: 0, + async fetch(req) { + const { pathname } = new URL(req.url); + if (pathname === `/${PKG}`) { + return Response.json({ + name: PKG, + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: PKG, + version: "1.0.0", + dist: { tarball: `http://localhost:${server.port}/${PKG}/-/${PKG}-1.0.0.tgz`, shasum: tgzSha1 }, + }, + }, + }); + } + if (pathname === `/${PKG}/-/${PKG}-1.0.0.tgz`) { + const headers = { "content-type": "application/octet-stream", "content-length": String(tgzBytes.length) }; + if (!stallTarballUntil) return new Response(tgzBytes, { headers }); + // Send the first half, then hold the rest back until the fixture has + // grabbed a handle, so the extraction dir is guaranteed to be held + // when bun tries to rename it into the cache. + const half = tgzBytes.length >> 1; + return new Response( + new ReadableStream({ + type: "direct", + async pull(ctrl) { + ctrl.write(tgzBytes.subarray(0, half)); + await ctrl.flush(); + await stallTarballUntil; + ctrl.write(tgzBytes.subarray(half)); + await ctrl.flush(); + ctrl.close(); + }, + }), + { headers }, + ); + } + return new Response("not found", { status: 404 }); + }, + }); + return server; +} + +function spawnBlocker(watchDir: string, subdirFilter: string, holdMs: number) { + const proc = Bun.spawn({ + cmd: [ + bunExe(), + join(import.meta.dir, "bun-install-windows-rename-retry-fixture.ts"), + watchDir, + subdirFilter, + String(holdMs), + ], + env: bunEnv, + stdout: "pipe", + stderr: "inherit", + stdin: "ignore", + }); + const ready = Promise.withResolvers(); + const held = Promise.withResolvers(); + let output = ""; + const drained = (async () => { + const decoder = new TextDecoder(); + for await (const chunk of proc.stdout) { + output += decoder.decode(chunk, { stream: true }); + if (output.includes("READY")) ready.resolve(); + if (output.includes("HELD") || output.includes("MISSED")) held.resolve(); + } + ready.resolve(); + held.resolve(); + })(); + return { + ready: ready.promise, + held: held.promise, + async finish() { + proc.kill(); + await proc.exited; + await drained; + return output; + }, + }; +} + +async function runInstall(cwd: string, env: Record) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd, + env: { ...bunEnv, ...env }, + stdout: "pipe", + stderr: "pipe", + stdin: "ignore", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +function scaffold(name: string, bunfig: string, packageJson: Record) { + const dir = tempDir(name, { + "cache/.keep": "", + "tmp/.keep": "", + "project/package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + dependencies: { [PKG]: "1.0.0" }, + ...packageJson, + }), + "project/bunfig.toml": bunfig, + }); + const root = String(dir); + const cache = join(root, "cache"); + const tmp = join(root, "tmp"); + return { + [Symbol.dispose]: () => dir[Symbol.dispose](), + project: join(root, "project"), + cache, + tmp, + env: { BUN_INSTALL_CACHE_DIR: cache, BUN_TMPDIR: tmp, TEMP: tmp, TMP: tmp }, + }; +} + +const registryBunfig = (port: number) => `[install]\nregistry = "http://localhost:${port}/"\n`; + +const modes = [ + { mode: "default budget outlasts a 2s hold", budget: undefined, holdMs: HOLD_MS }, + { mode: `${ENV}=0 fails at once and names the variable`, budget: "0", holdMs: 15_000 }, +]; + +describe.skipIf(!isWindows).concurrent("bun install renames into the cache while a scanner holds a file open", () => { + test.each(modes)("extracted tarball: $mode", async ({ budget, holdMs }) => { + const blockerCaught = Promise.withResolvers(); + await using server = serveRegistry(blockerCaught.promise); + using s = scaffold("rename-retry-tarball", registryBunfig(server.port), {}); + + const blocker = spawnBlocker(s.tmp, "", holdMs); + await blocker.ready; + blocker.held.then(blockerCaught.resolve); + + const result = await runInstall(s.project, { + ...s.env, + // Stream the (small) tarball so files hit the temp dir before the registry stalls. + BUN_INSTALL_STREAMING_MIN_SIZE: "1", + [ENV]: budget, + }); + const blockerOut = await blocker.finish(); + expect(blockerOut).toContain("HELD"); + + if (budget === undefined) { + expect(result).toMatchObject({ exitCode: 0 }); + expect(existsSync(join(s.project, "node_modules", PKG, "bin.exe"))).toBe(true); + } else { + expect(result.stderr).toContain(`moving "${PKG}" to cache dir failed`); + expect(result.stderr).toContain(ENV); + expect(result.stderr).toContain("NtSetInformationFile"); + expect(result.exitCode).toBe(1); + } + }); + + test.each(modes)("patched package: $mode", async ({ budget, holdMs }) => { + await using server = serveRegistry(); + using s = scaffold("rename-retry-patch", registryBunfig(server.port), { + patchedDependencies: { [`${PKG}@1.0.0`]: `patches/${PKG}.patch` }, + }); + mkdirSync(join(s.project, "patches")); + writeFileSync(join(s.project, "patches", `${PKG}.patch`), patch); + + // The patch is applied in a `.-.tmp` dir under the temp dir. The + // tarball is extracted into a `.-.av-test-pkg` sibling first, + // which the filter keeps the fixture away from. + const blocker = spawnBlocker(s.tmp, ".tmp", holdMs); + await blocker.ready; + + const result = await runInstall(s.project, { ...s.env, [ENV]: budget }); + const blockerOut = await blocker.finish(); + expect(blockerOut).toContain("HELD"); + + if (budget === undefined) { + expect(result).toMatchObject({ exitCode: 0 }); + expect(readFileSync(join(s.project, "node_modules", PKG, "index.js"), "utf8")).toContain('"patched"'); + } else { + expect(result.stderr).toContain("renaming changes to cache dir"); + expect(result.stderr).toContain(ENV); + expect(result.exitCode).not.toBe(0); + } + }); + + test.each(modes)("global virtual store entry: $mode", async ({ budget, holdMs }) => { + await using server = serveRegistry(); + using s = scaffold("rename-retry-global-store", registryBunfig(server.port) + `linker = "isolated"\n`, {}); + + // Entries are assembled in `/links/.tmp-` and + // renamed to `/links/`. + const blocker = spawnBlocker(join(s.cache, "links"), ".tmp-", holdMs); + await blocker.ready; + + const result = await runInstall(s.project, { ...s.env, BUN_INSTALL_GLOBAL_STORE: "1", [ENV]: budget }); + const blockerOut = await blocker.finish(); + expect(blockerOut).toContain("HELD"); + + if (budget === undefined) { + expect(result).toMatchObject({ exitCode: 0 }); + const entry = readlinkSync(join(s.project, "node_modules", ".bun", `${PKG}@1.0.0`)); + expect(existsSync(join(entry, "node_modules", PKG, "bin.exe"))).toBe(true); + } else { + // Without the retry this path mistook the held staging dir for an + // entry a concurrent install had published, deleted it and reported + // success, leaving node_modules/.bun pointing at nothing. + expect(result.stderr).toContain(ENV); + expect(result.exitCode).not.toBe(0); + } + }); +});