Skip to content

install: treat --concurrent-scripts=0 as the default instead of spinning forever - #42379

Closed
robobun wants to merge 1 commit into
mainfrom
robobun/8bcc3e63/concurrent-scripts-zero
Closed

robobun wants to merge 1 commit into
mainfrom
robobun/8bcc3e63/concurrent-scripts-zero

Conversation

@robobun

@robobun robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • bun install --concurrent-scripts=0 never exits once a dependency lifecycle script is queued. One core spins until the process is killed. bun pm trust --concurrent-scripts=0 does the same.
  • The CLI parser keeps the 0 (src/install/PackageManager/CommandLineArguments.rs:1604). With a limit of 0 no script can start, so each one is deferred. complete_remaining_scripts (src/install/PackageInstaller.rs:920) then loops on while alive_count >= max_concurrent_lifecycle_scripts { manager.sleep() }. That is 0 >= 0, and sleep() returns at once because nothing is alive. src/runtime/cli/pm_trusted_command.rs:472 has the same loop.

Fix

  • The CLI parser maps 0 to None. The limit then falls back to the default, 2x the CPU count.
  • bunfig.toml already does this for [install] concurrentScripts = 0 (src/bunfig/bunfig.rs:1364), and the docs call the two settings equivalent. The flag already falls back to the default for a value it cannot parse, for example abc.
  • Verified: a new case in test/cli/install/bun-install-lifecycle-scripts.test.ts runs bun install and bun pm trust --all with the flag. Release 6a92015 fails it (killed at the 30 s spawn timeout). The fixed debug build passes in about 1 s.

Background

  • A lifecycle script is a preinstall, install, or postinstall command from a dependency's package.json. Bun runs them for trusted dependencies only.
  • --concurrent-scripts caps how many of these child processes run at once. LifecycleScriptSubprocess::alive_count() counts the live ones.
  • The hoisted installer starts a script when a slot is free. It defers the others to complete_remaining_scripts, which waits for a slot with manager.sleep() (one event loop tick).
Notes
  • Alternative: reject 0 with an error, the way --network-concurrency rejects a value that is not a number. This PR picks parity with bunfig.toml. An error is a one-line change if that is preferred.
  • After this change no input sets the limit to 0. The default is at least 4, because get_thread_count() is clamped to a minimum of 2.
  • Repro without a registry (file: dependency):
    d=$(mktemp -d); cd "$d"; mkdir dep
    echo '{"name":"dep","version":"1.0.0","scripts":{"postinstall":"echo postinstall-ran"}}' > dep/package.json
    echo '{"name":"app","version":"1.0.0","dependencies":{"dep":"file:./dep"},"trustedDependencies":["dep"]}' > package.json
    timeout -s KILL 15 bun install --concurrent-scripts=0; echo "exit=$?"   # exit=137 before, exit=0 after
  • bun pm trust dep --concurrent-scripts=0 on release 6a92015: also killed by the timeout (exit=137). The new test runs it after the install, so on an unfixed build the test stops at the install.
  • The isolated linker does not read this limit. --linker=isolated --concurrent-scripts=0 did not hang before.
  • bun bd test on the whole file: 121 pass, 3 fail ("node -p should work in postinstall scripts" and "ensureTempNodeGypScript works" x2). Those 3 fail the same way on main under bun bd test, because bun run exports NODE to the test process. install: stop debug builds from wiping the shared node shim dir on every start #37384 covers them. They pass with a release build.
  • Found on the way, not part of this PR: [install] concurrentScripts in bunfig.toml overrides the --concurrent-scripts flag (src/install/PackageManager/PackageManagerOptions.rs:569).

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

With a limit of 0 no lifecycle script can start. `bun install` and
`bun pm trust` then spin in `while alive_count >= max { sleep() }`,
because `sleep()` returns at once when nothing is alive.

bunfig.toml already maps `[install] concurrentScripts = 0` to the
default (2x CPU count). The CLI parser now does the same.
@robobun

robobun commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 4:22 PM PT - Sep 11th, 2026

✅ @robobun, your commit 8255d58591117fcc9e4c2a31ae52e76b5f781aa8 passed in Build #114450! 🎉


🧪   To try this PR locally:

bunx bun-pr 42379

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

bun-42379 --bun

@robobun

robobun commented Sep 11, 2026

Copy link
Copy Markdown
Collaborator Author

Status: fix pushed, waiting for CI.

Reproduced on 1.4.3-canary.1 (6a92015) with a file: dependency that has a postinstall script and is listed in trustedDependencies. timeout -s KILL 15 bun install --concurrent-scripts=0 exits 137 with one core busy. --concurrent-scripts=1 installs the same project in 7 ms. bun pm trust dep --concurrent-scripts=0 spins in the same way.

The new case in test/cli/install/bun-install-lifecycle-scripts.test.ts fails on that release build (the install is killed at the 30 s spawn timeout) and passes with this branch (bun bd test, about 1 s).

@coderabbitai

coderabbitai Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: d090bd70-387c-49cc-ad82-f6ec85d0f1a8

📥 Commits

Reviewing files that changed from the base of the PR and between 471b586 and 8255d58.

📒 Files selected for processing (2)
  • src/install/PackageManager/CommandLineArguments.rs
  • test/cli/install/bun-install-lifecycle-scripts.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 5 remain after this review.


Walkthrough

The CLI now treats --concurrent-scripts 0 as unset. Lifecycle-script tests verify that installation and trust commands complete without hanging and that script trust behavior remains correct.

Changes

Concurrent Scripts Configuration

Layer / File(s) Summary
Zero concurrency handling and lifecycle validation
src/install/PackageManager/CommandLineArguments.rs, test/cli/install/bun-install-lifecycle-scripts.test.ts
The parser ignores zero values for --concurrent-scripts. Tests verify completion, blocked untrusted scripts, and execution after bun pm trust --all.

Suggested reviewers: jarred-sumner

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 8255d

The zero-value concurrency flag now uses normal default scheduling and is covered for installation and trust flows. No actionable merge risk remains.

🚥 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 states the primary behavior change: treating --concurrent-scripts=0 as the default instead of causing an infinite loop.
Description check ✅ Passed The description explains the problem, root cause, fix, compatibility rationale, verification steps, test coverage, and known test limitations. It does not use the template headings verbatim, but it pr…

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

@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 — one-line parser fix that maps --concurrent-scripts=0 to the default, matching bunfig's existing concurrentScripts = 0 handling.

What was reviewed:

  • Confirmed .filter(|&n| n != 0) mirrors src/bunfig/bunfig.rs:1364 and that None falls through to the CPU-count default; unparseable/negative values already returned None via parse_int::<usize>().ok().
  • Checked the fix covers the whole class: parsing is shared, so both install and pm trust are fixed at the source; the test exercises both commands.
  • Test follows file conventions (reuses createPackagesWithScripts, touch via Bun shell like neighboring tests), drains pipes concurrently, and asserts signalCode: null so the hang-guard timeout produces a real failure rather than a pass.
Extended reasoning...

Overview

The PR changes one expression in src/install/PackageManager/CommandLineArguments.rs: after parsing --concurrent-scripts, it now applies .filter(|&n| n != 0) so a parsed 0 becomes None and the option falls back to the default (2× CPU count). This prevents complete_remaining_scripts and the pm trust wait loop from spinning forever on 0 >= 0. A new test in test/cli/install/bun-install-lifecycle-scripts.test.ts exercises both bun install --concurrent-scripts=0 and bun pm trust --all --concurrent-scripts=0 with a 30s SIGKILL spawn timeout as a hang guard.

Security risks

None. This is CLI option parsing for a concurrency limit; no auth, crypto, path handling, or untrusted-data parsing is touched. The value is a usize and the only new behavior is treating 0 the same as "unset".

Level of scrutiny

Low. The functional change is a single .filter() on an Option<usize>, and it brings the CLI flag into parity with the bunfig.toml path at src/bunfig/bunfig.rs:1364, which already does if n == 0 { None } else { Some(n) }. The failure mode being fixed (busy-spin on 0 >= 0) is well-explained and the fix is placed at the parsing layer that both affected commands share, satisfying the "fix the whole class" rule without touching each wait loop.

Other factors

The test is placed in the existing lifecycle-scripts test file inside the forceWaiterThread describe block, reuses the file's createPackagesWithScripts helper and the same touch postinstall pattern already used by neighboring tests, drains stdout/stderr/exited via Promise.all, and asserts a combined {err, out, signalCode, exitCode} object with signalCode: null — so if the hang regresses, the SIGKILL timeout fires and the assertion fails rather than silently passing. It also verifies the positive contract (postinstall.txt written for the trusted dep, then for the untrusted one after pm trust). No CODEOWNERS entries cover these paths, no prior review threads exist, and the bug hunt exited on dry_streak with no findings.

@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