From b1d2e7400c6ecb3c07d87355cecc2783981db02a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 11:17:38 +0000 Subject: [PATCH 1/4] bun test --parallel: treat fatal NTSTATUS worker exits as crashes on Windows A parallel test worker dying of a native fault on Windows (unhandled exception or __fastfail) terminates with the NTSTATUS as its exit code. The coordinator stored exit codes as u8, so 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN, what UCRT abort() and Rust aborts raise) was reported as 'worker crashed: exit code 9', indistinguishable from process.exit(9), and the run carried on in a fresh worker instead of aborting the way the POSIX fatal-signal path does. Capture the untruncated GetExitCodeProcess DWORD on Process, carry it into the parallel Worker, classify a fixed list of fatal NTSTATUS codes as worker panics (mirroring the POSIX signal list; TerminateProcess and Ctrl+C stay per-file failures like SIGKILL), and print NTSTATUS exit codes in hex. Adds a crash_handler.fastfail test binding that dies via __fastfail so tests can exercise a worker death the crash handler cannot intercept. --- src/js/internal-for-testing.ts | 1 + src/runtime/api/crash_handler_jsc.rs | 15 +++ src/runtime/cli/test/parallel/Coordinator.rs | 113 ++++++++++++++----- src/runtime/cli/test/parallel/Worker.rs | 12 +- src/runtime/cli/test/parallel/runner.rs | 1 + src/spawn/process.rs | 11 ++ test/cli/test/parallel.test.ts | 43 ++++++- 7 files changed, 163 insertions(+), 33 deletions(-) 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..d439235bffda 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,20 @@ pub(crate) mod js_bindings { Ok(JSValue::UNDEFINED) } + /// Dies the way foreign native code does when a fatal runtime check + /// fires, without Bun's crash handler ever running. On Windows + /// `std::process::abort()` is `__fastfail`: no exception dispatch (VEH/ + /// SEH cannot observe it), no banner, and the process exit code is the + /// raw NTSTATUS 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) — the same + /// death UCRT `abort()`, Rust aborts in addons, and /GS failures + /// produce. On POSIX it raises SIGABRT. 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(); + std::process::abort(); + } + #[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..a76a78cb43cb 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -569,24 +569,26 @@ 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). - let panicked = is_panic_status(status); + let raw_exit_code = w.raw_exit_code; + let panicked = is_panic_status(status, raw_exit_code); let was_bailed = self.bailed; if was_bailed && !panicked { self.account_unfinished(idx, b"aborted: sibling worker panicked"); } else { self.record_timing(idx, w.dispatched_at); - self.account_crash(idx, status); + self.account_crash(idx, status, raw_exit_code); } Output::flush(); // SAFETY: fresh root derivation — `account_crash` can reach `bail_out`, which retags the slots. let w = unsafe { &mut *self.workers.as_mut_ptr().add(slot) }; w.inflight = None; if panicked { - self.abort_on_worker_panic(idx, status); + self.abort_on_worker_panic(idx, status, raw_exit_code); } } @@ -647,13 +649,13 @@ impl<'a> Coordinator<'a> { self.files_done += 1; } - fn account_crash(&mut self, file_idx: u32, status: &SpawnStatus) { + fn account_crash(&mut self, file_idx: u32, status: &SpawnStatus, raw_exit_code: u32) { self.break_dots(); let mut buf = [0u8; 32]; bun_core::pretty_error!( "✗ {} (worker crashed: {})\n", bstr::BStr::new(self.rel_path(file_idx)), - bstr::BStr::new(describe_status(&mut buf, status)), + bstr::BStr::new(describe_status(&mut buf, status, raw_exit_code)), ); self.reporter.summary().fail += 1; self.reporter.summary().files += 1; @@ -670,7 +672,7 @@ impl<'a> Coordinator<'a> { /// files as aborted so the run ends immediately with a non-zero exit /// and the panic's stderr (already flushed via flushCaptured) is the /// last meaningful output, not buried under hundreds of later passes. - fn abort_on_worker_panic(&mut self, file_idx: u32, status: &SpawnStatus) { + fn abort_on_worker_panic(&mut self, file_idx: u32, status: &SpawnStatus, raw_exit_code: u32) { self.break_dots(); let mut buf = [0u8; 32]; bun_core::pretty_error!( @@ -678,7 +680,7 @@ impl<'a> Coordinator<'a> { "\nerror: a test worker process crashed with {} while running {}.\n", "This indicates a bug in Bun or in a native addon, not in the test itself. Aborting.\n", ), - bstr::BStr::new(describe_status(&mut buf, status)), + bstr::BStr::new(describe_status(&mut buf, status, raw_exit_code)), bstr::BStr::new(self.rel_path(file_idx)), ); Output::flush(); @@ -800,34 +802,87 @@ 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. -fn is_panic_status(status: &SpawnStatus) -> bool { - let Some(sig) = status.signal_code() else { - return false; - }; - use bun_core::SignalCode; +/// assertion failures abort() → SIGABRT. +/// +/// Windows delivers no signals: a fault that reaches Bun's crash handler +/// exits with code 3 (indistinguishable from process.exit(3), so it can +/// only be recognized by its banner in the captured stderr), but an +/// unhandled exception or `__fastfail` terminates the process with the +/// NTSTATUS as its exit code. `raw_exit_code` is the untruncated value +/// (`Status::Exited.code` is `u8` and collapses `0xC0000409` to 9); +/// recognized fatal NTSTATUS codes are classified as panics so a native +/// crash aborts the run instead of being recycled as a per-file failure. +/// `raw_exit_code` is always 0 on POSIX. +fn is_panic_status(status: &SpawnStatus, raw_exit_code: u32) -> bool { + 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 + ); + } + matches!(status, SpawnStatus::Exited(_)) && is_fatal_windows_exit_code(raw_exit_code) +} + +/// Windows NTSTATUS exit codes that mean the worker died of a native +/// fault — the moral equivalent of the fatal-signal list above. An +/// allowlist, not a `>= 0xC0000000` range check: `process.exit(-1)` exits +/// with 0xFFFFFFFF, so severity bits alone don't prove a crash. +/// Deliberate TerminateProcess (taskkill, job limits — exit code 1 or an +/// arbitrary value) intentionally stays non-panic, like SIGKILL on POSIX, +/// and so does 0xC000013A (Ctrl+C), the SIGINT analog. +#[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] { +fn describe_status<'b>( + buf: &'b mut [u8; 32], + status: &SpawnStatus, + raw_exit_code: u32, +) -> &'b [u8] { match status { SpawnStatus::Exited(e) => { + // Windows: prefer the untruncated code; NTSTATUS values print in + // hex ("exit code 0xC0000409"), the form Windows tooling uses. + let code = if raw_exit_code != 0 { + raw_exit_code + } else { + 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/runtime/cli/test/parallel/Worker.rs b/src/runtime/cli/test/parallel/Worker.rs index b7393f8918b9..50816cc6b062 100644 --- a/src/runtime/cli/test/parallel/Worker.rs +++ b/src/runtime/cli/test/parallel/Worker.rs @@ -70,6 +70,12 @@ pub struct Worker { /// Set when the process-exit notification arrives. Reaping waits for both /// this and `ipc.done` so trailing IPC frames are decoded first. pub(crate) exit_status: Option, + /// Windows: the untruncated exit code (`Process.windows_raw_exit_code`) + /// captured together with `exit_status`. `Status::Exited.code` is `u8`, + /// which collapses NTSTATUS crash codes into small integers that look + /// like `process.exit(N)`; crash classification needs the full value. + /// Always 0 on POSIX. + pub(crate) raw_exit_code: u32, pub(crate) reap_pending: bool, } @@ -332,7 +338,11 @@ impl Worker { Ok(()) } - pub(crate) fn on_process_exit(&mut self, _: &Process, status: Status, _: &Rusage) { + pub(crate) fn on_process_exit(&mut self, _process: &Process, status: Status, _: &Rusage) { + #[cfg(windows)] + { + self.raw_exit_code = _process.windows_raw_exit_code; + } self.alive = false; // SAFETY: coord backref valid for worker lifetime; mutation — see `coord` field doc (provenance caveats). unsafe { (*self.coord.cast_mut()).on_worker_exit(self, status) }; diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index 6e1efa7a63c6..ea3baf1513f7 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -135,6 +135,7 @@ pub(crate) fn run_as_coordinator( captured: Vec::new(), alive: false, exit_status: None, + raw_exit_code: 0, reap_pending: false, }); let w: *mut Worker = workers.last_mut().unwrap(); diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 4e77896756db..219188bb059e 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -124,6 +124,15 @@ pub struct Process { #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) pidfd: PidFdType, pub status: Status, + /// Untruncated `GetExitCodeProcess` DWORD captured in `on_exit_uv`. + /// `Exited::code` is `u8`, which collapses NTSTATUS crash codes into + /// small integers indistinguishable from `process.exit(N)` (e.g. + /// `0xC0000409`, `__fastfail`, becomes 9). Consumers that must tell a + /// native fault from a deliberate exit read this instead. 0 until the + /// process exits with a status (stays 0 when it was signaled via + /// `uv_process_kill` or the wait itself failed). + #[cfg(windows)] + pub windows_raw_exit_code: u32, pub poller: Poller, pub(crate) ref_count: bun_ptr::ThreadSafeRefCount, pub exit_handler: ProcessExitHandler, @@ -513,6 +522,7 @@ impl Process { } else if exit_status >= 0 { // The check is on the signed libuv `exit_status`, so a negative // `-UV_E*` reaches the Err arm. + this.windows_raw_exit_code = exit_status as u32; this.close(); this.on_exit( Status::Exited(Exited { @@ -2015,6 +2025,7 @@ mod spawn_process_body { event_loop: options.windows.loop_, pid: 0, status: Status::Running, + windows_raw_exit_code: 0, poller: Poller::Detached, exit_handler: ProcessExitHandler::default(), })); diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index fa0394508085..8088983be993 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,42 @@ 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", { + "a-ok.test.js": `import {test,expect} from "bun:test"; test("ok", () => expect(1).toBe(1));`, + "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"); + 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"; From 7b9d5450954e42e4b0496c405385377dd9e19f9a Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:09:53 +0000 Subject: [PATCH 2/4] parallel: kill siblings with SIGKILL on Windows so the abort actually terminates them libuv's Windows uv__kill only maps SIGQUIT/SIGTERM/SIGKILL/SIGINT to TerminateProcess and returns ENOSYS for anything else, so the kill(1) in abort_on_worker_panic (and abort_all) was a discarded error. That was dead code while Windows crashes were never classified as panics; now that fatal NTSTATUS exits take this path, a busy sibling would have kept the run alive past the banner. The Windows test now hangs its sibling file to exercise the termination, matching the POSIX test. --- src/runtime/cli/test/parallel/Coordinator.rs | 13 +++++++++++-- test/cli/test/parallel.test.ts | 7 ++++++- 2 files changed, 17 insertions(+), 3 deletions(-) diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index a76a78cb43cb..8cba6e070c4b 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -200,8 +200,11 @@ impl<'a> Coordinator<'a> { } #[cfg(not(unix))] { + // 9 (SIGKILL) → TerminateProcess. libuv-win only maps + // SIGQUIT/SIGTERM/SIGKILL/SIGINT; anything else is ENOSYS + // and would leave the worker running. // SAFETY: `p` is the live intrusive-refcounted *mut Process. - let _ = unsafe { (*p).kill(1) }; + let _ = unsafe { (*p).kill(9) }; } } } @@ -719,8 +722,14 @@ impl<'a> Coordinator<'a> { } #[cfg(not(unix))] { + // 9 (SIGKILL) → TerminateProcess; the killed sibling reaps + // as Signaled(9) → "aborted: sibling worker panicked". + // libuv-win returns ENOSYS for signals other than + // SIGQUIT/SIGTERM/SIGKILL/SIGINT, so e.g. kill(1) would + // leave the sibling running and the coordinator waiting on + // it past the banner. // SAFETY: `p` is the live intrusive-refcounted *mut Process. - let _ = unsafe { (*p).kill(1) }; + let _ = unsafe { (*p).kill(9) }; } } } diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index 8088983be993..2207ab4aa164 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -245,7 +245,10 @@ test.skipIf(!isWindows)( "--parallel: a worker dying with a fatal NTSTATUS prints the crash banner and aborts", async () => { using dir = tempDir("parallel-ntstatus", { - "a-ok.test.js": `import {test,expect} from "bun:test"; test("ok", () => expect(1).toBe(1));`, + // 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({ @@ -266,6 +269,8 @@ test.skipIf(!isWindows)( 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, From a6bc55747150c43ff38b03007b6357f9d657bbc6 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:12:39 +0000 Subject: [PATCH 3/4] Tighten comments --- src/runtime/api/crash_handler_jsc.rs | 13 +++---- src/runtime/cli/test/parallel/Coordinator.rs | 41 ++++++++------------ src/runtime/cli/test/parallel/Worker.rs | 8 ++-- src/spawn/process.rs | 11 ++---- 4 files changed, 28 insertions(+), 45 deletions(-) diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index d439235bffda..fbce2ed0f71c 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -152,14 +152,11 @@ pub(crate) mod js_bindings { Ok(JSValue::UNDEFINED) } - /// Dies the way foreign native code does when a fatal runtime check - /// fires, without Bun's crash handler ever running. On Windows - /// `std::process::abort()` is `__fastfail`: no exception dispatch (VEH/ - /// SEH cannot observe it), no banner, and the process exit code is the - /// raw NTSTATUS 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN) — the same - /// death UCRT `abort()`, Rust aborts in addons, and /GS failures - /// produce. On POSIX it raises SIGABRT. The `abort` binding above is - /// the opposite: it routes into the crash handler on purpose. + /// Dies like foreign native code, without Bun's crash handler running: + /// `std::process::abort()` is `__fastfail` on Windows (uncatchable, + /// exit code 0xC0000409, same as UCRT abort(), Rust aborts, /GS checks) + /// and SIGABRT on POSIX. 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(); diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index 8cba6e070c4b..554120bb6e0f 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -200,9 +200,8 @@ impl<'a> Coordinator<'a> { } #[cfg(not(unix))] { - // 9 (SIGKILL) → TerminateProcess. libuv-win only maps - // SIGQUIT/SIGTERM/SIGKILL/SIGINT; anything else is ENOSYS - // and would leave the worker running. + // 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(9) }; } @@ -722,12 +721,10 @@ impl<'a> Coordinator<'a> { } #[cfg(not(unix))] { - // 9 (SIGKILL) → TerminateProcess; the killed sibling reaps - // as Signaled(9) → "aborted: sibling worker panicked". - // libuv-win returns ENOSYS for signals other than - // SIGQUIT/SIGTERM/SIGKILL/SIGINT, so e.g. kill(1) would - // leave the sibling running and the coordinator waiting on - // it past the banner. + // 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(9) }; } @@ -813,15 +810,12 @@ impl<'a> Coordinator<'a> { /// (SIGSEGV/SIGBUS/SIGFPE/SIGILL) or SIGABRT for panics; JSC/WTF /// assertion failures abort() → SIGABRT. /// -/// Windows delivers no signals: a fault that reaches Bun's crash handler -/// exits with code 3 (indistinguishable from process.exit(3), so it can -/// only be recognized by its banner in the captured stderr), but an -/// unhandled exception or `__fastfail` terminates the process with the -/// NTSTATUS as its exit code. `raw_exit_code` is the untruncated value -/// (`Status::Exited.code` is `u8` and collapses `0xC0000409` to 9); -/// recognized fatal NTSTATUS codes are classified as panics so a native -/// crash aborts the run instead of being recycled as a per-file failure. -/// `raw_exit_code` is always 0 on POSIX. +/// Windows delivers no signals: an unhandled exception or `__fastfail` +/// exits with the NTSTATUS as the exit code, so recognized fatal values of +/// `raw_exit_code` (untruncated, `Exited.code` being `u8`; always 0 on +/// POSIX) 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, raw_exit_code: u32) -> bool { if let Some(sig) = status.signal_code() { use bun_core::SignalCode; @@ -839,13 +833,10 @@ fn is_panic_status(status: &SpawnStatus, raw_exit_code: u32) -> bool { matches!(status, SpawnStatus::Exited(_)) && is_fatal_windows_exit_code(raw_exit_code) } -/// Windows NTSTATUS exit codes that mean the worker died of a native -/// fault — the moral equivalent of the fatal-signal list above. An -/// allowlist, not a `>= 0xC0000000` range check: `process.exit(-1)` exits -/// with 0xFFFFFFFF, so severity bits alone don't prove a crash. -/// Deliberate TerminateProcess (taskkill, job limits — exit code 1 or an -/// arbitrary value) intentionally stays non-panic, like SIGKILL on POSIX, -/// and so does 0xC000013A (Ctrl+C), the SIGINT analog. +/// Fatal NTSTATUS exit codes — the Windows mirror of the signal list +/// above. An allowlist, not `>= 0xC0000000`: process.exit(-1) exits with +/// 0xFFFFFFFF. TerminateProcess (taskkill, job limits) and Ctrl+C +/// (0xC000013A) intentionally stay per-file failures, like SIGKILL. #[rustfmt::skip] fn is_fatal_windows_exit_code(code: u32) -> bool { matches!( diff --git a/src/runtime/cli/test/parallel/Worker.rs b/src/runtime/cli/test/parallel/Worker.rs index 50816cc6b062..f60761e024e2 100644 --- a/src/runtime/cli/test/parallel/Worker.rs +++ b/src/runtime/cli/test/parallel/Worker.rs @@ -70,11 +70,9 @@ pub struct Worker { /// Set when the process-exit notification arrives. Reaping waits for both /// this and `ipc.done` so trailing IPC frames are decoded first. pub(crate) exit_status: Option, - /// Windows: the untruncated exit code (`Process.windows_raw_exit_code`) - /// captured together with `exit_status`. `Status::Exited.code` is `u8`, - /// which collapses NTSTATUS crash codes into small integers that look - /// like `process.exit(N)`; crash classification needs the full value. - /// Always 0 on POSIX. + /// Windows: untruncated exit code (`Process.windows_raw_exit_code`) + /// captured with `exit_status`, since `Exited.code` is `u8` and crash + /// classification needs the full NTSTATUS. Always 0 on POSIX. pub(crate) raw_exit_code: u32, pub(crate) reap_pending: bool, } diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 219188bb059e..95c574aeae3f 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -124,13 +124,10 @@ pub struct Process { #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) pidfd: PidFdType, pub status: Status, - /// Untruncated `GetExitCodeProcess` DWORD captured in `on_exit_uv`. - /// `Exited::code` is `u8`, which collapses NTSTATUS crash codes into - /// small integers indistinguishable from `process.exit(N)` (e.g. - /// `0xC0000409`, `__fastfail`, becomes 9). Consumers that must tell a - /// native fault from a deliberate exit read this instead. 0 until the - /// process exits with a status (stays 0 when it was signaled via - /// `uv_process_kill` or the wait itself failed). + /// Untruncated `GetExitCodeProcess` DWORD captured in `on_exit_uv`; + /// `Exited::code` is `u8` and collapses NTSTATUS crash codes into small + /// integers indistinguishable from `process.exit(N)` (0xC0000409 → 9). + /// 0 until the process exits with a plain status. #[cfg(windows)] pub windows_raw_exit_code: u32, pub poller: Poller, From d442075649121272271c9fb6b50ebf6588855cf8 Mon Sep 17 00:00:00 2001 From: robobun <117481402+robobun@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:31:55 +0000 Subject: [PATCH 4/4] Carry the raw exit code in Exited, fix the fastfail POSIX contract, correct the allowlist rationale Review feedback, three items: - The untruncated Windows exit code now lives in the exit status itself (cfg(windows) Exited.raw, set at the single Windows construction site) instead of a parallel side-channel through Process and Worker fields plus widened signatures. The status was already threaded end-to-end; this deletes the duplicate state, and the eventual Exited::code widening (#33720) will now conflict at the struct, forcing the two fields to be reconciled rather than coexisting silently. - crash_handler.fastfail promised the crash handler never runs, but on non-ASAN POSIX std::process::abort() raises SIGABRT into Bun's own handler. The POSIX arm now uses raise_ignoring_panic_handler(SIGABRT), which resets dispositions first, making the documented contract true on both platforms; a POSIX twin of the Windows test pins it (raw SIGABRT death -> fatal-signal classification -> abort). - The allowlist comment justified itself with 'process.exit(-1) exits with 0xFFFFFFFF', which is false for Bun workers (process.exit truncates to u8 and exits 255). The true grounds: 0xC000013A is Ctrl+C, and foreign code can exit with arbitrary DWORDs (CRT exit(-1) is 0xFFFFFFFF). --- src/runtime/api/crash_handler_jsc.rs | 12 +++-- src/runtime/cli/test/parallel/Coordinator.rs | 57 ++++++++++---------- src/runtime/cli/test/parallel/Worker.rs | 10 +--- src/runtime/cli/test/parallel/runner.rs | 1 - src/spawn/process.rs | 13 ++--- test/cli/test/parallel.test.ts | 33 ++++++++++++ 6 files changed, 76 insertions(+), 50 deletions(-) diff --git a/src/runtime/api/crash_handler_jsc.rs b/src/runtime/api/crash_handler_jsc.rs index fbce2ed0f71c..d573f171b4af 100644 --- a/src/runtime/api/crash_handler_jsc.rs +++ b/src/runtime/api/crash_handler_jsc.rs @@ -152,15 +152,19 @@ pub(crate) mod js_bindings { Ok(JSValue::UNDEFINED) } - /// Dies like foreign native code, without Bun's crash handler running: - /// `std::process::abort()` is `__fastfail` on Windows (uncatchable, + /// 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 SIGABRT on POSIX. The `abort` binding above is the opposite: it - /// routes into the crash handler on purpose. + /// 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] diff --git a/src/runtime/cli/test/parallel/Coordinator.rs b/src/runtime/cli/test/parallel/Coordinator.rs index 554120bb6e0f..a43d8b297698 100644 --- a/src/runtime/cli/test/parallel/Coordinator.rs +++ b/src/runtime/cli/test/parallel/Coordinator.rs @@ -576,21 +576,20 @@ impl<'a> Coordinator<'a> { // 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). - let raw_exit_code = w.raw_exit_code; - let panicked = is_panic_status(status, raw_exit_code); + let panicked = is_panic_status(status); let was_bailed = self.bailed; if was_bailed && !panicked { self.account_unfinished(idx, b"aborted: sibling worker panicked"); } else { self.record_timing(idx, w.dispatched_at); - self.account_crash(idx, status, raw_exit_code); + self.account_crash(idx, status); } Output::flush(); // SAFETY: fresh root derivation — `account_crash` can reach `bail_out`, which retags the slots. let w = unsafe { &mut *self.workers.as_mut_ptr().add(slot) }; w.inflight = None; if panicked { - self.abort_on_worker_panic(idx, status, raw_exit_code); + self.abort_on_worker_panic(idx, status); } } @@ -651,13 +650,13 @@ impl<'a> Coordinator<'a> { self.files_done += 1; } - fn account_crash(&mut self, file_idx: u32, status: &SpawnStatus, raw_exit_code: u32) { + fn account_crash(&mut self, file_idx: u32, status: &SpawnStatus) { self.break_dots(); let mut buf = [0u8; 32]; bun_core::pretty_error!( "✗ {} (worker crashed: {})\n", bstr::BStr::new(self.rel_path(file_idx)), - bstr::BStr::new(describe_status(&mut buf, status, raw_exit_code)), + bstr::BStr::new(describe_status(&mut buf, status)), ); self.reporter.summary().fail += 1; self.reporter.summary().files += 1; @@ -674,7 +673,7 @@ impl<'a> Coordinator<'a> { /// files as aborted so the run ends immediately with a non-zero exit /// and the panic's stderr (already flushed via flushCaptured) is the /// last meaningful output, not buried under hundreds of later passes. - fn abort_on_worker_panic(&mut self, file_idx: u32, status: &SpawnStatus, raw_exit_code: u32) { + fn abort_on_worker_panic(&mut self, file_idx: u32, status: &SpawnStatus) { self.break_dots(); let mut buf = [0u8; 32]; bun_core::pretty_error!( @@ -682,7 +681,7 @@ impl<'a> Coordinator<'a> { "\nerror: a test worker process crashed with {} while running {}.\n", "This indicates a bug in Bun or in a native addon, not in the test itself. Aborting.\n", ), - bstr::BStr::new(describe_status(&mut buf, status, raw_exit_code)), + bstr::BStr::new(describe_status(&mut buf, status)), bstr::BStr::new(self.rel_path(file_idx)), ); Output::flush(); @@ -812,11 +811,11 @@ impl<'a> Coordinator<'a> { /// /// Windows delivers no signals: an unhandled exception or `__fastfail` /// exits with the NTSTATUS as the exit code, so recognized fatal values of -/// `raw_exit_code` (untruncated, `Exited.code` being `u8`; always 0 on -/// POSIX) 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, raw_exit_code: u32) -> bool { +/// `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 { if let Some(sig) = status.signal_code() { use bun_core::SignalCode; return matches!( @@ -830,13 +829,20 @@ fn is_panic_status(status: &SpawnStatus, raw_exit_code: u32) -> bool { | SignalCode::SIGSYS ); } - matches!(status, SpawnStatus::Exited(_)) && is_fatal_windows_exit_code(raw_exit_code) + #[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`: process.exit(-1) exits with -/// 0xFFFFFFFF. TerminateProcess (taskkill, job limits) and Ctrl+C -/// (0xC000013A) intentionally stay per-file failures, like SIGKILL. +/// 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!( @@ -863,20 +869,15 @@ fn is_fatal_windows_exit_code(code: u32) -> bool { ) } -fn describe_status<'b>( - buf: &'b mut [u8; 32], - status: &SpawnStatus, - raw_exit_code: u32, -) -> &'b [u8] { +fn describe_status<'b>(buf: &'b mut [u8; 32], status: &SpawnStatus) -> &'b [u8] { match status { SpawnStatus::Exited(e) => { - // Windows: prefer the untruncated code; NTSTATUS values print in + // Windows: report the untruncated code; NTSTATUS values print in // hex ("exit code 0xC0000409"), the form Windows tooling uses. - let code = if raw_exit_code != 0 { - raw_exit_code - } else { - u32::from(e.code) - }; + #[cfg(windows)] + let code: u32 = e.raw; + #[cfg(not(windows))] + let code: u32 = u32::from(e.code); let mut cursor: &mut [u8] = &mut buf[..]; if code >= 0x8000_0000 { write!(cursor, "exit code 0x{code:08X}").expect("unreachable"); diff --git a/src/runtime/cli/test/parallel/Worker.rs b/src/runtime/cli/test/parallel/Worker.rs index f60761e024e2..b7393f8918b9 100644 --- a/src/runtime/cli/test/parallel/Worker.rs +++ b/src/runtime/cli/test/parallel/Worker.rs @@ -70,10 +70,6 @@ pub struct Worker { /// Set when the process-exit notification arrives. Reaping waits for both /// this and `ipc.done` so trailing IPC frames are decoded first. pub(crate) exit_status: Option, - /// Windows: untruncated exit code (`Process.windows_raw_exit_code`) - /// captured with `exit_status`, since `Exited.code` is `u8` and crash - /// classification needs the full NTSTATUS. Always 0 on POSIX. - pub(crate) raw_exit_code: u32, pub(crate) reap_pending: bool, } @@ -336,11 +332,7 @@ impl Worker { Ok(()) } - pub(crate) fn on_process_exit(&mut self, _process: &Process, status: Status, _: &Rusage) { - #[cfg(windows)] - { - self.raw_exit_code = _process.windows_raw_exit_code; - } + pub(crate) fn on_process_exit(&mut self, _: &Process, status: Status, _: &Rusage) { self.alive = false; // SAFETY: coord backref valid for worker lifetime; mutation — see `coord` field doc (provenance caveats). unsafe { (*self.coord.cast_mut()).on_worker_exit(self, status) }; diff --git a/src/runtime/cli/test/parallel/runner.rs b/src/runtime/cli/test/parallel/runner.rs index ea3baf1513f7..6e1efa7a63c6 100644 --- a/src/runtime/cli/test/parallel/runner.rs +++ b/src/runtime/cli/test/parallel/runner.rs @@ -135,7 +135,6 @@ pub(crate) fn run_as_coordinator( captured: Vec::new(), alive: false, exit_status: None, - raw_exit_code: 0, reap_pending: false, }); let w: *mut Worker = workers.last_mut().unwrap(); diff --git a/src/spawn/process.rs b/src/spawn/process.rs index 95c574aeae3f..89fb90478519 100644 --- a/src/spawn/process.rs +++ b/src/spawn/process.rs @@ -124,12 +124,6 @@ pub struct Process { #[cfg(any(target_os = "linux", target_os = "android"))] pub(crate) pidfd: PidFdType, pub status: Status, - /// Untruncated `GetExitCodeProcess` DWORD captured in `on_exit_uv`; - /// `Exited::code` is `u8` and collapses NTSTATUS crash codes into small - /// integers indistinguishable from `process.exit(N)` (0xC0000409 → 9). - /// 0 until the process exits with a plain status. - #[cfg(windows)] - pub windows_raw_exit_code: u32, pub poller: Poller, pub(crate) ref_count: bun_ptr::ThreadSafeRefCount, pub exit_handler: ProcessExitHandler, @@ -519,12 +513,12 @@ impl Process { } else if exit_status >= 0 { // The check is on the signed libuv `exit_status`, so a negative // `-UV_E*` reaches the Err arm. - this.windows_raw_exit_code = exit_status as u32; this.close(); this.on_exit( Status::Exited(Exited { code: exit_code, signal: 0, + raw: exit_status as u32, }), &rusage, ); @@ -706,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 { @@ -2022,7 +2020,6 @@ mod spawn_process_body { event_loop: options.windows.loop_, pid: 0, status: Status::Running, - windows_raw_exit_code: 0, poller: Poller::Detached, exit_handler: ProcessExitHandler::default(), })); diff --git a/test/cli/test/parallel.test.ts b/test/cli/test/parallel.test.ts index 2207ab4aa164..8e5505a73338 100644 --- a/test/cli/test/parallel.test.ts +++ b/test/cli/test/parallel.test.ts @@ -276,6 +276,39 @@ test.skipIf(!isWindows)( 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";