Skip to content

spawn: share SIGCHLD between the waiter thread and process.on("SIGCHLD") - #42933

Open
robobun wants to merge 3 commits into
mainfrom
robobun/f1c667e7/sigchld-listener-waiter-thread
Open

robobun wants to merge 3 commits into
mainfrom
robobun/f1c667e7/sigchld-listener-waiter-thread

Conversation

@robobun

@robobun robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • On Linux without pidfd_open (old kernel, gVisor, seccomp), a process.on("SIGCHLD") listener added after the first Bun.spawn makes await proc.exited never resolve. A listener added before it never fires.
  • Two callers of sigaction(SIGCHLD) replace each other. WaiterThreadPosix::reload_handlers() (src/spawn/process.rs:1364) installs wakeup, which wakes the waiter thread. installForwardSignalHandler (src/jsc/bindings/BunProcess.cpp:1558) installs forwardSignal, which only queues the signal for JS. Removal of the last listener restores SIG_DFL (BunProcess.cpp:1675).

Fix

  • wakeup also calls Bun__onPosixSignal while a JS SIGCHLD listener exists. Bun__onSignalListenerCountChanged stores that fact before BunProcess.cpp changes the disposition.
  • BunProcess.cpp calls the new Bun__onSignalDispositionChanged after each such change. For SIGCHLD, the waiter thread then installs wakeup again and wakes once. While JS listens, wakeup has no SA_NOCLDSTOP, like forwardSignal.
  • Correct because the wake makes the thread call wait4 for each child again, so no child exit is lost while wakeup is not installed. Without the waiter thread, the calls only store a flag.
  • Verified: test/js/bun/spawn/spawn.test.ts (new block, both waiter thread cases time out without the fix). Also spawn-signal, spawn-kill-signal, spawnSync, process-signal-listener-count, and process.test.js -t signal.
  • Self-reviewed: 2 concerns raised, 2 addressed. The hook is generic, and the Notes name the same-class sites left unfixed.

Background

  • The waiter thread reports child exits when pidfd_open is not available. It sleeps in poll() on an eventfd. Its SIGCHLD handler, wakeup, writes the eventfd. Then the thread calls wait4(WNOHANG) for each child.
  • A signal has one disposition for the whole process. The last sigaction() call wins.
  • Bun__onPosixSignal is async-signal-safe. It queues a signal number for the process.on(<signal>) listeners.
Notes

Repro (sigchld-listener.js, run with BUN_GARBAGE_COLLECTOR_LEVEL=1 BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1 bun sigchld-listener.js after):

const order = process.argv[2];
let signals = 0;
const listen = () => process.on("SIGCHLD", () => { signals++; });
if (order === "before") listen();
// The first spawn starts the waiter thread, which installs its own SIGCHLD handler.
await Bun.spawn({ cmd: ["true"], stdio: ["ignore", "ignore", "ignore"] }).exited;
if (order === "after") listen();
const proc = Bun.spawn({ cmd: ["sh", "-c", "sleep 0.3"], stdio: ["ignore", "ignore", "ignore"] });
const result = await Promise.race([proc.exited.then(c => "exited " + c), Bun.sleep(3000).then(() => "TIMEOUT: exited never resolved")]);
console.log(JSON.stringify({ order, result, signals }));
process.exit(0);
order 1.4.3 canary this branch
after TIMEOUT: exited never resolved, signals 1 exited 0, signals 1
before exited 0, signals 0 exited 0, signals 2

Order of the calls. On each change of the SIGCHLD listener count, BunProcess.cpp does three things in this order:

  1. Bun__onSignalListenerCountChanged stores JS_LISTENS_FOR_SIGCHLD. From here on, wakeup forwards to JS (or stops), whichever thread installs it.
  2. It changes the disposition as before: forwardSignal for a first listener, the SIG_DFL check for the removal of the last one.
  3. Bun__onSignalDispositionChanged checks HANDLES_SIGCHLD. If it is set, it installs wakeup again and writes the eventfd.

The waiter thread sets HANDLES_SIGCHLD before its own sigaction. So whichever sigaction runs last, the final handler is wakeup:

  • The waiter thread installs last: wakeup is the handler.
  • BunProcess.cpp installs last: the waiter thread's sigaction ran before it, so HANDLES_SIGCHLD is set and step 3 installs wakeup again.

Between step 2 and step 3 a SIGCHLD goes to forwardSignal (JS gets it) or to SIG_DFL (nobody listens any more). The waiter thread misses it. The eventfd write in step 3 covers that: the child is a zombie by then, and the next wait4(WNOHANG) pass reaps it.

reload_handlers() reads the flag to choose sa_flags, and two threads can be in it (the JS thread in step 3, the waiter thread at its start). A lock around the read and the sigaction makes the last install use the last value of the flag. The signal handler never takes that lock.

The test. BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1 with BUN_GARBAGE_COLLECTOR_LEVEL set forces the waiter thread. The fixture spawns cat three times, one at a time. It waits for an echo before it closes stdin, so the waiter thread has already called wait4 for the child and sleeps. Only SIGCHLD can report the exit. It waits for proc.exited and, while it listens, for the listener count to reach the expected number. The second child also gets SIGSTOP and SIGCONT, and the listener must hear each one. It runs with the listener added before and after the first spawn, with the waiter thread and with pidfd. The tests are serial on purpose: bun test kills a process that a timed-out test left behind only for serial tests.

Without the fix (debug build of main, and 1.4.3 canary): the two waiter thread cases time out, the two pidfd cases pass. With the fix: 4 pass. The full spawn.test.ts passes on the debug build (148 pass, 0 fail), which includes its second run with the waiter thread.

Stress probes (not in the PR). 300 children exit while the main thread toggles the listener: 45 of 45 runs complete with the fix, 5 of 5 hang without it. A Worker does the first 100 spawns while the main thread toggles the listener on a 0 ms interval: 40 of 40 complete with the fix, 3 of 3 hang without it.

Same class, not fixed here. onDidChangeListeners treats a signal disposition as owned by JS listeners alone: the first listener replaces the handler, and removal of the last one goes to SIG_DFL. Other native users of a signal have the same conflict. Bun__onSignalDispositionChanged is generic so that they can use it too, but this PR changes only SIGCHLD:

Not changed.

  • The sa_flags line of wakeup. spawn: install the waiter thread's SIGCHLD handler with SA_RESTART #42911 adds SA_RESTART there. A test merge of the two branches is clean, and the result is SA_RESTART alone while JS listens, the same flags as forwardSignal.
  • The removal path still uses signal(). When wakeup is the handler, it restores wakeup with the flags of signal(). The waiter thread then installs wakeup with its own flags.
  • Other platforms. kqueue platforms do not use the waiter thread, so the SIGCHLD part is under cfg(linux, android).

Other checks. cargo clippy -p bun_spawn -p bun_jsc. cargo check -p bun_jsc for aarch64-apple-darwin, x86_64-unknown-freebsd, aarch64-linux-android and x86_64-pc-windows-msvc. verify-binary.ts binary reports nothing for the debug build. test/regression/issue/ctrl-c.test.ts fails 4 vite cases on a debug build in this container with and without the change (they time out at 5 s).


no test proof · iteration 0 · platform-specific test(s) that do not run on this machine, deferring to CI, which covers all platforms: test/js/bun/spawn/spawn.test.ts

On Linux without pidfd, the waiter thread and a JS SIGCHLD listener each
call sigaction(SIGCHLD) and replace the handler of the other. A listener
added after the first spawn left the waiter thread without a wakeup, so
proc.exited never resolved. A listener added before it never fired.

The waiter thread's handler now also forwards the signal to the JS
listeners. BunProcess.cpp reports each change of a signal disposition
for JS listeners through Bun__onSignalDispositionChanged. For SIGCHLD,
the waiter thread then installs its handler again and checks its
children once.
@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: fix and test are in this PR (#42933). The diff is green. One red test in CI is a known break on main (see below).

How I reproduced it

  1. Save the script from the Notes of the PR body as sigchld-listener.js.
  2. Run BUN_GARBAGE_COLLECTOR_LEVEL=1 BUN_FEATURE_FLAG_FORCE_WAITER_THREAD=1 bun sigchld-listener.js after.

On 1.4.3 canary this prints {"order":"after","result":"TIMEOUT: exited never resolved","signals":1}. With before it prints "signals":0: the listener never fires. On this branch both orders print exited 0 and a listener count above 0.

The new block in test/js/bun/spawn/spawn.test.ts does the same with a fixture. Without the fix, the two waiter thread cases time out (debug build of main, and 1.4.3 canary). With the fix, all 4 cases pass.

CI (build 116575)

@coderabbitai

coderabbitai Bot commented Sep 16, 2026 •

Copy link
Copy Markdown
Contributor

Review Change StackReview Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Essentials

Run ID: b77c4ff9-5bc6-4a5c-8662-ebb878304592

📥 Commits

Reviewing files that changed from the base of the PR and between 507daba and b89093b.

📒 Files selected for processing (5)
  • src/jsc/PosixSignalHandle.rs
  • src/jsc/bindings/BunProcess.cpp
  • src/spawn/process.rs
  • test/js/bun/spawn/spawn-sigchld-listener-fixture.ts
  • test/js/bun/spawn/spawn.test.ts

Included review availability: Your plan provides up to 10 included reviews per hour; 1 remains after this review.


Walkthrough

The change connects JavaScript SIGCHLD listener lifecycle events to Linux and Android child-process waiter handling. The waiter tracks listener state, forwards SIGCHLD notifications when required, and preserves child-exit processing. Tests cover waiter-thread and pidfd modes.

Changes

SIGCHLD listener disposition flow

Layer / File(s) Summary
Signal disposition callback wiring
src/jsc/bindings/BunProcess.cpp, src/jsc/PosixSignalHandle.rs
POSIX listener addition and removal now update native listener state and notify WaiterThread when SIGCHLD disposition changes.
Waiter-thread SIGCHLD state
src/spawn/process.rs
The waiter tracks SIGCHLD handler ownership and JavaScript listener state. It reloads the handler, wakes for child processing, and forwards SIGCHLD through Bun__onPosixSignal when listeners are registered.
SIGCHLD behavior coverage
test/js/bun/spawn/spawn-sigchld-listener-fixture.ts, test/js/bun/spawn/spawn.test.ts
Tests cover listener registration before and after spawning, waiter-thread and pidfd modes, child exits, signal counts, and listener removal.

Suggested reviewers: dylan-conway

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to b8909

The SIGCHLD listener and child-exit paths are covered across waiter-thread and pidfd modes, with no remaining actionable merge risk identified.

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely describes the main change: sharing SIGCHLD handling between the waiter thread and process.on("SIGCHLD").
Description check ✅ Passed The description provides detailed problem, fix, background, verification, test results, limitations, and platform scope. It does not use the template headings "What does this PR do?" and "How did you …

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline findings, two other things were checked and ruled out: the wakeup handler running in bun install (no JS VM) is fine because Bun__onPosixSignal returns before touching the event loop when there is no main-thread VM; and the Worker-start race cannot leave forwardSignal as the final SIGCHLD handler, since HANDLES_SIGCHLD is stored before the waiter thread's sigaction, so whichever side installs last ends with wakeup in place.

Extended reasoning...

Three confirmed findings are posted inline (SA_NOCLDSTOP changing observable behavior for JS SIGCHLD listeners under the waiter thread, a window where a Worker-started waiter thread's wakeup runs before JS_LISTENS_FOR_SIGCHLD is set, and the generic disposition hook not being wired for crash signals). This note records only what else was examined: the no-VM path of Bun__onPosixSignal in src/jsc/PosixSignalHandle.rs (early return on get_main_thread_vm() == None, eventfd write in wake() is async-signal-safe), and the hang variant of the Worker race in src/spawn/process.rs, which is excluded by the ordering of the HANDLES_SIGCHLD store relative to the waiter thread's sigaction. The inline findings warrant a human look before merge.

One verified lower-impact observation (a convention, logging or cleanup point) was not posted.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 src/spawn/process.rs — Users on Linux without pidfd who add process.on("SIGCHLD") after merging stop receiving the signal when a child is stopped or continued; the same script on a pidfd kernel still receives it. The listener's handler is now wakeup, which src/spawn/process.rs:1384 installs with SA_NOCLDSTOP, while forwardSignal at src/jsc/bindings/BunProcess.cpp:1570 has only SA_RESTART. Fix: while JS_LISTENS_FOR_SIGCHLD is set, install wakeup without SA_NOCLDSTOP so JS listeners see the same SIGCHLD set on both paths; reload_handlers already reruns on every toggle so the flags can follow the flag, and the waiter thread's wait4 at process.rs:1231 uses WNOHANG only, so an extra stop wakeup is a harmless no-op pass.

    Extended reasoning...

    Kernels without pidfd_open (pre-5.3, gVisor, older seccomp profiles) set the waiter thread flag at src/spawn_sys/spawn_process.rs:544. Every spawn then goes through the waiter thread. On the base branch, a listener added after the first spawn ran under forwardSignal (SA_RESTART, no SA_NOCLDSTOP), so a SIGSTOP/SIGCONT of a child delivered SIGCHLD to JS. After this change set_js_listens_for_sigchld at process.rs:1395 reruns reload_handlers, so the final handler is always wakeup with sa_flags SA_NOCLDSTOP at process.rs:1384. The kernel then does not generate SIGCHLD for a stopped or continued child at all, so neither wake() nor Bun__onPosixSignal runs. The listener silently never fires for stop/continue for this population, and the same script on a pidfd kernel does fire. The PR Notes name this as accepted, but the sizing is one line: SA_NOCLDSTOP is only there to spare the waiter thread a wakeup, and its wait4 pass without WUNTRACED already ignores stopped children. Population: every waiter-thread user with a SIGCHLD listener; rate: every child stop/continue. Remedy: set sa_flags to 0…

    Verification: nit, acknowledged in diff: the PR description's "Not changed" notes state "with the waiter thread, a JS SIGCHLD listener does not fire when a child stops. With pidfd it does, because forwardSignal has no SA_NOCLDSTOP" — that statement matches the code. Trigger: Linux without usable pidfd_open (src/spawn_sys/spawn_process.rs:544 sets the waiter thread flag on… | nit; acknowledged in diff: the…

Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
…position changes

The listener flag is now stored from Bun__onSignalListenerCountChanged,
which runs before BunProcess.cpp installs forwardSignal. The waiter
thread's handler can replace forwardSignal at any time after that, and
it then already forwards to the listener.

While a JS listener exists, the waiter thread installs its handler
without SA_NOCLDSTOP. The listener then also hears a stopped and a
continued child, as it does without the waiter thread. A lock orders
the flag read and the sigaction of the two threads that can install.
Comment thread src/jsc/PosixSignalHandle.rs Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/jsc/bindings/BunProcess.cpp Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/spawn/process.rs Outdated
@robobun

robobun commented Sep 16, 2026

Copy link
Copy Markdown
Collaborator Author

Changes after the review (127bab9, b89093b):

  • Stopped and continued children. While a JS SIGCHLD listener exists, the waiter thread installs wakeup without SA_NOCLDSTOP. The listener then hears a stopped child and a continued child, as it does with pidfd. Without a listener the flag stays. A lock around the flag read and the sigaction orders the two threads that can install. The fixture now sends SIGSTOP and SIGCONT to the second child. That step fails on the first commit of this PR and passes now.
  • Listener added while the waiter thread starts. Bun__onSignalListenerCountChanged stores the listener flag, and it runs before installForwardSignalHandler. wakeup can no longer run for a listener it does not know.
  • Crash signals. Not in this PR. The reason is in the thread and in the PR notes.
  • Comments. Each added comment in src/ is one line now.

A test merge with #42911 is clean. With both, the flags are SA_RESTART alone while JS listens, the same as forwardSignal.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I re-reviewed the two follow-up commits and found no bugs; the ordering race I raised on the previous push is addressed (the listener flag now moves into Bun__onSignalListenerCountChanged, which runs before either sigaction). Because this changes the process-wide SIGCHLD disposition shared by two owners across threads and is only exercisable on Linux without pidfd, a human look would still be worthwhile.

What was reviewed:

  • Handler ordering across the four interleavings of reload_handlers (waiter thread, under RELOAD_HANDLERS_LOCK) and onDidChangeListeners add/remove on the main thread; the eventfd wake() after each reinstall covers exits delivered while forwardSignal/SIG_DFL was installed.
  • wakeup remains async-signal-safe: bun_sys::write on the eventfd plus Bun__onPosixSignal, the same call forwardSignal already makes; the flag reads are plain SeqCst atomics with no lock.
  • Non-Linux targets: Bun__onSignalDispositionChanged is cfg(unix) with a no-op body outside Linux/Android, matching the #if !OS(WINDOWS) C++ declaration and call sites; the kqueue and pidfd paths are untouched.
  • The fixture drains stdout with proc.exited concurrently and asserts exact per-stage signal counts; the it.each cases are serial on purpose so a hung fixture is killed on the test timeout.
Extended reasoning...

Overview

The PR lets the Linux/Android spawn waiter thread (the non-pidfd fallback) and process.on("SIGCHLD") share the single SIGCHLD disposition. src/spawn/process.rs gains two AtomicBool flags, a Guarded<()> lock around reload_handlers, and a split of the old handler into wake() plus wakeup() which forwards to Bun__onPosixSignal when a JS listener exists. src/jsc/PosixSignalHandle.rs mirrors the listener count into the waiter thread flag inside Bun__onSignalListenerCountChanged and adds Bun__onSignalDispositionChanged, which src/jsc/bindings/BunProcess.cpp calls after installing forwardSignal and after restoring the old disposition. A fixture and a four-case it.each matrix in test/js/bun/spawn/spawn.test.ts cover waiter thread vs pidfd and listener before/after the first spawn.

Since my previous review, commits 127bab9 and b89093b moved the JS_LISTENS_FOR_SIGCHLD store from the post-sigaction hook into Bun__onSignalListenerCountChanged, which runs before any disposition change; that closes the interleaving I flagged where a Worker-started waiter thread could install wakeup with the flag still false. The second commit only shortens comments.

Security risks

None specific to this change. It does not parse untrusted input or touch auth/crypto. The one hazard class is signal-handler safety: wakeup now calls Bun__onPosixSignal, which is the same function the existing forwardSignal handler already invokes, and bun_sys::write on an eventfd initialized before the thread starts. No allocation, locking, or logging in the handler.

Level of scrutiny

High. Process-wide signal dispositions with two native owners and a cross-thread install order are a classic source of rare hangs, and the buggy configuration (Linux without pidfd_open) is not reproducible on most developer machines except via BUN_FEATURE_FLAG_FORCE_WAITER_THREAD. I traced the four interleavings of the waiter thread's reload_handlers and the main thread's add/remove path and found each ends with wakeup installed and the current flag value, with the trailing wake() covering any exit delivered to the interim handler. The remaining residual risk is the kind a maintainer familiar with the waiter thread and the crash-handler signal ownership should weigh, which is why I deferred rather than approved.

Other factors

The bug hunt ran to a dry streak with no findings. bun_spawn already references other Bun__* symbols by extern "C", so the new link reference follows precedent. The PR description explicitly names the same-class sites left unfixed (crash-signal restore, SIGINT/vm) as out of scope, which I noted as pre-existing on the prior push and did not re-raise. The test matrix asserts exact counts on a normalized JSON stream and orders the exit-code assertion last, per harness conventions; the serial it.each is justified inline.

@robobun

robobun commented Sep 16, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 8:26 AM PT - Sep 16th, 2026

❌ @robobun, your commit b89093b has 1 failures in Build #116575 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 42933

That installs a local version of the PR into your bun-42933 executable, so you can run:

bun-42933 --bun

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant