Skip to content
Merged
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
1 change: 1 addition & 0 deletions src/js/internal-for-testing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export const crash_handler = $rust("crash_handler.rs", "js_bindings.generate") a
rootError: () => void;
outOfMemory: () => void;
abort: () => void;
fastfail: () => void;
trap: () => void;
raiseIgnoringPanicHandler: () => void;
};
Expand Down
16 changes: 16 additions & 0 deletions src/runtime/api/crash_handler_jsc.rs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ pub(crate) mod js_bindings {
("rootError", __jsc_host_js_root_error),
("outOfMemory", __jsc_host_js_out_of_memory),
("abort", __jsc_host_js_abort),
("fastfail", __jsc_host_js_fastfail),
("trap", __jsc_host_js_trap),
(
"raiseIgnoringPanicHandler",
Expand Down Expand Up @@ -151,6 +152,21 @@ pub(crate) mod js_bindings {
Ok(JSValue::UNDEFINED)
}

/// Dies like foreign native code, with Bun's crash handler provably out
/// of the way on both platforms: `__fastfail` on Windows (uncatchable,
/// exit code 0xC0000409, same as UCRT abort(), Rust aborts, /GS checks)
/// and a raw SIGABRT on POSIX (handlers reset first, like the
/// `raiseIgnoringPanicHandler` binding below). The `abort` binding
/// above is the opposite: it routes into the crash handler on purpose.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[bun_jsc::host_fn]
fn js_fastfail(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult<JSValue> {
crash_handler::suppress_core_dumps_if_necessary();
#[cfg(windows)]
std::process::abort();
#[cfg(not(windows))]
Global::raise_ignoring_panic_handler(bun_core::SignalCode::SIGABRT);
}

#[bun_jsc::host_fn]
fn js_trap(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult<JSValue> {
crash_handler::suppress_core_dumps_if_necessary();
Expand Down
100 changes: 78 additions & 22 deletions src/runtime/cli/test/parallel/Coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -200,8 +200,10 @@ impl<'a> Coordinator<'a> {
}
#[cfg(not(unix))]
{
// SIGKILL → TerminateProcess; libuv-win ENOSYSes signals
// other than SIGQUIT/SIGTERM/SIGKILL/SIGINT.
Comment thread
robobun marked this conversation as resolved.
Resolved
// SAFETY: `p` is the live intrusive-refcounted *mut Process.
let _ = unsafe { (*p).kill(1) };
let _ = unsafe { (*p).kill(9) };
}
}
}
Expand Down Expand Up @@ -569,7 +571,8 @@ impl<'a> Coordinator<'a> {
// and the run continues in a fresh worker. If the worker was
// killed by a fatal signal — SIGABRT from Bun's own panic handler
// or a JSC/WTF assertion, SIGSEGV/SIGBUS/SIGFPE/SIGILL from native
// code — that's a Bun or addon bug and must not be
// code — or died with the Windows NTSTATUS equivalent, that's a
// Bun or addon bug and must not be
Comment thread
robobun marked this conversation as resolved.
Resolved
// masked by the rest of the suite passing: abort the whole run so
// the exit status reflects the crash. SIGKILL is treated as a
// regular failure (commonly the OOM killer or the user).
Expand Down Expand Up @@ -717,8 +720,12 @@ impl<'a> Coordinator<'a> {
}
#[cfg(not(unix))]
{
// SIGKILL → TerminateProcess (libuv-win ENOSYSes most
// signals, so e.g. kill(1) would leave the sibling running
// past the banner); it reaps as Signaled(9) →
// "aborted: sibling worker panicked".
Comment thread
robobun marked this conversation as resolved.
Resolved
// SAFETY: `p` is the live intrusive-refcounted *mut Process.
let _ = unsafe { (*p).kill(1) };
let _ = unsafe { (*p).kill(9) };
}
}
}
Expand Down Expand Up @@ -800,34 +807,83 @@ impl<'a> Coordinator<'a> {
/// as opposed to the test calling process.exit() or being SIGKILL'd by
/// the OOM killer. Bun's panic handler re-raises the original fault
/// (SIGSEGV/SIGBUS/SIGFPE/SIGILL) or SIGABRT for panics; JSC/WTF
/// assertion failures abort() → SIGABRT. On Windows
/// neither surfaces as a signal — abort() is exit code 3 and NTSTATUS
/// fault codes arrive as a plain exit status, both indistinguishable
/// from process.exit(N) — so this classification is effectively
/// POSIX-only and Windows worker crashes fall into the non-panic
/// per-file-failure branch.
/// assertion failures abort() → SIGABRT.
///
/// Windows delivers no signals: an unhandled exception or `__fastfail`
/// exits with the NTSTATUS as the exit code, so recognized fatal values of
/// `Exited.raw` (the untruncated code; `Exited.code` is `u8`) classify as
/// panics too. A fault Bun's crash handler catches still exits with code
/// 3, indistinguishable from process.exit(3), and stays a per-file
/// failure, recognizable only by its banner in stderr.
Comment thread
robobun marked this conversation as resolved.
Resolved
fn is_panic_status(status: &SpawnStatus) -> bool {
let Some(sig) = status.signal_code() else {
return false;
};
use bun_core::SignalCode;
if let Some(sig) = status.signal_code() {
use bun_core::SignalCode;
return matches!(
sig,
SignalCode::SIGILL
| SignalCode::SIGTRAP
| SignalCode::SIGABRT
| SignalCode::SIGBUS
| SignalCode::SIGFPE
| SignalCode::SIGSEGV
| SignalCode::SIGSYS
);
}
#[cfg(windows)]
if let SpawnStatus::Exited(e) = status {
return is_fatal_windows_exit_code(e.raw);
}
false
}

/// Fatal NTSTATUS exit codes — the Windows mirror of the signal list
/// above. An allowlist, not `>= 0xC0000000`, because high exit codes are
/// not all faults: 0xC000013A is Ctrl+C (the SIGINT analog), and foreign
/// code in the worker can exit with arbitrary DWORDs (CRT `exit(-1)` is
/// 0xFFFFFFFF). TerminateProcess (taskkill, job limits) stays a per-file
/// failure, like SIGKILL.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cfg(windows)]
#[rustfmt::skip]
fn is_fatal_windows_exit_code(code: u32) -> bool {
matches!(
sig,
SignalCode::SIGILL
| SignalCode::SIGTRAP
| SignalCode::SIGABRT
| SignalCode::SIGBUS
| SignalCode::SIGFPE
| SignalCode::SIGSEGV
| SignalCode::SIGSYS
code,
0x8000_0003 // STATUS_BREAKPOINT: unhandled int3 (SIGTRAP)
| 0x8000_0004 // STATUS_SINGLE_STEP (SIGTRAP)
| 0xC000_0005 // STATUS_ACCESS_VIOLATION (SIGSEGV)
| 0xC000_0006 // STATUS_IN_PAGE_ERROR (SIGBUS)
| 0xC000_001D // STATUS_ILLEGAL_INSTRUCTION (SIGILL)
| 0xC000_0025 // STATUS_NONCONTINUABLE_EXCEPTION
| 0xC000_008C // STATUS_ARRAY_BOUNDS_EXCEEDED
| 0xC000_008D..=0xC000_0093 // STATUS_FLOAT_* faults (SIGFPE)
| 0xC000_0094 // STATUS_INTEGER_DIVIDE_BY_ZERO (SIGFPE)
| 0xC000_0095 // STATUS_INTEGER_OVERFLOW (SIGFPE)
| 0xC000_0096 // STATUS_PRIVILEGED_INSTRUCTION (SIGILL)
| 0xC000_00FD // STATUS_STACK_OVERFLOW
| 0xC000_0374 // STATUS_HEAP_CORRUPTION
| 0xC000_0409 // STATUS_STACK_BUFFER_OVERRUN: __fastfail —
// UCRT abort(), Rust abort, /GS checks (SIGABRT)
| 0xC000_0417 // STATUS_INVALID_CRUNTIME_PARAMETER
| 0xC000_041D // STATUS_FATAL_USER_CALLBACK_EXCEPTION
| 0xC000_0420 // STATUS_ASSERTION_FAILURE
| 0xC000_0602 // STATUS_FAIL_FAST_EXCEPTION
)
}

fn describe_status<'b>(buf: &'b mut [u8; 32], status: &SpawnStatus) -> &'b [u8] {
match status {
SpawnStatus::Exited(e) => {
// Windows: report the untruncated code; NTSTATUS values print in
// hex ("exit code 0xC0000409"), the form Windows tooling uses.
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cfg(windows)]
let code: u32 = e.raw;
#[cfg(not(windows))]
let code: u32 = u32::from(e.code);
let mut cursor: &mut [u8] = &mut buf[..];
write!(cursor, "exit code {}", e.code).expect("unreachable");
if code >= 0x8000_0000 {
write!(cursor, "exit code 0x{code:08X}").expect("unreachable");
} else {
write!(cursor, "exit code {code}").expect("unreachable");
}
let remaining = cursor.len();
&buf[..buf.len() - remaining]
}
Expand Down
5 changes: 5 additions & 0 deletions src/spawn/process.rs
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ impl Process {
Status::Exited(Exited {
code: exit_code,
signal: 0,
raw: exit_status as u32,
}),
&rusage,
);
Expand Down Expand Up @@ -699,6 +700,10 @@ pub struct Exited {
/// `SignalCode` discriminants are 1..=31; storing it as the
/// enum and transmuting `0` would be UB. Convert via `Status::signal_code`.
pub signal: u8,
/// Untruncated `GetExitCodeProcess` DWORD; `code` is its low byte.
/// NTSTATUS crash codes only survive here (0xC0000409 → `code` 9).
Comment thread
robobun marked this conversation as resolved.
Resolved
#[cfg(windows)]
pub raw: u32,
}

impl Status {
Expand Down
81 changes: 78 additions & 3 deletions test/cli/test/parallel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,9 +200,10 @@ test("--parallel --bail stops dispatching new files after threshold", async () =
expect(exitCode).toBe(1);
});

// Worker crashes are classified as panics by fatal signal, which Windows
// never surfaces (a crash there is exit code 3, indistinguishable from
// process.exit) — see is_panic_status. So this contract is POSIX-only.
// POSIX classifies worker crashes as panics by fatal signal — see
// is_panic_status. (On Windows a crash caught by Bun's crash handler exits
// with code 3, indistinguishable from process.exit(3); the Windows
// classification below works on raw NTSTATUS exit codes instead.)
test.skipIf(isWindows)(
"--parallel --bail: a worker panic still prints the panic banner and stops sibling workers",
async () => {
Expand Down Expand Up @@ -234,6 +235,80 @@ test.skipIf(isWindows)(
isASAN || isDebug ? 60_000 : 20_000,
);

// Windows delivers no signals: a native fault that bypasses Bun's crash
// handler (__fastfail — UCRT abort(), Rust aborts in addons, /GS checks)
// terminates the worker with the raw NTSTATUS as its exit code. The
// coordinator must recognize that as a crash and abort the run like the
// fatal-signal path above, not narrow 0xC0000409 to "exit code 9" and carry
// on as if the test had called process.exit(9).
test.skipIf(!isWindows)(
"--parallel: a worker dying with a fatal NTSTATUS prints the crash banner and aborts",
async () => {
using dir = tempDir("parallel-ntstatus", {
// Hangs forever so its worker is still mid-file when the sibling
// crashes: the abort must terminate it (TerminateProcess) rather than
// wait for it, or the run would stall past the banner.
"a-hang.test.js": `import {test} from "bun:test"; test("hang", async () => { await new Promise(() => {}); }, 999999);`,
"b-fastfail.test.js": `import {test} from "bun:test"; import { crash_handler } from "bun:internal-for-testing"; test("fastfail", () => { crash_handler.fastfail(); });`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--parallel=2"],
// A deliberate crash must not upload a report (see the POSIX test above).
env: {
...bunEnv,
BUN_TEST_PARALLEL_SCALE_MS: "0",
BUN_CRASH_REPORT_URL: "",
BUN_ENABLE_CRASH_REPORTING: "0",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
// 0xC0000409 = STATUS_STACK_BUFFER_OVERRUN, reported untruncated and in hex
expect(stderr).toContain("(worker crashed: exit code 0xC0000409)");
expect(stderr).toContain("a test worker process crashed with exit code 0xC0000409");
expect(stderr).toContain("Aborting");
// the hung sibling was torn down and accounted, not waited on
expect(stderr).toContain("aborted: sibling worker panicked");
expect(exitCode).not.toBe(0);
},
isASAN || isDebug ? 60_000 : 20_000,
);

// POSIX twin of the NTSTATUS test: fastfail resets the SIGABRT disposition
// and raises it, so the worker dies by the raw signal with no crash-handler
// banner, and the fatal-signal classification aborts the run.
test.skipIf(isWindows)(
"--parallel: a worker dying of a raw SIGABRT (fastfail) aborts the run",
async () => {
using dir = tempDir("parallel-fastfail-posix", {
"a-hang.test.js": `import {test} from "bun:test"; test("hang", async () => { await new Promise(() => {}); }, 999999);`,
"b-fastfail.test.js": `import {test} from "bun:test"; import { crash_handler } from "bun:internal-for-testing"; test("fastfail", () => { crash_handler.fastfail(); });`,
});
await using proc = Bun.spawn({
cmd: [bunExe(), "test", "--parallel=2"],
// A deliberate crash must not upload a report (see the POSIX test above).
env: {
...bunEnv,
BUN_TEST_PARALLEL_SCALE_MS: "0",
BUN_CRASH_REPORT_URL: "",
BUN_ENABLE_CRASH_REPORTING: "0",
},
cwd: String(dir),
stderr: "pipe",
stdout: "pipe",
});
const [, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]);
expect(stderr).toContain("(worker crashed: SIGABRT)");
expect(stderr).toContain("a test worker process crashed with SIGABRT");
expect(stderr).toContain("Aborting");
expect(stderr).toContain("aborted: sibling worker panicked");
expect(exitCode).not.toBe(0);
},
isASAN || isDebug ? 60_000 : 20_000,
);

test("--parallel prints per-test lines under their file's header", async () => {
using dir = tempDir("parallel-output", {
"a.test.js": `import {test,expect} from "bun:test";
Expand Down