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
24 changes: 19 additions & 5 deletions src/jsc/VirtualMachine.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -4157,10 +4158,23 @@ 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) };
// `--preserve-symlinks-main`: the synthetic `bun:main` wrapper resolving
// the entry module must keep the symlink spelling. `finalize_result`
// stores the pre-realpath text in `.pretty` when it rewrites `.text`
// (see `Path::set_realpath`), so read it back for this one resolve.
Comment thread
robobun marked this conversation as resolved.
Outdated
let path_text = if is_main_entry_resolve
&& self.transpiler.resolver.opts.preserve_symlinks_main
&& 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) };
Comment thread
robobun marked this conversation as resolved.
ret.result = Some(result);

Ok(())
Expand Down
8 changes: 7 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,10 @@ 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 is reserved (inert) by default; restoring SIG_DFL would
// make it fatal. Restore the startup no-op handler instead.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
20 changes: 20 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,20 @@ extern "C" void bun_initialize_process()
sigaction(SIGTERM, &sa, nullptr);
sigaction(SIGINT, &sa, nullptr);
}

// Node.js reserves SIGUSR1 for the debugger and never lets it terminate
// the process by default. Bun does not start an inspector on SIGUSR1, but
// still makes the signal inert so ops tooling that signals a node-compatible
// process does not kill it. A handler (not SIG_IGN) is used so the
// disposition resets to SIG_DFL across exec() for child processes.
Comment thread
robobun marked this conversation as resolved.
Outdated
{
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
5 changes: 5 additions & 0 deletions src/resolver/options.rs
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ pub struct BundleOptions {
pub polyfill_node_globals: bool,
pub prefer_offline_install: bool,
pub preserve_symlinks: bool,
/// `--preserve-symlinks-main`: applies `preserve_symlinks` semantics to
/// the main entry module only. Consumed by the VM's `_resolve` when the
/// source is the synthetic `bun:main` wrapper.
Comment thread
robobun marked this conversation as resolved.
Outdated
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 +279,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
12 changes: 10 additions & 2 deletions src/resolver/resolver.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1665,8 +1665,16 @@ impl<'a> Resolver<'a> {
module_type_from_ext(name.ext).unwrap_or(options::ModuleType::Unknown);
}

if let Some(entries) = dir.get_entries_ref(self.generation) {
if let Some(query) = entries.get(name.filename) {
// `--preserve-symlinks` (Node.js semantics): keep the symlink
// spelling as the resolved path so `__filename` / the module
// cache key is the link, not the realpath. `dir.abs_real_path`
// is already left empty under this flag (see `dir_info_uncached`),
// so skipping this block is the only remaining realpath source.
Comment thread
robobun marked this conversation as resolved.
Outdated
if !self.opts.preserve_symlinks
&& let Some(entries) = dir.get_entries_ref(self.generation)
&& let Some(query) = entries.get(name.filename)
{
{
Comment thread
robobun marked this conversation as resolved.
Outdated
// SAFETY: entries_mutex held; rfs points at the process-global RealFS.
let symlink_path =
unsafe { query.entry().symlink(self.rfs_ptr(), self.store_fd) };
Expand Down
35 changes: 33 additions & 2 deletions src/runtime/cli/run_command.rs
Original file line number Diff line number Diff line change
Expand Up @@ -959,6 +959,11 @@ Full documentation is available at <magenta>https://bun.com/docs/cli/run<r>
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
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false);
// `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 @@ -2870,9 +2875,35 @@ impl RunCommand {
..Default::default()
});

let preserve_symlinks_main = ctx.runtime_options.preserve_symlinks_main
|| bun_core::env_var::NODE_PRESERVE_SYMLINKS_MAIN
.get()
.unwrap_or(false);
Comment thread
robobun marked this conversation as resolved.
Outdated

// Re-derive the canonical absolute path from the open fd (resolves
// symlinks).
let absolute_script_path: Box<[u8]> = {
// symlinks). With `--preserve-symlinks-main`, Node.js keeps the
// symlink spelling for the entry module's `__filename`, so skip the
// realpath and resolve `target` against cwd instead.
Comment thread
robobun marked this conversation as resolved.
Outdated
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
129 changes: 129 additions & 0 deletions test/cli/run/node-cli-flags.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { describe, expect, test } from "bun:test";

Check warning on line 1 in test/cli/run/node-cli-flags.test.ts

View check run for this annotation

Claude / Claude Code Review

PR title/description stale after dropping --heapsnapshot-signal

Commit 9683e8f3 dropped the `--heapsnapshot-signal` implementation (deferring to #35390), but the PR title, the Fix section (still mentions `Arguments.rs` / `pre_execution.ts`), and the Verification section (still claims 9 test cases including heap-snapshot coverage) weren't updated — the actual test file has 6 cases and no heapsnapshot tests. Please trim the title/description to match what actually ships (per CLAUDE.md #11, "NEVER overstate what you got done").
Comment thread
robobun marked this conversation as resolved.
import { bunEnv, bunExe, 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-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("child processes do not inherit an ignored SIGUSR1", async () => {
// Node.js installs a handler (reset to SIG_DFL on exec), not SIG_IGN
// (which would be inherited). A spawned `sh` must still be killable by
// SIGUSR1.
await using proc = Bun.spawn({
cmd: [
bunExe(),
"-e",
`const { spawnSync } = require("node:child_process");
const r = spawnSync("sh", ["-c", "kill -USR1 $$; sleep 5"]);
console.log(r.signal);`,
],
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("SIGUSR1");
expect(exitCode).toBe(0);
Comment thread
robobun marked this conversation as resolved.
Outdated
});
});
Loading