diff --git a/src/bundler/transpiler.rs b/src/bundler/transpiler.rs index 8fb72a09715a..f0c36fd217d8 100644 --- a/src/bundler/transpiler.rs +++ b/src/bundler/transpiler.rs @@ -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, diff --git a/src/jsc/VirtualMachine.rs b/src/jsc/VirtualMachine.rs index 191c40190c98..e4d4db229d53 100644 --- a/src/jsc/VirtualMachine.rs +++ b/src/jsc/VirtualMachine.rs @@ -4048,7 +4048,8 @@ impl VirtualMachine { 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(); @@ -4157,10 +4158,24 @@ impl VirtualMachine { 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`. + 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) }; ret.result = Some(result); Ok(()) diff --git a/src/jsc/bindings/BunProcess.cpp b/src/jsc/bindings/BunProcess.cpp index 8b5b4c123b76..e72dd3251e71 100644 --- a/src/jsc/bindings/BunProcess.cpp +++ b/src/jsc/bindings/BunProcess.cpp @@ -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) { @@ -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); } diff --git a/src/jsc/bindings/c-bindings.cpp b/src/jsc/bindings/c-bindings.cpp index c82a4e6bfa35..8bd2d91a3c1a 100644 --- a/src/jsc/bindings/c-bindings.cpp +++ b/src/jsc/bindings/c-bindings.cpp @@ -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. @@ -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. + { + 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(uv_get_osfhandle(fd)); diff --git a/src/options_types/context.rs b/src/options_types/context.rs index 5573c6990794..98ade7de413b 100644 --- a/src/options_types/context.rs +++ b/src/options_types/context.rs @@ -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) + } +} diff --git a/src/resolver/options.rs b/src/resolver/options.rs index a9d60ce759ad..b90d14e10822 100644 --- a/src/resolver/options.rs +++ b/src/resolver/options.rs @@ -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`). + pub preserve_symlinks_main: bool, pub rewrite_jest_for_tests: bool, pub tsconfig_override: Option>, pub production: bool, @@ -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(), diff --git a/src/runtime/cli/run_command.rs b/src/runtime/cli/run_command.rs index 8c28e83ecdea..a33f8b591fd5 100644 --- a/src/runtime/cli/run_command.rs +++ b/src/runtime/cli/run_command.rs @@ -959,6 +959,8 @@ Full documentation is available at https://bun.com/docs/cli/run 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 = @@ -2560,15 +2562,13 @@ impl RunCommand { bstr::BStr::new(target_name), bstr::BStr::new(fs_top_level_dir), ); - // Temporarily honor `--preserve-symlinks-main` / NODE_PRESERVE_SYMLINKS_MAIN - // for this one resolve. + let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main_effective(); let resolution: ::core::result::Result = { + // Suppress `DirInfo.abs_real_path` population (resolver.rs + // `dir_info_uncached`) for the entry's directory; the entry path + // itself is recovered from `.pretty` below. 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); + this_transpiler.resolver.opts.preserve_symlinks = preserve_symlinks_main; // 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( @@ -2606,10 +2606,22 @@ impl RunCommand { .or_else(|| bun_bundler::options::DEFAULT_LOADERS.get(ext).copied()) .unwrap_or(Loader::Tsx); if loader.can_be_run_by_bun() || loader == Loader::Html || loader == Loader::Md { - bun_core::scoped_log!(RUN_LOG, "Resolved to: `{}`", bstr::BStr::new(path.text)); + // `--preserve-symlinks-main`: `set_realpath` stashed the + // link spelling in `.pretty`. + let entry_text = + if preserve_symlinks_main && path.is_symlink && !path.pretty.is_empty() { + path.pretty + } else { + path.text + }; + bun_core::scoped_log!( + RUN_LOG, + "Resolved to: `{}`", + bstr::BStr::new(entry_text) + ); // borrowck — `_boot_and_handle_error` takes - // `&mut ctx`; copy `path.text` out of the resolver borrow. - let text: Box<[u8]> = path.text.to_vec().into_boxed_slice(); + // `&mut ctx`; copy `entry_text` out of the resolver borrow. + let text: Box<[u8]> = entry_text.to_vec().into_boxed_slice(); return Ok(Self::_boot_and_handle_error(ctx, &text, Some(loader))); } else { bun_core::scoped_log!( @@ -2870,9 +2882,30 @@ impl RunCommand { ..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::( + &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(_) => { diff --git a/test/cli/run/node-cli-flags.test.ts b/test/cli/run/node-cli-flags.test.ts new file mode 100644 index 000000000000..b80bdacbabe9 --- /dev/null +++ b/test/cli/run/node-cli-flags.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isPosix, tempDir } from "harness"; +import { symlinkSync } from "node:fs"; +import { join } from "node:path"; + +// Hermetic env: a contributor's shell may export NODE_PRESERVE_SYMLINKS{,_MAIN} +// (rush / pnpm monorepo setups do), which would flip the negative-control tests. +const env = { ...bunEnv, NODE_PRESERVE_SYMLINKS: undefined, NODE_PRESERVE_SYMLINKS_MAIN: undefined }; + +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, + 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, + 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); + }); + + 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, + 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); + }); + + async function expectEntryFilename( + cmd: string[], + cwd: string, + expected: string, + extraEnv: Record = {}, + ) { + await using proc = Bun.spawn({ + cmd, + env: { ...env, ...extraEnv }, + cwd, + 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(expected); + 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 expectEntryFilename( + [bunExe(), "--preserve-symlinks-main", "link.cjs"], + String(dir), + join(String(dir), "link.cjs"), + ); + }); + + test.concurrent("NODE_PRESERVE_SYMLINKS_MAIN=1 keeps the symlink path for the entry point", async () => { + using dir = tempDir("preserve-symlinks-main-env", { + "real.cjs": `console.log(__filename);`, + }); + symlinkSync(join(String(dir), "real.cjs"), join(String(dir), "link.cjs")); + await expectEntryFilename([bunExe(), "link.cjs"], String(dir), join(String(dir), "link.cjs"), { + NODE_PRESERVE_SYMLINKS_MAIN: "1", + }); + }); + + test.concurrent("--preserve-symlinks-main applies to an extensionless entry via `bun run`", async () => { + // Exercises the resolver fallback path (no fast-run for extensionless). + using dir = tempDir("preserve-symlinks-main-run", { + "real.js": `console.log(__filename);`, + "package.json": `{}`, + }); + symlinkSync(join(String(dir), "real.js"), join(String(dir), "link")); + await expectEntryFilename( + [bunExe(), "run", "--preserve-symlinks-main", "link"], + String(dir), + join(String(dir), "link"), + ); + }); +}); + +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, + 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, + 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, + 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); + }, + ); +});