Compare commits

..

1 Commits

Author SHA1 Message Date
Claude Bot
9dadf729c3 fix(install): resolve symlink before uninstall in global link
When running `bun link -g <package>`, the source symlink and destination
could be in the same directory (~/.bun/install/global/node_modules/). The
previous code order would delete the symlink via uninstallBeforeInstall()
before resolving its realpath, causing a FileNotFound error.

This fix moves the realpath resolution before the uninstall step to ensure
we capture the target path while the symlink still exists.

Fixes #25746

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-12 12:02:58 +00:00
4 changed files with 99 additions and 180 deletions

View File

@@ -1528,16 +1528,6 @@ pub const TestCommand = struct {
// Randomize the order of test files if --randomize flag is set
if (random) |rand| {
rand.shuffle(PathString, test_files);
} else {
// Sort test files alphabetically by path for consistent ordering.
// This ensures the same execution order regardless of how files are
// specified (explicit paths vs directory scanning) or filesystem
// directory entry ordering. See: https://github.com/oven-sh/bun/issues/25797
std.mem.sort(PathString, test_files, {}, struct {
fn lessThan(_: void, a: PathString, b: PathString) bool {
return strings.order(a.slice(), b.slice()) == .lt;
}
}.lessThan);
}
vm.hot_reload = ctx.debug.hot_reload;

View File

@@ -1239,9 +1239,6 @@ pub const PackageInstall = struct {
pub fn installFromLink(this: *@This(), skip_delete: bool, destination_dir: std.fs.Dir) Result {
const dest_path = this.destination_dir_subpath;
// If this fails, we don't care.
// we'll catch it the next error
if (!skip_delete and !strings.eqlComptime(dest_path, ".")) this.uninstallBeforeInstall(destination_dir);
const subdir = std.fs.path.dirname(dest_path);
@@ -1249,9 +1246,17 @@ pub const PackageInstall = struct {
// cache_dir_subpath in here is actually the full path to the symlink pointing to the linked package
const symlinked_path = this.cache_dir_subpath;
var to_buf: bun.PathBuffer = undefined;
// Resolve realpath BEFORE uninstall, because when linking a package globally
// (e.g. `bun link -g my-cli`), the source symlink and destination may be in
// the same directory (~/.bun/install/global/node_modules/). In that case,
// uninstallBeforeInstall would delete the symlink we need to read from.
const to_path = this.cache_dir.realpath(symlinked_path, &to_buf) catch |err|
return Result.fail(err, .linking_dependency, @errorReturnTrace());
// If this fails, we don't care.
// we'll catch it the next error
if (!skip_delete and !strings.eqlComptime(dest_path, ".")) this.uninstallBeforeInstall(destination_dir);
const dest = std.fs.path.basename(dest_path);
// When we're linking on Windows, we want to avoid keeping the source directory handle open
if (comptime Environment.isWindows) {

View File

@@ -0,0 +1,91 @@
// https://github.com/oven-sh/bun/issues/25746
// `bun link -g <package>` creates a broken symlink because the installation
// process deletes the source symlink before reading from it.
import { spawn } from "bun";
import { afterEach, beforeEach, expect, it } from "bun:test";
import { mkdir, writeFile } from "fs/promises";
import { bunEnv, bunExe, stderrForInstall, tempDir } from "harness";
import { join } from "path";
let link_dir: string;
let globalDir: string;
let originalHome: string | undefined;
beforeEach(async () => {
link_dir = tempDir("bun-link-global", {});
globalDir = tempDir("bun-global-dir", {});
originalHome = bunEnv.HOME;
// Override HOME so bun link uses our test global directory
bunEnv.HOME = globalDir;
});
afterEach(async () => {
if (originalHome !== undefined) {
bunEnv.HOME = originalHome;
}
});
it("should successfully link a package globally with bun link -g", async () => {
// Create a test package with a bin entry
await mkdir(join(link_dir, "bin"), { recursive: true });
await writeFile(
join(link_dir, "package.json"),
JSON.stringify({
name: "my-test-cli",
version: "1.0.0",
bin: {
"my-test-cli": "./bin/cli.js",
},
}),
);
await writeFile(join(link_dir, "bin", "cli.js"), '#!/usr/bin/env node\nconsole.log("hello from my-test-cli");');
// Step 1: Register the package with `bun link`
const proc1 = spawn({
cmd: [bunExe(), "link"],
cwd: link_dir,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout1, stderr1, exitCode1] = await Promise.all([
new Response(proc1.stdout).text(),
new Response(proc1.stderr).text(),
proc1.exited,
]);
expect(stderrForInstall(stderr1).split(/\r?\n/)).toEqual([""]);
expect(stdout1).toContain('Success! Registered "my-test-cli"');
expect(exitCode1).toBe(0);
// Step 2: Link globally with `bun link -g my-test-cli`
// This was failing with "FileNotFound: failed linking dependency/workspace to node_modules for package my-test-cli"
const proc2 = spawn({
cmd: [bunExe(), "link", "-g", "my-test-cli"],
cwd: link_dir,
stdout: "pipe",
stderr: "pipe",
env: bunEnv,
});
const [stdout2, stderr2, exitCode2] = await Promise.all([
new Response(proc2.stdout).text(),
new Response(proc2.stderr).text(),
proc2.exited,
]);
// The install should succeed without FileNotFound errors
const stderrFiltered = stderrForInstall(stderr2);
expect(stderrFiltered).not.toContain("FileNotFound");
expect(stderrFiltered).not.toContain("failed linking dependency");
expect(stdout2).toContain("installed my-test-cli@link:my-test-cli");
expect(exitCode2).toBe(0);
// Verify the global package can be read (symlink is valid)
const globalNodeModules = join(globalDir, ".bun", "install", "global", "node_modules");
const packageJson = await Bun.file(join(globalNodeModules, "my-test-cli", "package.json")).json();
expect(packageJson.name).toBe("my-test-cli");
expect(packageJson.version).toBe("1.0.0");
});

View File

@@ -1,167 +0,0 @@
import { describe, expect, test } from "bun:test";
import { bunEnv, bunExe, tempDir } from "harness";
// Test that test files are sorted alphabetically for consistent execution order.
// https://github.com/oven-sh/bun/issues/25797
//
// This ensures that running `bun test .` produces the same order as running
// `bun test file1.test.ts file2.test.ts ...` (which VSCode does).
describe("issue #25797", () => {
test("test files are sorted alphabetically", async () => {
// Create test files with names that would appear in different orders
// depending on filesystem vs alphabetical sorting
using dir = tempDir("test-sort-order", {
"z_last.test.ts": `
import { test, expect } from "bun:test";
test("z_last", () => { console.log("FILE:z_last"); expect(true).toBe(true); });
`,
"a_first.test.ts": `
import { test, expect } from "bun:test";
test("a_first", () => { console.log("FILE:a_first"); expect(true).toBe(true); });
`,
"m_middle.test.ts": `
import { test, expect } from "bun:test";
test("m_middle", () => { console.log("FILE:m_middle"); expect(true).toBe(true); });
`,
});
// Run tests using directory scanning (bun test .)
await using proc1 = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, stderr1, exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
// Run tests using explicit file paths in reverse alphabetical order
// (simulating how VSCode might pass them in a different order)
await using proc2 = Bun.spawn({
cmd: [bunExe(), "test", "z_last.test.ts", "m_middle.test.ts", "a_first.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout2, stderr2, exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
expect(exitCode1).toBe(0);
expect(exitCode2).toBe(0);
// Extract the order of FILE: markers from output
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order1 = getFileOrder(stdout1);
const order2 = getFileOrder(stdout2);
// Both should produce alphabetical order
expect(order1).toEqual(["a_first", "m_middle", "z_last"]);
expect(order2).toEqual(["a_first", "m_middle", "z_last"]);
// Both methods should produce the same order
expect(order1).toEqual(order2);
});
test("test files in subdirectories are sorted alphabetically", async () => {
using dir = tempDir("test-sort-subdirs", {
"tests/b_second.test.ts": `
import { test, expect } from "bun:test";
test("b_second", () => { console.log("FILE:b_second"); expect(true).toBe(true); });
`,
"tests/a_first.test.ts": `
import { test, expect } from "bun:test";
test("a_first", () => { console.log("FILE:a_first"); expect(true).toBe(true); });
`,
"other/c_third.test.ts": `
import { test, expect } from "bun:test";
test("c_third", () => { console.log("FILE:c_third"); expect(true).toBe(true); });
`,
});
// Run tests using directory scanning
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, , exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(exitCode).toBe(0);
// Extract the order of FILE: markers from output
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order = getFileOrder(stdout);
// Files should be sorted alphabetically by full path
// other/c_third.test.ts comes before tests/a_first.test.ts and tests/b_second.test.ts
expect(order).toEqual(["c_third", "a_first", "b_second"]);
});
test("explicit paths and directory scanning produce same order", async () => {
using dir = tempDir("test-explicit-vs-scan", {
"test_c.test.ts": `
import { test, expect } from "bun:test";
test("test_c", () => { console.log("FILE:test_c"); expect(true).toBe(true); });
`,
"test_a.test.ts": `
import { test, expect } from "bun:test";
test("test_a", () => { console.log("FILE:test_a"); expect(true).toBe(true); });
`,
"test_b.test.ts": `
import { test, expect } from "bun:test";
test("test_b", () => { console.log("FILE:test_b"); expect(true).toBe(true); });
`,
});
// Method 1: Directory scanning
await using proc1 = Bun.spawn({
cmd: [bunExe(), "test", "."],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
// Method 2: Explicit paths in scrambled order
await using proc2 = Bun.spawn({
cmd: [bunExe(), "test", "./test_b.test.ts", "./test_c.test.ts", "./test_a.test.ts"],
cwd: String(dir),
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout1, , exitCode1] = await Promise.all([proc1.stdout.text(), proc1.stderr.text(), proc1.exited]);
const [stdout2, , exitCode2] = await Promise.all([proc2.stdout.text(), proc2.stderr.text(), proc2.exited]);
expect(exitCode1).toBe(0);
expect(exitCode2).toBe(0);
const getFileOrder = (output: string): string[] => {
const matches = output.match(/FILE:(\w+)/g) || [];
return matches.map(m => m.replace("FILE:", ""));
};
const order1 = getFileOrder(stdout1);
const order2 = getFileOrder(stdout2);
// Both should be alphabetically sorted
expect(order1).toEqual(["test_a", "test_b", "test_c"]);
expect(order2).toEqual(["test_a", "test_b", "test_c"]);
});
});