Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions src/event_loop/MiniEventLoop.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Comment thread
robobun marked this conversation as resolved.
pub unsafe fn enqueue_task_concurrent_raw(
this: *const Self,
task: NonNull<AnyTaskWithExtraContext>,
) {
// 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, <field>)` of the
/// embedded `AnyTaskWithExtraContext`.
///
Expand Down
2 changes: 2 additions & 0 deletions src/install/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
49 changes: 40 additions & 9 deletions src/install/lifecycle_script_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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()
};

Expand All @@ -661,6 +664,9 @@ impl<'a> LifecycleScriptSubprocess<'a> {
(*manager)
.active_lifecycle_scripts
.insert(this.cast::<LifecycleScriptSubprocess<'static>>());
// 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
Expand All @@ -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`
Expand Down Expand Up @@ -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>(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.
Comment thread
robobun marked this conversation as resolved.
#[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>(self)) };
return;
}
#[cfg(unix)]
crate::lifecycle_signals::on_script_exited();

match status {
Status::Exited(exit) => {
if exit.code > 0 {
Expand Down Expand Up @@ -960,15 +984,7 @@ impl<'a> LifecycleScriptSubprocess<'a> {
unsafe { Self::destroy(std::ptr::from_mut::<Self>(self)) };
}
Status::Signaled(signal) => {
self.print_output();
let signal_code = bun_sys::SignalCode::from(signal);

bun_core::pretty_errorln!(
"<r><red>error<r><d>:<r> <b>{}<r> script from \"<b>{}<r>\" terminated by {}<r>",
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
Expand Down Expand Up @@ -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!(
"<r><red>error<r><d>:<r> <b>{}<r> script from \"<b>{}<r>\" terminated by {}<r>",
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) {
Expand Down Expand Up @@ -1086,6 +1113,10 @@ impl<'a> LifecycleScriptSubprocess<'a> {
foreground: bool,
ctx: Option<InstallCtx<'a>>,
) -> 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),
Expand Down
84 changes: 84 additions & 0 deletions src/install/lifecycle_signals.rs
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
robobun marked this conversation as resolved.

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<PackageManager> = 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.
Comment thread
robobun marked this conversation as resolved.
pub(crate) fn pending() -> Option<bun_core::SignalCode> {
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.
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
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);
}
},
);
}
}
16 changes: 16 additions & 0 deletions src/io/heap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,22 @@ impl<T: HeapNode, Context: HeapContext<T>> Intrusive<T, Context> {
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() {
Expand Down
126 changes: 126 additions & 0 deletions src/spawn/exit_signals.rs
Original file line number Diff line number Diff line change
@@ -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.
Comment thread
robobun marked this conversation as resolved.

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.
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
static QUEUED: AtomicBool = AtomicBool::new(false);
static EVENT_LOOP: AtomicPtr<MiniEventLoop> = AtomicPtr::new(core::ptr::null_mut());
/// Allocated once, never freed: the handler must not allocate.
static TASK: AtomicPtr<AnyTaskWithExtraContext> = AtomicPtr::new(core::ptr::null_mut());
static ON_SIGNAL: Mutex<Option<fn(c_int)>> = Mutex::new(None);
/// Dispositions replaced by `hook`; `None` while not hooked.
static PREVIOUS: Mutex<Option<[libc::sigaction; SIGNALS.len()]>> = 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.
Comment thread
robobun marked this conversation as resolved.
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.
Comment thread
robobun marked this conversation as resolved.
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<c_int> {
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.
Comment thread
robobun marked this conversation as resolved.
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);
}
}
4 changes: 4 additions & 0 deletions src/spawn/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
Expand Down
Loading
Loading