Skip to content

cli: honor --preserve-symlinks/-main for __filename, make SIGUSR1 inert by default - #35782

Open
robobun wants to merge 8 commits into
mainfrom
farm/5c75b191/node-flag-compat-signals-symlinks
Open

cli: honor --preserve-symlinks/-main for __filename, make SIGUSR1 inert by default#35782
robobun wants to merge 8 commits into
mainfrom
farm/5c75b191/node-flag-compat-signals-symlinks

Conversation

@robobun

@robobun robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator

Two Node.js CLI flag behaviors bun accepted (echoed in execArgv) without the effect.

Reproduction

# --preserve-symlinks
echo 'console.log(__filename)' > real.cjs
ln -s real.cjs link.cjs
echo 'require("./link.cjs")' > main.cjs
bun --preserve-symlinks main.cjs    # prints .../real.cjs (wrong)
node --preserve-symlinks main.cjs   # prints .../link.cjs

# SIGUSR1
bun -e 'process.kill(process.pid,"SIGUSR1"); setImmediate(()=>console.log("alive"))'
# exits 138 (SIGUSR1); node survives and prints 'alive'

Cause

--preserve-symlinks was parsed and stored on resolver.opts.preserve_symlinks, but the runtime module-resolve path always returned result_path.text (the realpath written by Path::set_realpath in finalize_result), so a required symlink's __filename was always the realpath.

--preserve-symlinks-main had two more gaps: the fast entry-point path (maybe_open_with_bun_js) derived the script path via get_fd_path(fd), which is a realpath; and the synthetic bun:main wrapper re-resolved the entry through _resolve without the flag applied.

SIGUSR1 had no default handler, so the POSIX default action (terminate) applied. Node.js installs its debugger-activation handler at startup so the signal is never fatal.

Fix

  • VirtualMachine.rs _resolve: finalize_result already stashes the pre-realpath spelling in .pretty when it rewrites .text. Return .pretty when preserve_symlinks (for required modules) or preserve_symlinks_main (for the entry resolved from bun:main) is set. This keeps the main entry realpath'd under --preserve-symlinks alone, matching Node.js and keeping test-require-symlink.js green.
  • run_command.rs: when preserve_symlinks_main is set, resolve the entry target against cwd instead of get_fd_path; propagate the flag to resolver.opts.preserve_symlinks_main. Shared flag+env precedence extracted to RuntimeOptions::preserve_symlinks_main_effective().
  • c-bindings.cpp / BunProcess.cpp: install a no-op SIGUSR1 handler in bun_initialize_process (a handler rather than SIG_IGN, so the disposition resets to SIG_DFL across exec() for children); restore to the no-op instead of SIG_DFL when the last JS listener is removed.

Verification

test/cli/run/node-cli-flags.test.ts (7 cases): required symlink __filename with and without the flag, --preserve-symlinks alone does not apply to the entry, entry __filename under -main, SIGUSR1 survives by default and after removing the last listener, SIGUSR1 is handled (not SIG_IGN) per /proc/self/status SigIgn. 4/7 fail on stock bun and all pass with this change. test/js/node/test/parallel/test-require-symlink.js also passes.

Related


no test proof · iteration 1 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/run/node-cli-flags.test.ts

…rt, implement --heapsnapshot-signal

Three Node.js CLI flags that bun accepted silently but did not honor:

--preserve-symlinks / --preserve-symlinks-main: the flags were parsed and
plumbed to the resolver, but finalize_result() unconditionally rewrote the
resolved path to its realpath, so a required symlink still reported the
realpath as __filename (Node.js reports the symlink path). Gate the
file-level realpath in finalize_result on !preserve_symlinks. For -main,
the fast entry-point path in maybe_open_with_bun_js called get_fd_path()
(realpath) and the bun:main wrapper re-resolved the entry without the flag;
both now honor preserve_symlinks_main.

SIGUSR1: Node.js reserves SIGUSR1 for the debugger and never lets it
terminate the process. bun left it at SIG_DFL so ops tooling that signals a
node-compatible process would kill it. Install a no-op handler at startup
(not SIG_IGN, so the disposition resets to SIG_DFL across exec() for
children), and keep it installed when the last JS listener is removed.

--heapsnapshot-signal=<SIG>: was swallowed entirely, so the configured
signal killed the process instead of writing a heap snapshot. Declare the
flag, route it through pre_execution to a process.on(sig) handler that
calls v8.writeHeapSnapshot(), matching Node.js.
@coderabbitai

coderabbitai Bot commented Jul 25, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 4bb6f3b6-d035-479f-9d62-139a33819f95

📥 Commits

Reviewing files that changed from the base of the PR and between ef71c79 and bb90f37.

📒 Files selected for processing (2)
  • src/runtime/cli/run_command.rs
  • test/cli/run/node-cli-flags.test.ts

Walkthrough

Changes

The PR adds effective --preserve-symlinks-main propagation through runtime, resolver, and VM entry resolution, plus non-Windows SIGUSR1 no-op handling and CLI coverage for both behaviors.

Main-entry symlink preservation

Layer / File(s) Summary
Symlink option contracts
src/options_types/context.rs, src/resolver/options.rs, src/bundler/transpiler.rs
Adds the effective runtime option and resolver field, defaults the field to false, and includes it in bundler resolver options.
Runtime option wiring
src/runtime/cli/run_command.rs
Propagates the effective setting into resolver paths and preserves the requested entry path when enabled.
Entry resolution behavior
src/jsc/VirtualMachine.rs, test/cli/run/node-cli-flags.test.ts
Applies main-entry-specific symlink path selection and tests the relevant CLI flag combinations.

SIGUSR1 handling

Layer / File(s) Summary
SIGUSR1 handler lifecycle
src/jsc/bindings/c-bindings.cpp, src/jsc/bindings/BunProcess.cpp
Defines and installs a non-Windows no-op SIGUSR1 handler and restores it when listeners are removed.
SIGUSR1 process tests
test/cli/run/node-cli-flags.test.ts
Tests inert SIGUSR1 behavior and Linux child-process signal disposition.

Possibly related PRs

  • oven-sh/bun#35463: Also changes RunCommand entry-point handling and fd-based path resolution.

Suggested reviewers: jarred-sumner

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main CLI compatibility changes and is concise.
Description check ✅ Passed It explains the change and includes verification, though it doesn't use the template's exact headings.
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.

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

@github-actions

Copy link
Copy Markdown
Contributor

Found 4 issues this PR may fix:

  1. --preserve-symlinks-main should apply to test file path resolution for snapshot tests (Bun within hermetic build systems) #26695 - --preserve-symlinks-main was accepted but had no effect; this PR makes it actually preserve the symlink spelling for __filename
  2. process.argv[1] contains the real path when running on a symlink #2900 - process.argv[1] resolved to the real path when running via a symlink; the --preserve-symlinks changes address this
  3. execAsIfNode doesn't resolve symlinks for entry point (breaks .bin/ scripts) #28331 - execAsIfNode didn't resolve symlinks for entry point, breaking .bin/ scripts; the resolver and VM entry resolution changes fix the symlink handling codepath
  4. Dependency resolution does not appear to follow symlink context when in a dockerfile #11073 - Running a .bin/ symlink gave wrong __dirname (pointed at node_modules/.bin instead of real target); the default symlink resolution fix addresses this

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

Fixes #26695
Fixes #2900
Fixes #28331
Fixes #11073

🤖 Generated with Claude Code

@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

None of the four suggested issues are directly closed by this PR:

Leaving them out of the PR description so they are not auto-closed.

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. node:v8 profiling APIs, real perf_hooks nodeTiming, --diagnostic-dir and --heapsnapshot-signal (+11 tests) #35390 - Also implements --heapsnapshot-signal CLI flag in the same code locations (pre_execution.ts, Arguments.rs, VirtualMachine.rs)

🤖 Generated with Claude Code

Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/c-bindings.cpp Outdated
Comment thread src/resolver/options.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/runtime/cli/run_command.rs Outdated
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 PM PT - Jul 25th, 2026

@robobun, your commit bb90f37 has 1 failures in Build #81660 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35782

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

bun-35782 --bun

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

Additional findings (outside current diff — PR may have been updated during review):

  • 🟡 src/js/internal/process/pre_execution.ts:341-346 — The process.platform !== "win32" gate skips both validateSignalName and the process.on registration on Windows, but Node's initializeHeapSnapshotSignalHandlers has no platform gate — on Windows, node --heapsnapshot-signal=NOTREAL still fails with ERR_UNKNOWN_SIGNAL, and valid Windows signals (SIGBREAK, SIGINT) get a working handler via libuv. Consider dropping the gate (Bun's process.on(signal) already works on Windows via uv_signal_t), or at least moving validateSignalName outside it; the 'rejects an unknown signal name' test is inside describe.skipIf(isWindows) so this divergence is currently untested.

    Extended reasoning...

    What the bug is

    The new --heapsnapshot-signal bootstrap block in src/js/internal/process/pre_execution.ts:341-346 is gated on process.platform !== "win32":

    if (heapsnapshotSignal !== null && process.platform !== "win32") {
      require("internal/validators").validateSignalName(heapsnapshotSignal);
      process.on(heapsnapshotSignal as NodeJS.Signals, () => {
        require("node:v8").writeHeapSnapshot();
      });
    }

    On Windows this skips both the validateSignalName call and the process.on registration. Node.js's initializeHeapSnapshotSignalHandlers in lib/internal/process/pre_execution.js has no such gate — it unconditionally calls validateSignalName(signal) and process.on(signal, ...) on every platform.

    Code path that triggers it

    On Windows, bun --heapsnapshot-signal=NOTREAL -e 0:

    1. Arguments.rs parses the flag into execArgv.
    2. VirtualMachine.rs sees --heapsnapshot-signal in is_bootstrap_flag and loads pre_execution.ts.
    3. The execArgv scan sets heapsnapshotSignal = "NOTREAL".
    4. The process.platform !== "win32" check is false, so the block is skipped entirely.
    5. The script runs with exit code 0.

    Under Node.js on Windows, step 4 would instead call validateSignalName("NOTREAL"), which throws ERR_UNKNOWN_SIGNAL, and the process exits nonzero before the entry script runs.

    Similarly, bun --heapsnapshot-signal=SIGBREAK script.js on Windows silently installs no handler, whereas Node registers a working process.on('SIGBREAK', ...) via libuv (and Bun's own process.on(signal) supports this on Windows via Bun__UVSignalHandle__init — see BunProcess.cpp:1181).

    Why existing code doesn't prevent it

    The test that asserts rejection of a bad signal name ('rejects an unknown signal name' in test/cli/run/node-cli-flags.test.ts) is inside describe.skipIf(isWindows)("--heapsnapshot-signal", ...), so nothing exercises the Windows path.

    Impact

    Windows-only Node-compat divergence on a diagnostic flag. In practice the primary use case (SIGUSR1/SIGUSR2) doesn't exist on Windows, so the feature itself is niche there; the observable differences are (a) a bad flag value is silently accepted instead of erroring, and (b) the few Windows-deliverable signals (SIGBREAK, SIGINT, SIGHUP) don't get a snapshot handler. Nothing breaks if merged as-is — this is a strict improvement over the prior state where the flag did nothing on any platform.

    How to fix

    Drop the platform gate to match Node exactly:

    if (heapsnapshotSignal !== null) {
      require("internal/validators").validateSignalName(heapsnapshotSignal);
      process.on(heapsnapshotSignal as NodeJS.Signals, () => {
        require("node:v8").writeHeapSnapshot();
      });
    }

    Bun's process.on(signal) already routes through uv_signal_t on Windows, so the handler registration will work for Windows-supported signals and validateSignalName will reject the rest. If there's a reason to keep the handler POSIX-only, at minimum move validateSignalName(heapsnapshotSignal) outside the platform check so the error-reporting matches Node. The 'rejects an unknown signal name' test could then be lifted out of the skipIf(isWindows) block.

    Step-by-step proof

    1. On Windows, run node --heapsnapshot-signal=NOTREAL -e 0. Node's initializeHeapSnapshotSignalHandlers calls validateSignalName('NOTREAL') with no platform check → throws TypeError [ERR_UNKNOWN_SIGNAL]: Unknown signal: NOTREAL, exit code 1.
    2. On Windows, run bun --heapsnapshot-signal=NOTREAL -e 0 with this PR. heapsnapshotSignal = "NOTREAL", but process.platform === "win32" so the if at line 341 is false → no validation, no error, exit code 0.
    3. Divergence: Node rejects, Bun accepts.

Comment thread test/cli/run/node-cli-flags.test.ts Outdated
Comment thread src/resolver/resolver.rs Outdated
…sult, replace spawn-based SigIgn test with /proc/self/status check
Comment thread src/jsc/VirtualMachine.rs Outdated
Comment thread src/jsc/bindings/c-bindings.cpp
Comment thread src/resolver/options.rs
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
Comment thread src/resolver/resolver.rs Outdated
@robobun robobun changed the title cli: honor --preserve-symlinks/-main for __filename, make SIGUSR1 inert, implement --heapsnapshot-signal cli: honor --preserve-symlinks/-main for __filename, make SIGUSR1 inert by default Jul 25, 2026
Comment thread test/cli/run/node-cli-flags.test.ts
Comment thread src/runtime/cli/run_command.rs Outdated
… does)

test-require-symlink.js Worker case: with --preserve-symlinks but not
--preserve-symlinks-main, the main entry is still realpath'd in Node.js.
Move the preserve decision out of finalize_result (which cannot tell
main from non-main) into _resolve, which picks .pretty (the pre-realpath
spelling set_realpath stashed) when the applicable flag is set.

Also extract preserve_symlinks_main_effective() for the three call sites.
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: 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/cli/run/node-cli-flags.test.ts`:
- Around line 74-84: The existing `.cjs` test only covers direct file opening;
extend the relevant CLI test suite with separate symlink cases for extensionless
`bun run link` resolution and `NODE_PRESERVE_SYMLINKS_MAIN=1`. Use the
appropriate command/environment setup and assert each case preserves the
original link spelling, including successful exit and clean stderr.
🪄 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: b4f39b24-070f-43c4-ab34-92435f90d581

📥 Commits

Reviewing files that changed from the base of the PR and between 04bb5c4 and 740fb31.

📒 Files selected for processing (8)
  • src/bundler/transpiler.rs
  • src/jsc/VirtualMachine.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/jsc/bindings/c-bindings.cpp
  • src/options_types/context.rs
  • src/resolver/options.rs
  • src/runtime/cli/run_command.rs
  • test/cli/run/node-cli-flags.test.ts

Comment thread test/cli/run/node-cli-flags.test.ts
Comment thread src/runtime/cli/run_command.rs
@robobun

robobun commented Jul 25, 2026

Copy link
Copy Markdown
Collaborator Author

CI status at bb90f37: every test lane that built is green for this diff (test/cli/run/node-cli-flags.test.ts 9/9, test-require-symlink.js passing). Remaining red is unrelated infra:

Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread src/jsc/VirtualMachine.rs
Comment thread test/cli/run/node-cli-flags.test.ts
Comment thread src/runtime/cli/run_command.rs Outdated
Comment thread src/runtime/cli/run_command.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.

No new findings on bb90f37 — all four prior inline concerns were addressed. This touches the runtime module-resolve hot path (VirtualMachine::_resolve) and installs a process-wide SIGUSR1 handler at startup, so worth a human look before merging.

What was reviewed: the .pretty-vs-.text selection in _resolve and both entry paths (fast maybe_open_with_bun_js cwd-join and the bun run resolver fallback); SIGUSR1 install uses a real handler (not SIG_IGN) with SA_RESTART, and onDidChangeListeners restores to the no-op rather than SIG_DFL; the uncalled LoaderHooks::resolve sibling in jsc_hooks.rs — confirmed dead, author intentionally excluded; test hermeticity — NODE_PRESERVE_SYMLINKS{,_MAIN} now cleared for negative controls.

The one open inline comment is comment-cop lint on the 3-line explanatory comment at run_command.rs:2567-2569.

Extended reasoning...

Overview

Two Node-compat behaviors: (1) --preserve-symlinks / --preserve-symlinks-main now affect the path returned from module resolution (previously parsed but ignored — _resolve always returned the realpath'd .text), and (2) SIGUSR1 is made non-fatal by installing a no-op handler in bun_initialize_process. Eight files: the resolve tail in VirtualMachine::_resolve, three entry-point sites in run_command.rs, a new preserve_symlinks_main field on resolver BundleOptions (plus its projection and default), a shared preserve_symlinks_main_effective() helper, the SIGUSR1 handler in c-bindings.cpp / restore path in BunProcess.cpp, and a 9-case test file.

Security risks

None identified. The preserve-symlinks change only selects between two paths the resolver already produced (realpath .text vs. pre-realpath .pretty); it does not relax any containment check. The SIGUSR1 handler is a genuine no-op with SA_RESTART, not SIG_IGN, so exec()'d children revert to SIG_DFL — the SigIgn-mask test verifies this on Linux.

Level of scrutiny

High. VirtualMachine::_resolve is on every require()/import path, and returning .pretty instead of .text changes the module-cache key (intended under --preserve-symlinks, matching Node). bun_initialize_process runs unconditionally at startup, so the SIGUSR1 disposition change applies to every bun invocation including bun install, bun test, etc. Neither is a config tweak or mechanical refactor; both are user-visible behavioral changes to core paths.

Other factors

The PR went through five review iterations: my earlier 🔴 (bun run slow path still realpath'd) and three 🟡 nits (dead jsc_hooks.rs sibling, non-hermetic negative-control env, stale saved_preserve comment) were all addressed or explicitly declined with rationale. CodeRabbit's slow-path/env-var coverage request was addressed in ef71c79. The bug-hunting system found nothing on the current head. Test coverage is solid (flag on/off, entry vs. required, env-var form, bun vs. bun run, listener add/remove, SigIgn mask), and the author reports test-require-symlink.js stays green. The remaining open thread is automated comment-cop lint on a 3-line comment, not a substantive review. Given the surface area — module resolution identity + process-wide signal disposition — I'm deferring rather than approving.

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