Skip to content
Open
1 change: 1 addition & 0 deletions src/bundler/transpiler.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1118,6 +1118,7 @@ pub(crate) fn resolver_bundle_options_subset(
polyfill_node_globals: src.polyfill_node_globals,
prefer_offline_install: src.prefer_offline_install,
preserve_symlinks: src.preserve_symlinks,
preserve_symlinks_main: false,
rewrite_jest_for_tests: src.rewrite_jest_for_tests,
tsconfig_override: src.tsconfig_override.clone(),
production: src.production,
Expand Down
25 changes: 20 additions & 5 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4048,7 +4048,8 @@
return Err(crate::CrateError::ModuleNotFound);
}

let is_special_source = source == MAIN_FILE_NAME || Macro::is_macro_path(source);
let is_main_entry_resolve = source == MAIN_FILE_NAME;
let is_special_source = is_main_entry_resolve || Macro::is_macro_path(source);
let mut query_string: &[u8] = b"";
let normalized_specifier = normalize_specifier_for_resolution(specifier, &mut query_string);
let top_level_dir = self.top_level_dir();
Expand Down Expand Up @@ -4157,10 +4158,24 @@
let result_path = result
.path_const()
.ok_or(crate::CrateError::ModuleNotFound)?;
// SAFETY: `result_path.text` borrows the resolver's arena, which
// outlives `ResolveFunctionResult` (see the struct's lifetime-erasure
// note).
ret.path = unsafe { bun_ptr::detach_lifetime(result_path.text) };
// Node.js: `--preserve-symlinks` keeps the link spelling for required
// modules only; `--preserve-symlinks-main` extends it to the entry.
// `Path::set_realpath` stashed the pre-realpath spelling in `.pretty`.
Comment thread
robobun marked this conversation as resolved.
let opts = &self.transpiler.resolver.opts;
let preserve = if is_main_entry_resolve {
opts.preserve_symlinks_main
} else {
opts.preserve_symlinks
};
let path_text = if preserve && result_path.is_symlink && !result_path.pretty.is_empty() {
result_path.pretty
} else {
result_path.text
};
// SAFETY: `result_path.text`/`.pretty` borrow the resolver's arena,
// which outlives `ResolveFunctionResult` (see the struct's
// lifetime-erasure note).
ret.path = unsafe { bun_ptr::detach_lifetime(path_text) };

Check warning on line 4178 in src/jsc/VirtualMachine.rs

View check run for this annotation

Claude / Claude Code Review

Sibling _resolve in jsc_hooks.rs not updated with preserve-symlinks .pretty selection

The `.pretty`-vs-`.text` selection added here has an un-updated byte-for-byte twin at `src/runtime/jsc_hooks.rs:5054` (`_resolve()`, registered as `LoaderHooks::resolve` in `__BUN_LOADER_HOOKS`), which still unconditionally returns `result_path.text`. Grepping every `(hooks.<field>)` call in `src/jsc/ModuleLoader.rs` shows `LoaderHooks::resolve` is never invoked today, so there's no live behavioral bug — but this PR introduces the divergence between the two copies. Per REVIEW.md ("Fix the whole
Comment thread
robobun marked this conversation as resolved.
ret.result = Some(result);

Ok(())
Expand Down
7 changes: 6 additions & 1 deletion src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1395,6 +1395,9 @@ extern "C" bool Bun__shouldIgnoreOneDisconnectEventListener(JSC::JSGlobalObject*
extern "C" void Bun__ensureSignalHandler();
extern "C" bool Bun__isMainThreadVM();
extern "C" void Bun__onPosixSignal(int signalNumber);
#if !OS(WINDOWS)
extern "C" void Bun__noopSignalHandler(int);
#endif

__attribute__((noinline)) static void forwardSignal(int signalNumber)
{
Expand Down Expand Up @@ -1587,7 +1590,9 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
if (signalToContextIdsMap->find(signalNumber) != signalToContextIdsMap->end() && eventEmitter.listenerCount(eventName) == 0) {

#if !OS(WINDOWS)
if (void (*oldHandler)(int) = signal(signalNumber, SIG_DFL); oldHandler != forwardSignal) {
// SIGUSR1 stays inert (reserved); SIG_DFL would make it fatal.
void (*restoreTo)(int) = signalNumber == SIGUSR1 ? Bun__noopSignalHandler : SIG_DFL;
if (void (*oldHandler)(int) = signal(signalNumber, restoreTo); oldHandler != forwardSignal) {
// Don't uninstall the old handler if it's not the one we installed.
signal(signalNumber, oldHandler);
}
Expand Down
17 changes: 17 additions & 0 deletions src/jsc/bindings/c-bindings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -572,6 +572,12 @@ extern "C" void Bun__setCTRLHandler(BOOL add)

extern "C" int32_t bun_is_stdio_null[3] = { 0, 0, 0 };

#if !OS(WINDOWS)
extern "C" void Bun__noopSignalHandler(int)
{
}
#endif

extern "C" void bun_initialize_process()
{
// Disable printf() buffering. We buffer it ourselves.
Expand Down Expand Up @@ -653,6 +659,17 @@ extern "C" void bun_initialize_process()
sigaction(SIGTERM, &sa, nullptr);
sigaction(SIGINT, &sa, nullptr);
}

// Node.js reserves SIGUSR1 (debugger) so it is never fatal by default; a
// handler rather than SIG_IGN so exec()'d children revert to SIG_DFL.
Comment thread
robobun marked this conversation as resolved.
{
struct sigaction sa;
memset(&sa, 0, sizeof(sa));
sigemptyset(&sa.sa_mask);
sa.sa_flags = SA_RESTART;
sa.sa_handler = Bun__noopSignalHandler;
sigaction(SIGUSR1, &sa, nullptr);
}
#elif OS(WINDOWS)
for (int fd = 0; fd <= 2; ++fd) {
auto handle = reinterpret_cast<HANDLE>(uv_get_osfhandle(fd));
Expand Down
11 changes: 11 additions & 0 deletions src/options_types/context.rs
Original file line number Diff line number Diff line change
Expand Up @@ -622,3 +622,14 @@ impl Default for RuntimeOptions {
}
}
}

impl RuntimeOptions {
/// `--preserve-symlinks-main` or `NODE_PRESERVE_SYMLINKS_MAIN=1`.
#[inline]
pub fn preserve_symlinks_main_effective(&self) -> bool {
self.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false)
}
}
4 changes: 4 additions & 0 deletions src/resolver/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,9 @@ pub struct BundleOptions {
pub polyfill_node_globals: bool,
pub prefer_offline_install: bool,
pub preserve_symlinks: bool,
/// `--preserve-symlinks-main`: `preserve_symlinks` for the entry only
/// (read by `_resolve` when source is `bun:main`).
Comment thread
robobun marked this conversation as resolved.
pub preserve_symlinks_main: bool,
pub rewrite_jest_for_tests: bool,
pub tsconfig_override: Option<Box<[u8]>>,
pub production: bool,
Expand Down Expand Up @@ -275,6 +278,7 @@ impl Default for BundleOptions {
polyfill_node_globals: false,
prefer_offline_install: false,
preserve_symlinks: false,
preserve_symlinks_main: false,
rewrite_jest_for_tests: false,
tsconfig_override: None,
output_dir: Box::default(),
Expand Down
32 changes: 26 additions & 6 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,8 @@
vm.argv = std::mem::take(&mut ctx.passthrough);
// `InitOptions` has no `store_fd` field, so set it on the resolver directly.
vm.transpiler.resolver.store_fd = ctx.debug.hot_reload != cli::command::HotReload::None;
vm.transpiler.resolver.opts.preserve_symlinks_main =
ctx.runtime_options.preserve_symlinks_main_effective();
// `vm.dns_result_order` is a `u8` until the b2-cycle widens
// it to `bun_dns::Order`; the enum is `#[repr(u8)]` so `as u8` is exact.
vm.dns_result_order =
Expand Down Expand Up @@ -2563,12 +2565,9 @@
// Temporarily honor `--preserve-symlinks-main` / NODE_PRESERVE_SYMLINKS_MAIN
// for this one resolve.
let resolution: ::core::result::Result<bun_resolver::Result, bun_resolver::Error> = {
let saved_preserve = this_transpiler.resolver.opts.preserve_symlinks;
this_transpiler.resolver.opts.preserve_symlinks =
ctx.runtime_options.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false);
ctx.runtime_options.preserve_symlinks_main_effective();

Check failure on line 2570 in src/runtime/cli/run_command.rs

View check run for this annotation

Claude / Claude Code Review

bun run <symlink> path (boot_bun_run) still realpaths under --preserve-symlinks-main

The `bun run <symlink>` entry path (via `boot_bun_run`) still realpaths `__filename` under `--preserve-symlinks-main` — only the `bun <symlink>` fast path is fixed. Commit 740fb314 reverted the `finalize_result` gate, so the temporary `preserve_symlinks` override at 2568–2589 no longer influences `path.text`, and line 2611 hands the realpath to `_boot_and_handle_error`; when `bun:main` re-resolves it, `is_symlink` is false and the new `.pretty` fallback in `_resolve` never fires. Line 2611 shoul
Comment thread
robobun marked this conversation as resolved.
Outdated
// SAFETY: `Transpiler::init` always sets `fs`; resolver-cache lifetime.
let top_level_dir = unsafe { (*this_transpiler.fs).top_level_dir };
let resolved = match this_transpiler.resolver.resolve(
Expand Down Expand Up @@ -2870,9 +2869,30 @@
..Default::default()
});

let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main_effective();

// Re-derive the canonical absolute path from the open fd (resolves
// symlinks).
let absolute_script_path: Box<[u8]> = {
// symlinks), or under `--preserve-symlinks-main` join against cwd.
let absolute_script_path: Box<[u8]> = if preserve_symlinks_main {
let mut cwd_buf = PathBuffer::uninit();
let Ok(cwd) = bun_core::getcwd(&mut cwd_buf) else {
let _ = bun_sys::close(fd);
return false;
};
let cwd_len = cwd.as_bytes().len();
cwd_buf[cwd_len] = paths::SEP;
let joined = paths::resolve_path::join_abs_string_buf::<paths::platform::Auto>(
&cwd_buf[..cwd_len + 1],
&mut script_name_buf.0,
&[target],
);
let joined = strings::without_trailing_slash(joined);
if joined.is_empty() {
let _ = bun_sys::close(fd);
return false;
}
joined.to_vec().into_boxed_slice()
} else {
let resolved = match bun_sys::get_fd_path(fd, &mut script_name_buf) {
Ok(p) => p,
Err(_) => {
Expand Down
154 changes: 154 additions & 0 deletions test/cli/run/node-cli-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { describe, expect, test } from "bun:test";
Comment thread
robobun marked this conversation as resolved.
import { bunEnv, bunExe, isLinux, isPosix, tempDir } from "harness";
import { symlinkSync } from "node:fs";
import { join } from "node:path";

describe("--preserve-symlinks", () => {
test.concurrent("required symlink reports the symlink path as __filename", async () => {
using dir = tempDir("preserve-symlinks-require", {
"real.cjs": `module.exports = __filename;`,
"main.cjs": `console.log(require("./link.cjs"));`,
});
symlinkSync(join(String(dir), "real.cjs"), join(String(dir), "link.cjs"));

await using proc = Bun.spawn({
cmd: [bunExe(), "--preserve-symlinks", "main.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe(join(String(dir), "link.cjs"));
expect(exitCode).toBe(0);
});

test.concurrent("without the flag, required symlink realpaths __filename", async () => {
using dir = tempDir("preserve-symlinks-off", {
"real.cjs": `module.exports = __filename;`,
"main.cjs": `console.log(require("./link.cjs"));`,
});
symlinkSync(join(String(dir), "real.cjs"), join(String(dir), "link.cjs"));

await using proc = Bun.spawn({
cmd: [bunExe(), "main.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe(join(String(dir), "real.cjs"));
expect(exitCode).toBe(0);
});
Comment thread
robobun marked this conversation as resolved.

test.concurrent("--preserve-symlinks alone does not apply to the entry point", async () => {
// Node.js: --preserve-symlinks affects required modules only; the main
// module is realpath'd unless --preserve-symlinks-main is also passed.
using dir = tempDir("preserve-symlinks-not-main", {
"sub/real.cjs": `console.log(__filename);`,
});
symlinkSync(join(String(dir), "sub", "real.cjs"), join(String(dir), "link.cjs"));

await using proc = Bun.spawn({
cmd: [bunExe(), "--preserve-symlinks", "link.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe(join(String(dir), "sub", "real.cjs"));
expect(exitCode).toBe(0);
});

test.concurrent("--preserve-symlinks-main keeps the symlink path for the entry point", async () => {
using dir = tempDir("preserve-symlinks-main", {
"real.cjs": `console.log(__filename);`,
});
symlinkSync(join(String(dir), "real.cjs"), join(String(dir), "link.cjs"));

await using proc = Bun.spawn({
cmd: [bunExe(), "--preserve-symlinks-main", "link.cjs"],
env: bunEnv,
cwd: String(dir),
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe(join(String(dir), "link.cjs"));
expect(exitCode).toBe(0);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
});
});

describe.skipIf(!isPosix)("SIGUSR1 default disposition", () => {
test.concurrent("SIGUSR1 does not terminate the process by default", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`process.kill(process.pid, "SIGUSR1");
setImmediate(() => { console.log("survived"); process.exit(0); });`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe("survived");
expect(exitCode).toBe(0);
expect(proc.signalCode).toBeNull();
});

test.concurrent("SIGUSR1 stays inert after the last listener is removed", async () => {
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const fn = () => {};
process.on("SIGUSR1", fn);
process.removeListener("SIGUSR1", fn);
process.kill(process.pid, "SIGUSR1");
setImmediate(() => { console.log("survived"); process.exit(0); });`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe("survived");
expect(exitCode).toBe(0);
});

test.concurrent.skipIf(!isLinux)(
"SIGUSR1 is handled, not SIG_IGN, so exec()'d children revert to SIG_DFL",
async () => {
// Bun's own spawn path resets every signal to SIG_DFL in the child, so a
// spawned-process probe cannot distinguish a handler from SIG_IGN. Read
// the parent process's SigIgn mask directly instead.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const status = require("node:fs").readFileSync("/proc/self/status", "utf8");
const sigIgn = BigInt("0x" + status.match(/^SigIgn:\\s+([0-9a-f]+)/m)[1]);
const SIGUSR1 = require("node:os").constants.signals.SIGUSR1;
console.log(((sigIgn >> BigInt(SIGUSR1 - 1)) & 1n).toString());`,
],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});
const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toBe("");
expect(stdout.trim()).toBe("0");
expect(exitCode).toBe(0);
},
);
});
Loading