diff --git a/src/install/PackageInstall.rs b/src/install/PackageInstall.rs index 6adf994d1ad9..9b52e96b2269 100644 --- a/src/install/PackageInstall.rs +++ b/src/install/PackageInstall.rs @@ -234,6 +234,7 @@ pub enum Step { OpeningCacheDir, OpeningDestDir, CopyingFiles, + MovingIntoPlace, LinkingDependency, } @@ -244,11 +245,58 @@ impl Step { Step::CopyingFiles => b"copying files from cache to destination", Step::OpeningCacheDir => b"opening cache/package/version dir", Step::OpeningDestDir => b"opening node_modules/package dir", + Step::MovingIntoPlace => b"moving copied files into node_modules/package dir", Step::LinkingDependency => b"linking dependency/workspace to node_modules", } } } +/// Where a package is linked to before being renamed onto its real path, which +/// later installs take as proof that it is installed: `@scope/name` becomes +/// `@scope/.bun-tmp-`. Hashed because a name may already be NAME_MAX long, +/// deterministic so the next install of the package removes a stale one. +pub(crate) struct StagingPath<'a>(pub(crate) &'a [u8]); + +impl core::fmt::Display for StagingPath<'_> { + fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result { + let name_start = strings::last_index_of_char(self.0, b'/').map_or(0, |slash| slash + 1); + let scope = &self.0[..name_start]; + write!( + f, + "{}.bun-tmp-{:016x}", + bstr::BStr::new(scope), + bun_wyhash::hash(self.0) + ) + } +} + +/// Renames a fully linked `StagingPath` (relative to `dir`) onto `dest`. Fails if +/// `dest` is occupied. +pub(crate) fn rename_staging_into_place(dir: Fd, staging: &ZStr, dest: &ZStr) -> sys::Maybe<()> { + #[cfg(windows)] + { + // A scanner still holding a just-written file open fails the rename for a + // few milliseconds (#11250 is the same failure for the cache). An occupied + // `dest` fails with the same errors and is not worth waiting on. + const RETRIES: u32 = 6; + for attempt in 0..RETRIES { + match sys::renameat(dir, staging, dir, dest) { + Err(err) + if matches!( + err.get_errno(), + sys::E::EPERM | sys::E::EACCES | sys::E::EBUSY + ) && !sys::directory_exists_at(dir, dest).unwrap_or(false) => + { + // 10ms, 20ms, ... 320ms: 630ms in total. + std::thread::sleep(std::time::Duration::from_millis(10u64 << attempt)); + } + result => return result, + } + } + } + sys::renameat(dir, staging, dir, dest) +} + // PORTING.md §Global mutable state: install-main-thread enum. `RacyCell` // (no `Atomic`) — writers are the CLI option-load and the // clonefile/hardlink fallback in `install_with_method`, all on the install @@ -1011,6 +1059,7 @@ impl<'a> PackageInstall<'a> { fn install_with_clonefile_each_dir( &mut self, destination_dir: &Dir, + dest_subpath: &ZStr, ) -> crate::Result { let cached_package_dir = match open_dir(self.cache_dir, self.cache_dir_subpath) { Ok(d) => d, @@ -1073,12 +1122,13 @@ impl<'a> PackageInstall<'a> { Ok(()) } - let subdir = match destination_dir.make_open_path( - self.destination_dir_subpath.as_bytes(), - OpenDirOptions::default(), - ) { + let subdir = match destination_dir + .make_open_path(dest_subpath.as_bytes(), OpenDirOptions::default()) + { Ok(d) => d, - Err(err) => return Ok(InstallResult::fail(err.into(), Step::OpeningDestDir, None)), + Err(err) => { + return Ok(InstallResult::fail(err.into(), Step::OpeningDestDir, None)); + } }; if let Err(err) = copy(&subdir, &mut walker_) { return Ok(InstallResult::fail(err, Step::CopyingFiles, None)); @@ -1089,15 +1139,14 @@ impl<'a> PackageInstall<'a> { // https://www.unix.com/man-page/mojave/2/fclonefileat/ #[cfg(target_os = "macos")] - fn install_with_clonefile(&mut self, destination_dir: &Dir) -> crate::Result { - if self.destination_dir_subpath.as_bytes()[0] == b'@' { - if let Some(slash) = strings::index_of_char_z(self.destination_dir_subpath, SEP) { - let slash = slash as usize; - self.destination_dir_subpath_buf[slash] = 0; - // SAFETY: NUL written above. - let subdir = ZStr::from_buf(self.destination_dir_subpath_buf, slash); - let _ = sys::mkdirat(destination_dir, subdir, 0o755); - self.destination_dir_subpath_buf[slash] = SEP; + fn install_with_clonefile( + &mut self, + destination_dir: &Dir, + dest_subpath: &ZStr, + ) -> crate::Result { + if dest_subpath.as_bytes()[0] == b'@' { + if let Some(slash) = strings::index_of_char_usize(dest_subpath.as_bytes(), SEP) { + let _ = destination_dir.make_dir(&dest_subpath.as_bytes()[..slash]); } } @@ -1105,7 +1154,7 @@ impl<'a> PackageInstall<'a> { self.cache_dir, self.cache_dir_subpath, destination_dir.fd(), - self.destination_dir_subpath, + dest_subpath, ) { Ok(()) => Ok(InstallResult::Success), Err(e) => match e.get_errno() { @@ -1116,7 +1165,9 @@ impl<'a> PackageInstall<'a> { // But, this can happen if this package contains a node_modules folder // We want to continue installing as many packages as we can, so we shouldn't block while downloading // We use the slow path in this case - sys::Errno::EEXIST => self.install_with_clonefile_each_dir(destination_dir), + sys::Errno::EEXIST => { + self.install_with_clonefile_each_dir(destination_dir, dest_subpath) + } sys::Errno::EACCES => Err(crate::Error::Sys(bun_errno::SystemErrno::EACCES)), _ => Err(crate::Error::Unexpected), }, @@ -1126,10 +1177,10 @@ impl<'a> PackageInstall<'a> { fn init_install_dir( &mut self, destination_dir: &Dir, + destpath: &ZStr, method: Method, ) -> Result> { let destbase = destination_dir; - let destpath = self.destination_dir_subpath; let cached_package_dir = match { #[cfg(windows)] @@ -1286,8 +1337,13 @@ impl<'a> PackageInstall<'a> { } } - fn install_with_copyfile(&mut self, destination_dir: &Dir) -> InstallResult { - let mut state = match self.init_install_dir(destination_dir, Method::Copyfile) { + fn install_with_copyfile( + &mut self, + destination_dir: &Dir, + dest_subpath: &ZStr, + ) -> InstallResult { + let mut state = match self.init_install_dir(destination_dir, dest_subpath, Method::Copyfile) + { Ok(state) => state, Err(failure) => return InstallResult::Failure(failure), }; @@ -1531,8 +1587,12 @@ impl<'a> PackageInstall<'a> { InstallResult::Success } - fn install_with_hardlink(&mut self, dest_dir: &Dir) -> crate::Result { - let mut state = match self.init_install_dir(dest_dir, Method::Hardlink) { + fn install_with_hardlink( + &mut self, + dest_dir: &Dir, + dest_subpath: &ZStr, + ) -> crate::Result { + let mut state = match self.init_install_dir(dest_dir, dest_subpath, Method::Hardlink) { Ok(state) => state, Err(failure) => return Ok(InstallResult::Failure(failure)), }; @@ -1719,8 +1779,12 @@ impl<'a> PackageInstall<'a> { Ok(InstallResult::Success) } - fn install_with_symlink(&mut self, dest_dir: &Dir) -> crate::Result { - let mut state = match self.init_install_dir(dest_dir, Method::Symlink) { + fn install_with_symlink( + &mut self, + dest_dir: &Dir, + dest_subpath: &ZStr, + ) -> crate::Result { + let mut state = match self.init_install_dir(dest_dir, dest_subpath, Method::Symlink) { Ok(state) => state, Err(failure) => return Ok(InstallResult::Failure(failure)), }; @@ -2297,7 +2361,7 @@ impl<'a> PackageInstall<'a> { &mut self, skip_delete: bool, destination_dir: &Dir, - method_: Method, + method: Method, resolution_tag: resolution::Tag, ) -> InstallResult { let _tracer = bun_core::perf::trace("PackageInstaller.install"); @@ -2308,7 +2372,54 @@ impl<'a> PackageInstall<'a> { self.uninstall_before_install(destination_dir); } - let mut supported_method_to_use = method_; + let mut staging_buf = path::path_buffer_pool::get(); + let Ok(staging) = bun_core::fmt::buf_print_z( + &mut staging_buf[..], + format_args!("{}", StagingPath(self.destination_dir_subpath.as_bytes())), + ) else { + return InstallResult::fail( + crate::Error::Sys(bun_errno::SystemErrno::ENAMETOOLONG), + Step::OpeningDestDir, + None, + ); + }; + // A stale one may hold files of another version, which the backends would keep. + if let Err(err) = destination_dir.delete_tree(staging.as_bytes()) { + return InstallResult::fail(err.into(), Step::OpeningDestDir, None); + } + + if let failure @ InstallResult::Failure(_) = + self.install_into(destination_dir, staging, method, resolution_tag) + { + let _ = destination_dir.delete_tree(staging.as_bytes()); + return failure; + } + + let dest = self.destination_dir_subpath; + let mut renamed = rename_staging_into_place(destination_dir.fd(), staging, dest); + if renamed.is_err() && dest.as_bytes() != b"." { + // Occupied: a workspace depended on under two names is walked as two trees, + // so the packages inside it are installed twice. The later one replaces it. + self.uninstall_before_install(destination_dir); + renamed = rename_staging_into_place(destination_dir.fd(), staging, dest); + } + match renamed { + Ok(()) => InstallResult::Success, + Err(err) => { + let _ = destination_dir.delete_tree(staging.as_bytes()); + InstallResult::fail(err.into(), Step::MovingIntoPlace, None) + } + } + } + + fn install_into( + &mut self, + destination_dir: &Dir, + dest_subpath: &ZStr, + method: Method, + resolution_tag: resolution::Tag, + ) -> InstallResult { + let mut supported_method_to_use = method; if resolution_tag == resolution::Tag::Folder && !self @@ -2324,7 +2435,7 @@ impl<'a> PackageInstall<'a> { { // First, attempt to use clonefile // if that fails due to ENOTSUP, mark it as unsupported and then fall back to copyfile - match self.install_with_clonefile(destination_dir) { + match self.install_with_clonefile(destination_dir, dest_subpath) { Ok(result) => return result, Err(err) => { if err == crate::Error::NotSupported { @@ -2346,7 +2457,7 @@ impl<'a> PackageInstall<'a> { Method::ClonefileEachDir => { #[cfg(target_os = "macos")] { - match self.install_with_clonefile_each_dir(destination_dir) { + match self.install_with_clonefile_each_dir(destination_dir, dest_subpath) { Ok(result) => return result, Err(err) => { if err == crate::Error::NotSupported { @@ -2367,7 +2478,7 @@ impl<'a> PackageInstall<'a> { } #[allow(unused_labels)] Method::Hardlink => 'outer: { - match self.install_with_hardlink(destination_dir) { + match self.install_with_hardlink(destination_dir, dest_subpath) { Ok(result) => return result, Err(err) => { #[cfg(not(windows))] @@ -2392,7 +2503,7 @@ impl<'a> PackageInstall<'a> { } } Method::Symlink => { - return match self.install_with_symlink(destination_dir) { + return match self.install_with_symlink(destination_dir, dest_subpath) { Ok(result) => result, Err(err) => { if err == crate::Error::Sys(bun_errno::SystemErrno::ENOENT) { @@ -2411,7 +2522,7 @@ impl<'a> PackageInstall<'a> { } // TODO: linux io_uring - self.install_with_copyfile(destination_dir) + self.install_with_copyfile(destination_dir, dest_subpath) } } diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index d89105ac2ead..9a2d9de222b9 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2221,6 +2221,7 @@ pub(crate) fn install_isolated_packages( .ok() .unwrap_or(false); } + // Likewise, the package directory appears here only once fully linked. installer.append_real_store_path(&mut store_path, entry_id, installer::Which::Final); // Capture the length instead of a `ResetScope` so // `store_path` stays unborrowed. diff --git a/src/install/isolated_install/Installer.rs b/src/install/isolated_install/Installer.rs index ce346a116b37..19f5d82907cf 100644 --- a/src/install/isolated_install/Installer.rs +++ b/src/install/isolated_install/Installer.rs @@ -15,7 +15,9 @@ use bun_sys::{FdDirExt as _, FdExt as _}; use crate::bin_real; use crate::lockfile::package; use crate::lockfile_real::PackageIDSlice; -use crate::package_install::{Method as InstallMethod, Summary as InstallSummary}; +use crate::package_install::{ + Method as InstallMethod, StagingPath, Summary as InstallSummary, rename_staging_into_place, +}; use crate::package_manager_real::Command; use crate::postinstall_optimizer; use crate::postinstall_optimizer::PostinstallOptimizer; @@ -397,35 +399,18 @@ impl<'a> Installer<'a> { let mut staging = AutoAbsPath::init(); self.append_global_store_entry_path(&mut staging, entry_id, Which::Staging); let _ = Fd::cwd().delete_tree(staging.slice()); - } - - // attempt deleting the package so the next install will install it again - match pkg_res.tag { - ResolutionTag::Uninitialized - | ResolutionTag::SingleFileModule - | ResolutionTag::Root - | ResolutionTag::Workspace - | ResolutionTag::Symlink => {} - - // to be safe make sure we only delete packages in the store + } else if matches!( + pkg_res.tag, ResolutionTag::Npm - | ResolutionTag::Git - | ResolutionTag::Github - | ResolutionTag::LocalTarball - | ResolutionTag::RemoteTarball - | ResolutionTag::Folder => { - let mut store_path = AutoRelPath::init(); - - // OOM/capacity: fire-and-forget - let _ = store_path.append_fmt(format_args!( - "node_modules/{}", - store::entry::fmt_store_path(entry_id, self.store, self.lockfile()), - )); - - let _ = sys::unlink(store_path.slice_z()); - } - - _ => {} + | ResolutionTag::Git + | ResolutionTag::Github + | ResolutionTag::LocalTarball + | ResolutionTag::RemoteTarball + ) { + // A failed link only ever wrote to the staging directory. + let mut staging = AutoPath::init_top_level_dir(); + self.append_real_store_path(&mut staging, entry_id, Which::Staging); + let _ = Fd::cwd().delete_tree(staging.slice()); } if self.manager().options.enable.fail_early() { @@ -1237,14 +1222,15 @@ impl Task { } } - // hardlink/copyfile overlay an existing tree, keeping files a previous patched build added. - let mut previous = AutoPath::init_top_level_dir(); - installer.append_real_store_path( - &mut previous, - self.entry_id, - Which::Final, - ); - let _ = Fd::cwd().delete_tree(previous.slice()); + // The final path must be free for the rename below, and the backends + // would keep whatever a stale staging tree holds. + for which in [Which::Final, Which::Staging] { + let mut leftover = AutoPath::init_top_level_dir(); + installer.append_real_store_path(&mut leftover, self.entry_id, which); + if let sys::Result::Err(err) = Fd::cwd().delete_tree(leftover.slice()) { + return Ok(Yield::failure(TaskError::LinkPackage(err))); + } + } } if uses_global_store { @@ -1349,8 +1335,7 @@ impl Task { }, } - step = self.next_step(current_step); - continue 'step; + break 'backend; } } @@ -1419,8 +1404,7 @@ impl Task { } } - step = self.next_step(current_step); - continue 'step; + break 'backend; } // fallthrough copyfile @@ -1487,12 +1471,21 @@ impl Task { } } - step = self.next_step(current_step); - continue 'step; + break 'backend; } } } - // unreachable: every backend arm continues to next_step or returns + + if !uses_global_store { + if let sys::Result::Err(err) = + installer.commit_local_store_package(self.entry_id) + { + return Ok(Yield::failure(TaskError::LinkPackage(err))); + } + } + + step = self.next_step(current_step); + continue 'step; } Step::SymlinkDependencies => { @@ -2544,6 +2537,17 @@ impl<'a> Installer<'a> { } } + /// Renames a project-local entry's fully linked `StagingPath` onto its final + /// path, which is what the next install's skip check looks at. + pub(crate) fn commit_local_store_package(&self, entry_id: StoreEntryId) -> sys::Result<()> { + debug_assert!(!self.entry_uses_global_store(entry_id)); + let mut staging = AutoPath::init_top_level_dir(); + self.append_real_store_path(&mut staging, entry_id, Which::Staging); + let mut final_ = AutoPath::init_top_level_dir(); + self.append_real_store_path(&mut final_, entry_id, Which::Final); + rename_staging_into_place(Fd::cwd(), staging.slice_z(), final_.slice_z()) + } + /// Project-local path `node_modules/.bun/` (the symlink that /// points at the global virtual-store entry). Relative to top-level dir. pub(crate) fn append_local_store_entry_path( @@ -2713,7 +2717,35 @@ impl<'a> Installer<'a> { buf.append(pkg_name.slice(string_buf)); return; } - self.append_store_path(buf, entry_id); + match which { + Which::Final => self.append_store_path(buf, entry_id), + Which::Staging => self.append_store_package_path(buf, entry_id, which), + } + } + + /// `node_modules/.bun//node_modules/`, or with + /// `Which::Staging` the `StagingPath` next to it. + fn append_store_package_path( + &self, + buf: &mut impl paths::PathLike, + entry_id: StoreEntryId, + which: Which, + ) { + let string_buf = self.lockfile().buffers.string_bytes.as_slice(); + let node_id = self.store.entries.items_node_id()[entry_id.get() as usize]; + let pkg_id = self.store.nodes.items_pkg_id()[node_id.get() as usize]; + let pkg_name = self.lockfile().packages.items_name()[pkg_id as usize].slice(string_buf); + + buf.append(NODE_MODULES_BUN.as_bytes()); + buf.append_fmt(format_args!( + "{}", + store::entry::fmt_store_path(entry_id, self.store, self.lockfile()), + )); + buf.append(b"node_modules"); + match which { + Which::Final => buf.append(pkg_name), + Which::Staging => buf.append_fmt(format_args!("{}", StagingPath(pkg_name))), + } } pub(crate) fn append_store_path(&self, buf: &mut impl paths::PathLike, entry_id: StoreEntryId) { @@ -2778,16 +2810,7 @@ impl<'a> Installer<'a> { buf.append(symlink_dir_path); buf.append(pkg_res.symlink().slice(string_buf)); } - _ => { - let pkg_name = pkg_names[pkg_id as usize]; - buf.append(NODE_MODULES_BUN.as_bytes()); - buf.append_fmt(format_args!( - "{}", - store::entry::fmt_store_path(entry_id, self.store, self.lockfile()), - )); - buf.append(b"node_modules"); - buf.append(pkg_name.slice(string_buf)); - } + _ => self.append_store_package_path(buf, entry_id, Which::Final), } } @@ -2827,12 +2850,12 @@ impl<'a> Installer<'a> { #[derive(Clone, Copy, PartialEq, Eq)] pub enum Which { - /// The published location (`/links/`). Use for symlink - /// *targets* that point at other entries, and for the warm-hit check. + /// The published location. Use for symlink *targets* that point at other + /// entries, and for the warm-hit check. Final, - /// The per-process temp sibling (`.tmp-`) the build - /// steps write into. Use for *destinations* of clonefile/hardlink/ - /// dep-symlink/bin-link when building this entry. + /// What the build steps write into, renamed onto `Final` once complete: the + /// whole entry (`.tmp-`) for a global-store entry, only the + /// package directory (`StagingPath`) for a project-local one. Staging, } diff --git a/test/cli/install/bun-install-staging.test.ts b/test/cli/install/bun-install-staging.test.ts new file mode 100644 index 000000000000..66556b65be7e --- /dev/null +++ b/test/cli/install/bun-install-staging.test.ts @@ -0,0 +1,237 @@ +// Packages are linked out of the cache into a staging directory that is renamed +// onto the package's final path once complete. An install killed while linking +// must not leave a partial copy at the final path: both linkers treat an +// installed package.json as "this package is installed", so a truncated +// directory that already received its package.json would be reported as up to +// date by every later `bun install`. The rename must also cope with the final +// path being occupied already, which the hoisted linker does to itself. +import { expect, test } from "bun:test"; +import { bunEnv, bunExe, tempDir } from "harness"; +import { existsSync, readdirSync, readFileSync, rmSync } from "node:fs"; +import { join } from "node:path"; + +// Enough files that the kill below lands long before linking finishes, even +// when the test process is slow to notice that linking has started. +const DIR_COUNT = 32; +const FILES_PER_DIR = 64; + +const archiveEntries: Record = { + "package/package.json": JSON.stringify({ name: "many-files", version: "1.0.0" }), +}; +for (let dir = 0; dir < DIR_COUNT; dir++) { + for (let file = 0; file < FILES_PER_DIR; file++) { + archiveEntries[`package/d${dir}/f${file}.js`] = `module.exports = ${dir * FILES_PER_DIR + file};\n`; + } +} + +const expectedTree = new Set(); +for (const entry of Object.keys(archiveEntries)) { + const relative = entry.slice("package/".length); + const slash = relative.indexOf("/"); + if (slash !== -1) expectedTree.add(relative.slice(0, slash)); + expectedTree.add(relative); +} + +// Summarized rather than compared entry by entry so a failure reads as a few +// lines instead of a diff of thousands of paths. +function compareWithExpectedTree(packageDir: string) { + const actual = new Set((readdirSync(packageDir, { recursive: true }) as string[]).map(p => p.replaceAll("\\", "/"))); + const missing = [...expectedTree].filter(entry => !actual.has(entry)); + const unexpected = [...actual].filter(entry => !expectedTree.has(entry)); + return { + entries: actual.size, + missing: missing.length, + firstMissing: missing.slice(0, 3), + unexpected, + }; +} +const completeTree = { entries: expectedTree.size, missing: 0, firstMissing: [], unexpected: [] }; + +function serveRegistry(tgz: Uint8Array) { + return Bun.serve({ + port: 0, + fetch(request) { + const { origin, pathname } = new URL(request.url); + switch (pathname) { + case "/many-files": + return Response.json({ + name: "many-files", + "dist-tags": { latest: "1.0.0" }, + versions: { + "1.0.0": { + name: "many-files", + version: "1.0.0", + dist: { tarball: `${origin}/many-files-1.0.0.tgz` }, + }, + }, + }); + case "/many-files-1.0.0.tgz": + return new Response(tgz); + default: + return new Response("not found", { status: 404 }); + } + }, + }); +} + +async function install(cwd: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd, + env: bunEnv, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; +} + +// Kills `bun install` as soon as anything shows up in the directory the package +// is installed into (the package is the only thing that goes there), i.e. while +// its files are being linked out of the cache. +async function installAndKillWhileLinking(cwd: string, packageParentDir: string) { + await using proc = Bun.spawn({ + cmd: [bunExe(), "install"], + cwd, + env: bunEnv, + stdout: "ignore", + stderr: "pipe", + }); + let exited = false; + proc.exited.then(() => (exited = true)); + let killed = false; + while (!exited && !killed) { + if (existsSync(packageParentDir) && readdirSync(packageParentDir).length > 0) { + proc.kill("SIGKILL"); + killed = true; + } else { + await Bun.sleep(0); + } + } + const [stderr, exitCode] = await Promise.all([proc.stderr.text(), proc.exited]); + // Without a kill the install finished (or died) before anything showed up; + // a finished install is still a valid starting point, a failed one is not. + if (!killed) { + expect(stderr).toBe(""); + expect(exitCode).toBe(0); + } +} + +const packageDirs = { + hoisted: (root: string) => join(root, "node_modules", "many-files"), + isolated: (root: string) => join(root, "node_modules", ".bun", "many-files@1.0.0", "node_modules", "many-files"), +}; + +// Not concurrent: the kill has to land while the package is still being +// linked, which a poll loop sharing the event loop with another test's +// assertions cannot guarantee. +for (const [linker, packageDir] of Object.entries(packageDirs)) { + test(`${linker} linker: a package whose install was interrupted is installed again`, async () => { + const tgz = await new Bun.Archive(archiveEntries, { compress: "gzip" }).bytes(); + using registry = serveRegistry(tgz); + using dir = tempDir(`interrupted-install-${linker}`, { + "package.json": JSON.stringify({ name: "app", dependencies: { "many-files": "1.0.0" } }), + "bunfig.toml": ({ root }) => + Bun.TOML.stringify({ install: { registry: registry.url.href, cache: join(root, ".bun-cache"), linker } }), + }); + const root = String(dir); + const installed = packageDir(root); + const installedParent = join(installed, ".."); + + // Warm the cache and record what a finished install looks like. + const warm = await install(root); + expect(warm.stderr).not.toContain("error"); + expect(warm.exitCode).toBe(0); + expect(compareWithExpectedTree(installed)).toEqual(completeTree); + const finishedParentListing = readdirSync(installedParent).sort(); + rmSync(join(root, "node_modules"), { recursive: true }); + + await installAndKillWhileLinking(root, installedParent); + // Either the package is not at its final path yet, or all of it is. + if (existsSync(installed)) { + expect(compareWithExpectedTree(installed)).toEqual(completeTree); + } + + const repaired = await install(root); + expect(repaired.stderr).not.toContain("error"); + expect(repaired.exitCode).toBe(0); + expect(compareWithExpectedTree(installed)).toEqual(completeTree); + expect(readdirSync(installedParent).sort()).toEqual(finishedParentListing); + }); +} + +// A workspace that other packages depend on under a second name is linked into +// node_modules under both names, and the hoisted linker walks the packages +// nested inside it once per name. The second walk finds the first one's result +// already sitting at the final path. +const aliasLinkDirs = { + hoisted: "node_modules/inner-alias", + isolated: "packages/second/node_modules/inner-alias", +}; + +for (const [linker, aliasLinkDir] of Object.entries(aliasLinkDirs)) { + test(`${linker} linker: a package reached through two workspace aliases is installed under both`, async () => { + const depTarball = await new Bun.Archive( + { + "package/package.json": JSON.stringify({ name: "dep", version: "1.0.0" }), + "package/index.js": "module.exports = 1;\n", + }, + { compress: "gzip" }, + ).bytes(); + using dir = tempDir(`staging-alias-${linker}`, { + // The root's own `dep` keeps inner's `dep` from being hoisted out of inner. + "package.json": JSON.stringify({ + name: "app", + workspaces: ["packages/*"], + dependencies: { dep: "file:./dep-root" }, + }), + "dep-root/package.json": JSON.stringify({ name: "dep", version: "2.0.0" }), + "dep-1.0.0.tgz": Buffer.from(depTarball), + "packages/inner/package.json": JSON.stringify({ + name: "inner", + version: "1.0.0", + dependencies: { dep: "file:../../dep-1.0.0.tgz" }, + }), + "packages/second/package.json": JSON.stringify({ + name: "second", + version: "1.0.0", + dependencies: { "inner-alias": "workspace:inner@*" }, + }), + "bunfig.toml": ({ root }) => Bun.TOML.stringify({ install: { cache: join(root, ".bun-cache"), linker } }), + }); + const root = String(dir); + + const { stderr, exitCode } = await install(root); + expect(stderr).not.toContain("error"); + expect(exitCode).toBe(0); + + const version = (packageDir: string) => + JSON.parse(readFileSync(join(root, packageDir, "package.json"), "utf8")).version; + expect({ + rootDep: version("node_modules/dep"), + innerDep: version("packages/inner/node_modules/dep"), + aliasedInnerDep: version(join(aliasLinkDir, "node_modules/dep")), + innerNodeModules: readdirSync(join(root, "packages/inner/node_modules")), + }).toEqual({ rootDep: "2.0.0", innerDep: "1.0.0", aliasedInnerDep: "1.0.0", innerNodeModules: ["dep"] }); + }); +} + +// An alias may already be as long as a file name can be, so the staging +// directory's name cannot be derived from it by adding to it. +for (const linker of ["hoisted", "isolated"]) { + test(`${linker} linker: a package whose alias is 255 characters long is installed`, async () => { + const alias = Buffer.alloc(255, "a").toString(); + using dir = tempDir(`staging-long-alias-${linker}`, { + "package.json": JSON.stringify({ name: "app", dependencies: { [alias]: "file:./a-package" } }), + "a-package/package.json": JSON.stringify({ name: "a-package", version: "1.0.0" }), + "bunfig.toml": ({ root }) => Bun.TOML.stringify({ install: { cache: join(root, ".bun-cache"), linker } }), + }); + const root = String(dir); + + const { stderr, exitCode } = await install(root); + expect(stderr).not.toContain("error"); + expect(exitCode).toBe(0); + expect(JSON.parse(readFileSync(join(root, "node_modules", alias, "package.json"), "utf8")).name).toBe("a-package"); + expect(readdirSync(join(root, "node_modules")).filter(name => name !== ".bun")).toEqual([alias]); + }); +}