Skip to content

Conversation

@ruisilva450
Copy link

Summary

Fix a bug where running bun install --omit=dev (or --omit=optional / --omit=peer)
would omit those dependency types from the new install plan but leave previously-installed
top-level package directories in node_modules. This PR adds a safe, performant post-install
cleanup that removes top-level entries that are no longer present in the final lockfile.

Motivation

Users expect bun install --omit=... (or production installs) to produce a node_modules
tree that reflects the requested dependency features. Previously Bun would stop installing
omitted types but would not remove stale directories that were installed by prior runs,
leading to confusing leftovers on disk and possible behavior mismatches.

What this PR does

  • Adds scheduleTopLevelOmittedCleanup(manager: *PackageManager) in src/install/PackageInstall.zig:
    • Performs a two-pass, top-level-only scan of node_modules:
      • PASS 1: count candidate top-level entries (and immediate children of scoped dirs) not
        present in the final lockfile (using name_hash membership checks).
      • PASS 2: schedule asynchronous deletion tasks for those candidates on the existing
        installer thread pool.
    • Batches pending-task increments to minimize atomic contention.
    • Uses the lockfile name_hash array for fast membership tests.
    • Only inspects top-level entries (and immediate scope children) to keep I/O minimal and safe.
  • Calls the new cleanup from src/cli/install_command.zig after a successful install when any
    of local_package_features.{dev,optional,peer} is disabled (e.g. via --omit or production).
  • Deletions are scheduled asynchronously on the existing thread pool so foreground install
    performance is preserved.

Why this approach

  • Performance: top-level-only scanning and hash-based membership checks are cheap even on large projects.
  • Safety: avoids recursive sweeping of the full node_modules tree to reduce risk and I/O cost.
  • UX: removes stale top-level packages users reasonably expect to disappear after --omit.

Files changed

  • src/install/PackageInstall.zig
    • Add scheduleTopLevelOmittedCleanup(manager) with two-pass candidate discovery and background DeleteTask scheduling.
  • src/cli/install_command.zig
    • Invoke cleanup after install when local dependency features are disabled.

Testing & verification

Manual test plan:

  1. bun install to populate node_modules including devDependencies.
  2. bun install --omit=dev — verify that top-level dev package directories not present in the final lockfile are deleted.
  3. Verify scoped packages (@scope/pkg) behavior — immediate children are considered and removed when omitted.
  4. Verify workspace symlinks / workspace packages are preserved when present in the lockfile.
  5. Confirm no-op when node_modules is missing.

Automated:

  • Run existing CI/tests. This change is limited to the installer; run full CI to validate.

Performance notes

  • The cleanup scans only top-level node_modules entries and immediate scoped children (no deep recursion).
  • Deletions are scheduled on the installer thread pool and the pending-task counter is incremented once for the batch to minimize atomic contention.

Backward compatibility & risk

  • No change for installs without --omit flags.
  • The cleanup only removes top-level directories not present in the lockfile; nested or transitive dependencies are not touched.
  • Deletions are performed asynchronously; failures during deletion are logged (debug builds) but do not abort the install.
  • There is a small risk of removing packages if lockfile resolution is incorrect; however, membership is determined from the final lockfile produced by the install process to minimize false positives.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Jan 29, 2026

Walkthrough

The changes implement a new top-level cleanup mechanism for the package installation process. A public function is added to scan node_modules and schedule deletion of packages not listed in the lockfile. The install command is updated to invoke this cleanup after successful installation when dependency categories are missing.

Changes

Cohort / File(s) Summary
Install Command Integration
src/cli/install_command.zig
Introduces a public alias to PackageInstall and adds a runtime check to schedule top-level omitted cleanup after successful install when any dependency category (dev, optional, or peer) is absent.
Package Installation Cleanup
src/install/PackageInstall.zig
Implements new public function scheduleTopLevelOmittedCleanup that scans top-level node_modules for packages not in the lockfile, counts candidates, and schedules thread-pool deletion tasks with per-task error handling and debug logging support.

Suggested reviewers

  • Jarred-Sumner
  • cirospaciari
🚥 Pre-merge checks | ✅ 2
✅ Passed checks (2 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: adding cleanup for omitted top-level node_modules after install when using --omit flags.
Description check ✅ Passed The description is comprehensive and well-structured, exceeding the basic template with detailed sections on motivation, implementation, testing, and risk assessment.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 6

🤖 Fix all issues with AI agents
In `@src/cli/install_command.zig`:
- Around line 85-91: The cleanup call
PackageInstall.scheduleTopLevelOmittedCleanup(manager) is currently invoked with
try which will abort the install on allocation/errors; change it to run as
best-effort by catching and handling any error instead of propagating it (e.g.,
replace the try with a catch/if-error pattern that logs or warns the error and
continues), keeping the surrounding condition that checks
manager.options.local_package_features so the cleanup remains optional and
non-fatal to a successful bun install.

In `@src/install/PackageInstall.zig`:
- Around line 1237-1268: The pending-task counter can drift because
candidate_count is computed before a second pass that may fail or find different
entries; change the code to collect actual candidate package paths (e.g., into
an ArrayList or SliceList) during the first pass instead of only incrementing
candidate_count, then call manager.incrementPendingTasks with the collected list
length (list.len) and schedule deletions from that list in the second pass
(using the same collected entries when opening scope_dir or node entries);
alternatively, if you prefer minimal changes, ensure every failure in the second
pass (e.g., scope_dir.openDir, scope_dir.iterate, node_modules_dir.openDir)
decrements the pending counter via manager.decrementPendingTasks so pending
tasks cannot remain >0 — update logic around candidate_count,
manager.incrementPendingTasks, and the second-pass iteration to use the
collected list or perform symmetric decrement on failures.
- Around line 1231-1233: Replace the relative openDir("node_modules") calls that
use cwd with an absolute path built from FileSystem.instance.top_level_dir using
bun.path.joinAbsString(), e.g., construct a single absolute node_modules path
string once and use that value for both openDir/openFile calls (replace usages
of cwd and variable node_modules_dir with the new absolute path) so the code no
longer depends on current working directory; update the variables where
node_modules_dir and subsequent opens are created to use that joined absolute
path.
- Line 1233: Remove the early defer that closes node_modules_dir so we don't
double-close the handle after it is explicitly closed and reopened;
specifically, locate the variable node_modules_dir in PackageInstall.zig and
delete the first defer node_modules_dir.close() that runs before the explicit
node_modules_dir.close() and reassignment, and likewise remove the earlier defer
paired with the later defer at the second occurrence (the block around lines
1271–1273) so only the final close is deferred for the currently-open handle.
- Around line 1221-1227: Remove the inline imports inside
scheduleTopLevelOmittedCleanup: delete the local consts for std, bun,
FileSystem, String, and PackageNameHash and instead reference the existing
file-scope constants; then add a file-scope alias const PackageNameHash =
install.PackageNameHash to the top-level import block so
scheduleTopLevelOmittedCleanup uses the shared PackageNameHash constant rather
than creating duplicates.
- Around line 1294-1341: The DeleteTask structs use public field names and named
type literal initialization; change the field declarations to private by
prefixing with # (e.g., `#absolute_path`, `#task`), update all internal accesses in
methods run and deinit to use self.#absolute_path and this.#absolute_path, and
switch initializations from the named-type literal (DeleteTask{ ... }) to a
declaration literal with private keys (.{ .#absolute_path = abs, .#task = .{
.callback = &DeleteTask.run } }); apply the same changes for the second
DeleteTask instance as well and update the allocator create/assignment sites
that set t.* accordingly.

Comment on lines +85 to +91

if (!manager.options.local_package_features.dev_dependencies
or !manager.options.local_package_features.optional_dependencies
or !manager.options.local_package_features.peer_dependencies)
{
try PackageInstall.scheduleTopLevelOmittedCleanup(manager);
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# View the context around lines 85-91 in install_command.zig
sed -n '75,100p' src/cli/install_command.zig

Repository: oven-sh/bun

Length of output: 897


🏁 Script executed:

# Find the definition of scheduleTopLevelOmittedCleanup
fd -e zig | xargs rg -l "scheduleTopLevelOmittedCleanup" | head -5

Repository: oven-sh/bun

Length of output: 113


🏁 Script executed:

# Search for the function signature
rg -n "fn scheduleTopLevelOmittedCleanup" src/

Repository: oven-sh/bun

Length of output: 166


🏁 Script executed:

# View the implementation of scheduleTopLevelOmittedCleanup
sed -n '1221,1280p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 2791


🏁 Script executed:

# Look for similar cleanup patterns in PackageInstall.zig to understand error handling approach
rg -n "Cleanup" src/install/PackageInstall.zig -B 2 -A 2 | head -50

Repository: oven-sh/bun

Length of output: 234


🏁 Script executed:

# See more of the implementation to understand all error paths
sed -n '1221,1300p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 4121


🏁 Script executed:

# Check how this function is called in other places, if at all
rg -n "scheduleTopLevelOmittedCleanup" src/

Repository: oven-sh/bun

Length of output: 265


🏁 Script executed:

# Look for patterns of cleanup operations in PackageManager or PackageInstall to understand error handling style
rg -n "catch \|err\|" src/install/PackageInstall.zig | head -20

Repository: oven-sh/bun

Length of output: 1850


Treat omit-cleanup as best-effort to avoid failing successful installs.

try will propagate allocation errors from within the cleanup operation and abort bun install even though the package installation succeeded. Since this is supplementary cleanup for unused packages, it should gracefully degrade on error rather than fail the command.

🔧 Suggested change
-        try PackageInstall.scheduleTopLevelOmittedCleanup(manager);
+        PackageInstall.scheduleTopLevelOmittedCleanup(manager) catch |err| {
+            if (PackageManager.verbose_install) {
+                Output.debugWarn("omit cleanup skipped: {s}", .{`@errorName`(err)});
+            }
+        };
🤖 Prompt for AI Agents
In `@src/cli/install_command.zig` around lines 85 - 91, The cleanup call
PackageInstall.scheduleTopLevelOmittedCleanup(manager) is currently invoked with
try which will abort the install on allocation/errors; change it to run as
best-effort by catching and handling any error instead of propagating it (e.g.,
replace the try with a catch/if-error pattern that logs or warns the error and
continues), keeping the surrounding condition that checks
manager.options.local_package_features so the cleanup remains optional and
non-fatal to a successful bun install.

Comment on lines +1221 to +1227
pub fn scheduleTopLevelOmittedCleanup(manager: *PackageManager) !void {
const std = @import("std");
const bun = @import("bun");
const FileSystem = bun.fs.FileSystem;
const String = bun.Semver.String;
const PackageNameHash = bun.install.PackageNameHash;

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "PackageInstall.zig" 2>/dev/null | head -5

Repository: oven-sh/bun

Length of output: 37


🏁 Script executed:

# Find the file in the repository
fd -t f "PackageInstall.zig" 2>/dev/null | head -1

Repository: oven-sh/bun

Length of output: 85


🏁 Script executed:

# List files in src/install/
ls -la src/install/ | grep -i package

Repository: oven-sh/bun

Length of output: 486


🏁 Script executed:

# Check if the file exists and read the section in question
wc -l src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 90


🏁 Script executed:

# Read the specific section mentioned in the review
sed -n '1221,1227p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 352


🏁 Script executed:

# Get broader context around the function
sed -n '1215,1240p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 946


🏁 Script executed:

# Check the end of the file for imports
tail -50 src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 1651


🏁 Script executed:

# Look for `@import` statements in the file
rg -n "@import" src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 343


🏁 Script executed:

# Check for file-scope constants like PackageNameHash, FileSystem, String, etc.
rg -n "^const (std|bun|FileSystem|String|PackageNameHash)" src/install/PackageInstall.zig | head -20

Repository: oven-sh/bun

Length of output: 262


🏁 Script executed:

# More comprehensive search for file-level constants
rg -n "^const\s+" src/install/PackageInstall.zig | head -30

Repository: oven-sh/bun

Length of output: 1255


🏁 Script executed:

# Get the entire end of file (last 100 lines) to see import section
tail -100 src/install/PackageInstall.zig | cat -n

Repository: oven-sh/bun

Length of output: 4795


🏁 Script executed:

# Check what const declarations exist near the imports at file end
tail -150 src/install/PackageInstall.zig | grep -n "const"

Repository: oven-sh/bun

Length of output: 2160


🏁 Script executed:

# Directly read lines 1221-1227 with line numbers
cat -n src/install/PackageInstall.zig | sed -n '1221,1227p'

Repository: oven-sh/bun

Length of output: 401


🏁 Script executed:

# Let's get a larger section and count lines properly
sed -n '1215,1300p' src/install/PackageInstall.zig | cat -n

Repository: oven-sh/bun

Length of output: 4807


🏁 Script executed:

# Check the structure of imports at file end and beginning
echo "=== File start (first 50 lines) ===" && head -50 src/install/PackageInstall.zig && echo -e "\n=== File end (last 80 lines) ===" && tail -80 src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 4529


🏁 Script executed:

# Get file information and specific lines
echo "Total lines:" && wc -l src/install/PackageInstall.zig && echo -e "\n=== Lines 1-30 ===" && head -30 src/install/PackageInstall.zig && echo -e "\n=== Lines around 1221 ===" && sed -n '1215,1235p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 1603


🏁 Script executed:

# Simple check: look at the actual file content
head -40 src/install/PackageInstall.zig | tail -20

Repository: oven-sh/bun

Length of output: 714


🏁 Script executed:

# Try reading with explicit line ranges
awk 'NR>=1 && NR<=30' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 784


🏁 Script executed:

# Read the function at lines 1221-1235
awk 'NR>=1221 && NR<=1235' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 693


🏁 Script executed:

# Get the very last lines of the file
awk 'END{print NR}' src/install/PackageInstall.zig && echo "---Last 60 lines---" && awk 'NR>END-60' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 160


🏁 Script executed:

# Python to read file sections clearly
python3 << 'PYTHON_EOF'
with open("src/install/PackageInstall.zig", "r") as f:
    lines = f.readlines()
    
print(f"Total lines: {len(lines)}\n")
print("=== First 40 lines ===")
for i, line in enumerate(lines[:40], 1):
    print(f"{i:4d}: {line}", end="")
    
print("\n\n=== Lines 1221-1235 ===")
for i, line in enumerate(lines[1220:1235], 1221):
    print(f"{i:4d}: {line}", end="")
    
print("\n\n=== Last 70 lines ===")
for i, line in enumerate(lines[-70:], len(lines)-70+1):
    print(f"{i:4d}: {line}", end="")
PYTHON_EOF

Repository: oven-sh/bun

Length of output: 5224


Remove inline imports and use file-scope constants.
The @import("std"), @import("bun"), and derived aliases at lines 1222–1226 duplicate declarations already available at file scope (lines 1654–1671). Inline @import violates the Zig style guideline.

Use the existing file-scope constants directly and add PackageNameHash to the file-scope import section:

Changes required

In the function, remove lines 1222–1226:

-        const std = `@import`("std");
-        const bun = `@import`("bun");
-        const FileSystem = bun.fs.FileSystem;
-        const String = bun.Semver.String;
-        const PackageNameHash = bun.install.PackageNameHash;

At the file-scope constants after line 1681, add:

const PackageNameHash = install.PackageNameHash;
🤖 Prompt for AI Agents
In `@src/install/PackageInstall.zig` around lines 1221 - 1227, Remove the inline
imports inside scheduleTopLevelOmittedCleanup: delete the local consts for std,
bun, FileSystem, String, and PackageNameHash and instead reference the existing
file-scope constants; then add a file-scope alias const PackageNameHash =
install.PackageNameHash to the top-level import block so
scheduleTopLevelOmittedCleanup uses the shared PackageNameHash constant rather
than creating duplicates.

Comment on lines +1231 to +1233
var cwd = std.fs.cwd();
var node_modules_dir = cwd.openDir("node_modules", .{}) catch return;
defer node_modules_dir.close();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🛠️ Refactor suggestion | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# First, check the actual code at the specified lines
echo "=== Lines 1231-1233 ==="
sed -n '1225,1240p' src/install/PackageInstall.zig

echo -e "\n=== Lines 1272-1273 ==="
sed -n '1266,1280p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 1408


🏁 Script executed:

#!/bin/bash
# Search for all openDir calls with "node_modules" in the file
rg -n 'openDir\("node_modules"' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 216


🏁 Script executed:

#!/bin/bash
# Check if FileSystem.instance.top_level_dir is available and used elsewhere
rg -n 'FileSystem\.instance\.top_level_dir' src/install/PackageInstall.zig | head -20

Repository: oven-sh/bun

Length of output: 631


🏁 Script executed:

#!/bin/bash
# Verify bun.openDirA and bun.path.joinAbsString usage patterns in the codebase
echo "=== bun.openDirA usage examples ==="
rg -n 'bun\.openDirA' src/ -A 2 | head -30

echo -e "\n=== bun.path.joinAbsString usage examples ==="
rg -n 'bun\.path\.joinAbsString' src/ -A 2 | head -30

Repository: oven-sh/bun

Length of output: 4512


🏁 Script executed:

#!/bin/bash
# Check the context around bun.openDirA calls to see if they truly use absolute paths
echo "=== Context for line 1157 (bun.openDirA usage) ==="
sed -n '1150,1165p' src/install/PackageInstall.zig

echo -e "\n=== Check what dirname is in these contexts ==="
sed -n '1145,1160p' src/install/PackageInstall.zig | grep -B5 "dirname"

Repository: oven-sh/bun

Length of output: 1782


🏁 Script executed:

#!/bin/bash
# Check if there's a pattern showing bun.openDirAbsolute vs bun.openDirA
echo "=== Check bun.openDirAbsolute usage pattern ==="
rg -B3 'bun\.openDirAbsolute' src/install/ | head -40

Repository: oven-sh/bun

Length of output: 1428


🏁 Script executed:

#!/bin/bash
# Verify: does top_level_dir actually contain "node_modules" subdirectory?
# Check how it's used in nearby lines
sed -n '1180,1295p' src/install/PackageInstall.zig | grep -A2 -B2 "top_level_dir"

Repository: oven-sh/bun

Length of output: 815


Replace relative node_modules path with absolute path.

Opening "node_modules" relative to cwd violates the absolute-path requirement and can scan the wrong directory if cwd changes. Use FileSystem.instance.top_level_dir with bun.path.joinAbsString() and reuse it for both opens.

🔧 Suggested change
-        var cwd = std.fs.cwd();
-        var node_modules_dir = cwd.openDir("node_modules", .{}) catch return;
+        var cwd = std.fs.cwd();
+        const node_modules_path = bun.path.joinAbsString(FileSystem.instance.top_level_dir, &.{ "node_modules" }, .auto);
+        var node_modules_dir = bun.openDirA(cwd, node_modules_path) catch return;
-        node_modules_dir = cwd.openDir("node_modules", .{}) catch return;
+        node_modules_dir = bun.openDirA(cwd, node_modules_path) catch return;

Also applies to: 1272-1273

🤖 Prompt for AI Agents
In `@src/install/PackageInstall.zig` around lines 1231 - 1233, Replace the
relative openDir("node_modules") calls that use cwd with an absolute path built
from FileSystem.instance.top_level_dir using bun.path.joinAbsString(), e.g.,
construct a single absolute node_modules path string once and use that value for
both openDir/openFile calls (replace usages of cwd and variable node_modules_dir
with the new absolute path) so the code no longer depends on current working
directory; update the variables where node_modules_dir and subsequent opens are
created to use that joined absolute path.

// Try open top-level node_modules; nothing to do if missing.
var cwd = std.fs.cwd();
var node_modules_dir = cwd.openDir("node_modules", .{}) catch return;
defer node_modules_dir.close();
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# Get the file size first to determine read strategy
wc -l src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 90


🏁 Script executed:

# Read context around line 1233 and lines 1271-1273
sed -n '1220,1250p' src/install/PackageInstall.zig | cat -n

Repository: oven-sh/bun

Length of output: 1550


🏁 Script executed:

# Read context around lines 1271-1273
sed -n '1260,1280p' src/install/PackageInstall.zig | cat -n

Repository: oven-sh/bun

Length of output: 1069


🏁 Script executed:

# Search for all node_modules_dir defer statements
rg -n "defer.*node_modules_dir" src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 144


🏁 Script executed:

# Get broader context to understand scope and variable lifecycle
sed -n '1200,1300p' src/install/PackageInstall.zig | cat -n

Repository: oven-sh/bun

Length of output: 5418


Remove the first defer node_modules_dir.close() to prevent double-closing the reopened handle.

After the explicit node_modules_dir.close() at line 1258 and reassignment, the first defer (line 1220) remains active and will attempt to close the newly-opened handle at function exit, conflicting with the second defer (line 1260).

Suggested fix
         // Try open top-level node_modules; nothing to do if missing.
         var cwd = std.fs.cwd();
         var node_modules_dir = cwd.openDir("node_modules", .{}) catch return;
-        defer node_modules_dir.close();

The same issue occurs at lines 1271–1273 where the second defer is added after reassignment.

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defer node_modules_dir.close();
🤖 Prompt for AI Agents
In `@src/install/PackageInstall.zig` at line 1233, Remove the early defer that
closes node_modules_dir so we don't double-close the handle after it is
explicitly closed and reopened; specifically, locate the variable
node_modules_dir in PackageInstall.zig and delete the first defer
node_modules_dir.close() that runs before the explicit node_modules_dir.close()
and reassignment, and likewise remove the earlier defer paired with the later
defer at the second occurrence (the block around lines 1271–1273) so only the
final close is deferred for the currently-open handle.

Comment on lines +1237 to +1268
// PASS 1: count candidates
var candidate_count: usize = 0;
var it = node_modules_dir.iterate();
while (it.next() catch null) |entry| {
if (!(entry.kind == .directory or entry.kind == .sym_link)) continue;
const name = entry.name;
if (name.len == 0) continue;
if (name[0] == '.') continue;

if (name[0] == '@') {
// scoped packages: check immediate children only
var scope_dir = node_modules_dir.openDir(name) catch continue;
defer scope_dir.close();
var sit = scope_dir.iterate();
while (sit.next() catch null) |inner| {
if (!(inner.kind == .directory or inner.kind == .sym_link)) continue;
const buf = try std.fmt.allocPrint(bun.default_allocator, "{s}/{s}", .{ name, inner.name });
const hash = String.Builder.stringHash(buf);
if (std.mem.indexOfScalar(PackageNameHash, name_hashes, hash) == null) candidate_count += 1;
bun.default_allocator.free(buf);
}
} else {
const hash = String.Builder.stringHash(name);
if (std.mem.indexOfScalar(PackageNameHash, name_hashes, hash) == null) candidate_count += 1;
}
}

if (candidate_count == 0) return;

// Increment pending tasks once for the batch
manager.incrementPendingTasks(@intCast(u32, candidate_count));

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Pending-task count can drift from scheduled deletions.
candidate_count is computed before rescan, then incrementPendingTasks is called even if the second open fails or the directory contents change. That can leave the pending counter permanently >0 and keep the installer alive.

Consider collecting candidate paths in pass 1 (ArrayList), incrementing by list.len, and scheduling from that list, or adjust the counter when pass 2 fails.

Also applies to: 1270-1273

🤖 Prompt for AI Agents
In `@src/install/PackageInstall.zig` around lines 1237 - 1268, The pending-task
counter can drift because candidate_count is computed before a second pass that
may fail or find different entries; change the code to collect actual candidate
package paths (e.g., into an ArrayList or SliceList) during the first pass
instead of only incrementing candidate_count, then call
manager.incrementPendingTasks with the collected list length (list.len) and
schedule deletions from that list in the second pass (using the same collected
entries when opening scope_dir or node entries); alternatively, if you prefer
minimal changes, ensure every failure in the second pass (e.g.,
scope_dir.openDir, scope_dir.iterate, node_modules_dir.openDir) decrements the
pending counter via manager.decrementPendingTasks so pending tasks cannot remain
>0 — update logic around candidate_count, manager.incrementPendingTasks, and the
second-pass iteration to use the collected list or perform symmetric decrement
on failures.

Comment on lines +1294 to +1341
const DeleteTask = struct {
absolute_path: []const u8,
task: jsc.WorkPoolTask = .{ .callback = &run },

pub fn run(task_ptr: *jsc.WorkPoolTask) void {
var self: *@This() = @fieldParentPtr("task", task_ptr);
var debug_timer = bun.Output.DebugTimer.start();
defer {
PackageManager.get().decrementPendingTasks();
PackageManager.get().wake();
}
defer self.deinit();

const dirname = std.fs.path.dirname(self.absolute_path) orelse {
Output.debugWarn("Unexpectedly failed to get dirname of {s}", .{ self.absolute_path });
return;
};
const basename = std.fs.path.basename(self.absolute_path);

var dir = bun.openDirA(std.fs.cwd(), dirname) catch |err| {
if (comptime Environment.isDebug or Environment.enable_asan) {
Output.debugWarn("Failed to delete {s}: {s}", .{ self.absolute_path, @errorName(err) });
}
return;
};
defer bun.FD.fromStdDir(dir).close();

dir.deleteTree(basename) catch |err| {
if (comptime Environment.isDebug or Environment.enable_asan) {
Output.debugWarn("Failed to delete {s} in {s}: {s}", .{ basename, dirname, @errorName(err) });
}
};

if (Environment.isDebug) {
_ = &debug_timer;
debug("deleteTree({s}, {s}) = {f}", .{ basename, dirname, debug_timer });
}
}

pub fn deinit(this: *@This()) void {
bun.default_allocator.free(this.absolute_path);
bun.destroy(this);
}
};

var t = try bun.default_allocator.create(DeleteTask);
t.* = DeleteTask{ .absolute_path = abs, .task = .{ .callback = &DeleteTask.run } };
manager.thread_pool.schedule(bun.ThreadPool.Batch.from(&t.task));
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

sed -n '1294,1341p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 2854


🏁 Script executed:

sed -n '1350,1397p' src/install/PackageInstall.zig

Repository: oven-sh/bun

Length of output: 2690


🏁 Script executed:

rg -n "DeleteTask" src/install/PackageInstall.zig | head -20

Repository: oven-sh/bun

Length of output: 548


🏁 Script executed:

rg -B 3 -A 1 '\.\{.*\.#' src --type zig | head -40

Repository: oven-sh/bun

Length of output: 2558


🏁 Script executed:

rg '#\w+.*=' src --type zig -B 2 -A 2 | grep -A 5 'struct {' | head -50

Repository: oven-sh/bun

Length of output: 1171


🏁 Script executed:

fd --type f --name "*.zig" -x rg -l '#\w+.*:.*=' {} \; | head -5

Repository: oven-sh/bun

Length of output: 305


🏁 Script executed:

# Search for struct field initialization patterns with # prefix
rg '= \.\{' src --type zig -B 5 | grep -E '(#\w+|struct \{)' | head -30

Repository: oven-sh/bun

Length of output: 1923


Apply # prefix for private fields and use decl literals in DeleteTask structs.

Both DeleteTask struct definitions (lines 1294-1341 and 1350-1397) are missing the # prefix on private fields and should use decl literals instead of named type literals for initialization. Update field declarations to use #absolute_path and #task, access them as self.#absolute_path and this.#absolute_path within methods, and initialize with .{ .#absolute_path = abs, .#task = ... } instead of DeleteTask{ ... }.

Suggested changes
-const DeleteTask = struct {
-    absolute_path: []const u8,
-    task: jsc.WorkPoolTask = .{ .callback = &run },
+const DeleteTask = struct {
+    `#absolute_path`: []const u8,
+    `#task`: jsc.WorkPoolTask = .{ .callback = &run },
     
     pub fn run(task_ptr: *jsc.WorkPoolTask) void {
         var self: *@This() = `@fieldParentPtr`("task", task_ptr);
-        const dirname = std.fs.path.dirname(self.absolute_path) orelse {
-            Output.debugWarn("Unexpectedly failed to get dirname of {s}", .{ self.absolute_path });
+        const dirname = std.fs.path.dirname(self.#absolute_path) orelse {
+            Output.debugWarn("Unexpectedly failed to get dirname of {s}", .{ self.#absolute_path });
         ...
     }
     
     pub fn deinit(this: *@This()) void {
-        bun.default_allocator.free(this.absolute_path);
+        bun.default_allocator.free(this.#absolute_path);
         bun.destroy(this);
     }
 };
 
 var t = try bun.default_allocator.create(DeleteTask);
-t.* = DeleteTask{ .absolute_path = abs, .task = .{ .callback = &DeleteTask.run } };
+t.* = .{ .#absolute_path = abs, .#task = .{ .callback = &DeleteTask.run } };
🤖 Prompt for AI Agents
In `@src/install/PackageInstall.zig` around lines 1294 - 1341, The DeleteTask
structs use public field names and named type literal initialization; change the
field declarations to private by prefixing with # (e.g., `#absolute_path`, `#task`),
update all internal accesses in methods run and deinit to use
self.#absolute_path and this.#absolute_path, and switch initializations from the
named-type literal (DeleteTask{ ... }) to a declaration literal with private
keys (.{ .#absolute_path = abs, .#task = .{ .callback = &DeleteTask.run } });
apply the same changes for the second DeleteTask instance as well and update the
allocator create/assignment sites that set t.* accordingly.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant