Skip to content

Make the Windows hardlink install backend link serially - #33113

Open
dylan-conway wants to merge 3 commits into
mainfrom
claude/windows-serial-hardlink-install
Open

dylan-conway wants to merge 3 commits into
mainfrom
claude/windows-serial-hardlink-install

Conversation

@dylan-conway

Copy link
Copy Markdown
Member

Summary

bun install on Windows (default --backend hardlink) materialized node_modules ~6x slower than --backend copyfile — about 3.7ms per file on a 24-core Windows 11 machine with Defender's real-time protection on — even though a single CreateHardLinkW costs the same ~0.5ms as a CopyFileW on the same volume.

The cause is not the syscall and not extra per-file work (the hardlink path issues fewer syscalls than copyfile). The Windows-only code fanned each package's per-file links out to the full cpu_count thread pool and blocked on a per-package barrier. Every barrier resynchronizes the pool, so each package starts with a synchronized burst of ~24 concurrent CreateHardLinkW calls. Those bursts trigger pathological antivirus filter work (MsMpEng burned 20+ CPU-seconds during a 1.7s install; the same links unbatched cost it ~3s, mostly asynchronous), which pends a few links per package for 20–180ms — and the barrier puts every straggler on the critical path. Install time scales almost linearly with pool size: 425ms at 2 threads, 1914ms at 24, for a 12-package / 516-file warm install. Link creation is kernel-serialized anyway, so the pool bought nothing even without the barrier (steady-state parallel linking measures 0.7ms/op vs 0.55ms/op serial).

This change makes the Windows branch link inline and serially on the install thread, exactly like the unix branch's linkat loop:

  • Warm link-only install: 1921ms → 367ms (debug-build A/B, same toolchain), with byte-identical output trees; a raw-syscall simulation of the same serial pattern lands at ~300ms, parity with copyfile, while keeping hardlink's size-independent cost and cache disk-sharing for large files.
  • Destination directories are now created from the walker's Directory entries (the walker yields a directory before its contents), so the first file in each directory no longer pays a guaranteed-failed CreateHardLinkW through the AV filter, and empty directories install like the unix and copyfile branches.
  • The now-unused HardLinkWindowsInstallTask / NewTaskQueue / HasWorkPoolTask / HARDLINK_QUEUE machinery is deleted, along with the dead FailedToCopyFile error arm (nothing produces that error).
  • Fixes an off-by-one shared by the hardlink/copyfile/symlink branches: a path exactly filling the wide-path buffer tail passed the NameTooLong guard and panicked on the NUL terminator write instead of returning an error.
  • Error semantics now match unix: the first failing file aborts the package instead of attempting every remaining file and reporting the first error after the barrier.

Test plan

  • New test/cli/install/install-backends.test.ts: per-backend (hardlink/copyfile, +clonefile on macOS) tree-correctness from a local tarball, nlink > 1 proves the hardlink backend actually links (and nlink == 1 for copy backends), and a --force reinstall re-materializes deleted files. Passes on the debug build and on the released bun.
  • Output-tree equivalence: hardlink vs copyfile installs produce 516/516 byte-identical files (sha1) on the benchmark fixture.
  • Warm-install A/B on the same debug toolchain: hardlink 1921ms → 367ms; copyfile unchanged (~360ms → ~313ms).
  • test/cli/install/bun-link.test.ts, bun-add.test.ts, bun-install-pathname-trailing-slash.test.ts on the patched Windows debug build (the one bun-add failure is a pre-existing environment issue: its fixture shells out to bare tar, which fails under GNU tar when Git's usr/bin precedes System32 in PATH; it fails identically without this change).
  • cargo check across all 10 target/platform combinations.

…hread pool

Synchronized CreateHardLinkW bursts from the per-package thread-pool
fan-out get pended 20-180ms each by the AV minifilter, and the
per-package barrier put every straggler on the critical path (~3.7ms
per file; 6x slower than --backend copyfile on a 24-core machine with
Defender on, scaling almost linearly with pool size). Link creation is
kernel-serialized anyway, so the pool bought nothing even without the
barrier.

Link serially on the install thread like the unix branch: a 12-package
/ 516-file warm install drops from ~1.9s to ~0.37s (debug build A/B),
with byte-identical output trees. Create destination directories from
the walker's Directory entries (the walker yields a directory before
its contents) so the first file in each directory no longer pays a
doomed CreateHardLinkW through the filter, and so empty directories
install like the unix and copyfile branches.

Delete the now-unused HardLinkWindowsInstallTask, NewTaskQueue,
HasWorkPoolTask, and HARDLINK_QUEUE machinery; drop the dead
FailedToCopyFile arm (no producer exists); fix the NameTooLong guard
off-by-one shared by the hardlink/copyfile/symlink branches (a path
exactly filling the buffer tail panicked on the NUL write instead of
returning NameTooLong).

Error semantics now match unix: the first failing file aborts the
package instead of attempting every remaining file and reporting the
first error after the barrier.
@robobun

robobun commented Jun 30, 2026 •

Copy link
Copy Markdown
Collaborator
Updated 8:32 PM PT - Jun 29th, 2026

❌ @robobun, your commit 4583418 has 4 failures in Build #67040 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 33113

That installs a local version of the PR into your bun-33113 executable, so you can run:

bun-33113 --bun

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. error: Failed to link ***: EPERM on Windows #13659 - EPERM errors during hardlinking on Windows caused by antivirus (360 Total Security) quarantining files during concurrent CreateHardLinkW bursts; serial linking and CopyFileW fallback should help
  2. Install slower than pnpm and getting stuck on random packages. #14379 - Install slower than pnpm and getting stuck on random packages on Windows; directly addressed by eliminating concurrent CreateHardLinkW bursts that trigger Defender-induced stalls
  3. Bun unsable on windows, Stuck on Skip installing #18941 - bun install freezes during install phase on Windows; matches the thread pool + Defender contention pattern this PR fixes
  4. bun install reports success but creates empty package dirs on OneDrive-mounted Windows paths #30264 - bun install reports success but creates empty package dirs on OneDrive-mounted Windows paths; the PR's changed error semantics (abort on first failure) and CopyFileW fallback should surface or fix these silent failures

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #13659
Fixes #14379
Fixes #18941
Fixes #30264

🤖 Generated with Claude Code

@coderabbitai

coderabbitai Bot commented Jun 30, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 29 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 2d8a8420-bbe8-41b4-8a85-1432e2e83bf4

📥 Commits

Reviewing files that changed from the base of the PR and between 93307ca and 4583418.

📒 Files selected for processing (2)
  • src/install/PackageInstall.rs
  • test/cli/install/install-backends.test.ts

Walkthrough

Windows package hardlink installation is rewritten from a thread-pool task-queue model to a synchronous inline walker. A new hardlink_file_windows helper encapsulates CreateHardLinkW with delete-and-retry and CopyFileW fallback. Off-by-one NUL-terminator bounds checks (> → >=) are fixed in hardlink and symlink path-buffer code. A new test suite verifies all install backends.

Changes

Windows Hardlink Install Rewrite + Tests

Layer / File(s) Summary
hardlink_file_windows helper and import cleanup
src/install/PackageInstall.rs, src/install/npm.rs
Adds Windows-only hardlink_file_windows performing CreateHardLinkW with delete-and-retry for FILE_EXISTS/ACCESS_DENIED, mkdir_recursive_os_path for missing parents, and CopyFileW fallback when hardlinks are unsupported. Removes the old NewTaskQueue/HardLinkWindowsInstallTask async infrastructure. Tightens cfg(not(windows)) import gating and updates a comment in npm.rs referencing the old function.
Synchronous walker loop and bounds fixes
src/install/PackageInstall.rs
Rewrites the Windows hardlink walker to call hardlink_file_windows inline without task scheduling. Changes NUL-terminator guards from > to >= in hardlink and symlink path-buffer construction. Removes the Windows-specific FailedToCopyFile post-loop branch and the queue init/wait code.
Backend install tests
test/cli/install/install-backends.test.ts
New concurrent test suite covering hardlink, copyfile, and (macOS) clonefile backends: verifies installed file byte contents, nlink > 1 for hardlinks vs nlink === 1 for copy backends, and --force restore of deleted files.

Possibly related PRs

  • oven-sh/bun#32643: Both PRs modify the Windows symlink install path-buffer logic in PackageInstall.rs; the retrieved PR changes symlink_w error handling while this PR fixes the >= NUL-terminator guard in the same code region.

Suggested reviewers

  • Jarred-Sumner
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly states the main change: Windows hardlink installs are now done serially.
Description check ✅ Passed It includes a clear summary and a detailed test plan, covering the template's required information.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/install/PackageInstall.rs`:
- Around line 1518-1523: The directory-handling branch in
PackageInstall::hardlink_file_windows is swallowing mkdir_w failures by
assigning the result to a discarded variable, which can hide empty-directory
creation errors. Update the is_dir path to check and propagate the sys::mkdir_w
result instead of treating it as best-effort, so the install fails when an empty
directory cannot be created and the package tree is preserved correctly.

In `@test/cli/install/install-backends.test.ts`:
- Around line 14-20: The install-backends fixture only covers file entries, so
the new Directory-walker path is not validated; update the PKG_FILES fixture in
install-backends.test.ts to include an actual empty directory entry and extend
the install assertion to verify that empty directory is materialized. Use the
existing tarball/fixture setup in the install-backends test to locate the
relevant package entries and keep the coverage tied to the backend install
behavior. This should exercise the empty-directory case directly so the
regression is covered by the same test suite.
- Around line 60-86: In the install backend test, assert the result of
install([]) and install(["--force"]) before any filesystem reads or stat calls,
so a failed bun install is reported directly instead of being masked by ENOENT
from Bun.file(...).text() or statSync(). Update the test in
test/cli/install/install-backends.test.ts by checking the first and second
install results immediately after each call, using the existing install() return
value and existing exitCode assertions, before touching pkgDir or any
node_modules paths.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 05cb5fd9-d8b3-4003-9dd4-bf9742e611a2

📥 Commits

Reviewing files that changed from the base of the PR and between 16a7269 and 93307ca.

📒 Files selected for processing (3)
  • src/install/PackageInstall.rs
  • src/install/npm.rs
  • test/cli/install/install-backends.test.ts

Comment on lines +1518 to +1523
if is_dir {
// The walker yields a directory before its contents, so each
// file's first CreateHardLinkW finds its parent (mirrors the
// unix arm's make_path; failures surface on the file ladder).
let _ = sys::mkdir_w(bun_core::WStr::from_buf(head1, dest_len));
continue;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Don't ignore directory-creation failures for empty directory entries.

Line 1522 drops the sys::mkdir_w result. If an empty directory cannot be created, this walker can still return success because there is no later file entry to trip hardlink_file_windows, so the install silently omits part of the package tree. Bubble the error here instead of treating it as best-effort. As per coding guidelines, "Never swallow a failure or signal success on one." Based on PR objectives, this path is supposed to preserve empty directories.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/install/PackageInstall.rs` around lines 1518 - 1523, The
directory-handling branch in PackageInstall::hardlink_file_windows is swallowing
mkdir_w failures by assigning the result to a discarded variable, which can hide
empty-directory creation errors. Update the is_dir path to check and propagate
the sys::mkdir_w result instead of treating it as best-effort, so the install
fails when an empty directory cannot be created and the package tree is
preserved correctly.

Source: Coding guidelines

Comment on lines +14 to +20
const PKG_FILES: Record<string, string> = {
"package/package.json": JSON.stringify({ name: "backend-pkg", version: "1.0.0", main: "index.js" }),
"package/index.js": `module.exports = require("./lib/a.js") + require("./lib/deep/b.js");\n`,
"package/lib/a.js": `module.exports = "a".repeat(64);\n`,
"package/lib/deep/b.js": `module.exports = "b".repeat(64);\n`,
"package/README.md": `# backend-pkg\n`,
};

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Add a real empty-directory case to this fixture.

This tarball only contains files, so the new walker path for Directory entries is never exercised. The nested lib/deep files cover “first file in a directory”, but the PR’s empty-directory materialization fix can still regress without this suite noticing. Please add an empty directory entry and assert it exists after install. Based on learnings, every behavioral change should ship with automated coverage in the same PR.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/install/install-backends.test.ts` around lines 14 - 20, The
install-backends fixture only covers file entries, so the new Directory-walker
path is not validated; update the PKG_FILES fixture in install-backends.test.ts
to include an actual empty directory entry and extend the install assertion to
verify that empty directory is materialized. Use the existing tarball/fixture
setup in the install-backends test to locate the relevant package entries and
keep the coverage tied to the backend install behavior. This should exercise the
empty-directory case directly so the regression is covered by the same test
suite.

Source: Coding guidelines

Comment on lines +60 to +86
const first = await install([]);
const pkgDir = join(String(projDir), "node_modules", "backend-pkg");

// Every file materialized with identical contents.
for (const [archivePath, contents] of Object.entries(PKG_FILES)) {
const rel = archivePath.replace("package/", "");
expect(await Bun.file(join(pkgDir, rel)).text()).toBe(contents);
}

// hardlink must share the inode with the cache copy; copy-based
// backends must not.
const nlink = statSync(join(pkgDir, "lib", "deep", "b.js")).nlink;
if (backend === "hardlink") {
expect(nlink).toBeGreaterThan(1);
} else {
expect(nlink).toBe(1);
}

expect(first.exitCode).toBe(0);

// A forced reinstall must re-materialize deleted files through the same
// backend. (Deletion, not tampering: with hardlink the node_modules name
// shares the cache inode, so writes through it would corrupt the cache.)
rmSync(join(pkgDir, "lib", "a.js"));
const second = await install(["--force"]);
expect(await Bun.file(join(pkgDir, "lib", "a.js")).text()).toBe(PKG_FILES["package/lib/a.js"]);
expect(second.exitCode).toBe(0);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Assert the install result before touching node_modules.

If either install fails here, the later Bun.file(...).text() / statSync() calls can throw ENOENT and hide the real bun install failure. In test/cli/install, guard the install result before any filesystem assertions for both the first install and the --force reinstall.

Suggested change
       const first = await install([]);
       const pkgDir = join(String(projDir), "node_modules", "backend-pkg");
+      expect({ stdout: first.stdout, exitCode: first.exitCode }).toMatchObject({ exitCode: 0 });

       // Every file materialized with identical contents.
       for (const [archivePath, contents] of Object.entries(PKG_FILES)) {
         const rel = archivePath.replace("package/", "");
         expect(await Bun.file(join(pkgDir, rel)).text()).toBe(contents);
@@
-      expect(first.exitCode).toBe(0);
-
       // A forced reinstall must re-materialize deleted files through the same
       // backend. (Deletion, not tampering: with hardlink the node_modules name
       // shares the cache inode, so writes through it would corrupt the cache.)
       rmSync(join(pkgDir, "lib", "a.js"));
       const second = await install(["--force"]);
+      expect({ stdout: second.stdout, exitCode: second.exitCode }).toMatchObject({ exitCode: 0 });
       expect(await Bun.file(join(pkgDir, "lib", "a.js")).text()).toBe(PKG_FILES["package/lib/a.js"]);
-      expect(second.exitCode).toBe(0);
📝 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
const first = await install([]);
const pkgDir = join(String(projDir), "node_modules", "backend-pkg");
// Every file materialized with identical contents.
for (const [archivePath, contents] of Object.entries(PKG_FILES)) {
const rel = archivePath.replace("package/", "");
expect(await Bun.file(join(pkgDir, rel)).text()).toBe(contents);
}
// hardlink must share the inode with the cache copy; copy-based
// backends must not.
const nlink = statSync(join(pkgDir, "lib", "deep", "b.js")).nlink;
if (backend === "hardlink") {
expect(nlink).toBeGreaterThan(1);
} else {
expect(nlink).toBe(1);
}
expect(first.exitCode).toBe(0);
// A forced reinstall must re-materialize deleted files through the same
// backend. (Deletion, not tampering: with hardlink the node_modules name
// shares the cache inode, so writes through it would corrupt the cache.)
rmSync(join(pkgDir, "lib", "a.js"));
const second = await install(["--force"]);
expect(await Bun.file(join(pkgDir, "lib", "a.js")).text()).toBe(PKG_FILES["package/lib/a.js"]);
expect(second.exitCode).toBe(0);
const first = await install([]);
const pkgDir = join(String(projDir), "node_modules", "backend-pkg");
expect({ stdout: first.stdout, exitCode: first.exitCode }).toMatchObject({ exitCode: 0 });
// Every file materialized with identical contents.
for (const [archivePath, contents] of Object.entries(PKG_FILES)) {
const rel = archivePath.replace("package/", "");
expect(await Bun.file(join(pkgDir, rel)).text()).toBe(contents);
}
// hardlink must share the inode with the cache copy; copy-based
// backends must not.
const nlink = statSync(join(pkgDir, "lib", "deep", "b.js")).nlink;
if (backend === "hardlink") {
expect(nlink).toBeGreaterThan(1);
} else {
expect(nlink).toBe(1);
}
// A forced reinstall must re-materialize deleted files through the same
// backend. (Deletion, not tampering: with hardlink the node_modules name
// shares the cache inode, so writes through it would corrupt the cache.)
rmSync(join(pkgDir, "lib", "a.js"));
const second = await install(["--force"]);
expect({ stdout: second.stdout, exitCode: second.exitCode }).toMatchObject({ exitCode: 0 });
expect(await Bun.file(join(pkgDir, "lib", "a.js")).text()).toBe(PKG_FILES["package/lib/a.js"]);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@test/cli/install/install-backends.test.ts` around lines 60 - 86, In the
install backend test, assert the result of install([]) and install(["--force"])
before any filesystem reads or stat calls, so a failed bun install is reported
directly instead of being masked by ENOENT from Bun.file(...).text() or
statSync(). Update the test in test/cli/install/install-backends.test.ts by
checking the first and second install results immediately after each call, using
the existing install() return value and existing exitCode assertions, before
touching pkgDir or any node_modules paths.

Source: Learnings

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I didn't find any bugs, but this rewrites the default Windows install backend (removing the thread-pool/barrier machinery, changing error-abort semantics, and adding directory-entry handling), and CI showed build-rust failures on the pre-autofix commit — worth a human look before merge.

Extended reasoning...

Overview

This PR rewrites the Windows hardlink install backend in src/install/PackageInstall.rs from a thread-pool fan-out (HardLinkWindowsInstallTask / NewTaskQueue / HARDLINK_QUEUE / WaitGroup) to a serial inline loop on the install thread, mirroring the unix linkat path. It deletes ~200 lines of concurrency infrastructure, extracts a hardlink_file_windows helper, adds directory-entry handling in the walker (so parent dirs are created before their files), fixes a > → >= off-by-one in three NUL-terminator bounds guards (hardlink/copyfile/symlink), removes a dead FailedToCopyFile arm, tightens cfg-gated imports, updates a stale comment in npm.rs, and adds a new cross-backend correctness test.

Security risks

None identified. The change is confined to local filesystem operations (CreateHardLinkW/CopyFileW/mkdir) on paths already derived from the package cache and destination dir. No auth, crypto, network, or untrusted-input parsing is touched.

Level of scrutiny

High. This is the default install backend on Windows — every bun install on Windows runs this code path. The PR changes observable behavior in two ways: (1) error semantics now abort on the first failing file instead of attempting all files and reporting the first error after the barrier, and (2) empty directories are now materialized from walker Directory entries. Both are reasonable and well-argued in the description, but they are behavioral changes to a hot, platform-specific path that I cannot verify locally (Windows-only), and they could affect edge cases like partially-failed installs or packages with unusual directory layouts.

Other factors

  • CI: robobun reported build-rust failures across 9 non-Windows targets on commit 93307ca; an autofix.ci commit (29786eb) followed, but the timeline doesn't yet show a green build for it.
  • Outstanding comments: coderabbitai left three open comments (best-effort mkdir_w for empty dirs, missing empty-directory test coverage, and test exitCode assertion ordering). The let _ = mkdir_w is intentional per the inline comment ("failures surface on the file ladder") and matches the unix arm's let _ = make_path, but the author hasn't responded yet.
  • Test coverage: The new install-backends.test.ts is a good correctness check (per-backend tree contents + nlink verification + --force reinstall), though it doesn't exercise the Windows-specific code path on non-Windows CI.
  • The PR description is exceptionally thorough with benchmarks and rationale, and the bug hunter found nothing — but the scope (rewriting a default install backend, removing concurrency primitives, changing error semantics) is well beyond a mechanical change.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

oh i bet this makes it slower when anti-virus is not in use

Nothing produces this error, and the arm was a no-op: the unconditional
return below it builds the identical InstallResult::fail.
@robobun

robobun commented Jun 30, 2026

Copy link
Copy Markdown
Collaborator

I built and tested this on Windows and independently verified both correctness and the performance claim. Summary: the conclusion holds and the change is the right call, but one sentence in the justification is narrower than stated, so I measured the regime it doesn't cover.

Correctness

  • test/cli/install/install-backends.test.ts passes on the patched debug build on Windows and Linux, and against the released bun.
  • Output-tree equivalence on a 9634-file fixture: identical relative-path + size sets before vs after, nlink == 2 on a sampled installed file, so the after build is genuinely hardlinking rather than silently hitting the CopyFileW fallback (its timings are also well below the copyfile backend's on the same fixture).
  • All three NameTooLong off-by-one sites (hardlink, copyfile, symlink) got the >= fix. The isolated-install code uses a different buffer scheme (path_buffer_pool + make_open_path) and is not in the same class.
  • The walker yields a Directory entry before descending into it (walker_skippable.rs pushes the stack frame before the return Ok(Some(...))), so the single-level mkdir_w per directory entry is sufficient.

The performance claim without an AV filter

Every number in the description is from a machine with Defender's real-time protection on. The one sentence that generalizes past that is:

Link creation is kernel-serialized anyway, so the pool bought nothing even without the barrier (steady-state parallel linking measures 0.7ms/op vs 0.55ms/op serial)

I ran the same A/B on a 16-vCPU Windows Server 2019 VM with a local NVMe SSD and no Defender at all (the WinDefend service is not installed). That regime matters: excluding node_modules from real-time protection is the standard advice given to Windows JS developers, and many CI environments disable it or exclude the build tree.

Methodology: both binaries are debug builds from the same toolchain and worktree, differing only in src/install/PackageInstall.rs and src/install/npm.rs (before = fb24aac703's versions, after = this branch). Warm link-only installs (bun.lock present, cache populated, node_modules removed before every run), --ignore-scripts --backend hardlink, one BUN_INSTALL_CACHE_DIR on the same NTFS volume. 10 to 12 runs per binary per fixture in a randomized interleaved order, plus one discarded warmup each, to remove drift and ordering bias. Ranges below are the full sorted spread; the before and after ranges never overlap in any row.

fixture packages files before (thread pool) after (serial) result
typescript, @types/node, date-fns, rxjs, ramda, lodash 8 9634 median 735ms [697..785] median 904ms [877..946] after 1.23x slower
react, react-dom, + 10 small utils 15 243 median 177ms [170..189] median 170ms [163..176] after slightly faster
jest, eslint 329 5412 median 983ms [917..1021] median 810ms [739..866] after 1.21x faster

(A second pass of the first fixture on a noisier stretch of the VM landed at 808ms vs 1500ms, same direction, wider spread.)

So without an AV filter the result is shape-dependent, and it tracks the description's own analysis of where the per-package barrier hurts:

  • Many small packages, which is the shape of the description's own 12-package / 516-file fixture and of almost every real node_modules: serial wins, about 1.2x on jest+eslint. At roughly 16 files per package on a 16-thread pool, the per-file fan-out plus per-package barrier is pure overhead with or without an AV filter.
  • A few very large packages (typescript and @types/node are ~8600 of the 9634 files between them): serial loses, 1.2x to 1.9x here. Behind one barrier with thousands of files, the pool does get real parallelism out of NTFS once no filter driver is serializing it. The per-op numbers agree: before ~0.08ms/link effective, after ~0.09ms/link, both far below the 0.55 to 0.7ms measured under Defender in the description, so this VM and that machine are in different bottleneck regimes.

I think the debug-build numbers are, if anything, conservative for the rows where before wins: the per-file cost unique to the old path (a Box<[u16]> and a Box<Task> allocation per file, thread-pool scheduling, WaitGroup traffic) is exactly the part a release build shrinks most, while the CreateHardLinkW cost is a syscall and unaffected. A release A/B would widen that gap in the old path's favor, not close it.

Conclusion

The change still looks right to land:

  • With Defender on, which is the default for nearly every Windows user: the description's ~5x win, plus not burning 20+ CPU-seconds of MsMpEng per install.
  • Without Defender, on realistic many-package trees: still a win.
  • Without Defender, on trees dominated by a few huge packages: the one losing case, worth 1.2x to 1.9x of an already sub-second step.

And the RacyCell<MaybeUninit<HardLinkQueue>> / WaitGroup machinery goes away. I would just not lean on "link creation is kernel-serialized anyway" as a general fact; on this hardware it parallelizes 1.2x to 1.9x once the filter driver is out of the way. The real wins come from the per-package barrier and the AV interaction, which the rest of the description already argues correctly.

I also pushed 4583418: the description says the dead FailedToCopyFile arm was removed, but only the one in install_with_hardlink was. The identical no-op sibling in install_with_symlink was the last reference to that error name, so it is gone too.

This branch has not been deployed

No deployments
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.

3 participants