Skip to content

Make --preserve-symlinks and --preserve-symlinks-main work in the runtime and bundler - #36757

Open
robobun wants to merge 11 commits into
mainfrom
farm/b87aa853/preserve-symlinks
Open

Make --preserve-symlinks and --preserve-symlinks-main work in the runtime and bundler#36757
robobun wants to merge 11 commits into
mainfrom
farm/b87aa853/preserve-symlinks

Conversation

@robobun

@robobun robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator

Fixes #36755

--preserve-symlinks was accepted but had no effect in the runtime: module identity was always the real path, so every specifier inside a symlinked module (relative and bare alike) resolved from the symlink target's directory instead of the link's. Reproduces on Linux as well as Windows. bun build --preserve-symlinks and --preserve-symlinks-main were also no-ops.

Repro

shared/lib/relative.mjs      import dep from "../gen/dep.mjs"
app/gen/dep.mjs              the real target, owned by the app
app/lib/relative.mjs   ->    ../../shared/lib/relative.mjs  (symlink)
$ bun --preserve-symlinks main-relative.mjs
error: Cannot find module '../gen/dep.mjs' from '/tmp/ps/shared/lib/relative.mjs'
$ node --preserve-symlinks main-relative.mjs
RESOLVED-FROM-APP-GEN

Cause

The flag was parsed and stored on resolver.opts.preserve_symlinks, but its only consumer was the directory realpath skip in dir_info. The per-file symlink resolution in Resolver::finalize_result was unconditional: it always called path.set_realpath(symlink), which swaps path.text (the module's identity) to the target. Three more gaps on top of that:

  • BundleOptions::from_api hardcoded preserve_symlinks: false, so bun build --preserve-symlinks never reached the bundler's resolver.
  • The fast entry-point path (maybe_open_with_bun_js) derived the script path via get_fd_path(fd), a realpath, ignoring --preserve-symlinks-main.
  • The synthetic main module resolve in the VM applied the general flag to the entry point; Node applies only --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN there (workers included).

Fix

  • Gate the symlink-resolution block in finalize_result on !opts.preserve_symlinks, so the link path stays the module's identity and specifiers inside it resolve from the link's directory. This fixes the runtime and bun build in one place.
  • BundleOptions::from_api reads transform.preserve_symlinks.
  • The entry-point fast path keeps the logical absolute path when --preserve-symlinks-main / NODE_PRESERVE_SYMLINKS_MAIN is set.
  • The VM's main-entry resolve overrides the resolver option with the main flag for that one resolve (in both directions, so NODE_PRESERVE_SYMLINKS=1 alone no longer preserves a worker's entry path, matching Node).

Verification

Full matrix (relative/bare/ESM/CJS/import.meta.url/symlinked entry, with each flag combination) now matches node exactly; 7 new tests in test/js/bun/resolve/resolve.test.ts fail on the released bun and pass with this change. test/js/node/test/parallel/test-require-symlink.js, test-module-main-preserve-symlinks-fail.js, and test-module-symlinked-peer-modules.js pass.

Related open PRs touch neighboring ground with different scope: #31958 threads the flag into Bun.build and handles symlinked directories (but keeps realpathing individual file symlinks, so the repro above still fails), and #35782 addresses __filename spelling at the VM layer for a different issue. This PR fixes the resolver itself, which is what #36755's repro needs.


no test proof · iteration 4 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/js/bun/resolve/resolve.test.ts

--preserve-symlinks was parsed into resolver options but the per-file
symlink resolution in Resolver::finalize_result was unconditional, so
every resolved module was canonicalized to its real path and the flag
had no effect on relative specifiers, bare specifiers, import.meta.url,
or __filename. Gate that block on the option, matching Node: with the
flag, the link path stays the module's identity so specifiers inside a
symlinked module resolve from the link's directory.

bun build hardcoded preserve_symlinks: false in BundleOptions::from_api,
so the CLI flag never reached the bundler's resolver. Read it from the
transform options.

--preserve-symlinks-main had two gaps: the fast entry-point path in
run_command derived the script path via get_fd_path (a realpath), and
the synthetic main module resolve in the VM applied the general flag
rather than the main flag. Node applies only --preserve-symlinks-main /
NODE_PRESERVE_SYMLINKS_MAIN to the entry point (workers included), so
the VM main resolve now overrides the resolver option with the main
flag for that one resolve.

Fixes #36755
@coderabbitai

coderabbitai Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

--preserve-symlinks now retains symlink paths in runtime, resolver, and bundler flows. Entry-point handling supports --preserve-symlinks-main and its environment variable. Tests cover ESM, CJS, dependencies, entry points, workers, and bundling.

Changes

Symlink Preservation

Layer / File(s) Summary
Propagate symlink-preservation options
src/bundler/options.rs, src/resolver/resolver.rs, src/options_types/context.rs
Bundler options retain the configured value. Resolver results retain symlink paths when enabled. Runtime options expose preserve_symlinks_main and NODE_PRESERVE_SYMLINKS_MAIN state.
Resolve runtime entry paths
src/sys/lib.rs, src/jsc/VirtualMachine.rs, src/runtime/cli/run_command.rs, src/jsc/web_worker.rs
Runtime entry resolution uses descriptor-based canonicalization when required. It preserves symlink spellings for main and worker entries without mutating shared resolver options.
Validate symlink resolution behavior
test/js/bun/resolve/resolve.test.ts
Tests cover relative and bare imports, ESM and CJS, import.meta.url, environment variables, symlinked entry points, workers, and bundling.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed The changes address issue #36755 by preserving symlink identities and applying the behavior across runtime, bun run, workers, and bun build.
Out of Scope Changes check ✅ Passed The code and tests remain within the linked issue scope for preserve-symlinks behavior in the runtime and bundler.
Title check ✅ Passed The title clearly summarizes the main change to make both preserve-symlinks flags work in the runtime and bundler.
Description check ✅ Passed The description explains the problem, cause, fix, scope, reproduction, and verification results, although its headings differ from the template.

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

@github-actions github-actions Bot added the claude label Aug 2, 2026
@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 1:49 AM PT - Aug 2nd, 2026

@robobun, your commit 88fed92 has 1 failures in Build #87655 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 36757

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

bun-36757 --bun

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

Found 3 issues this PR may fix:

  1. Bun.build (bundler) cannot resolve a dependency imported from a symlinked file; no --preserve-symlinks equivalent #31957 - PR wires preserve_symlinks into BundleOptions::from_api, directly fixing bundler resolution failures with symlinked files
  2. Bun.build follows symlinks #13365 - PR enables --preserve-symlinks for the bundler, giving users the option to prevent Bun.build from dereferencing symlinks
  3. Bun.build output does not use symlinked paths #8467 - PR's bundler preserve_symlinks support allows Bun.build to use symlinked paths in entrypoints and output

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

Fixes #31957
Fixes #13365
Fixes #8467

🤖 Generated with Claude Code

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Not adding those to the PR description: #13365 and #8467 are about the Bun.build JS API, which this PR does not touch (no preserveSymlinks option is added, so the behavior cannot be enabled from Bun.build). #31957 is partially covered here (the bun build CLI flag now works, including for individual file symlinks), but #31958 is already open and scoped to it, including the Bun.build API option, so I'll leave closing that one to it.

@github-actions

github-actions Bot commented Aug 2, 2026

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. cli: honor --preserve-symlinks/-main for __filename, make SIGUSR1 inert by default #35782 - Also makes --preserve-symlinks and --preserve-symlinks-main work in the runtime, modifying the same files (VirtualMachine.rs, run_command.rs, resolver options)
  2. Make the bundler honor --preserve-symlinks and add preserveSymlinks to Bun.build #31958 - Also makes the bundler honor --preserve-symlinks, modifying the same files (options.rs, resolver.rs) to read the flag instead of hardcoding false

🤖 Generated with Claude Code

@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

Overlapping area but not the same fix, as noted at the end of the PR description: #31958 keeps realpathing individual file symlinks (only composed directory realpaths are skipped), so the repro in #36755 still fails with it, and it is scoped to the Bun.build API for #31957. #35782 addresses __filename spelling by swapping to the pretty path at the VM layer for a different issue and does not touch the resolver, so relative and bare specifiers inside symlinked modules still resolve from the target directory in the bundler. This PR gates the canonicalization in the resolver itself, which is what #36755 needs.

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/resolver/resolver.rs
Comment thread src/runtime/cli/run_command.rs
Comment thread src/jsc/VirtualMachine.rs Outdated
Flipping resolver.opts.preserve_symlinks around the entry-point resolve
could seed the path-keyed DirInfo cache with entries computed under the
wrong mode (abs_real_path left empty), so later resolves under a
symlinked directory kept their link path even with the flag off. Instead,
resolve with the process-wide mode and adjust only the returned entry
path: recover the link spelling from the pretty path when the main flag
is on, or canonicalize via open + get_fd_path when only the general flag
is on (workers included).
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/jsc/VirtualMachine.rs

@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: 2

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/runtime/cli/run_command.rs (1)

2863-2901: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Assert exact paths in the symlink test. Compare entryLine and fooLine with the expected absolute paths derived from dir, after normalizing separators. toEndWith() would not detect a duplicated working-directory prefix.

🤖 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/runtime/cli/run_command.rs` around lines 2863 - 2901, Update the symlink
test assertions in test/js/bun/resolve/resolve.test.ts (lines 1209-1230) to
compare entryLine and fooLine against the expected absolute paths derived from
dir, normalizing path separators first; replace suffix-only toEndWith checks
with exact equality. The runtime path handling in src/runtime/cli/run_command.rs
(lines 2863-2901) requires no direct change.
🤖 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/jsc/VirtualMachine.rs`:
- Around line 4106-4119: The temporary main-preserve handling in the resolver
path must not mutate shared resolver options or allow path-only DirInfo cache
entries to be reused across preserve_symlinks modes. Update the main-module
resolution flow around main_preserve to apply symlink preservation after
resolution via the resolved path/realpath handling, while leaving
opts.preserve_symlinks unchanged for the cached directory lookup.

In `@test/js/bun/resolve/resolve.test.ts`:
- Around line 1196-1207: Strengthen the negative case in the
`--preserve-symlinks` invocation by asserting that `withoutMain.stderr` contains
the expected module-not-found failure from resolving the relative import through
`shared/`, while retaining the nonzero exit-code assertion.

---

Outside diff comments:
In `@src/runtime/cli/run_command.rs`:
- Around line 2863-2901: Update the symlink test assertions in
test/js/bun/resolve/resolve.test.ts (lines 1209-1230) to compare entryLine and
fooLine against the expected absolute paths derived from dir, normalizing path
separators first; replace suffix-only toEndWith checks with exact equality. The
runtime path handling in src/runtime/cli/run_command.rs (lines 2863-2901)
requires no direct change.
🪄 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: f452e9d7-ed0a-41d7-abdf-d544db5f41bf

📥 Commits

Reviewing files that changed from the base of the PR and between 9395d0e and a5d63b9.

📒 Files selected for processing (5)
  • src/bundler/options.rs
  • src/jsc/VirtualMachine.rs
  • src/resolver/resolver.rs
  • src/runtime/cli/run_command.rs
  • test/js/bun/resolve/resolve.test.ts

Comment thread src/jsc/VirtualMachine.rs
Comment thread test/js/bun/resolve/resolve.test.ts
…utating resolver options

Same dir-cache hazard as the VM main resolve: the DirInfo cache is a
process-lifetime singleton keyed by path only, so flipping
opts.preserve_symlinks for the bun run fallback resolve could cache
entries computed under the wrong mode. Resolve with the process-wide
mode and adjust only the entry spelling afterwards (pretty path when the
main flag is on, open + get_fd_path when only the general flag is on).

Also strengthen the tests: exact path assertions in the
symlinked-directory case, a stderr assertion on the negative case, and
coverage for the extensionless bun run fallback path.
Comment thread test/js/bun/resolve/resolve.test.ts
Comment thread src/runtime/cli/run_command.rs
Comment thread src/runtime/cli/run_command.rs
Comment thread src/runtime/cli/run_command.rs
Comment thread src/runtime/cli/run_command.rs Outdated

@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: 1

🤖 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 `@test/js/bun/resolve/resolve.test.ts`:
- Around line 1205-1210: Extend the extensionless fallback test around the
existing `fallback` invocation to also run `bun run --preserve-symlinks
./mainsym` without `--preserve-symlinks-main`. Assert that this invocation emits
the expected missing-module diagnostic, produces empty stdout, and exits with a
nonzero status, while preserving the existing positive-case assertions.
🪄 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: 3c69b59c-93f3-451c-b62b-c134e987406f

📥 Commits

Reviewing files that changed from the base of the PR and between a5d63b9 and c751659.

📒 Files selected for processing (2)
  • src/runtime/cli/run_command.rs
  • test/js/bun/resolve/resolve.test.ts

Comment thread test/js/bun/resolve/resolve.test.ts
@robobun

robobun commented Aug 2, 2026

Copy link
Copy Markdown
Collaborator Author

CI status at 361e8b5: every failure is unrelated to this diff. The new preserve-symlinks tests pass on all lanes; the red entries are a pre-existing bun-upgrade failure on Windows 11 aarch64 (canary artifact missing for that platform, also red on main) and batch-flaky tests that passed when retried or run alone (napi GC condition, request-clone leak thresholds, tty/shell regression batch flakes). The diff itself is green and ready for review.

Comment thread src/runtime/cli/run_command.rs Outdated
…rve_symlinks_main_enabled

Collapses the duplicated open + get_fd_path canonicalization in
VirtualMachine and run_command into one bun_sys helper (using the path
buffer pool in both places), and folds the repeated flag-or-env-var
expression into a RuntimeOptions method.
Comment thread src/jsc/VirtualMachine.rs
Comment thread src/sys/lib.rs
Comment thread src/jsc/VirtualMachine.rs
The worker entry funnel (resolve_entry_point_specifier) returned the
resolved realpath unconditionally, discarding the link spelling before
the VM's main resolve could recover it, so a symlinked worker entry
diverged from Node under --preserve-symlinks-main. Recover the pretty
spelling there, mirroring the two run_command entry paths. Verified
against Node: worker entry keeps the link path with -main, is realpathed
without it (test-require-symlink still passes).
Comment thread src/jsc/web_worker.rs Outdated
Comment thread src/jsc/web_worker.rs
…oads

resolve_entry_point_specifier also serves the preload_modules loop in
create(); preloads are not the main module, so they follow the general
--preserve-symlinks flag. Thread is_main_entry through and gate the
pretty recovery on it.
Comment thread src/jsc/web_worker.rs

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

All four prior findings (dir-cache option flip, weak negative assertion, duplicated realpath helper, worker entry / preload scoping) have been addressed and no new issues surfaced this pass, but module resolution identity is a critical path and the -main/-general interaction across four entry funnels (fast path, resolve fallback, VM synthetic main, worker) plus the shared dir-cache is subtle enough that a maintainer should sign off.

What was reviewed:

  • finalize_result gate on opts.preserve_symlinks and its effect on the shared dir cache — the post-resolve adjustment (rather than flipping the option) keeps DirInfo entries consistent.
  • realpath_for_main's &'static transmute — the box is pushed into resolved_path_dups before the slice escapes, matching dupe_resolved_path.
  • is_main_entry threading in resolve_entry_point_specifier — both callers pass the correct value; the preload loop stays on the general flag.
  • New bun_sys::realpath_by_open — fd is closed on both the ok and err arms of get_fd_path.
Extended reasoning...

Overview

The PR wires --preserve-symlinks and --preserve-symlinks-main through to actual behavior across the runtime and bundler. Eight files change: the resolver's finalize_result gains a gate on opts.preserve_symlinks; BundleOptions::from_api reads transform.preserve_symlinks instead of hardcoding false; the VM's _resolve gains a main_preserve post-resolve adjustment for the synthetic main import; run_command.rs gains the same adjustment at both the resolve-fallback path and the direct-file fast path; web_worker.rs threads is_main_entry so a worker's entry (but not its preloads) honors -main; bun_sys gains a shared realpath_by_open helper; and RuntimeOptions gains preserve_symlinks_main_enabled() to fold the flag+env check. Eight new concurrent tests in resolve.test.ts cover the ESM/CJS/bare/import.meta.url/entry/worker/build matrix with both positive and negative (specific stderr) assertions.

Security risks

None identified. This is a resolution-semantics change; no new untrusted-input parsing, no auth/crypto, no privilege boundaries. realpath_by_open opens read-only and bounds-checks the input path length before copying into the pooled buffer.

Level of scrutiny

High. Module resolution identity is on the hot path of every import in every project, and symlink handling is exactly where package-manager layouts (pnpm, workspaces) diverge. The fix deliberately avoids flipping resolver.opts.preserve_symlinks for a single call because that option shapes the process-lifetime DirInfo cache (keyed by path only) — an earlier revision did flip it and was corrected during review. That kind of non-local invariant, plus the four separate entry funnels that must agree in both the Some(true) (recover .pretty) and Some(false) (realpath after the fact) directions, is the sort of thing a maintainer familiar with the resolver's cache model should confirm.

Other factors

The PR went through four rounds of substantive review feedback (all from the automated bug hunter, all now resolved): the dir-cache option flip, a weak negative test assertion, near-identical realpath helpers, the worker-entry Some(true) gap, and the preload-vs-entry scoping. Each was fixed in a follow-up commit and the fixes look correct on re-inspection. CI is reported green for the diff (unrelated flakes only). The unsafe transmute in realpath_for_main follows the existing dupe_resolved_path pattern in the same file (box pushed to resolved_path_dups, drained in destroy()). The test suite is thorough and uses test.concurrent per file conventions. Given the breadth (resolver + bundler + four entry paths + workers) and the criticality of module identity, this exceeds the bar for auto-approval even with clean CI and no open findings.

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.

--preserve-symlinks has no effect in the runtime — module paths are still canonicalized to the real path

1 participant