Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
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
55 changes: 46 additions & 9 deletions src/install/lifecycle_script_runner.rs
Original file line number Diff line number Diff line change
Expand Up @@ -650,6 +650,11 @@ impl<'a> LifecycleScriptSubprocess<'a> {
},

stream: false,
// The kernel kills the script when bun dies without a chance to
// forward a signal (SIGKILL, a crash), so no script outlives
// the install.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cfg(any(target_os = "linux", target_os = "android"))]
linux_pdeathsig: Some(bun_sys::SignalCode::SIGKILL.0),
..Default::default()
};

Expand All @@ -661,6 +666,10 @@ impl<'a> LifecycleScriptSubprocess<'a> {
(*manager)
.active_lifecycle_scripts
.insert(this.cast::<LifecycleScriptSubprocess<'static>>());
// Before the spawn, so that no signal can end `bun install` between
// the child's start and the handler's installation.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[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 +679,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 +853,24 @@ 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)) };

#[cfg(unix)]
if crate::lifecycle_signals::pending().is_some() {
// `bun install` received a signal and forwarded it to this script.
// Whatever the script's status, do not chain into its next
// script. `on_script_exited` dies by the signal after the last
// running script is gone.
Comment thread
robobun marked this conversation as resolved.
Resolved
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 +989,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 +1035,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 +1118,11 @@ impl<'a> LifecycleScriptSubprocess<'a> {
foreground: bool,
ctx: Option<InstallCtx<'a>>,
) -> Result<(), crate::Error> {
// `bun install` is about to die by a forwarded signal. Start nothing new.
#[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
110 changes: 110 additions & 0 deletions src/install/lifecycle_signals.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
//! SIGINT, SIGTERM and SIGHUP sent to `bun install` while a lifecycle script
//! runs (CI cancellation, `docker stop`, `timeout(1)`, a hung-up ssh session).
//!
//! Without this the default action ends `bun install` at once and every
//! running script is reparented to init, still writing into `node_modules`.
//! Instead, while at least one script runs, the signal is forwarded to each
//! script and `bun install` waits. No new script starts. When the last
//! script has exited, `bun install` dies by the forwarded signal, the same way
//! it already does for a script that dies by a signal on its own. The hook
//! comes out as soon as the signal is forwarded, so a second signal takes the
//! default action (on Linux the scripts then die with `bun install`, see
//! `linux_pdeathsig` in the runner).
//!
//! The hook itself is `bun_spawn::exit_signals`. This module is the install
//! half: which scripts run, what to forward, when to exit.
Comment thread
robobun marked this conversation as resolved.
Resolved

use core::ffi::c_int;
use core::sync::atomic::{AtomicBool, AtomicPtr, AtomicUsize, Ordering};

use bun_spawn::exit_signals;

use crate::PackageManager;
use crate::lifecycle_script_runner::LifecycleScriptSubprocess;

/// Number of lifecycle scripts that are running right now.
static RUNNING: AtomicUsize = AtomicUsize::new(0);
/// Set once the signal has been forwarded and the install is draining.
static DRAINING: AtomicBool = AtomicBool::new(false);
static MANAGER: AtomicPtr<PackageManager> = AtomicPtr::new(core::ptr::null_mut());

/// The signal that `bun install` forwarded to the running scripts, if any.
/// While this is `Some`, no new lifecycle script starts and an exiting script
/// does not chain into the next one.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn pending() -> Option<bun_core::SignalCode> {
if !DRAINING.load(Ordering::Relaxed) {
return None;
}
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)
}

/// A lifecycle script is about to be spawned. Hooks the signals on the first
/// one.
///
/// `manager` is the live `PackageManager` that owns the script. The signal
/// callback dereferences it until the last script has exited, so it must
/// carry allocation-rooted provenance, not a transient `&mut` reborrow.
Comment thread
robobun marked this conversation as resolved.
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 (caller contract). Only the `event_loop`
// field is borrowed, and the borrow ends with this statement.
let event_loop =
bun_event_loop::EventLoopHandle::from_any(unsafe { &mut (*manager).event_loop });
exit_signals::hook(event_loop, on_signal);
}

/// A lifecycle script exited (after it left `active_lifecycle_scripts`), or
/// failed to spawn. After the last one: dies by the forwarded signal when
/// draining, else unhooks.
Comment thread
robobun marked this conversation as resolved.
Resolved
pub(crate) fn on_script_exited() {
if RUNNING.fetch_sub(1, Ordering::Relaxed) != 1 {
return;
}
if let Some(sig) = pending() {
die(sig);
}
exit_signals::unhook();
Comment thread
robobun marked this conversation as resolved.
Resolved
MANAGER.store(core::ptr::null_mut(), Ordering::Relaxed);
}

fn die(sig: bun_core::SignalCode) -> ! {
bun_core::Output::flush();
bun_core::Global::raise_ignoring_panic_handler(sig);
}

/// Runs on the 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 between the handler and this callback.
// Nothing to wait for: the default action, now.
Comment thread
robobun marked this conversation as resolved.
Resolved
die(signal_code(sig));
}
DRAINING.store(true, Ordering::Relaxed);
exit_signals::unhook();
let forward = signal_code(sig) as u8;
// SAFETY: `manager` is the live `PackageManager` stored by
// `on_script_started`; nothing else walks or mutates the heap while this
// runs on the install 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);
}
},
);
}
}
17 changes: 17 additions & 0 deletions src/io/heap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,23 @@ impl<T: HeapNode, Context: HeapContext<T>> Intrusive<T, Context> {
result
}

/// Visit every element in the heap, in no particular order. `f` must not
/// insert into or remove from the heap. This is an O(N) operation.
Comment thread
robobun marked this conversation as resolved.
Resolved
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
Loading
Loading