diff --git a/src/js/internal-for-testing.ts b/src/js/internal-for-testing.ts index 195adde6a905..93e247b68377 100644 --- a/src/js/internal-for-testing.ts +++ b/src/js/internal-for-testing.ts @@ -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; }; diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index a460a072e65f..d573f171b4af 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -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", @@ -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. + #[bun_jsc::host_fn] + fn js_fastfail(_global: &JSGlobalObject, _frame: &CallFrame) -> JsResult { + 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 { crash_handler::suppress_core_dumps_if_necessary(); diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index 6c32279778a3..a43d8b297698 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -200,8 +200,10 @@ impl<'a> Coordinator<'a> { } #[cfg(not(unix))] { + // SIGKILL → TerminateProcess; libuv-win ENOSYSes signals + // other than SIGQUIT/SIGTERM/SIGKILL/SIGINT. // SAFETY: `p` is the live intrusive-refcounted *mut Process. - let _ = unsafe { (*p).kill(1) }; + let _ = unsafe { (*p).kill(9) }; } } } @@ -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 // 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). @@ -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". // SAFETY: `p` is the live intrusive-refcounted *mut Process. - let _ = unsafe { (*p).kill(1) }; + let _ = unsafe { (*p).kill(9) }; } } } @@ -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. 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. +#[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. + #[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] } diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 4e77896756db..89fb90478519 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -518,6 +518,7 @@ impl Process { Status::Exited(Exited { code: exit_code, signal: 0, + raw: exit_status as u32, }), &rusage, ); @@ -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). + #[cfg(windows)] + pub raw: u32, } impl Status { diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index fa0394508085..8e5505a73338 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -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 () => { @@ -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";