From 80f3665e88d5831b7f6e3bdd4c4dc23b15d05226 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:17:24 +0000 Subject: [PATCH 1/5] install: fail the isolated install when a link: dependency's target is missing With the isolated linker, a store entry for a link: resolution was marked done without looking at the filesystem, and the dependents then symlinked node_modules/ at the global link dir entry whether or not it existed. Installing a lockfile whose linked package had since been unlinked or deleted produced a dangling symlink and exit code 0, while the hoisted linker reports the failure and exits 1. Open the link target as a directory when the entry is processed, exactly as the hoisted linker's install_from_link does, and route a failure through on_task_fail so the install reports the package and exits 1. --- src/install/isolated_install.rs | 24 ++++++- test/cli/install/isolated-install.test.ts | 78 +++++++++++++++++++++++ 2 files changed, 101 insertions(+), 1 deletion(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index b406dd64f6cf..1d4866f711ec 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2170,7 +2170,29 @@ pub(crate) fn install_isolated_packages( // .monotonic is okay because the task isn't running on another thread. entry_steps[entry_id.get() as usize] .store(installer::Step::Done as u32, Ordering::Relaxed); - installer.on_task_complete(entry_id, installer::CompleteState::Skipped); + + // The lockfile only stores the name, so the `bun link` registration may be + // gone by now. Dependents symlink to it blindly, making this the only place + // a missing target can fail the install (same `openat` as hoisted's + // `install_from_link`). + let mut link_target: AbsPath = AbsPath::init_top_level_dir(); + installer.append_store_path(&mut link_target, entry_id); + match sys::openat( + Fd::cwd(), + link_target.slice_z(), + sys::O::RDONLY | sys::O::DIRECTORY, + 0, + ) { + Ok(fd) => { + use bun_sys::FdExt as _; + fd.close(); + installer.on_task_complete(entry_id, installer::CompleteState::Skipped); + } + Err(err) => { + installer + .on_task_fail(entry_id, &installer::TaskError::LinkPackage(err)); + } + } continue; } ResolutionTag::Folder => { diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 3d3b6a5428ad..5d657a22dd32 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -367,6 +367,84 @@ test("can install folder dependencies on root package", async () => { ]); }); +describe("link: dependencies", () => { + async function bunInstall(cwd: string, env: NodeJS.Dict) { + await using proc = spawn({ + cmd: [bunExe(), "install"], + cwd, + env, + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + return { stdout, stderr, exitCode }; + } + + // The lockfile records a `link:` dependency by name only, so the `bun link` + // registration behind that name can be gone by the time the lockfile is + // installed: `bun unlink` removes the global link dir entry, deleting the + // package leaves the entry dangling. + test.concurrent.each([ + { + name: "linked-pkg", + gone: "the package was unlinked", + async remove(pkgDir: string, env: NodeJS.Dict) { + await using proc = spawn({ cmd: [bunExe(), "unlink"], cwd: pkgDir, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain(`success: unlinked package "linked-pkg"`); + expect(exitCode).toBe(0); + }, + }, + { + name: "@scope/linked-pkg", + gone: "the linked directory was deleted", + async remove(pkgDir: string) { + await rm(pkgDir, { recursive: true, force: true }); + }, + }, + ])("installing from the lockfile fails when $gone", async ({ name, remove }) => { + using dir = tempDir("isolated-link-target", { + "pkg/package.json": JSON.stringify({ name, version: "1.0.0" }), + "app/package.json": JSON.stringify({ name: "app", dependencies: { [name]: `link:${name}` } }), + "app/bunfig.toml": `[install]\nlinker = "isolated"\n`, + }); + const pkgDir = join(String(dir), "pkg"); + const appDir = join(String(dir), "app"); + // `bun link` registers into $BUN_INSTALL/install/global; keep it private to this test. + const env = { ...bunEnv, BUN_INSTALL: join(String(dir), "bun-install") }; + + { + await using proc = spawn({ cmd: [bunExe(), "link"], cwd: pkgDir, env, stdout: "pipe", stderr: "pipe" }); + const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); + expect(stdout).toContain(`Success! Registered "${name}"`); + expect(exitCode).toBe(0); + } + + let result = await bunInstall(appDir, env); + expect(result.stderr).toContain("Saved lockfile"); + expect(result.exitCode).toBe(0); + expect(await file(join(appDir, "node_modules", name, "package.json")).json()).toEqual({ name, version: "1.0.0" }); + + await remove(pkgDir, env); + + // Reinstalling over the existing node_modules... + result = await bunInstall(appDir, env); + expect(result.stderr).toContain("ENOENT"); + expect(result.stderr).toContain(`failed to link package: ${name}@link:`); + expect(result.stdout).toContain("Failed to install 1 package"); + expect(result.exitCode).toBe(1); + + // ...and installing into a fresh one (a clone with the lockfile checked in) + // must both report the missing package instead of leaving a dangling symlink. + await rm(join(appDir, "node_modules"), { recursive: true, force: true }); + result = await bunInstall(appDir, env); + expect(result.stderr).toContain("ENOENT"); + expect(result.stderr).toContain(`failed to link package: ${name}@link:`); + expect(result.stdout).toContain("Failed to install 1 package"); + expect(result.exitCode).toBe(1); + }); +}); + describe("isolated workspaces", () => { test("basic", async () => { const { packageJson, packageDir } = await registry.createTestDir({ bunfigOpts: { linker: "isolated" } }); From e800c4afc07b4775e5fb9cc1888cf8ee693679b9 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 04:54:01 +0000 Subject: [PATCH 2/5] test: drain stderr of the link/unlink processes too --- test/cli/install/isolated-install.test.ts | 32 ++++++++++------------- 1 file changed, 14 insertions(+), 18 deletions(-) diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index 5d657a22dd32..bb11b39e1837 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -368,9 +368,9 @@ test("can install folder dependencies on root package", async () => { }); describe("link: dependencies", () => { - async function bunInstall(cwd: string, env: NodeJS.Dict) { + async function runBun(args: string[], cwd: string, env: NodeJS.Dict) { await using proc = spawn({ - cmd: [bunExe(), "install"], + cmd: [bunExe(), ...args], cwd, env, stdout: "pipe", @@ -389,10 +389,9 @@ describe("link: dependencies", () => { name: "linked-pkg", gone: "the package was unlinked", async remove(pkgDir: string, env: NodeJS.Dict) { - await using proc = spawn({ cmd: [bunExe(), "unlink"], cwd: pkgDir, env, stdout: "pipe", stderr: "pipe" }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(stdout).toContain(`success: unlinked package "linked-pkg"`); - expect(exitCode).toBe(0); + const result = await runBun(["unlink"], pkgDir, env); + expect(result.stdout).toContain(`success: unlinked package "linked-pkg"`); + expect(result.exitCode).toBe(0); }, }, { @@ -413,31 +412,28 @@ describe("link: dependencies", () => { // `bun link` registers into $BUN_INSTALL/install/global; keep it private to this test. const env = { ...bunEnv, BUN_INSTALL: join(String(dir), "bun-install") }; - { - await using proc = spawn({ cmd: [bunExe(), "link"], cwd: pkgDir, env, stdout: "pipe", stderr: "pipe" }); - const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]); - expect(stdout).toContain(`Success! Registered "${name}"`); - expect(exitCode).toBe(0); - } + let result = await runBun(["link"], pkgDir, env); + expect(result.stdout).toContain(`Success! Registered "${name}"`); + expect(result.exitCode).toBe(0); - let result = await bunInstall(appDir, env); + result = await runBun(["install"], appDir, env); expect(result.stderr).toContain("Saved lockfile"); expect(result.exitCode).toBe(0); expect(await file(join(appDir, "node_modules", name, "package.json")).json()).toEqual({ name, version: "1.0.0" }); await remove(pkgDir, env); - // Reinstalling over the existing node_modules... - result = await bunInstall(appDir, env); + // Both reinstalling over the existing node_modules and installing into a + // fresh one (a clone with the lockfile checked in) must report the missing + // package instead of leaving a dangling symlink behind. + result = await runBun(["install"], appDir, env); expect(result.stderr).toContain("ENOENT"); expect(result.stderr).toContain(`failed to link package: ${name}@link:`); expect(result.stdout).toContain("Failed to install 1 package"); expect(result.exitCode).toBe(1); - // ...and installing into a fresh one (a clone with the lockfile checked in) - // must both report the missing package instead of leaving a dangling symlink. await rm(join(appDir, "node_modules"), { recursive: true, force: true }); - result = await bunInstall(appDir, env); + result = await runBun(["install"], appDir, env); expect(result.stderr).toContain("ENOENT"); expect(result.stderr).toContain(`failed to link package: ${name}@link:`); expect(result.stdout).toContain("Failed to install 1 package"); From b49da998e6a910538ea44f8b73a86ce2568cfb31 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:20:07 +0000 Subject: [PATCH 3/5] test: describe the node_modules state after a failed link: install accurately --- test/cli/install/isolated-install.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/cli/install/isolated-install.test.ts b/test/cli/install/isolated-install.test.ts index bb11b39e1837..36067092485a 100644 --- a/test/cli/install/isolated-install.test.ts +++ b/test/cli/install/isolated-install.test.ts @@ -425,7 +425,9 @@ describe("link: dependencies", () => { // Both reinstalling over the existing node_modules and installing into a // fresh one (a clone with the lockfile checked in) must report the missing - // package instead of leaving a dangling symlink behind. + // package and exit 1 instead of silently succeeding. As with every other + // failed entry, node_modules/ still points at the registration and + // resolves again once the package is re-linked. result = await runBun(["install"], appDir, env); expect(result.stderr).toContain("ENOENT"); expect(result.stderr).toContain(`failed to link package: ${name}@link:`); From 95a33e16be82b0f11d04d109e5c4d8c2e4ccb8dc Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:24:01 +0000 Subject: [PATCH 4/5] install: shorten the link: target check comment --- src/install/isolated_install.rs | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index 1d4866f711ec..dd773ff36534 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2171,10 +2171,8 @@ pub(crate) fn install_isolated_packages( entry_steps[entry_id.get() as usize] .store(installer::Step::Done as u32, Ordering::Relaxed); - // The lockfile only stores the name, so the `bun link` registration may be - // gone by now. Dependents symlink to it blindly, making this the only place - // a missing target can fail the install (same `openat` as hoisted's - // `install_from_link`). + // Dependents link to it unchecked, so fail here if the `bun link` + // registration is gone (same openat as hoisted's `install_from_link`). let mut link_target: AbsPath = AbsPath::init_top_level_dir(); installer.append_store_path(&mut link_target, entry_id); match sys::openat( From 5dbf7e1309e31ab89451ee405caad8ecd1dec046 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Thu, 13 Aug 2026 10:29:51 +0000 Subject: [PATCH 5/5] install: trim the link: target check comment to one line --- src/install/isolated_install.rs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/install/isolated_install.rs b/src/install/isolated_install.rs index dd773ff36534..85982ca7d373 100644 --- a/src/install/isolated_install.rs +++ b/src/install/isolated_install.rs @@ -2171,8 +2171,7 @@ pub(crate) fn install_isolated_packages( entry_steps[entry_id.get() as usize] .store(installer::Step::Done as u32, Ordering::Relaxed); - // Dependents link to it unchecked, so fail here if the `bun link` - // registration is gone (same openat as hoisted's `install_from_link`). + // Same target check as the hoisted linker's `install_from_link`. let mut link_target: AbsPath = AbsPath::init_top_level_dir(); installer.append_store_path(&mut link_target, entry_id); match sys::openat(