Skip to content

install: stop debug builds from wiping the shared node shim dir on every start - #37384

Closed
robobun wants to merge 6 commits into
mainfrom
farm/92e5f73a/node-shim-debug-wipe-race
Closed

robobun wants to merge 6 commits into
mainfrom
farm/92e5f73a/node-shim-debug-wipe-race

Conversation

@robobun

@robobun robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator

Repro

Debug builds only (bun bd). With a package.json script probe: node -e "console.log(typeof Bun)", start a handful of bun-debug run --bun probe at the same time. About 1-2% of them print undefined (the script fell through to the real node) or fail with node: not found:

$ bun /tmp/stress.ts ./build/debug/bun-debug 16 5     # 16 concurrent runs, 5 rounds
FAIL: {"out":"undefined","err":"$ node -e \"console.log(typeof Bun)\"","code":0}
FAIL: {"out":"undefined","err":"$ node -e \"console.log(typeof Bun)\"","code":0}
failures: 2/80

Any test file that runs several --bun processes or PATH-less lifecycle scripts concurrently (bun-run.test.ts and bun-install-lifecycle-scripts.test.ts are both describe.concurrent) can hit this under bun bd test, on Linux and on Windows. Release builds never wiped the directory, so the race itself is debug-only.

Cause

RunCommand::create_fake_temporary_node_executable (src/install/lib.rs) puts the node and bun links in one directory shared by every running bun of the same build: <tmp>/bun-node-<sha>, or the single fixed <tmp>/bun-node-debug for debug builds. Under #[cfg(bun_debug)] both the POSIX and the Windows arm ran delete_tree_absolute on that directory before recreating it, on every process start. A process starting up therefore removed the links a sibling had already planted and put on its scripts' PATH. (On NTFS, deleting a hard link to a running image succeeds as long as another link to it remains, so the Windows wipe removes live links as well; the test below fails on Windows against main too.) Depending on timing the sibling's script found no node at all, or the sibling's own symlink() failed with ENOENT because the directory was gone between its mkdir and symlink, in which case it silently went on without the shim directory on PATH.

The wipe existed so that links left behind by an earlier debug binary would not be reused blindly.

Fix

Drop the wipe in both arms and have the "link already exists" path verify the link instead, so the only thing ever removed from the directory is a link that belongs to some other binary:

  • POSIX already did this: the EEXIST branch readlinks the link and re-points it once if it targets a different path, and a rebuilt binary at the same path is picked up through the symlink. Only the wipe is removed.
  • Windows reused an existing hard link blindly. It now compares the identity (st_dev/st_ino, volume serial plus file index, the same check node_fs.rs uses to detect copying a file onto itself) of the existing link with the running image, reuses it on a match, and otherwise deletes that one link and creates it again, mirroring the POSIX replaced loop. A rebuilt binary is a new file, so this keeps the protection the wipe provided, and it also stops Windows release builds from reusing a link made by a different binary at the same commit. The mkdir-and-retry on other errors is unchanged.

Concurrent starts of the same binary now never remove anything. Two different binaries sharing the directory still re-point the links back and forth, which is the pre-existing release behavior on POSIX.

Test environment

The bun-install-lifecycle-scripts.test.ts failures that prompted this had a second, unrelated cause. bun bd goes through bun run, which exports NODE, npm_node_execpath and a set of npm_* variables to its script, and the harness bunEnv spreads process.env, so every bun spawned by a test inherits them. EnvLoader::get_node_path honors NODE/npm_node_execpath before looking at PATH, so bun install with PATH="" never created the shims. Most npm_* values are only set when absent, which is also why the npm_config_user_agent test in bunx.test.ts fails under bun bd test on main. bunEnv now strips NODE and npm_* next to the existing NODE_ENV/BUN_DEBUG_* strips. CI starts bun test directly and never had these variables, so this changes local runs only. The two PATH-less lifecycle tests now spawn with { ...testEnv, PATH: "" }; before, one variant spread the env before PATH was cleared and the other spawned with the raw env, so neither waiter-thread variant tested what it said.

Verification

New test in test/cli/install/bun-run.test.ts: one bun run --bun whose script runs as node through the shim, prints what node resolves to, and keeps re-resolving it until told to stop, while four more bun run --bun processes start up and run node through the shims. The watcher re-resolves about 40k times a second in a debug build and a wipe leaves the link missing for a whole delete-tree, mkdir and relink cycle, so every wipe is caught: against main's lib.rs it failed 6/6 on Linux and 6/6 on Windows (plus roughly 200 runs of earlier variants of the same shape, some under load) with

-   "exitCode": 0,
+   "exitCode": 1,
-     "ok",
+     "missing",

and with this branch it passes 6/6 on both (debug builds). Release builds have no wipe, so in CI the test passes before and after; the fail-before is specific to bun bd.

The Windows identity check was also exercised directly: with %TEMP%\bun-node-debug\node.exe replaced by a hard link to cmd.exe, one bun-debug run --bun re-pointed it at bun-debug.exe (checked with fsutil hardlink list) and left the still-correct bun.exe link alone.

Also green with bun bd test: bun-run.test.ts (Linux 293, Windows 403), bun-install-lifecycle-scripts.test.ts (118 on Linux, 3 of which failed before; the two PATH-less tests on Windows), the npm_config_user_agent test in bunx.test.ts on both platforms (failed locally before), and test/regression/issue/26207.test.ts.

Related: #35565 also removes the POSIX wipe, as one part of re-keying the directory per user. This PR is only the race fix and can land independently of that design change.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/install/bun-install-lifecycle-scripts.test.ts test/cli/install/bun-run.test.ts

…ery start

create_fake_temporary_node_executable deleted /tmp/bun-node-debug before
recreating it in debug builds. The directory is shared by every concurrently
running debug bun, so one process starting up removed the node/bun links a
sibling had already put on its scripts' PATH, and the sibling's script failed
with "node: not found" or ran a real node instead. The EEXIST path already
re-points a link that targets a different binary, which is all the cleanup
the directory needs.

The two lifecycle-script tests that run with an empty PATH also drop
NODE/npm_node_execpath from the environment. Under "bun bd test" the runner
is itself started through "bun run", which sets those to the real node, and
bun honors them instead of creating the shims, so the tests failed for an
unrelated reason.
@coderabbitai

coderabbitai Bot commented Aug 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

The change removes destructive temporary-link cleanup, adds identity-based Windows link recovery, and documents POSIX collision handling. It adds concurrent shim-race coverage and isolates test subprocess environments from inherited Node and npm metadata.

Changes

Concurrent link and test-environment safety

Layer / File(s) Summary
POSIX and Windows link handling
src/install/lib.rs
POSIX link creation preserves valid existing links. Windows link creation validates file identity, removes stale links once, creates missing directories, and retries.
Concurrent shim regression coverage
test/cli/install/bun-run.test.ts
A regression test runs concurrent --bun processes while monitoring the shared node shim for missing or changed resolution.
Isolated subprocess environments
test/cli/install/bun-install-lifecycle-scripts.test.ts, test/harness.ts
Lifecycle tests pass copied environments with an empty PATH. The harness removes NODE and npm_* variables from test environments.

Possibly related PRs

  • oven-sh/bun#33622: Addresses related concurrent shim handling and temporary-link race safety.
  • oven-sh/bun#35105: Modifies the same temporary Node link handling in src/install/lib.rs.
  • oven-sh/bun#36955: Covers related concurrent install link creation and same-file validation.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly summarizes the primary fix: preventing debug builds from deleting the shared Node shim directory.
Description check ✅ Passed The description explains the cause, fix, affected tests, verification steps, and platform-specific behavior, satisfying the template requirements.

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

@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed (both platforms), waiting on CI.

Reproduced on linux-x64 with a debug build: 16 concurrent bun-debug run --bun <script that runs node> leave ~1-2% of the scripts running the real node (or failing with node: not found), because each starting debug process deleted the shared bun-node-debug directory out from under the others. The same wipe existed in the Windows arm and the new test in test/cli/install/bun-run.test.ts fails against main's src/install/lib.rs on both Linux (6/6) and Windows (6/6), and passes with this branch on both. Windows now verifies an existing hard link by file identity instead of wiping.

The three bun-install-lifecycle-scripts.test.ts failures seen under bun bd test had a separate cause (NODE/npm_* inherited from the bun run that launches bun bd); the harness bunEnv now strips those, which also fixes the npm_config_user_agent test in bunx.test.ts locally. Details in the PR description.

@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.

LGTM — debug-only removal of a racy directory wipe, with a well-constructed regression test.

What was reviewed:

  • Confirmed the removed delete_tree_absolute is redundant: the EEXIST branch already readlinks and re-points stale links, so debug now matches release behavior.
  • Checked the envWithoutNode refactor fixes the latent bug where the waiter-thread variant's testEnv was spread before env.PATH = "", so it never actually got an empty PATH; the second test now also correctly receives BUN_FEATURE_FLAG_FORCE_WAITER_THREAD.
  • Verified the new race test drains all pipes, uses await using, awaits the observable condition (first stdout line) before spawning siblings, and is stable under describe.concurrent since all processes use the same bunExe() and the fix guarantees no unlink of a matching link.
Extended reasoning...

Overview

This PR removes a 5-line #[cfg(bun_debug)] block in src/install/lib.rs that called delete_tree_absolute on the shared /tmp/bun-node-debug shim directory before every --bun run. The wipe raced with sibling debug processes that had already put that directory on their scripts' PATH, causing intermittent node: not found or fall-through to the real node. The EEXIST branch below the removed block already verifies the existing symlink target via readlink and re-points it if stale, so the wipe served no purpose. A comment is updated to document that a matching link must never be removed. The Windows debug wipe is intentionally left in place (hardlinks cannot be verified with readlink).

The two lifecycle-script tests that clear PATH are refactored to use a new envWithoutNode helper that also clears NODE/npm_node_execpath (which bun bd's bun run wrapper exports). This fixes a latent test bug: the waiter-thread variant's testEnv = { ...env, ... } was spread before env.PATH = "" was assigned, so it never actually ran with empty PATH; and ensureTempNodeGypScript works was passing raw env instead of testEnv, missing the waiter-thread flag entirely. A new test in bun-run.test.ts reproduces the race with a busy-polling watcher script plus four concurrent --bun siblings.

Security risks

None. The change is debug-build-only and strictly removes a destructive filesystem operation. Release builds are byte-identical. No auth, crypto, or untrusted-input paths are touched.

Level of scrutiny

Low-to-moderate. The native change is a 7-line deletion gated behind #[cfg(bun_debug)], aligning debug behavior with release. The test changes are larger by line count but are straightforward: a helper extraction, two call-site refactors that fix pre-existing test-only bugs (well explained in the PR description), and one new regression test. I traced the testEnv spread ordering to confirm the PR description's claim about the waiter-thread variant.

Other factors

The new race test follows harness conventions: tempDir, await using, forEachLine for line-buffered stdout, all pipes drained via Promise.all, awaits the observable readiness signal (first stdout line = the resolved shim path) before spawning siblings, and asserts a combined { stdout, stderr, exitCode } object. The busy-loop in watch.sh is justified inline (the missing-link window is only a few syscalls wide). The PR description reports 5/5 failures against main and 10/10 passes with the fix, plus both affected test files green under bun bd test. Skipped on Windows since the watcher is a sh script and the Windows path is unchanged.

…npm_* in the test harness

The Windows arm of create_fake_temporary_node_executable had the same
per-start delete_tree of the shared bun-node-debug directory, and deleting a
hard link to a running image succeeds on NTFS, so concurrent debug processes
lost each other's node.exe/bun.exe there as well. Instead of wiping, compare
the identity (st_dev/st_ino) of an existing link with the running image on
ALREADY_EXISTS and replace only a link that is a different file, mirroring the
readlink check in the POSIX arm. This keeps the protection against links left
by a previous build that the wipe provided, and also stops release builds from
blindly reusing a link made by another binary at the same commit.

The regression test now uses a JS watcher instead of a sh script so it runs on
Windows as well.

harness bunEnv now strips NODE and npm_* the same way it strips NODE_ENV and
BUN_DEBUG_*: `bun run` exports them to its script, so a runner started via
`bun bd test` leaks them into every spawned bun, where NODE/npm_node_execpath
suppress shim creation and most npm_* values are only set when absent. This
replaces the per-file helper in bun-install-lifecycle-scripts.test.ts and also
fixes bunx.test.ts's npm_config_user_agent test under `bun bd test`.
@robobun

robobun commented Aug 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 3:29 AM PT - Aug 11th, 2026

✅ @robobun, your commit b5437a265270fcb4db0a24db1b0694a9a5e952dd passed in Build #92150! 🎉


🧪   To try this PR locally:

bunx bun-pr 37384

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

bun-37384 --bun

Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved

@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/lib.rs`:
- Around line 692-699: Update the hard-link replacement logic around image_stat
and the existing-link handling so an unavailable executable identity is treated
as a match/keep condition. When openat_os_path or stat returns None, do not
remove or recreate an existing hard link; retain the current replacement
behavior only when the image identity is known and confirmed different.

In `@test/cli/install/bun-run.test.ts`:
- Around line 1144-1155: Update the watch.js loop in the install test to include
the sibling-process exit state as an additional termination condition, ensuring
the child stops if the parent fails before writing the stop file. Preserve the
existing Bun.which("node") validation and success behavior while preventing an
unbounded busy loop.

In `@test/harness.ts`:
- Around line 121-124: Update the environment-key filtering in the harness
cleanup block to match the “npm_” prefix case-insensitively, removing both
lower- and upper-case npm-prefixed variables such as NPM_CONFIG_REGISTRY while
preserving the existing NODE handling.
🪄 Autofix

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: e6721d5a-c1fc-4604-85c5-3732adea8306

📥 Commits

Reviewing files that changed from the base of the PR and between 9fcdea8 and 3c27dd1.

📒 Files selected for processing (4)
  • src/install/lib.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts
  • test/cli/install/bun-run.test.ts
  • test/harness.ts

Comment thread src/install/lib.rs Resolved
Comment thread test/cli/install/bun-run.test.ts Resolved
Comment thread test/harness.ts Resolved
Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved
Comment thread src/install/lib.rs Resolved
…stat'ed

Only a link known to be a different file is replaced; if the identity of the
running executable is unavailable, the existing link is left alone, as before.
The watcher fixture in the race test also gets a deadline so a killed test run
cannot leave it spinning.
Comment thread src/install/lib.rs Resolved

@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.

My earlier comment at src/install/lib.rs:727 is resolved — 0ac0025 switched the check to is_none_or, so an unknown image_stat now keeps the existing link. I reviewed this pass and found no bugs. A human look would still be worthwhile: the Windows ALREADY_EXISTS arm is now a stat-and-maybe-replace on release builds too (previously a pure no-op), and the bunEnv strip in test/harness.ts touches every test file.

What was reviewed:

  • Windows loop state machine (made_dir/replaced) — every path terminates; replaced bounds delete/retry to once per name.
  • is_none_or + inner .is_ok_and: link kept when image stat fails; only replaced when identity is known-different.
  • bunEnv strip scoped to NODE/lowercase npm_* — matches what bun run exports; CI never had these so no CI behavior change.
  • New test: watcher awaits its own shim line before siblings start; await using kills it on failure; assertions cover both watcher and sibling exit codes.
Extended reasoning...

Overview

This PR removes the #[cfg(bun_debug)] delete_tree_absolute wipe of the shared bun-node-* shim directory in both the POSIX and Windows arms of RunCommand::create_fake_temporary_node_executable, and replaces the Windows ALREADY_EXISTS => {} no-op with a file-identity check (st_dev/st_ino) that deletes and recreates only a link known to point at a different binary. It also strips NODE and lowercase npm_* from bunEnv/ciEnv in test/harness.ts, fixes two lifecycle-script tests to spawn with {...testEnv, PATH: ""} instead of mutating the shared env, and adds a concurrent shim-race test in bun-run.test.ts.

Prior review

I left an inline comment on an earlier commit noting that is_some_and on a None image_stat would still delete-and-recreate. That was fixed in 0ac0025: the check is now let keep = image_stat.as_ref().is_none_or(|image| ...), so keep = true when the running image cannot be stat'ed and the existing link is left alone. The current diff reflects this. The comment-cop and CodeRabbit threads are all resolved.

Security risks

None identified. The shim directory ownership/mode check on POSIX is unchanged; the Windows path adds a read-only open + stat of an existing hard link. No user input reaches the changed code.

Level of scrutiny

Medium-high. The POSIX side is a strict deletion of debug-only code plus a one-line comment. The Windows side is new control flow (a loop with two boolean guards) that now runs on release builds as well — before this PR, release Windows hit ALREADY_EXISTS => {} and never touched an existing link. The PR description argues this is a correctness improvement (stops release Windows reusing a link at a different binary of the same commit), and the replaced guard bounds the delete to once per name, but it is a runtime behavior change on a path every bun run --bun and lifecycle script goes through. The test/harness.ts change affects the environment of every spawned subprocess in the test suite; the reasoning (only strips what bun run leaks, CI never had these) is sound and was verified on both platforms per the PR thread.

Other factors

The new test is well-constructed: it awaits the watcher's first stdout line (the shim path, asserted to match /bun-node.../node) before starting siblings, uses await using for cleanup, drains stderr, and asserts both the watcher's and siblings' exit codes. The PR description documents 6/6 fail-before on both platforms with bun bd and a manual fsutil hardlink list check of the Windows identity path. The two lifecycle-script test fixes correct a real bug (one variant spread testEnv before mutating env.PATH, the other passed raw env).

Given the release-Windows behavior change and the harness-wide env change, I'm deferring rather than approving so a maintainer can confirm both are intended.

@robobun

robobun commented Aug 27, 2026

Copy link
Copy Markdown
Collaborator Author

The same race showed up again at 5ff2d9f. Main still has both #[cfg(bun_debug)] wipes in src/install/lib.rs (lines 568 and 704).

Probe: a package.json script one: node -e "console.log('Bun is ' + typeof Bun)", 8 concurrent bun-debug --bun run --silent one, 5 rounds. 2 of 40 printed Bun is undefined. It also flaked a describe.concurrent block of five --bun tests in test/cli/run/multi-run.test.ts on a debug lane. That block is serial now to work around it.

This branch now conflicts with main in test/cli/install/bun-run.test.ts only: the harness import line, and the new test was appended where main also appended tests. src/install/lib.rs, test/harness.ts, and bun-install-lifecycle-scripts.test.ts merge cleanly. The helpers the Windows arm uses (File::openat_os_path, openat_windows, DeleteFileBun, exe_path_w, mkdir_w) are unchanged on main, so a rebase needs no code change.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

This is Claude (an AI assistant) writing on Jarred's behalf.

Included in #44968.

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

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants