Skip to content
Open
Show file tree
Hide file tree
Changes from 5 commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
76a38f7
ffi: clear SA_ONSTACK on SIGPWR after dlopen
robobun May 21, 2026
efe6944
[autofix.ci] apply automated fixes
autofix-ci[bot] May 21, 2026
44ba51c
comment why Bun__repairJscGcSignalAfterDlopen exists
robobun May 21, 2026
05c79cc
drop the watchdog setTimeout — await-using + test timeout cover it
robobun May 21, 2026
0ef5a49
[autofix.ci] apply automated fixes
autofix-ci[bot] May 21, 2026
ce6f4c1
ship the real fix from WebKit#235, drop the band-aid
robobun May 21, 2026
c0c0e6c
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun May 23, 2026
be86d91
ci: retrigger — WebKit #235 preview autobuild is now published
robobun May 23, 2026
14c08e5
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun May 23, 2026
ee62500
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun May 25, 2026
a42d8e1
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun May 26, 2026
5700893
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jun 2, 2026
008c293
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jun 16, 2026
000610a
build: point WEBKIT_VERSION at rebased oven-sh/WebKit#235 preview (66…
robobun Jun 16, 2026
1f4deae
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jun 17, 2026
152f661
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jun 28, 2026
0968c00
build: align WEBKIT_VERSION to WebKit#235 697e8a79 (comment fix)
robobun Jun 28, 2026
7a95440
test(31158): filter benign ASAN stderr banner before asserting
robobun Jun 28, 2026
e8111d1
ci: retrigger (WebKit#235 preview tarball autobuild-preview-pr-235-69…
robobun Jun 28, 2026
8a95f9b
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jul 1, 2026
63e2247
Merge remote-tracking branch 'origin/main' into farm/ebd23fe3/fix-311…
robobun Jul 14, 2026
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
9 changes: 9 additions & 0 deletions src/jsc/bindings/BunProcess.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -386,6 +386,13 @@ extern "C" void Bun__unlink(const char*, size_t);

extern "C" void CrashHandler__setDlOpenAction(const char* action);
extern "C" bool Bun__VM__allowAddons(void* vm);
// Post-dlopen repair for JSC's Linux SIGPWR suspend/resume signal: clears
// SA_ONSTACK from our installed handler if the dlopen'd library added it
// (Go's cgo `runtime.setsigstack` is the canonical offender). Without this,
// the next `Thread::suspend()` retries forever and the event loop hangs on
// any WASM compile/install. See `sys::repair_jsc_gc_signal_after_dlopen` —
// no-op on non-Linux so no OS guard is needed here. oven-sh/bun#31158.
extern "C" void Bun__repairJscGcSignalAfterDlopen();
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

JSC_DEFINE_HOST_FUNCTION(Process_functionDlopen, (JSC::JSGlobalObject * globalObject_, JSC::CallFrame* callFrame))
{
Expand Down Expand Up @@ -534,6 +541,8 @@ JSC_DEFINE_HOST_FUNCTION(Process_functionDlopen, (JSC::JSGlobalObject * globalOb
CrashHandler__setDlOpenAction(utf8.data());
void* handle = dlopen(utf8.data(), RTLD_LAZY);
CrashHandler__setDlOpenAction(nullptr);
if (handle)
Bun__repairJscGcSignalAfterDlopen();
Comment thread
robobun marked this conversation as resolved.
Outdated

tryToDeleteIfNecessary();
#endif
Expand Down
73 changes: 72 additions & 1 deletion src/sys/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6091,7 +6091,12 @@ pub fn dlopen(filename: &ZStr, flags: i32) -> Option<*mut c_void> {
{
// SAFETY: filename is NUL-terminated.
let p = unsafe { libc::dlopen(filename.as_ptr(), flags) };
if p.is_null() { None } else { Some(p) }
if !p.is_null() {
repair_jsc_gc_signal_after_dlopen();
Some(p)
} else {
None
}
}
#[cfg(windows)]
{
Expand All @@ -6101,6 +6106,72 @@ pub fn dlopen(filename: &ZStr, flags: i32) -> Option<*mut c_void> {
if p.is_null() { None } else { Some(p.cast()) }
}
}

/// Undo `SA_ONSTACK` that a just-`dlopen`ed library may have slapped onto JSC's
/// thread-suspend signal.
///
/// JavaScriptCore suspends threads for GC/code-cache flushes by delivering a
/// per-thread signal (`SIGPWR` on Linux in Bun's WebKit fork; `SIGUSR1`
/// elsewhere) and having the handler snapshot the interrupted thread's
/// context. The handler's sanity check — "am I running on the tracked thread's
/// stack?" — uses its own frame pointer and therefore only holds when the
/// signal arrives on the normal stack.
///
/// Several language runtimes iterate every signal in `initsig()`-style hooks
/// when loaded as a shared library and, for any signal whose handler they
/// don't want to own, force `SA_ONSTACK` onto the inherited disposition so
/// their own threads' synchronous faults stay on a managed alt stack. Go's
/// cgo runtime is the canonical offender — `runtime.setsigstack` sets
/// `SA_ONSTACK` on *every* signal whose handler is non-default, including
/// ours — but any runtime that follows the same recipe (Mono, some JNI
/// layouts, Rust async runtimes compiled as cdylibs) trips the same wire.
///
/// When that happens in a process that already has an alternate signal stack
/// installed (ASAN's runtime, Bun's own crash handler, libbacktrace, …) the
/// next JSC suspend delivers the signal onto the alt stack; the stack check
/// fails on every attempt; `Thread::suspend()` retries forever and the event
/// loop stops. See oven-sh/bun#31158.
///
/// Fixing this inside WTF (use the interrupted SP from ucontext instead of
/// the handler's own SP) is the clean path and should go upstream too;
/// clearing `SA_ONSTACK` here is the belt-and-suspenders patch so the
/// current prebuilt WebKit doesn't deadlock in the meantime.
#[cfg(unix)]
pub fn repair_jsc_gc_signal_after_dlopen() {
// Only Linux reproduces this: upstream WTF uses SIGUSR1 everywhere but
// Bun remapped to SIGPWR on Linux so userland SIGUSR1 stays available.
// `SA_ONSTACK` is a POSIX concept so the clearing itself works anywhere,
// but the wrongness we're correcting (`SA_ONSTACK` on our GC signal, plus
// an alt stack) only lines up on Linux in practice.
#[cfg(target_os = "linux")]
{
const SIGPWR: i32 = 30;
// SAFETY: both `oldact` and `newact` are stack-local, properly sized,
// and the kernel writes/reads only their POD contents.
unsafe {
let mut oldact: libc::sigaction = core::mem::zeroed();
if libc::sigaction(SIGPWR, core::ptr::null(), &raw mut oldact) != 0 {
return;
}
if (oldact.sa_flags & libc::SA_ONSTACK) == 0 {
return;
}
let mut newact = oldact;
newact.sa_flags &= !libc::SA_ONSTACK;
let _ = libc::sigaction(SIGPWR, &raw const newact, core::ptr::null_mut());
}
}
}

#[cfg(not(unix))]
pub fn repair_jsc_gc_signal_after_dlopen() {}

/// C-ABI wrapper so call sites outside the Rust tree (BunProcess.cpp's
/// `process.dlopen`) can use the same post-dlopen repair.
#[unsafe(no_mangle)]
pub extern "C" fn Bun__repairJscGcSignalAfterDlopen() {
repair_jsc_gc_signal_after_dlopen();
}
/// sys.zig:4565 — `dlsym(handle, name)`.
pub fn dlsym_impl(handle: Option<*mut c_void>, name: &ZStr) -> Option<*mut c_void> {
#[cfg(unix)]
Expand Down
89 changes: 89 additions & 0 deletions test/regression/issue/31158.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
// Regression test for https://github.com/oven-sh/bun/issues/31158.
//
// When a shared library (e.g. Go's cgo runtime) force-sets `SA_ONSTACK` on
// JavaScriptCore's thread-suspend signal (SIGPWR on Linux in Bun's WebKit
// fork), every delivery of that signal on a thread that has an alternate
// signal stack (ASAN runtime, Bun's crash handler, libbacktrace) runs the
// handler on the alt stack. WTF's stack-check in `signalHandlerSuspendResume`
// used `currentStackPointer()` — the handler's own SP — so the sanity
// check failed every time, `Thread::suspend()` retried forever, and the
// event loop stopped advancing on the next WASM compile/install.
//
// Triggers the exact shape of the bug without needing Go installed in CI:
// force `SA_ONSTACK` onto SIGPWR via `bun:ffi`, then call `dlopen` once
// more so the repair fires, then compile+call a WASM function (which
// triggers `resetInstructionCacheOnAllThreads`). Without the fix,
// `setTimeout` after the WASM call never fires and the child hangs.
import { expect, test } from "bun:test";
import { bunEnv, bunExe, isGlibc } from "harness";

// The bug is only reachable on Linux: SIGPWR is only used as the suspend/
// resume signal on Linux (see `vendor/WebKit/Source/WTF/wtf/posix/
// ThreadingPOSIX.cpp` — USE(BUN_JSC_ADDITIONS) branch). The reproduction
// dlopens `libc.so.6` directly so it needs glibc; musl names its libc
// differently and static-musl builds of Bun don't even have a dynamic
// loader in the room.
test.skipIf(!isGlibc)(
"event loop survives SA_ONSTACK on SIGPWR + WASM (oven-sh/bun#31158)",
async () => {
const script = /* ts */ `
import { dlopen, FFIType, ptr } from "bun:ffi";

// 1. Open libc so we can flip SA_ONSTACK on SIGPWR the same way Go's
// cgo 'initsig' does on every inherited handler.
const libc = dlopen("libc.so.6", {
sigaction: { args: [FFIType.i32, FFIType.ptr, FFIType.ptr], returns: FFIType.i32 },
});

// x86_64 glibc layout: sa_handler (8), sa_mask (128), sa_flags (4), sa_restorer (8).
// We only touch sa_flags so this layout holds on aarch64 too (same offsets).
const SIGACTION_SIZE = 152;
const FLAGS_OFFSET = 8 + 128;
const SIGPWR = 30;
const SA_ONSTACK = 0x08000000;

const buf = new ArrayBuffer(SIGACTION_SIZE);
const view = new DataView(buf);

libc.symbols.sigaction(SIGPWR, null, ptr(buf));
view.setInt32(FLAGS_OFFSET, view.getInt32(FLAGS_OFFSET, true) | SA_ONSTACK, true);
libc.symbols.sigaction(SIGPWR, ptr(buf), null);

// 2. Trigger a fresh dlopen — the fix clears SA_ONSTACK here.
dlopen("libc.so.6", { getpid: { args: [], returns: FFIType.i32 } });
Comment thread
claude[bot] marked this conversation as resolved.
Outdated

// 3. Exercise the WASM path that calls resetInstructionCacheOnAllThreads.
// The bytes below are a minimal WASM module with one exported function
// that loops 10_000 times and returns — enough to trigger compilation.
const bytes = new Uint8Array([
0, 97, 115, 109, 1, 0, 0, 0, 1, 5, 1, 96, 0, 1, 127, 3, 2, 1, 0, 7, 5,
1, 1, 102, 0, 0, 10, 34, 1, 32, 1, 1, 127, 65, 0, 33, 0, 2, 64, 3, 64,
32, 0, 65, 1, 106, 33, 0, 32, 0, 65, 144, 206, 0, 72, 13, 0, 11, 11, 32,
0, 11,
]);
const inst = new WebAssembly.Instance(new WebAssembly.Module(bytes));
(inst.exports.f as () => number)();

// 4. A setTimeout that must fire — the bug makes this hang forever.
await new Promise(r => setTimeout(r, 10));
console.log("EVENT_LOOP_ALIVE");
`;

// `await using` scopes the child to this block — if the test-runner
// timeout fires because the bug reproduced, `Bun.spawn`'s disposer
// kills the child on the way out, no harness-side watchdog needed.
await using proc = Bun.spawn({
cmd: [bunExe(), "-e", script],
env: bunEnv,
stdout: "pipe",
stderr: "pipe",
});

const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);

expect(stderr).toBe("");
Comment thread
robobun marked this conversation as resolved.
Outdated
expect(stdout).toContain("EVENT_LOOP_ALIVE");
expect(exitCode).toBe(0);
},
15_000,
);
Loading