install: fix broken dep symlinks in workspace members under a symlinked directory by robobun · Pull Request #29601 · oven-sh/bun · GitHub
Skip to content

install: fix broken dep symlinks in workspace members under a symlinked directory#29601

Open
robobun wants to merge 6 commits intomainfrom
farm/75f0258e/workspace-symlink-node-modules
Open

install: fix broken dep symlinks in workspace members under a symlinked directory#29601
robobun wants to merge 6 commits intomainfrom
farm/75f0258e/workspace-symlink-node-modules

Conversation

@robobun
Copy link
Copy Markdown
Collaborator

@robobun robobun commented Apr 22, 2026

Fixes #29598.

Repro

mkdir -p root/app external/shared-lib root/repos
ln -s ../../external root/repos/ext

cat > external/shared-lib/package.json <<'EOF'
{ "name": "shared-lib", "private": true, "type": "module",
  "exports": { ".": "./index.js" },
  "dependencies": { "is-number": "7.0.0" } }
EOF
cat > external/shared-lib/index.js <<'EOF'
import isNumber from 'is-number'
export const check = (value) => isNumber(value)
EOF
cat > root/package.json <<'EOF'
{ "name": "root", "private": true,
  "workspaces": ["app", "repos/ext/shared-lib"] }
EOF
cat > root/app/package.json <<'EOF'
{ "name": "app", "private": true, "type": "module",
  "dependencies": { "shared-lib": "workspace:*" } }
EOF
cat > root/app/index.js <<'EOF'
import { check } from 'shared-lib'
console.log(check(1))
EOF

cd root && bun install && bun app/index.js

bun install succeeds. bun app/index.js then fails with:

error: ENOENT reading "/tmp/.../external/shared-lib/node_modules/is-number"

The written link:

external/shared-lib/node_modules/is-number -> ../../../../node_modules/.bun/is-number@7.0.0/node_modules/is-number

Four ..s from external/shared-lib/node_modules walks past the project root.

Cause

src/install/isolated_install/Installer.zig, the .symlink_dependencies task step: dest is built by string-joining top_level_dir + the lockfile's workspace_path + "node_modules" + dep_name. For a workspace member listed through a path that traverses a symlink (repos/ext/shared-lib here), that string path resolves physically to a different directory than the joined string suggests — in the repro, the staging node_modules really lives at external/shared-lib/node_modules (three levels under /tmp/bug-repro), not root/repos/ext/shared-lib/node_modules (four levels). dest.relative(&dep_store_path) is pure string arithmetic, so it counted the deeper logical depth and emitted one extra ...

The link landed at its real location, but with a content string anchored to a path that doesn't exist from there.

Fix

When the entry is a workspace, resolve the workspace directory's real path once (open + getFdPath) before the dep loop. Use that as the from for Path.relative so the stored target string is relative to where the symlink is physically created, not to the symlinked logical path. The dest passed to sys.symlink stays logical so the kernel follows the same chain it always did. When the workspace path is already canonical, the extra open is skipped — the existing hot path runs unchanged.

Verification

  • Debug build on the repro above: is-number -> ../../../root/node_modules/.bun/... (3 ..s), bun app/index.js prints true.
  • readlink -f resolves the link to the .bun/ store entry.
  • Direct-directory control (same workspace layout without the symlink) still writes the existing 4-.. link and runs — no regression.
  • Test covering the symlinked-workspace shape added to test/cli/install/isolated-install.test.ts.

…ed directory

When a workspace member's declared path traverses a symlink (e.g. a
`repos/ext` subdir that symlinks to an out-of-tree directory), the
isolated installer wrote each dep symlink target as a relative path
computed from the logical workspace string — but the symlink itself
landed at the symlink's real target. The `..`-prefix was anchored to
the deeper logical path, so from the physical location it walked past
the project root and pointed at nothing. `bun install` reported
success, `bun <app>` errored with ENOENT on the dep.

Resolve the workspace dir's real path once per workspace entry and
anchor each dep link target at that canonical directory. The symlink's
dest stays logical so the kernel follows the same chain it always did.
@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 22, 2026

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 22, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Installer resolves workspace member directories to their canonical realpaths when members live under symlinked directories and anchors package-local dependency symlink targets to that realpath. A regression test verifies runnable dependency links for a symlinked workspace member and install-time syscall failures now surface as install failures.

Changes

Cohort / File(s) Summary
Core installer fix
src/install/isolated_install/Installer.zig
During .symlink_dependencies, the workspace entry directory is opened and its realpath obtained via getFdPath; if the realpath differs, an absolute canonical_entry_node_modules is constructed and preferred when computing package-local dependency symlink targets. Failures from open/getFdPath now return .failure(.{ .symlink_dependencies = err }) instead of continuing.
Test coverage
test/cli/install/isolated-install.test.ts
Add regression test "symlinked workspace members get runnable dependency links": sets up a symlinked workspace member, runs bun install, asserts dependency symlinks are materialized at the member’s resolved real directory (both unscoped and scoped targets include the .bun store path), verifies package.json in linked dirs, and confirms runtime execution succeeds with expected output and exit code 0.
🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly and specifically describes the main fix: resolving broken dependency symlinks in workspace members under symlinked directories.
Description check ✅ Passed The description comprehensively covers the repro steps, root cause analysis, the fix approach, and verification, fully satisfying the template requirements.
Linked Issues check ✅ Passed The PR code changes directly address issue #29598: computing symlink targets from the workspace member's real path instead of the logical symlinked path, ensuring correct relative symlinks.
Out of Scope Changes check ✅ Passed All code changes are scoped to fixing the symlinked workspace dependency symlink issue: Installer.zig logic for canonical path resolution and a targeted regression test.

✏️ 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.

Comment thread src/install/isolated_install/Installer.zig
robobun and others added 2 commits April 22, 2026 15:15
The canonical-path fix hard-coded the from-base at `<real_ws>/node_modules`,
which is one level too shallow for a scoped dep: the symlink actually lives
at `<real_ws>/node_modules/@scope/<name>`, so the computed `..`-prefix
was missing one hop and scoped imports from the workspace dangled. Mirror
the fallback's `undo(1)` on the canonical path (append the full dep_name
including the scope segment, then trim one component) so the from-base
matches the symlink's real parent directory for both scoped and unscoped
names. Test extended to exercise both.
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 `@src/install/isolated_install/Installer.zig`:
- Around line 905-915: The current code in the block using bun.sys.open and
bun.sys.getFdPath silently falls back to the label :resolve_canonical on error,
which causes dest.relative(&dep_store_path) to recreate the broken symlink
target; instead, handle failures by surfacing .symlink_dependencies (e.g.,
return or set the dependency representation to .symlink_dependencies) rather
than breaking to :resolve_canonical. Modify the error arms of the two switch
statements on bun.sys.open(...) and bun.sys.getFdPath(...) (the dir_fd and
real_buf handling) to propagate or assign the symlink dependency case
(.symlink_dependencies) and exit the function/flow appropriately, ensuring the
code that calls or inspects symlink handling sees .symlink_dependencies instead
of performing the old dest.relative logic.
🪄 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: 77504515-28ef-4388-a8eb-0e3aca90a1d7

📥 Commits

Reviewing files that changed from the base of the PR and between 4676632 and a066861.

📒 Files selected for processing (2)
  • src/install/isolated_install/Installer.zig
  • test/cli/install/isolated-install.test.ts

Comment thread src/install/isolated_install/Installer.zig
robobun added 2 commits April 22, 2026 15:28
…path branch

Two follow-ups to the symlinked-workspace symlink fix:

- If opening or `getFdPath`ing the workspace dir fails, return
  `.symlink_dependencies` with the sys error instead of silently
  falling back to the logical-path computation — that fallback is
  exactly what produced the broken links this branch was added to
  fix, so swallowing an error here would let the original bug
  resurface on unusual filesystems.
- The canonical-path branch carried a copy of the
  nested-`node_modules` collision check from the fallback, but
  `entryStoreNodeModulesPackageName` always returns null for
  `.workspace` entries, so that inner block could never fire.
  Remove it and note why in a comment.

Also tighten the explanatory comments and use `runBunInstall`
in the test.
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 `@src/install/isolated_install/Installer.zig`:
- Around line 908-912: The current code places a large bun.PathBuffer (real_buf)
on the installer task stack before calling bun.sys.getFdPath; instead borrow a
buffer from bun.path_buffer_pool (e.g., call bun.path_buffer_pool.acquire() or
the pool API used elsewhere) into a bun.PathBuffer variable, pass that pooled
buffer to bun.sys.getFdPath, and ensure you release/return the buffer to
bun.path_buffer_pool in both the success and error paths (including the .err
branch that returns .failure(.{ .symlink_dependencies = err })). Update the code
around real_buf, the switch on bun.sys.getFdPath(), and any early returns so the
pooled buffer is always released.
🪄 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: 7671c880-3019-4b6c-bbf5-121b49165ec4

📥 Commits

Reviewing files that changed from the base of the PR and between 74ecf36 and 045350d.

📒 Files selected for processing (1)
  • src/install/isolated_install/Installer.zig

Comment on lines +908 to +912
var real_buf: bun.PathBuffer = undefined;
const real = switch (bun.sys.getFdPath(dir_fd, &real_buf)) {
.result => |r| r,
.err => |err| return .failure(.{ .symlink_dependencies = err }),
};
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.

🛠️ Refactor suggestion | 🟠 Major

Move real_buf off the task stack.

bun.PathBuffer is MAX_PATH_BYTES-sized, so putting real_buf on the installer task stack adds a large per-thread allocation in a hot path. Borrow it from bun.path_buffer_pool before calling bun.sys.getFdPath().

Proposed fix
-                        var real_buf: bun.PathBuffer = undefined;
-                        const real = switch (bun.sys.getFdPath(dir_fd, &real_buf)) {
+                        const real_buf = bun.path_buffer_pool.get();
+                        defer bun.path_buffer_pool.put(real_buf);
+                        const real = switch (bun.sys.getFdPath(dir_fd, real_buf)) {
                             .result => |r| r,
                             .err => |err| return .failure(.{ .symlink_dependencies = err }),
                         };
As per coding guidelines, use `bun.PathBuffer` for path buffers and `bun.path_buffer_pool` for pooled buffers to avoid large stack allocations, especially on Windows.
📝 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
var real_buf: bun.PathBuffer = undefined;
const real = switch (bun.sys.getFdPath(dir_fd, &real_buf)) {
.result => |r| r,
.err => |err| return .failure(.{ .symlink_dependencies = err }),
};
const real_buf = bun.path_buffer_pool.get();
defer bun.path_buffer_pool.put(real_buf);
const real = switch (bun.sys.getFdPath(dir_fd, real_buf)) {
.result => |r| r,
.err => |err| return .failure(.{ .symlink_dependencies = err }),
};
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/install/isolated_install/Installer.zig` around lines 908 - 912, The
current code places a large bun.PathBuffer (real_buf) on the installer task
stack before calling bun.sys.getFdPath; instead borrow a buffer from
bun.path_buffer_pool (e.g., call bun.path_buffer_pool.acquire() or the pool API
used elsewhere) into a bun.PathBuffer variable, pass that pooled buffer to
bun.sys.getFdPath, and ensure you release/return the buffer to
bun.path_buffer_pool in both the success and error paths (including the .err
branch that returns .failure(.{ .symlink_dependencies = err })). Update the code
around real_buf, the switch on bun.sys.getFdPath(), and any early returns so the
pooled buffer is always released.

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 22, 2026

CI failed only on windows-11-aarch64-test-bun with a known-flaky GC timing check in test/js/bun/net/socket.test.ts:

Expected: <= 3
Received: 4
      at expectMaxObjectTypeCount (test\harness.ts:148:51)
      at async test\js\bun\net\socket.test.ts:778:9

Line 778 asserts TCPSocket <= 3 on Windows after GC — this is the same post-GC object-count check that has previously needed Windows-specific wiggle-room (e.g. bd347a4a socket leak: widen Windows TCPSocket threshold by one (FIXME), 1dcd25d2 socket leak check: extend maxWait to 5s on Windows). My PR only touches src/install/isolated_install/Installer.zig and test/cli/install/isolated-install.test.ts; the install test suite passed on every platform (debian x64/aarch64/musl/asan, darwin, windows-x64-baseline, etc.) — 44 pass / 0 fail on debian-13-x64.

… GC flake

Build 47298 failed with:
- windows-11-aarch64: socket.test.ts 'should not leak memory' (TCPSocket <= 3
  but got 4 — known Windows GC-timing flake, tracked via prior 'widen threshold'
  commits bd347a4 and 1dcd25d)
- darwin-13/14 x64/aarch64: agents never picked up, jobs expired

None related to this PR. The symlinked-workspace test passed cleanly on every
platform it ran on (44/44 on debian-13-x64).
Comment on lines +889 to +894
// If the workspace path traverses a symlink, dep symlinks
// live under the realpath, so their relative targets must
// be computed from there — not from the logical string,
// which would walk past the project root. Null when the
// path is already canonical so the hot path stays
// syscall-free.
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 comment's "Null when the path is already canonical so the hot path stays syscall-free" (and the PR description's "the extra open is skipped") read as if the open+getFdPath probe is gated on canonical-ness, but it runs unconditionally for every workspace entry — only the assignment at line 914 is gated. The cost is negligible (3 syscalls × workspace count, dwarfed by the per-dep symlink()s below), so this is just a wording fix: consider rephrasing to e.g. "Null when the realpath matches, so the per-dep loop takes the existing fallback unchanged."

Extended reasoning...

What's inaccurate

The comment at Installer.zig:889-894 ends with: "Null when the path is already canonical so the hot path stays syscall-free." The PR description similarly says: "When the workspace path is already canonical, the extra open is skipped — the existing hot path runs unchanged." Both sentences causally link "path is already canonical" → "no extra syscalls". But the implementation at lines 897-919 unconditionally executes bun.sys.open() (line 902), bun.sys.getFdPath() (line 909), and dir_fd.close() (line 906 deferred) for every entry where pkg_res.tag == .workspace. The eqlLong check at line 914 happens after those syscalls and only gates whether canonical_entry_node_modules gets populated — not whether the realpath probe runs.

Step-by-step proof

For a non-symlinked workspace member packages/pkg1:

  1. Line 897: pkg_res.tag == .workspace is true → enter the block.
  2. Line 902: bun.sys.open("<top>/packages/pkg1", ...)syscall 1.
  3. Line 909: bun.sys.getFdPath(dir_fd, ...)syscall 2 (e.g. readlink(/proc/self/fd/N) on Linux, fcntl(F_GETPATH) on macOS).
  4. Line 914: real equals workspace_abs.slice(), so canonical_entry_node_modules stays null.
  5. Line 906 (deferred): dir_fd.close()syscall 3.
  6. The per-dep loop then takes the fallback dest.relative(...) branch — pure string math, identical to pre-PR behavior.

So the canonical-path workspace pays 3 syscalls it didn't pay before, contradicting both the comment and the PR description. There is no code path where "the extra open is skipped" for a canonical workspace.

Addressing the alternative reading

A counter-argument is that "hot path" refers to non-workspace entries (npm/git/tarball — the vast majority of store entries), which skip the entire if (pkg_res.tag == .workspace) block and genuinely add zero syscalls. That reading makes the implementation defensible, but it doesn't fit the sentence: "Null when the path is already canonical so the hot path stays syscall-free" explicitly conditions on "path is already canonical", which is a property only meaningful inside the workspace branch (non-workspace entries have no canonical-vs-logical distinction here). If the intended meaning were "non-workspace entries are unaffected", the sentence wouldn't mention canonical-ness at all. The PR description's "the extra open is skipped" is even more explicit and is simply not what the code does.

It's also worth noting that both branches of the per-dep target: block (lines 968-986) are pure string arithmetic — neither does syscalls — so "syscall-free" doesn't actually distinguish the null vs. non-null cases inside the loop either.

Why it's only a nit

The perf impact is immaterial: 3 extra syscalls × workspace-member count, executed once per install task, in a step whose loop body immediately below issues a symlink() syscall per dependency. Even a 1000-workspace monorepo adds ~3000 syscalls spread across thread-pool workers — noise. And the suggested mitigation of "lstat each path component first" would typically cost more syscalls than the current open+getFdPath+close, so don't add a pre-check; the unconditional probe is the right implementation choice.

Suggested fix

Just reword the last sentence of the comment so a future reader doesn't assume the probe is conditional, e.g.:

// ... Null when the realpath matched the logical path, so the
// per-dep loop below takes the existing `dest.relative(...)`
// fallback unchanged.

(The PR description is out-of-scope for merged code, but if you're updating it anyway, drop "the extra open is skipped".)

@robobun
Copy link
Copy Markdown
Collaborator Author

robobun commented Apr 22, 2026

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.

Explicit workspace members under symlinked directories install, then fail at runtime with broken package-local node_modules links

1 participant