diff --git a/src/event_loop/MiniEventLoop.rs b/src/event_loop/MiniEventLoop.rs index 6219edb62805..cb96dcbd4971 100644 --- a/src/event_loop/MiniEventLoop.rs +++ b/src/event_loop/MiniEventLoop.rs @@ -357,6 +357,23 @@ impl MiniEventLoop { unsafe { (*self.loop_ptr()).wakeup() }; } + /// [`enqueue_task_concurrent`](Self::enqueue_task_concurrent) for a + /// signal handler, which may have interrupted `tick` and so must not form + /// `&mut Self`. Async-signal-safe (lock-free push + `us_wakeup_loop`). + /// + /// # Safety + /// `this` is live; `task` outlives the queued item and is not queued yet. + pub unsafe fn enqueue_task_concurrent_raw( + this: *const Self, + task: NonNull, + ) { + // SAFETY: per fn contract; field projection only, no `&Self`. + unsafe { + (*core::ptr::addr_of!((*this).concurrent_tasks)).push(task); + bun_uws::us_wakeup_loop((*this).loop_); + } + } + /// The caller supplies `field_offset = core::mem::offset_of!(C, )` of the /// embedded `AnyTaskWithExtraContext`. /// diff --git a/src/install/lib.rs b/src/install/lib.rs index 867cf974ec01..09adfdecb3d1 100644 --- a/src/install/lib.rs +++ b/src/install/lib.rs @@ -116,6 +116,8 @@ pub mod git_runner; pub mod hoisted_install; pub mod isolated_install; pub mod lifecycle_script_runner; +#[cfg(unix)] +pub(crate) mod lifecycle_signals; pub mod migration; #[path = "PackageInstall.rs"] pub mod package_install; diff --git a/src/install/lifecycle_script_runner.rs b/src/install/lifecycle_script_runner.rs index f415b55cf5e5..7ececf7d7fdc 100644 --- a/src/install/lifecycle_script_runner.rs +++ b/src/install/lifecycle_script_runner.rs @@ -650,6 +650,9 @@ impl<'a> LifecycleScriptSubprocess<'a> { }, stream: false, + // No script outlives a killed or crashed install. + #[cfg(any(target_os = "linux", target_os = "android"))] + linux_pdeathsig: Some(bun_sys::SignalCode::SIGKILL.0), ..Default::default() }; @@ -661,6 +664,9 @@ impl<'a> LifecycleScriptSubprocess<'a> { (*manager) .active_lifecycle_scripts .insert(this.cast::>()); + // Hooked before the spawn so no signal lands in between. + #[cfg(unix)] + crate::lifecycle_signals::on_script_started(manager); let spawned = match bun_spawn::spawn_process( &spawn_options, // argv is `[*const c_char; 4]` with trailing null — exactly the @@ -670,6 +676,8 @@ impl<'a> LifecycleScriptSubprocess<'a> { ) { Ok(Ok(s)) => s, res => { + #[cfg(unix)] + crate::lifecycle_signals::on_script_exited(); #[cfg(windows)] { // `spawn_process_windows` only `heap::take`s the `Stdio::Buffer` @@ -842,6 +850,22 @@ impl<'a> LifecycleScriptSubprocess<'a> { // fields (`heap`/`manager`) — see `ensure_not_in_heap` doc. unsafe { Self::ensure_not_in_heap(std::ptr::from_mut::(self)) }; + // A signal is pending: do not chain or exit on this script's status; + // `on_script_exited` ends the process after the last one. + #[cfg(unix)] + if crate::lifecycle_signals::pending().is_some() { + if let Status::Signaled(signal) = status { + self.print_terminated_by(signal); + } + crate::lifecycle_signals::on_script_exited(); + self.decrement_pending_script_tasks(); + // SAFETY: `self` was created by `Self::new` (heap::alloc); uniquely owned here. + unsafe { Self::destroy(std::ptr::from_mut::(self)) }; + return; + } + #[cfg(unix)] + crate::lifecycle_signals::on_script_exited(); + match status { Status::Exited(exit) => { if exit.code > 0 { @@ -960,15 +984,7 @@ impl<'a> LifecycleScriptSubprocess<'a> { unsafe { Self::destroy(std::ptr::from_mut::(self)) }; } Status::Signaled(signal) => { - self.print_output(); - let signal_code = bun_sys::SignalCode::from(signal); - - bun_core::pretty_errorln!( - "error: {} script from \"{}\" terminated by {}", - bstr::BStr::new(self.script_name()), - bstr::BStr::new(&self.package_name), - signal_code.fmt(Output::enable_ansi_colors_stderr()), - ); + self.print_terminated_by(signal); // `Status::signal_code()` range-checks 1..=31 (`bun_core::SignalCode` is // exhaustive); RT signals (>31) fall back to SIGTERM so the diverging @@ -1014,6 +1030,17 @@ impl<'a> LifecycleScriptSubprocess<'a> { } } + fn print_terminated_by(&mut self, signal: u8) { + self.print_output(); + let signal_code = bun_sys::SignalCode::from(signal); + bun_core::pretty_errorln!( + "error: {} script from \"{}\" terminated by {}", + bstr::BStr::new(self.script_name()), + bstr::BStr::new(&self.package_name), + signal_code.fmt(Output::enable_ansi_colors_stderr()), + ); + } + /// This function may free the *LifecycleScriptSubprocess pub(crate) fn on_process_exit(&mut self, proc: *mut Process, _: Status, _: &Rusage) { if self.process.as_ref().map(ProcessHandle::as_ptr) != Some(proc) { @@ -1086,6 +1113,10 @@ impl<'a> LifecycleScriptSubprocess<'a> { foreground: bool, ctx: Option>, ) -> Result<(), crate::Error> { + #[cfg(unix)] + if crate::lifecycle_signals::pending().is_some() { + return Ok(()); + } let package_name = list.package_name.clone(); let lifecycle_subprocess = Self::new(LifecycleScriptSubprocess { manager: bun_ptr::BackRef::new_mut(manager), diff --git a/src/install/lifecycle_signals.rs b/src/install/lifecycle_signals.rs new file mode 100644 index 000000000000..237dd3750b52 --- /dev/null +++ b/src/install/lifecycle_signals.rs @@ -0,0 +1,84 @@ +//! Forwards SIGINT/SIGTERM/SIGHUP sent to `bun install` to its running +//! lifecycle scripts, waits for them, then dies by the same signal (hook: +//! `bun_spawn::exit_signals`). After forwarding, a second signal is default. + +use core::ffi::c_int; +use core::sync::atomic::{AtomicPtr, AtomicUsize, Ordering}; + +use bun_spawn::exit_signals; + +use crate::PackageManager; +use crate::lifecycle_script_runner::LifecycleScriptSubprocess; + +static RUNNING: AtomicUsize = AtomicUsize::new(0); +static MANAGER: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); + +/// A hooked signal arrived (forwarded or about to be). While `Some`, no +/// script starts or chains, and the install ends by it after the last script. +pub(crate) fn pending() -> Option { + exit_signals::received().map(signal_code) +} + +fn signal_code(sig: c_int) -> bun_core::SignalCode { + u8::try_from(sig) + .ok() + .and_then(bun_core::SignalCode::from_raw) + .unwrap_or(bun_core::SignalCode::DEFAULT) +} + +/// Call before spawning a lifecycle script. `manager` (allocation-rooted) is +/// dereferenced from the signal callback until the last script exits. +pub(crate) fn on_script_started(manager: *mut PackageManager) { + if RUNNING.fetch_add(1, Ordering::Relaxed) != 0 { + return; + } + MANAGER.store(manager, Ordering::Relaxed); + // SAFETY: `manager` is live; only `event_loop` is borrowed, for this call. + let event_loop = + bun_event_loop::EventLoopHandle::from_any(unsafe { &mut (*manager).event_loop }); + exit_signals::hook(event_loop, on_signal); +} + +/// Call when a lifecycle script exited or failed to spawn. After the last one +/// unhooks, then dies by a signal that arrived while hooked, if any. +pub(crate) fn on_script_exited() { + if RUNNING.fetch_sub(1, Ordering::Relaxed) != 1 { + return; + } + exit_signals::unhook(); + MANAGER.store(core::ptr::null_mut(), Ordering::Relaxed); + // Checked after `unhook` so a signal either shows up here or already took + // the default action; none is lost in between. + if let Some(sig) = pending() { + die(sig); + } +} + +fn die(sig: bun_core::SignalCode) -> ! { + bun_core::Output::flush(); + bun_core::Global::raise_ignoring_panic_handler(sig); +} + +/// Install thread, between event loop ticks. +fn on_signal(sig: c_int) { + let manager = MANAGER.load(Ordering::Relaxed); + if manager.is_null() || RUNNING.load(Ordering::Relaxed) == 0 { + // The last script exited before this ran: nothing to wait for. + die(signal_code(sig)); + } + exit_signals::unhook(); + let forward = signal_code(sig) as u8; + // SAFETY: `manager` is live (see `on_script_started`); the heap is only + // touched on this thread. + unsafe { + (*manager).active_lifecycle_scripts.for_each( + |script: *mut LifecycleScriptSubprocess<'static>| { + if let Some(process) = &(*script).process + && !process.process_mut().has_exited() + { + let _ = process.kill(forward); + } + }, + ); + } +} diff --git a/src/io/heap.rs b/src/io/heap.rs index 8c98a576f3a2..1401e6c5388c 100644 --- a/src/io/heap.rs +++ b/src/io/heap.rs @@ -99,6 +99,22 @@ impl> Intrusive { result } + /// Visit every element (unordered, O(N)). `f` must not insert or remove. + pub unsafe fn for_each(&self, mut f: impl FnMut(*mut T)) { + // SAFETY: all reachable nodes from `self.root` are valid for the heap's lifetime. + Self::for_each_internal(self.root, &mut f); + } + + unsafe fn for_each_internal(node: *mut T, f: &mut impl FnMut(*mut T)) { + if node.is_null() { + return; + } + f(node); + // SAFETY: `node` is non-null and valid (checked above / invariant). + Self::for_each_internal((*node).heap().child, f); + Self::for_each_internal((*node).heap().next, f); + } + /// Look at the next maximum value but do not remove it. This is an O(N) operation. pub unsafe fn find_max(&self) -> *mut T { if self.root.is_null() { diff --git a/src/spawn/exit_signals.rs b/src/spawn/exit_signals.rs new file mode 100644 index 000000000000..e8335b2c4bd5 --- /dev/null +++ b/src/spawn/exit_signals.rs @@ -0,0 +1,126 @@ +//! SIGINT/SIGTERM/SIGHUP hook for a supervisor of background children (`bun +//! install` running lifecycle scripts): the handler records the signal and +//! posts the supervisor's callback to its `MiniEventLoop`. Contrast +//! [`crate::ctrl_c`], where the child shares the terminal and nothing is +//! forwarded. + +use core::ffi::c_int; +use core::ptr::NonNull; +use core::sync::atomic::{AtomicBool, AtomicI32, AtomicPtr, Ordering}; + +use bun_core::Mutex; +use bun_event_loop::AnyTaskWithExtraContext::{AnyTaskWithExtraContext, New}; +use bun_event_loop::EventLoopHandle; +use bun_event_loop::MiniEventLoop::MiniEventLoop; + +pub const SIGNALS: [c_int; 3] = [libc::SIGINT, libc::SIGTERM, libc::SIGHUP]; + +/// Last hooked signal received, 0 when none. Never cleared, so a task queued +/// under one hook still sees it after an unhook/hook cycle. +static RECEIVED: AtomicI32 = AtomicI32::new(0); +/// `TASK` is in the loop's queue. `SeqCst` with `RECEIVED`: a handler (any +/// thread) that sees this set relies on the queued task reading its store. +static QUEUED: AtomicBool = AtomicBool::new(false); +static EVENT_LOOP: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +/// Allocated once, never freed: the handler must not allocate. +static TASK: AtomicPtr = AtomicPtr::new(core::ptr::null_mut()); +static ON_SIGNAL: Mutex> = Mutex::new(None); +/// Dispositions replaced by `hook`; `None` while not hooked. +static PREVIOUS: Mutex> = Mutex::new(None); + +/// Until [`unhook`], a hooked signal runs `on_signal(sig)` on the loop's +/// thread at its next tick (coalesced; latest wins). Skips an inherited +/// `SIG_IGN`. Returns `false` (no hook) for a JS event loop. +pub fn hook(event_loop: EventLoopHandle, on_signal: fn(c_int)) -> bool { + let EventLoopHandle::Mini(mini) = event_loop else { + return false; + }; + let mut previous = PREVIOUS.lock(); + *ON_SIGNAL.lock() = Some(on_signal); + if previous.is_some() { + return true; + } + if TASK.load(Ordering::Relaxed).is_null() { + let task = New::<(), ()>::init(NonNull::<()>::dangling().as_ptr(), run_task); + TASK.store(bun_core::heap::into_raw(Box::new(task)), Ordering::Relaxed); + } + // Published before the dispositions so the handler always finds a loop. + EVENT_LOOP.store(mini.as_ptr(), Ordering::Release); + + let mut saved: [libc::sigaction; SIGNALS.len()] = [bun_core::ffi::zeroed(); SIGNALS.len()]; + // SAFETY: zeroed `sigaction` is valid; all pointers are to live locals. + unsafe { + let mut act: libc::sigaction = bun_core::ffi::zeroed(); + act.sa_sigaction = handler as *const () as usize; + act.sa_flags = libc::SA_RESTART; + libc::sigemptyset(&raw mut act.sa_mask); + for (i, sig) in SIGNALS.iter().enumerate() { + if libc::sigaction(*sig, core::ptr::null(), &raw mut saved[i]) != 0 { + continue; + } + if saved[i].sa_sigaction == libc::SIG_IGN { + continue; + } + libc::sigaction(*sig, &raw const act, core::ptr::null_mut()); + } + } + *previous = Some(saved); + true +} + +/// Restores the dispositions [`hook`] replaced. [`received`] keeps its value. +/// No-op when not hooked. +pub fn unhook() { + let mut previous = PREVIOUS.lock(); + let Some(saved) = previous.take() else { + return; + }; + // SAFETY: `saved` holds dispositions the kernel returned in `hook`. + unsafe { + for (i, sig) in SIGNALS.iter().enumerate() { + libc::sigaction(*sig, &raw const saved[i], core::ptr::null_mut()); + } + } + EVENT_LOOP.store(core::ptr::null_mut(), Ordering::Release); +} + +/// The last hooked signal received, if any (also after [`unhook`]). +pub fn received() -> Option { + match RECEIVED.load(Ordering::SeqCst) { + 0 => None, + sig => Some(sig), + } +} + +extern "C" fn handler(sig: c_int) { + RECEIVED.store(sig, Ordering::SeqCst); + if QUEUED.swap(true, Ordering::SeqCst) { + return; + } + let event_loop = EVENT_LOOP.load(Ordering::Acquire); + let task = NonNull::new(TASK.load(Ordering::Relaxed)); + let (false, Some(task)) = (event_loop.is_null(), task) else { + // Raced with `unhook` on another thread: take the default action. + // `sig` is blocked in its own handler, so the raise lands on return. + QUEUED.store(false, Ordering::SeqCst); + // SAFETY: zeroed `sigaction` is valid; both calls are async-signal-safe. + unsafe { + let mut act: libc::sigaction = bun_core::ffi::zeroed(); + act.sa_sigaction = libc::SIG_DFL; + libc::sigaction(sig, &raw const act, core::ptr::null_mut()); + libc::raise(sig); + } + return; + }; + // SAFETY: the loop is live while hooked; `task` is not queued (`QUEUED` + // was false). Raw form because this may interrupt the loop inside `tick`. + unsafe { MiniEventLoop::enqueue_task_concurrent_raw(event_loop, task) }; +} + +fn run_task(_: *mut (), _: *mut ()) { + QUEUED.store(false, Ordering::SeqCst); + let on_signal = *ON_SIGNAL.lock(); + if let (Some(on_signal), Some(sig)) = (on_signal, received()) { + on_signal(sig); + } +} diff --git a/src/spawn/lib.rs b/src/spawn/lib.rs index d963cea5a19a..ba895047bd4e 100644 --- a/src/spawn/lib.rs +++ b/src/spawn/lib.rs @@ -38,6 +38,10 @@ pub mod posix_spawn { /// Ctrl+C handling for a process acting as a shell for foreground children. #[path = "ctrl_c.rs"] pub mod ctrl_c; +/// SIGINT/SIGTERM/SIGHUP hook for a supervisor of background children. +#[cfg(unix)] +#[path = "exit_signals.rs"] +pub mod exit_signals; /// `Process` / `Poller` / `WaiterThread` / `spawn_process` / `sync` / /// `Status` / `SpawnOptions` / `SpawnResult`. #[path = "process.rs"] diff --git a/test/cli/install/bun-install-lifecycle-signals.test.ts b/test/cli/install/bun-install-lifecycle-signals.test.ts new file mode 100644 index 000000000000..9706702adf87 --- /dev/null +++ b/test/cli/install/bun-install-lifecycle-signals.test.ts @@ -0,0 +1,159 @@ +// A signal sent to `bun install` while a lifecycle script runs is forwarded +// to the script. `bun install` waits for the script, then dies by the same +// signal. Without this the script keeps running, reparented to init, and +// keeps writing into node_modules. On Linux a script also dies with a bun +// install that had no chance to forward anything (SIGKILL). +import { describe, expect, test } from "bun:test"; +import { bunEnv, bunExe, isLinux, isPosix, tempDir } from "harness"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +// The hook records its pid, then waits. On TERM/INT/HUP it records the +// signal. In "exit" mode it then exits 0, otherwise it keeps running so that +// the test can check the escalation path. `wait` on a background `sleep` +// returns as soon as a trapped signal arrives, so the trap runs at once. +const hook = ` +on_signal() { + printf %s "$1" > got-signal.tmp && mv got-signal.tmp got-signal + if [ "$mode" = exit ]; then exit 0; fi +} +mode=$1 +trap 'on_signal SIGTERM' TERM +trap 'on_signal SIGINT' INT +trap 'on_signal SIGHUP' HUP +printf %s "$$" > hook-pid.tmp && mv hook-pid.tmp hook-pid +while :; do + sleep 1 & + wait $! +done +`; + +const files = (mode: "exit" | "stay") => ({ + "package.json": JSON.stringify({ + name: "app", + version: "1.0.0", + // `exec` so that the hook is the direct child of bun install whatever + // the system shell does with `sh -c`. + scripts: { postinstall: `exec sh hook.sh ${mode}` }, + }), + "hook.sh": hook, +}); + +// Both files are written with a rename, so they are never seen half-written. +async function waitForFile(path: string): Promise { + for (;;) { + try { + return readFileSync(path, "utf8"); + } catch {} + await Bun.sleep(10); + } +} + +function isAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err: any) { + return err.code !== "ESRCH"; + } +} + +async function waitForExit(pid: number) { + while (isAlive(pid)) { + await Bun.sleep(10); + } +} + +function killQuietly(pid: number) { + try { + process.kill(pid, "SIGKILL"); + } catch {} +} + +// Spawns `bun install` in `dir` and waits until the hook runs. `stderr` is +// drained from the start; it resolves once bun install and the hook (which +// inherits it) are both gone. +async function startInstall(dir: string) { + const proc = Bun.spawn({ + cmd: [bunExe(), "install"], + env: bunEnv, + cwd: dir, + stdout: "ignore", + stderr: "pipe", + }); + const stderr = proc.stderr.text(); + const hookPid = await Promise.race([ + waitForFile(join(dir, "hook-pid")).then(Number), + proc.exited.then(async code => { + throw new Error(`bun install exited with ${code} before the hook started: ${await stderr}`); + }), + ]); + // Never hand 0 or NaN to kill(): pid 0 is the whole process group. + expect(hookPid).toBeGreaterThan(1); + return { proc, stderr, hookPid }; +} + +describe.skipIf(!isPosix).concurrent("bun install forwards signals to lifecycle scripts", () => { + for (const signal of ["SIGTERM", "SIGINT", "SIGHUP"] as const) { + test(`${signal} reaches the postinstall script and bun install waits for it`, async () => { + using dir = tempDir("install-signal", files("exit")); + const { proc, stderr, hookPid } = await startInstall(String(dir)); + await using _ = proc; + try { + expect(isAlive(hookPid)).toBe(true); + + proc.kill(signal); + await proc.exited; + + // bun install reaps the hook before it dies, so the hook is gone by now. + expect(isAlive(hookPid)).toBe(false); + expect(await waitForFile(join(String(dir), "got-signal"))).toBe(signal); + expect(await stderr).not.toContain("error:"); + expect(proc.signalCode).toBe(signal); + } finally { + killQuietly(hookPid); + } + }); + } + + test("bun install waits for a script that outlives the first signal, a second one ends it", async () => { + using dir = tempDir("install-signal-twice", files("stay")); + const { proc, hookPid } = await startInstall(String(dir)); + await using _ = proc; + try { + proc.kill("SIGTERM"); + // bun install must still be alive once the hook has seen the signal. + const first = await Promise.race([waitForFile(join(String(dir), "got-signal")), proc.exited]); + expect(first).toBe("SIGTERM"); + expect(isAlive(hookPid)).toBe(true); + expect(proc.exitCode).toBeNull(); + + // The hook is out after the first signal, so this one takes the + // default action. + proc.kill("SIGTERM"); + await proc.exited; + expect(proc.signalCode).toBe("SIGTERM"); + if (isLinux) { + // PR_SET_PDEATHSIG: the kernel kills the script with bun install. + await waitForExit(hookPid); + } + } finally { + killQuietly(hookPid); + } + }); + + // No signal to forward here: the kernel does it (PR_SET_PDEATHSIG). + test.skipIf(!isLinux)("a script does not outlive a bun install that is SIGKILLed", async () => { + using dir = tempDir("install-sigkill", files("stay")); + const { proc, hookPid } = await startInstall(String(dir)); + await using _ = proc; + try { + proc.kill("SIGKILL"); + await proc.exited; + await waitForExit(hookPid); + expect(proc.signalCode).toBe("SIGKILL"); + } finally { + killQuietly(hookPid); + } + }); +});