Compare commits

...

5 Commits

Author SHA1 Message Date
autofix-ci[bot]
001d2642d8 [autofix.ci] apply automated fixes 2025-08-15 04:04:50 +00:00
Claude Bot
8c66bda1ea fix: make PackageID public for HashMap type usage
Makes Tree.PackageID public to fix compilation error when using it as HashMap
key type in the cycle detection implementation.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 04:00:56 +00:00
Claude Bot
541bfd0fcd perf: optimize cycle detection with reusable HashMap
Improves performance of the circular dependency fix by eliminating repeated
HashMap allocations during dependency hoisting:

Performance improvements:
- Uses single reusable HashMap in Builder instead of per-call allocation
- Eliminates O(n) allocation overhead for each hoistDependency call
- Retains capacity between calls for better memory reuse
- Reduces garbage collection pressure in large dependency trees

The HashMap is now:
- Initialized once per Builder lifecycle
- Cleared with clearRetainingCapacity() for each top-level hoist operation
- Properly cleaned up with defer statement

This maintains the cycle detection benefits while being much more efficient
for real-world dependency trees with hundreds/thousands of packages.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 03:48:59 +00:00
Claude Bot
476bf5f2c9 improve: use cycle detection instead of depth limit for hoisting
Replaces the depth-limiting approach with proper cycle detection using a visited
set. This prevents infinite recursion in circular dependencies while allowing
legitimate deep dependency trees (which can exceed 1000 levels in npm ecosystem).

The new approach:
- Tracks visited packages during hoisting traversal
- Detects actual cycles rather than imposing arbitrary depth limits
- Uses defer to ensure proper cleanup of visited set
- Handles both circular dependencies and deep legitimate trees correctly

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 03:44:40 +00:00
Claude Bot
e565ab42b1 fix: prevent infinite recursion in lockfile hoisting with circular dependencies
Fixes a segmentation fault that occurred when hoisting dependencies with circular
dependency chains. The hoistDependency function would recursively call itself
indefinitely when traversing parent trees, causing stack overflow.

Changes:
- Added depth tracking to hoistDependency to prevent infinite recursion
- Split function into hoistDependency (public) and hoistDependencyWithDepth (internal)
- Added max depth limit of 1000 to prevent stack overflow
- Return dependency_loop when depth limit is reached to gracefully handle cycles

Test case reproduces the crash with pkg-a -> pkg-b -> pkg-c -> pkg-a circular dependency.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-15 03:40:42 +00:00
5 changed files with 478 additions and 2 deletions

View File

@@ -902,7 +902,9 @@ pub fn hoist(
.manager = manager,
.install_root_dependencies = install_root_dependencies,
.workspace_filters = workspace_filters,
.hoist_visited_packages = std.AutoHashMap(Tree.PackageID, void).init(allocator),
};
defer builder.hoist_visited_packages.deinit();
try (Tree{}).processSubtree(
Tree.root_dep_id,

View File

@@ -246,6 +246,8 @@ pub fn Builder(comptime method: BuilderMethod) type {
sort_buf: std.ArrayListUnmanaged(DependencyID) = .{},
workspace_filters: if (method == .filter) []const WorkspaceFilter else void = if (method == .filter) &.{},
install_root_dependencies: if (method == .filter) bool else void,
// Reusable set for cycle detection during hoisting to avoid repeated allocations
hoist_visited_packages: std.AutoHashMap(PackageID, void),
pub fn maybeReportError(this: *@This(), comptime fmt: string, args: anytype) void {
this.log.addErrorFmt(null, logger.Loc.Empty, this.allocator, fmt, args) catch {};
@@ -557,6 +559,33 @@ fn hoistDependency(
comptime method: BuilderMethod,
builder: *Builder(method),
) !HoistDependencyResult {
// Clear the reusable visited set for this top-level hoisting operation
builder.hoist_visited_packages.clearRetainingCapacity();
return hoistDependencyWithVisited(this, as_defined, hoist_root_id, package_id, dependency, dependency_lists, trees, method, builder, &builder.hoist_visited_packages);
}
fn hoistDependencyWithVisited(
this: *Tree,
comptime as_defined: bool,
hoist_root_id: Id,
package_id: PackageID,
dependency: *const Dependency,
dependency_lists: []Lockfile.DependencyIDList,
trees: []Tree,
comptime method: BuilderMethod,
builder: *Builder(method),
visited_packages: *std.AutoHashMap(PackageID, void),
) !HoistDependencyResult {
// Check if we've already visited this package during hoisting
// This prevents infinite recursion in circular dependency scenarios
if (visited_packages.contains(package_id)) {
return .dependency_loop;
}
// Mark this package as visited
try visited_packages.put(package_id, {});
// Ensure we remove this package from visited set when function exits
defer _ = visited_packages.remove(package_id);
const this_dependencies = this.dependencies.get(dependency_lists[this.id].items);
for (0..this_dependencies.len) |i| {
const dep_id = this_dependencies[i];
@@ -614,7 +643,7 @@ fn hoistDependency(
// this dependency was not found in this tree, try hoisting or placing in the next parent
if (this.parent != invalid_id and this.id != hoist_root_id) {
const id = trees[this.parent].hoistDependency(
const id = trees[this.parent].hoistDependencyWithVisited(
false,
hoist_root_id,
package_id,
@@ -623,6 +652,7 @@ fn hoistDependency(
trees,
method,
builder,
visited_packages,
) catch unreachable;
if (!as_defined or id != .dependency_loop) return id; // 1 or 2
}
@@ -645,6 +675,8 @@ pub const TreeFiller = std.fifo.LinearFifo(FillItem, .Dynamic);
const string = []const u8;
const stringZ = [:0]const u8;
pub const PackageID = install.PackageID;
const std = @import("std");
const Allocator = std.mem.Allocator;
@@ -662,7 +694,6 @@ const String = bun.Semver.String;
const install = bun.install;
const Dependency = install.Dependency;
const DependencyID = install.DependencyID;
const PackageID = install.PackageID;
const PackageNameHash = install.PackageNameHash;
const Resolution = install.Resolution;
const invalid_dependency_id = install.invalid_dependency_id;

View File

@@ -0,0 +1,190 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
// Performance benchmarks for dependency hoisting with cycle detection
test("benchmark: small dependency tree (10 packages)", async () => {
const packageJson = {
name: "bench-small",
dependencies: {},
};
// Create 10 packages with linear dependencies: pkg-0 -> pkg-1 -> pkg-2 -> ... -> pkg-9
const files = { "package.json": JSON.stringify(packageJson) };
for (let i = 0; i < 10; i++) {
const deps = i < 9 ? { [`pkg-${i + 1}`]: `file:./pkg-${i + 1}` } : {};
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}
const dir = tempDirWithFiles("bench-small", files);
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Small tree (10 packages): ${duration.toFixed(2)}ms`);
}, 30000);
test("benchmark: medium dependency tree (50 packages)", async () => {
const packageJson = {
name: "bench-medium",
dependencies: {},
};
// Create 50 packages with linear dependencies
const files = { "package.json": JSON.stringify(packageJson) };
for (let i = 0; i < 50; i++) {
const deps = i < 49 ? { [`pkg-${i + 1}`]: `file:./pkg-${i + 1}` } : {};
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}
const dir = tempDirWithFiles("bench-medium", files);
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Medium tree (50 packages): ${duration.toFixed(2)}ms`);
}, 30000);
test("benchmark: wide dependency tree (20 packages, each depends on 5 others)", async () => {
const packageJson = {
name: "bench-wide",
dependencies: {},
};
// Create 20 packages where each depends on 5 others (wide tree)
const files = { "package.json": JSON.stringify(packageJson) };
for (let i = 0; i < 20; i++) {
const deps = {};
// Each package depends on the next 5 packages (cyclically)
for (let j = 1; j <= 5; j++) {
const depIndex = (i + j) % 20;
deps[`pkg-${depIndex}`] = `file:./pkg-${depIndex}`;
}
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}
const dir = tempDirWithFiles("bench-wide", files);
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Wide tree (20x5 deps): ${duration.toFixed(2)}ms`);
}, 30000);
test("benchmark: complex dependency tree with multiple cycles", async () => {
const packageJson = {
name: "bench-complex",
dependencies: {},
};
// Create a complex dependency structure with multiple cycles
const files = { "package.json": JSON.stringify(packageJson) };
// Create 15 packages with complex interdependencies
const depStructure = {
0: [1, 2, 3], // pkg-0 -> pkg-1, pkg-2, pkg-3
1: [4, 5], // pkg-1 -> pkg-4, pkg-5
2: [6, 7], // pkg-2 -> pkg-6, pkg-7
3: [8, 9], // pkg-3 -> pkg-8, pkg-9
4: [10, 0], // pkg-4 -> pkg-10, pkg-0 (cycle)
5: [11, 1], // pkg-5 -> pkg-11, pkg-1 (cycle)
6: [12, 2], // pkg-6 -> pkg-12, pkg-2 (cycle)
7: [13, 3], // pkg-7 -> pkg-13, pkg-3 (cycle)
8: [14, 4], // pkg-8 -> pkg-14, pkg-4
9: [0, 5], // pkg-9 -> pkg-0, pkg-5 (cycle)
10: [6, 7], // pkg-10 -> pkg-6, pkg-7
11: [8, 9], // pkg-11 -> pkg-8, pkg-9
12: [10, 11], // pkg-12 -> pkg-10, pkg-11
13: [12, 4], // pkg-13 -> pkg-12, pkg-4
14: [13, 5], // pkg-14 -> pkg-13, pkg-5
};
for (let i = 0; i < 15; i++) {
const deps = {};
const depIndices = depStructure[i] || [];
for (const depIndex of depIndices) {
deps[`pkg-${depIndex}`] = `file:./pkg-${depIndex}`;
}
files[`pkg-${i}/package.json`] = JSON.stringify({
name: `pkg-${i}`,
dependencies: deps,
});
packageJson.dependencies[`pkg-${i}`] = `file:./pkg-${i}`;
}
const dir = tempDirWithFiles("bench-complex", files);
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
console.log(`Complex tree (15 packages, multiple cycles): ${duration.toFixed(2)}ms`);
}, 30000);

View File

@@ -0,0 +1,146 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
// Simple performance test with common patterns
test("benchmark: React-like dependency pattern", async () => {
// Simulate a typical React project structure
const dir = tempDirWithFiles("react-bench", {
"package.json": JSON.stringify({
name: "react-bench",
dependencies: {
"comp-a": "file:./comp-a",
"comp-b": "file:./comp-b",
"comp-c": "file:./comp-c",
"shared": "file:./shared",
},
}),
"comp-a/package.json": JSON.stringify({
name: "comp-a",
dependencies: {
"shared": "file:../shared",
"react": "^18.0.0",
},
}),
"comp-b/package.json": JSON.stringify({
name: "comp-b",
dependencies: {
"shared": "file:../shared",
"comp-a": "file:../comp-a",
"react": "^18.0.0",
},
}),
"comp-c/package.json": JSON.stringify({
name: "comp-c",
dependencies: {
"shared": "file:../shared",
"comp-b": "file:../comp-b",
"react": "^18.0.0",
},
}),
"shared/package.json": JSON.stringify({
name: "shared",
dependencies: {
"lodash": "^4.17.21",
},
}),
});
const times = [];
// Run 3 times and take average
for (let i = 0; i < 3; i++) {
// Clean lockfile
await Bun.spawn({
cmd: ["rm", "-f", "bun.lock"],
cwd: dir,
}).exited;
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
times.push(duration);
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
}
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
console.log(`React-like pattern (3 runs avg): ${avgTime.toFixed(2)}ms`);
console.log(`Individual times: ${times.map(t => t.toFixed(2)).join(", ")}ms`);
}, 60000);
test("benchmark: No circular dependencies baseline", async () => {
// Simple linear dependency chain (no cycles)
const dir = tempDirWithFiles("linear-bench", {
"package.json": JSON.stringify({
name: "linear-bench",
dependencies: {
"pkg-1": "file:./pkg-1",
},
}),
"pkg-1/package.json": JSON.stringify({
name: "pkg-1",
dependencies: {
"pkg-2": "file:../pkg-2",
},
}),
"pkg-2/package.json": JSON.stringify({
name: "pkg-2",
dependencies: {
"pkg-3": "file:../pkg-3",
},
}),
"pkg-3/package.json": JSON.stringify({
name: "pkg-3",
dependencies: {
"lodash": "^4.17.21",
},
}),
});
const times = [];
// Run 5 times for good statistical significance
for (let i = 0; i < 5; i++) {
// Clean lockfile
await Bun.spawn({
cmd: ["rm", "-f", "bun.lock"],
cwd: dir,
}).exited;
const start = performance.now();
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
const duration = performance.now() - start;
times.push(duration);
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
}
const avgTime = times.reduce((a, b) => a + b, 0) / times.length;
const minTime = Math.min(...times);
const maxTime = Math.max(...times);
console.log(`Linear dependencies (5 runs avg): ${avgTime.toFixed(2)}ms`);
console.log(`Range: ${minTime.toFixed(2)}ms - ${maxTime.toFixed(2)}ms`);
}, 60000);

View File

@@ -0,0 +1,107 @@
import { expect, test } from "bun:test";
import { bunEnv, bunExe, tempDirWithFiles } from "harness";
test("circular dependencies should not cause infinite recursion during hoisting", async () => {
// This test reproduces a crash where circular dependencies in workspace packages
// would cause infinite recursion in the hoistDependency function, leading to
// stack overflow and segmentation fault.
//
// The circular dependency pattern is: pkg-a -> pkg-b -> pkg-c -> pkg-a
//
// The fix uses cycle detection rather than depth limiting to properly handle
// both circular dependencies and legitimately deep dependency trees.
const dir = tempDirWithFiles("circular-deps", {
"package.json": JSON.stringify({
name: "root",
dependencies: {
"pkg-a": "file:./pkg-a",
"pkg-b": "file:./pkg-b",
"pkg-c": "file:./pkg-c",
},
scripts: {
"o": "bun outdated",
},
}),
"pkg-a/package.json": JSON.stringify({
name: "pkg-a",
dependencies: {
"pkg-b": "file:../pkg-b",
},
}),
"pkg-b/package.json": JSON.stringify({
name: "pkg-b",
dependencies: {
"pkg-c": "file:../pkg-c",
},
}),
"pkg-c/package.json": JSON.stringify({
name: "pkg-c",
dependencies: {
"pkg-a": "file:../pkg-a",
"react": "^18.0.0",
},
}),
// Include a lockfile that reproduces the problematic state
"bun.lock": JSON.stringify(
{
lockfileVersion: 1,
workspaces: {
"": {
name: "root",
dependencies: {
"pkg-a": "file:./pkg-a",
"pkg-b": "file:./pkg-b",
"pkg-c": "file:./pkg-c",
},
},
},
packages: {
"js-tokens": [
"js-tokens@4.0.0",
"",
{},
"sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==",
],
"loose-envify": [
"loose-envify@1.4.0",
"",
{ "dependencies": { "js-tokens": "^3.0.0 || ^4.0.0" }, "bin": { "loose-envify": "cli.js" } },
"sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==",
],
"pkg-a": ["pkg-a@file:pkg-a", { "dependencies": { "pkg-b": "file:../pkg-b" } }],
"pkg-b": ["pkg-b@file:pkg-b", { "dependencies": { "pkg-c": "file:../pkg-c" } }],
"pkg-c": ["pkg-c@file:pkg-c", { "dependencies": { "pkg-a": "file:../pkg-a", "react": "^18.0.0" } }],
"react": [
"react@18.3.1",
"",
{ "dependencies": { "loose-envify": "^1.1.0" } },
"sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
],
"pkg-a/pkg-b": ["pkg-b@file:pkg-b", {}],
"pkg-b/pkg-c": ["pkg-c@file:pkg-c", {}],
"pkg-c/pkg-a": ["pkg-a@file:pkg-a", {}],
},
},
null,
2,
),
});
// Run bun install - this should complete without crashing
await using proc = Bun.spawn({
cmd: [bunExe(), "install"],
env: bunEnv,
cwd: dir,
stderr: "pipe",
stdout: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// Should exit successfully, not crash with segmentation fault
expect(exitCode).toBe(0);
expect(stderr).not.toContain("panic");
expect(stderr).not.toContain("Segmentation fault");
expect(stdout).toContain("packages installed");
}, 30000); // 30 second timeout