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/jsc/PosixSignalHandle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@ static WATCH_SIGINT_LISTENERS: AtomicU32 = AtomicU32::new(0);
/// count change here (main-thread VM only, platform signal numbers).
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__onSignalListenerCountChanged(number: i32, count: i32) {
// The SIGCHLD handler of the spawn waiter thread forwards to the JS listeners.
#[cfg(any(target_os = "linux", target_os = "android"))]
if number == libc::SIGCHLD {
bun_spawn::process::WaiterThread::set_js_listens_for_sigchld(count > 0);
}
let watch_signal = i32::from(WATCH_MODE_KILL_SIGNAL.load(Ordering::Relaxed));
if watch_signal == 0 {
return;
Expand All @@ -152,6 +157,18 @@ pub(crate) extern "C" fn Bun__onSignalListenerCountChanged(number: i32, count: i
}
}

/// C++ `onDidChangeListeners` changed the disposition of `number` for JS listeners. Native users of it take it back.
#[cfg(unix)]
#[unsafe(no_mangle)]
pub(crate) extern "C" fn Bun__onSignalDispositionChanged(number: i32) {
#[cfg(any(target_os = "linux", target_os = "android"))]
if number == libc::SIGCHLD {
bun_spawn::process::WaiterThread::on_sigchld_disposition_changed();
}
#[cfg(not(any(target_os = "linux", target_os = "android")))]
let _ = number;
}

/// Watcher-thread query: only ever true for `bun run --watch` (the count is
/// mirrored solely when `WATCH_MODE_KILL_SIGNAL` is set).
pub fn watch_kill_signal_has_listeners() -> bool {
Expand Down
7 changes: 7 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1541,6 +1541,10 @@ extern "C" void Bun__ensureSignalHandler();
extern "C" bool Bun__isMainThreadVM();
extern "C" void Bun__onPosixSignal(int signalNumber);
extern "C" void Bun__onSignalListenerCountChanged(int signalNumber, int listenerCount);
#if !OS(WINDOWS)
// Call it after the disposition of a signal changed for JS listeners. Native users of that signal take it back.
extern "C" void Bun__onSignalDispositionChanged(int signalNumber);
#endif

__attribute__((noinline)) static void forwardSignal(int signalNumber)
{
Expand Down Expand Up @@ -1628,6 +1632,7 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
if (auto signalNumber = signalNameToNumberMap->get(eventName.string())) {
int listenerCount = eventEmitter.listenerCount(eventName);
// Mirror the count for the watcher thread's --watch-kill-signal check.
// Keep it before the disposition changes below: the spawn waiter thread's SIGCHLD handler reads it.
Bun__onSignalListenerCountChanged(signalNumber, listenerCount);
#if OS(LINUX)
// SIGKILL and SIGSTOP cannot be handled, and JSC needs its own signal handler to
Expand All @@ -1653,6 +1658,7 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
#if !OS(WINDOWS)
Bun__ensureSignalHandler();
installForwardSignalHandler(signalNumber);
Bun__onSignalDispositionChanged(signalNumber);
#else
signal_handle.handle = Bun__UVSignalHandle__init(
eventEmitter.scriptExecutionContext()->jsGlobalObject(),
Expand All @@ -1676,6 +1682,7 @@ static void onDidChangeListeners(EventEmitter& eventEmitter, const Identifier& e
// Don't uninstall the old handler if it's not the one we installed.
signal(signalNumber, oldHandler);
}
Bun__onSignalDispositionChanged(signalNumber);
#else
SignalHandleValue signal_handle = signalToContextIdsMap->get(signalNumber);
Bun__UVSignalHandle__close(signal_handle.handle);
Expand Down
59 changes: 56 additions & 3 deletions src/spawn/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -997,6 +997,8 @@ pub mod waiter_thread_posix {
use bun_event_loop::ConcurrentTask::{ConcurrentTask, Task, TaskTag};
use bun_event_loop::task_tag;
use bun_threading::UnboundedQueue;
#[cfg(any(target_os = "linux", target_os = "android"))]
use core::sync::atomic::AtomicBool;

pub struct WaiterThreadPosix {
pub(crate) started: AtomicU32,
Expand Down Expand Up @@ -1368,21 +1370,46 @@ pub mod waiter_thread_posix {

#[cfg(any(target_os = "linux", target_os = "android"))]
{
// Set before the sigaction: a JS listener change after it must install `wakeup` again.
HANDLES_SIGCHLD.store(true, Ordering::SeqCst);
// Two threads can be here. The lock makes the last sigaction use the last flag value.
let _lock = RELOAD_HANDLERS_LOCK.lock();
let js_listens = JS_LISTENS_FOR_SIGCHLD.load(Ordering::SeqCst);
// SAFETY: sigaction with a valid handler.
unsafe {
let mut current_mask: libc::sigset_t = bun_core::ffi::zeroed();
libc::sigemptyset(&raw mut current_mask);
libc::sigaddset(&raw mut current_mask, libc::SIGCHLD);
let act = libc::sigaction {
let mut act = libc::sigaction {
sa_sigaction: wakeup as *const () as usize,
sa_mask: current_mask,
sa_flags: libc::SA_NOCLDSTOP,
sa_restorer: None,
};
if js_listens {
// A JS listener also hears a stopped or continued child, as with pidfd.
act.sa_flags &= !libc::SA_NOCLDSTOP;
}
libc::sigaction(libc::SIGCHLD, &raw const act, core::ptr::null_mut());
}
}
}

/// Main thread. Call it before the SIGCHLD disposition changes for a JS listener.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn set_js_listens_for_sigchld(listens: bool) {
JS_LISTENS_FOR_SIGCHLD.store(listens, Ordering::SeqCst);
}

/// Main thread. The SIGCHLD disposition changed for a JS listener: `wakeup` takes it back.
#[cfg(any(target_os = "linux", target_os = "android"))]
pub fn on_sigchld_disposition_changed() {
if HANDLES_SIGCHLD.load(Ordering::SeqCst) {
Self::reload_handlers();
// A child that exited while `wakeup` was not the handler did not wake the thread.
wake();
}
}
}

pub(crate) fn init() -> Result<(), std::io::Error> {
Expand Down Expand Up @@ -1418,13 +1445,39 @@ pub mod waiter_thread_posix {
Ok(())
}

/// `wakeup` is the SIGCHLD handler, or the waiter thread is about to install it.
#[cfg(any(target_os = "linux", target_os = "android"))]
static HANDLES_SIGCHLD: AtomicBool = AtomicBool::new(false);

/// `process.on("SIGCHLD")` has a listener. SIGCHLD has one disposition, so `wakeup` forwards to it.
#[cfg(any(target_os = "linux", target_os = "android"))]
static JS_LISTENS_FOR_SIGCHLD: AtomicBool = AtomicBool::new(false);

#[cfg(any(target_os = "linux", target_os = "android"))]
static RELOAD_HANDLERS_LOCK: bun_threading::Guarded<()> = bun_threading::Guarded::new(());

#[cfg(any(target_os = "linux", target_os = "android"))]
unsafe extern "C" {
/// `bun_jsc`: queues the signal for the `process.on(<signal>)` listeners. Async-signal-safe.
safe fn Bun__onPosixSignal(number: c_int);
}

/// Makes the waiter thread call `wait4` for each process again.
#[cfg(any(target_os = "linux", target_os = "android"))]
extern "C" fn wakeup(_: c_int) {
fn wake() {
let one: [u8; 8] = (1usize).to_ne_bytes();
// eventfd is write-once in init() before this handler is installed.
// eventfd is write-once in init() before the waiter thread starts.
let _ = bun_sys::write(instance_ref().eventfd, &one).unwrap_or(0);
}

#[cfg(any(target_os = "linux", target_os = "android"))]
extern "C" fn wakeup(signal: c_int) {
wake();
if JS_LISTENS_FOR_SIGCHLD.load(Ordering::SeqCst) {
Bun__onPosixSignal(signal);
}
}

pub(crate) fn loop_() {
// SAFETY: NUL-terminated literal.
Output::Source::configure_named_thread(bun_core::ZStr::from_static(b"Waitpid\0"));
Expand Down
62 changes: 62 additions & 0 deletions test/js/bun/spawn/spawn-sigchld-listener-fixture.ts

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

36 changes: 36 additions & 0 deletions test/js/bun/spawn/spawn.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -609,6 +609,42 @@ for (let [gcTick, label] of [
});
}

// SIGCHLD has one disposition. process.on("SIGCHLD") needs it, and the waiter thread needs it
// to learn that a child exited. The first spawn starts the waiter thread.
describe.skipIf(Boolean(process.env.BUN_FEATURE_FLAG_FORCE_WAITER_THREAD) || (!isLinux && !isAndroid))(
"a SIGCHLD listener and Bun.spawn both see each child exit",
() => {
const waiterThread = { "BUN_FEATURE_FLAG_FORCE_WAITER_THREAD": "1", "BUN_GARBAGE_COLLECTOR_LEVEL": "1" };
// Number of listener calls after each of the three child exits. The second child also stops
// and continues, which is one call each. The listener is removed before the third child.
const signals = { before: [1, 4, 4], after: [0, 3, 3] };

// Not concurrent: a fixture that waits forever is only killed on the timeout of a serial test.
it.each([
["waiter thread, listener added before the first spawn", waiterThread, "before"],
["waiter thread, listener added after the first spawn", waiterThread, "after"],
["pidfd, listener added before the first spawn", {}, "before"],
["pidfd, listener added after the first spawn", {}, "after"],
] as const)("%s", async (_, env, order) => {
await using proc = spawn({
cmd: [bunExe(), join(import.meta.dir, "spawn-sigchld-listener-fixture.ts"), order],
env: { ...bunEnv, ...env },
stdin: "ignore",
stdout: "pipe",
stderr: "inherit",
});
const [stdout, exitCode] = await Promise.all([proc.stdout.text(), proc.exited]);
const lines = stdout.split("\n").filter(Boolean);
expect(lines.map(line => JSON.parse(line))).toEqual([
{ child: "first spawn", exitCode: 0, signals: signals[order][0] },
{ child: "second spawn", exitCode: 0, signals: signals[order][1] },
{ child: "listener removed", exitCode: 0, signals: signals[order][2] },
]);
expect(exitCode).toBe(0);
});
},
);

// The waiter thread is the Linux fallback for kernels/sandboxes without pidfd;
// kqueue platforms (macOS, FreeBSD) always have EVFILT_PROC and its non-Linux
// loop has no wakeup for processes appended after it starts.
Expand Down
Loading