Skip to content

install: make globalStore opt-in to preserve pnpm-compatible canonical paths#29616

Open
robobun wants to merge 5 commits intomainfrom
farm/46677530/disable-global-store-default
Open

install: make globalStore opt-in to preserve pnpm-compatible canonical paths#29616
robobun wants to merge 5 commits intomainfrom
farm/46677530/disable-global-store-default

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 22, 2026

Fixes #29614.

Repro

mkdir repro && cd repro
cat > package.json <<EOF
{ "dependencies": { "debug": "4.4.3", "supports-color": "9.4.0" } }
EOF
bun install --linker=isolated
readlink -f node_modules/debug/src/node.js
# → /root/.bun/install/cache/links/debug@4.4.3+<hash>/node_modules/debug/src/node.js

The realpath is outside <project>/. Bundlers (rspack, webpack with the default resolver) and tools that canonicalize symlinks before walking ancestors for node_modules/ then can't see the project's top-level node_modules/, so they miss optional-peer / phantom / hoisted deps they used to resolve.

pnpm's virtual store lives inside the project (<project>/node_modules/.pnpm/) so canonical paths stay in-project. That's the layout the npm ecosystem assumes.

Cause

PR #29489 added the global virtual store and made it default-on. The speedup is real but the behavior change (realpath now points outside the project) breaks any tooling built against the pre-#29489 layout.

Fix

Flip install.globalStore to false by default. The isolated linker materializes packages inside <project>/node_modules/.bun/<storepath>/ (pnpm-compatible). The shared store is available as an explicit opt-in via install.globalStore = true or BUN_INSTALL_GLOBAL_STORE=1 for projects that want the warm-install speedup and know their tooling tolerates the out-of-project canonical path.

Verification

test/regression/issue/29614.test.ts:

  • With the fix: readlink -f node_modules/<pkg> resolves inside the project tree, and node_modules/.bun/<storepath> is a real directory.
  • Without the fix (on main): the same assertion fails — the entry is a symlink into <cache>/links/.
  • BUN_INSTALL_GLOBAL_STORE=1 still opts into the shared store (the speedup is preserved for users who want it).

Existing describe("global virtual store", …) tests that exercise the shared-store layout are updated to explicitly pass globalStore: true. The "off by default" behavior itself is pinned by a new test in that same block.

…l paths

PR #29489 turned on the global virtual store by default for the
isolated linker, moving materialized package files from inside the
project (`<project>/node_modules/.bun/<pkg>/`) to a shared location
(`<cache>/links/<pkg>-<hash>/`). Bundlers and tools that canonicalize
symlinks before walking ancestors for `node_modules/` (rspack,
webpack with the default resolver, many postinstall scripts) then
failed to resolve optional peer / phantom / hoisted dependencies —
the realpath no longer passed through the project's top-level
`node_modules/`.

This flips `install.globalStore` to `false` by default. The
isolated linker now keeps canonical paths inside the project (the
pnpm-compatible layout that the npm ecosystem assumes); the shared
store is available as an explicit opt-in via
`install.globalStore = true` in bunfig.toml or
`BUN_INSTALL_GLOBAL_STORE=1`.

Fixes #29614.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 22, 2026

Updated 8:46 PM PT - Apr 22nd, 2026

@robobun, your commit da3119d has 5 failures in Build #47378 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 29616

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

bun-29616 --bun

autofix-ci Bot and others added 2 commits April 22, 2026 23:17
The globalStore default has never shipped in a public release (PR #29489 landed after 1.3.13), so per the repo's test-organization policy the coverage belongs alongside the other global-store tests, not in test/regression/issue/. Fold the canonical-path assertion (what bundlers actually check) into the in-place 'off by default' test and mirror the existing BUN_INSTALL_GLOBAL_STORE=0 test with a =1 opt-in variant. Drops the hand-rolled tar builder.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 27b2adc3-a5c1-4b35-b0a4-46a66b2103d0

📥 Commits

Reviewing files that changed from the base of the PR and between 468ee98 and 3759f49.

📒 Files selected for processing (2)
  • docs/pm/isolated-installs.mdx
  • test/cli/install/isolated-install.test.ts

Walkthrough

The global virtual store for the isolated linker is now opt-in (default false). Docs, configuration parsing, and tests were updated so install.globalStore = true or BUN_INSTALL_GLOBAL_STORE=1 enable the global store; otherwise packages are materialized under the project at <project>/node_modules/.bun/.

Changes

Cohort / File(s) Summary
Documentation
docs/pm/global-store.mdx, docs/pm/isolated-installs.mdx, docs/runtime/bunfig.mdx
Updated wording and examples to document global virtual store as opt-in (default false) and show globalStore = true / BUN_INSTALL_GLOBAL_STORE=1 for enabling; added explicit note about project-local materialization under node_modules/.bun/.
Implementation
src/install/PackageManager/PackageManagerOptions.zig
Changed Enable.global_virtual_store default from true to false; adjusted env-var parsing to treat BUN_INSTALL_GLOBAL_STORE as opt-in (enabled for non-empty values other than "0").
Tests
test/cli/install/isolated-install.test.ts
Updated tests to reflect default-off behavior: many tests now explicitly set globalStore: true; added tests for default project-local materialization and BUN_INSTALL_GLOBAL_STORE=1 opt-in; adjusted one env-override test to start with bunfig enabling and assert env forces project-local layout; added path canonicalization helpers.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: making globalStore opt-in (instead of default-on) to preserve pnpm-compatible canonical paths.
Description check ✅ Passed The description provides comprehensive context: a clear reproduction case, root cause analysis, the fix rationale, and verification details covering both the fix and backward compatibility via BUN_INSTALL_GLOBAL_STORE=1.
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.

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


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

Copy link
Copy Markdown
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: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@test/regression/issue/29614.test.ts`:
- Line 157: The test currently asserts that readlinkSync(entry) contains a
platform-specific separator around "links" using `${sep}links${sep}`; change the
assertion to be separator-agnostic by matching either forward or backslash
around the literal "links" (e.g., use a regex/assertion that looks for a slash
or backslash, then "links", then a slash or backslash) when asserting on
readlinkSync(entry) so the test passes on all platforms; update the assertion
that references readlinkSync and entry accordingly.
🪄 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: 90e9404c-362f-47a0-9bc8-db50b58d8e5f

📥 Commits

Reviewing files that changed from the base of the PR and between 890ef5a and 36f0425.

📒 Files selected for processing (5)
  • docs/pm/global-store.mdx
  • docs/runtime/bunfig.mdx
  • src/install/PackageManager/PackageManagerOptions.zig
  • test/cli/install/isolated-install.test.ts
  • test/regression/issue/29614.test.ts

Comment thread test/regression/issue/29614.test.ts Outdated
Comment thread test/cli/install/isolated-install.test.ts
Comment thread docs/pm/global-store.mdx
- test: make the BUN_INSTALL_GLOBAL_STORE=0 test prove the env override
  path by first enabling the store via bunfig (otherwise with the new
  default=off it vacuously passes for the wrong reason).
- test: clarify the intent of the bunfig globalStore=false test — it is
  now the effective default, kept as a belt-and-suspenders pin that
  explicit `false` is not coerced to `true` by a config-parsing bug.
- test: use a separator-agnostic regex (`/[\\/]links[\\/]/`) instead of
  `${sep}links${sep}` so the assertion doesn't depend on slash style.
- docs: update isolated-installs.mdx to match the new opt-in default; it
  previously still said the global store was on by default, contradicting
  global-store.mdx and bunfig.mdx after the flip.
Copy link
Copy Markdown
Contributor

@claude claude Bot left a comment

Choose a reason for hiding this comment

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

No issues found and prior feedback was addressed in 3759f49 — but flipping the globalStore default back to off is a product-level tradeoff (ecosystem compat vs. the 7× warm-install win that #29489 shipped as default-on), so I'd like a maintainer to sign off on the direction rather than auto-approving.

Extended reasoning...

Overview

This PR flips install.globalStore from default-on to default-off (opt-in), reverting the default introduced by #29489 to fix #29614 (bundlers that canonicalize symlinks lose sight of the project's node_modules/). The actual source change is two lines in PackageManagerOptions.zig (the Enable.global_virtual_store field initializer and a val.len > 0 guard on the env-var parse); everything else is docs (global-store.mdx, isolated-installs.mdx, bunfig.mdx) and test updates in isolated-install.test.ts to explicitly pass globalStore: true where the shared-store layout is being exercised, plus new tests pinning the off-by-default behavior and the env-var opt-in.

Security risks

None. No auth, crypto, network, or untrusted-input handling is touched. The env-var parsing change is a strict tightening (empty string now stays off instead of toggling on).

Level of scrutiny

Code-wise this is mechanical and low-risk — a boolean flip plus consistent doc/test updates. However, it changes the default install layout for every isolated-linker user, deliberately reversing a recently-shipped default. That is a product/UX tradeoff (pnpm-compatible canonical paths vs. the warm-install speedup) that a maintainer should explicitly ratify rather than have a bot approve. Once the direction is agreed, the implementation itself needs no further scrutiny.

Other factors

My two prior findings (vacuous "disable" tests and stale wording in isolated-installs.mdx) and CodeRabbit's separator nit were all addressed in 3759f49. The robobun CI comment shows build failures on 468ee98 across every platform in scripts/build/ci.ts, which looks like an infra hiccup unrelated to this diff, but CI on 3759f49 should be confirmed green before merge.

test/js/bun/net/socket.test.ts's 'should not leak memory' asserts
TCPSocket count <= 3 on Windows, but the Windows 11 ARM64 runner
consistently sees 4. This is the same pre-existing flake that was
already diagnosed and widened-to-4 on a separate branch (commit
bd347a4: 'socket leak: widen Windows TCPSocket threshold by one
(FIXME)') but that fix hasn't landed on main yet.

The failing test is in src/js/bun/net/ and is completely unrelated to
this PR's package-manager layout change — there is no code path in
#29616 that touches TCPSocket, memory, or GC. Retrying on a different
VM.
Copy link
Copy Markdown
Collaborator

@Jarred-Sumner Jarred-Sumner left a comment

Choose a reason for hiding this comment

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

Fix the underlying issue. Why are we re-materializing? Why are we copying to .tmp folders? Let's fix the underlying issue instead of hacking around it by disabling.

/// `<project>/node_modules/`, which breaks bundlers (rspack, webpack)
/// and tools that canonicalize symlinks before walking ancestors for
/// `node_modules/` directories. See #29614.
global_virtual_store: bool = false,
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.

🔴 Flipping the default to false doesn't detach pre-existing global-store symlinks: with global_store_path == null, isolated_install.zig:1581 short-circuits has_stale_gvs_link to false (skipping the lstat), and the existsZ at :1620 follows the leftover symlink into <cache>/links/ so needs_install = false and the entry is skipped. A user upgrading from a #29489-era bun therefore still sees readlink -f node_modules/<pkg> escape into <cache>/links/ — exactly #29614 — until they rm -rf node_modules. Drop the global_store_path == null guard at :1581 (the lstat is cheap and appendLocalStoreEntryPath doesn't depend on it), and add an on→off test mirroring "upgrades a pre-global-store node_modules in place".

Extended reasoning...

What the bug is

This PR flips enable.global_virtual_store to default false so that fresh isolated installs keep canonical paths in-project. But it does not migrate a node_modules that was populated under the previous default-on behavior. The stale-link detector at isolated_install.zig:1581 short-circuits when global_store_path == null, so leftover node_modules/.bun/<storepath> → <cache>/links/... symlinks survive a plain bun install after upgrading. The result is exactly the #29614 symptom this PR is meant to fix, just on the upgrade-in-place path instead of the fresh-install path.

The specific code path

Walk through a user who installed under a #29489-era bun (GVS default-on), upgrades to a build with this PR, and runs bun install without deleting node_modules:

  1. PackageManagerOptions.zig:756enable.global_virtual_store = false (new default).
  2. isolated_install.zig:873global_store_path = null because enable.global_virtual_store is false.
  3. isolated_install.zig:~1272node_modules/.bun already exists, so mkdirat fails and is_new_bun_modules = false.
  4. Installer.zig:1606entryUsesGlobalStore() returns false because global_store_path == nulluses_global_store = false.
  5. isolated_install.zig:1580-1592has_stale_gvs_link: !uses_global_store is true, but the inner block hits if (installer.global_store_path == null) break :stale false; at :1581 and yields false. The lstat at :1588 that would have detected the leftover symlink never runs.
  6. isolated_install.zig:1614appendRealStorePath (since entryUsesGlobalStore is false) builds the project-local path node_modules/.bun/<storepath>/node_modules/<pkg>/package.json.
  7. isolated_install.zig:1620sys.existsZ (POSIX access(), Windows GetFileAttributesW) traverses the stale symlink at node_modules/.bun/<storepath> into <cache>/links/..., finds package.json, returns true → needs_install = false.
  8. isolated_install.zig:1636-1657!needs_install and !uses_global_store → the entry is marked .done/.skipped and the loop continues. The detach logic in the link_package task body never runs.

Why existing code doesn't prevent it

The :1581 guard was added under the assumption "if global_store_path is null, the store was never on, so no stale GVS link can exist." The comment at :1570-1579 only describes the eligibility-loss case (newly patched/trusted while the store stays enabled). The default flip in this PR invalidates that assumption: every user who installed under #29489's default-on behavior now lands in the global_store_path == null branch with stale links on disk. appendLocalStoreEntryPath (Installer.zig:~1667) does not depend on global_store_path, so the guard is purely an optimization, not a correctness requirement.

Step-by-step proof

Concrete scenario with a single dep no-deps@1.0.0:

step state
pre-PR bun install (GVS on) node_modules/.bun/no-deps@1.0.0 is a symlink → <cache>/links/no-deps@1.0.0-<hash>
upgrade bun to this PR filesystem unchanged
bun install (no flags, no bunfig change) global_store_path = null; is_new_bun_modules = false; uses_global_store = false; has_stale_gvs_link = false (short-circuited at :1581); existsZ("node_modules/.bun/no-deps@1.0.0/node_modules/no-deps/package.json") follows the symlink and returns true; needs_install = false → skipped
result readlink -f node_modules/no-deps<cache>/links/no-deps@1.0.0-<hash>/node_modules/no-deps — outside the project

So bundlers/resolvers that canonicalize before walking ancestors still miss the project's top-level node_modules/.

Impact

For users upgrading from a #29489-era bun (where GVS was default-on), this PR's stated fix for #29614 is silently a no-op until they rm -rf node_modules. The :1581 guard itself pre-dates this PR — explicit globalStore=true → false had the same gap — but the default flip turns it from an obscure config-change edge case into the common upgrade path for every existing user, which is why it should be addressed here rather than deferred.

Test coverage gap

No test exercises GVS on→off with a populated node_modules:

  • "upgrades a pre-global-store node_modules in place" covers off→on (real dir → symlink).
  • "an entry that loses global-store eligibility detaches" runs with globalStore: true, so global_store_path != null and the :1581 guard doesn't fire.

How to fix

Drop the if (installer.global_store_path == null) break :stale false; short-circuit at isolated_install.zig:1581. The lstat is cheap, only runs when !is_new_bun_modules, and correctly detects the stale link regardless of whether GVS is currently enabled. Add a test mirroring the existing off→on upgrade test: install with BUN_INSTALL_GLOBAL_STORE=1, assert the entry is a symlink, then re-install with the env var unset and assert the entry is now a real directory whose realpath stays in-project.

Comment on lines 401 to +404
if (env.get("BUN_INSTALL_GLOBAL_STORE")) |val| {
this.enable.global_virtual_store = !strings.eqlComptime(val, "0");
// Treat any non-empty value other than "0" as opt-in. Default is off;
// BUN_INSTALL_GLOBAL_STORE=1 enables it, =0 (or unset) keeps it off.
this.enable.global_virtual_store = val.len > 0 and !strings.eqlComptime(val, "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.

🟡 nit: the "(or unset) keeps it off" parenthetical is slightly inaccurate — when the env var is unset this block is skipped entirely, so a bunfig globalStore = true (lines ~296-298) persists and the store stays on. Consider "=0 forces it off; unset leaves the bunfig/default value untouched" to avoid implying unset overrides bunfig.

Extended reasoning...

What the issue is

The new comment on the BUN_INSTALL_GLOBAL_STORE handler reads:

Treat any non-empty value other than "0" as opt-in. Default is off; BUN_INSTALL_GLOBAL_STORE=1 enables it, =0 (or unset) keeps it off.

The "(or unset)" parenthetical is imprecise. When the env var is unset, env.get("BUN_INSTALL_GLOBAL_STORE") returns null, the entire if block is skipped, and this.enable.global_virtual_store retains whatever config.global_store set ~100 lines earlier (PackageManagerOptions.zig:296-298). So with globalStore = true in bunfig and the env var unset, the store stays on — "(or unset) keeps it off" is not strictly true. It is also a little odd structurally to describe the unset case from inside a block that only executes when the var is set.

Step-by-step

  1. Enable.global_virtual_store defaults to false (line 756).
  2. load() reads bunfig: if (config.global_store) |gs| { this.enable.global_virtual_store = gs; } (lines ~296-298). With bunfig globalStore = true, the field is now true.
  3. env.get("BUN_INSTALL_GLOBAL_STORE") returns null (unset). The whole if at line 401 is skipped.
  4. Result: global_virtual_store == true. The store is on, contradicting "(or unset) keeps it off".

The comment is correct for the no-bunfig case (default off → unset keeps it off) but reads as a precedence statement covering all cases.

Why existing code doesn't prevent it

The actual behavior is correct and already pinned by the new test BUN_INSTALL_GLOBAL_STORE=0 overrides a bunfig-enabled store, whose inline comment ("PackageManagerOptions.load reads BUN_INSTALL_GLOBAL_STORE after the bunfig values, so env wins") documents the real precedence. Only this inline code comment is imprecise.

Addressing the refutation

One verifier argued the leading "Default is off;" frames the rest of the sentence as describing env-var semantics relative to the built-in default, in which frame "(or unset) keeps it off" is accurate, and that a maintainer wanting the bunfig-vs-env story can see the config.global_store block ~100 lines earlier. That is a fair charitable reading and is why this is filed as a nit, not a bug — the runtime behavior is correct, the precedence is obvious from the code structure, and the comment's primary purpose (explaining why val.len > 0 and !eql("0") is used) is served. The wording is still objectively imprecise in the bunfig=true case, the comment is new in this PR, and the fix is a two-word tweak, so it seems worth surfacing without insisting on it.

Impact

No functional impact. A future maintainer reading this comment in isolation could come away thinking unset env always means store-off (i.e., that bunfig can't enable it without the env var), which is wrong. Minor doc-comment accuracy issue.

Suggested fix

// Treat any non-empty value other than "0" as opt-in. =1 enables it,
// =0 forces it off; unset leaves the bunfig/default value untouched.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 23, 2026

Investigating. What I've found so far:

Symptom 2 (re-materialization) root cause: default-trusted-dependencies.txt has 367 entries (esbuild, sharp, puppeteer, node-sass, etc.). The eligibility check in isolated_install.zig:928-982 marks these ineligible for GVS because their lifecycle scripts could mutate the install dir. That ineligibility propagates to every entry that depends on them (lines 1028-1033, 1238-1251). In a real-world app with viteesbuild, webpackterser-webpack-plugin (which has esbuild as optionalPeer synthesized by #29489 itself), this cascades across large chunks of the graph. Each ineligible entry re-materializes to <project>/node_modules/.bun/<pkg>/ on every warm install.

I can reproduce a smaller version (15 project-local out of 937 = 1.6%) with a real dep tree containing vite/webpack/esbuild. The reporter's 20% ratio at 10k packages fits if their graph has several trusted deps near the root of the dep graph.

But this is re-materialization to node_modules/.bun/<pkg>/, not /links/<pkg>.tmp-/ — the .tmp- pattern in appendGlobalStoreEntryPath(.staging) only fires for GVS-eligible entries. So the reporter's 2000 .tmp- references would mean 2000 eligible entries re-materializing, which my repro doesn't reproduce. I suspect their grep is matching something I'm not — without their lockfile I can't bisect further.

Symptom 1 (canonical path outside project) is architectural: with <project>/node_modules/.bun/<pkg> as a symlink to <cache>/links/<pkg>/, realpath() walks out of the project. Bundlers (rspack, webpack with default resolver, many postinstall scripts) canonicalize before walking ancestors for node_modules/, which is why it breaks. pnpm avoids this by keeping <project>/node_modules/.pnpm/<pkg>/ as a real directory with files as hardlinks from ~/.pnpm-store/.

What fix would you prefer?

  1. Put the shared store at <project>/node_modules/.bun/store/ (project-scoped). Canonical paths stay in-project; lose the cross-project share speedup. Closest to pnpm.
  2. Keep <cache>/links/ but hardlink files into <project>/node_modules/.bun/<pkg>/ instead of symlinking the directory. Canonical paths stay in-project, cross-project sharing via inodes (pnpm parity). Warm path is O(files) instead of O(1), but still faster than the pre-install: global virtual store for isolated linker (7x faster warm installs) #29489 clonefile path.
  3. Tighten trusted-deps eligibility (check meta.hasInstallScript instead of just the name) to shrink the poisoned set — addresses Symptom 2 only. Symptom 1 remains.

I'll hold off on pushing more until I hear which direction you want.

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.

install: global virtual store (#29489) breaks bundler resolution & re-materializes on warm installs

2 participants