Skip to content

bun test --parallel: abort the run when a Windows worker dies with a fatal NTSTATUS - #37129

Merged
Jarred-Sumner merged 4 commits into
mainfrom
farm/0f3cf2df/parallel-windows-ntstatus-crash
Aug 7, 2026
Merged

Jarred-Sumner merged 4 commits into
mainfrom
farm/0f3cf2df/parallel-windows-ntstatus-crash

Conversation

@robobun

@robobun robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

In Buildkite build 89973, both Windows test lanes (x64 and aarch64) printed, for one file of the parallel bucket:

✗ test\js\node\zlib\zlib-estimated-size-gc.test.ts (worker crashed: exit code 9)

with zero per-test output under the file header. The file then passed 6/6 when retried alone, so the event was recorded as a flaky test and forgotten.

"Exit code 9" was really NTSTATUS 0xC0000409 (STATUS_STACK_BUFFER_OVERRUN, i.e. __fastfail: what UCRT abort(), Rust aborts in native addons, and /GS stack checks raise). The libuv exit callback narrows the 32-bit GetExitCodeProcess value to u8, and 0xC0000409 & 0xFF == 9.

Two gaps compound into the masking, and the second is the one this PR is about. The coordinator classifies a worker death as a panic (which aborts the whole run, the behavior added in #30216) only by fatal signal. Windows never delivers a signal, so a native fault there was indistinguishable from process.exit(N): the file got marked failed, a fresh worker was spawned, and the run carried on, which in CI turns a genuine native crash into a "flaky" retry. The is_panic_status comment described exactly this limitation. And since the printed exit code was the truncated one, the log did not even identify the crash.

(#33720 separately widens the JS-visible Exited::code to match Node. This PR keeps code untouched, so it has no JS-visible effect and no dependency on that change; it stores the raw value alongside, and the two fields meet at the same struct, so whichever lands second reconciles them there.)

Fix

  • Exited gains a #[cfg(windows)] raw: u32 carrying the untruncated GetExitCodeProcess DWORD, set at its single Windows construction site (the libuv exit callback).
  • is_panic_status also treats a fixed allowlist of fatal NTSTATUS exit codes as panics, mirroring the POSIX fatal-signal list: access violation, in-page error, illegal/privileged instruction, the float and integer faults, stack overflow, heap corruption, __fastfail, CRT fatals, assertion failure. An allowlist rather than code >= 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) intentionally stays a per-file failure, like SIGKILL on POSIX.
  • describe_status prints the untruncated code, in hex for NTSTATUS-range values: worker crashed: exit code 0xC0000409.
  • The abort path's sibling termination used kill(1), which libuv-win rejects with ENOSYS (it only maps SIGQUIT/SIGTERM/SIGKILL/SIGINT to TerminateProcess), so the newly reachable Windows abort would have waited on a busy sibling instead of stopping it. Both abort paths now use SIGKILL.
  • New crash_handler.fastfail binding in bun:internal-for-testing: dies like foreign native code with the crash handler provably out of the way on both platforms — __fastfail on Windows (kernel-terminated with 0xC0000409, no VEH/SEH dispatch) and a raw SIGABRT on POSIX (dispositions reset first). Every existing crash-trigger binding routes through the crash handler, which exits with code 3 on Windows.

A crash that does reach Bun's Windows crash handler still exits with code 3, which genuinely cannot be told apart from process.exit(3); that case keeps the old behavior and is recognizable by its banner in the captured worker stderr.

Verification

The fix caught the real crash live: in this PR's own CI (build 90096, windows 2019 x64), the same zlib file's worker died again and the batch log now shows

✗ test\js\node\zlib\zlib-estimated-size-gc.test.ts (worker crashed: exit code 0xC0000409)

error: a test worker process crashed with exit code 0xC0000409 while running test\js\node\zlib\zlib-estimated-size-gc.test.ts.
This indicates a bug in Bun or in a native addon, not in the test itself. Aborting.

instead of exit code 9 and a silent continue. (The underlying zlib crash is reported separately.)

Tests, mirroring the existing POSIX segfault test (hung sibling + crashing file):

  • Windows (skipIf(!isWindows)): a fastfail worker must produce the hex report, the banner, the abort, and the terminated sibling accounted as aborted: sibling worker panicked. Verified on windows-x64: fails on the released canary, passes with this build; with the classification short-circuited, the run reproduces the CI logs' exact worker crashed: exit code 9 signature and carries on.
  • POSIX (skipIf(isWindows)): the same fixture dies by raw SIGABRT and must abort identically via the fatal-signal list. Fails on the released canary, passes with this build on linux x64 (debug+ASAN).

bun bd test test/cli/test/parallel.test.ts matches the main-built baseline on both platforms (the pre-existing failures on Windows debug builds are assertion noise reported separately; Windows CI lanes run release builds and are unaffected), and the existing process.exit(7) mid-run test still reports exit code 7 and continues on both.


no test proof · iteration 0 · Platform-specific test(s) that do not run on this machine. Deferring to CI, which covers all platforms: test/cli/test/parallel.test.ts

…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.
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Status: verified on windows-x64. The fastfail fixture reproduces the exact "worker crashed: exit code 9" signature from build 89973 when the classification is disabled, and aborts with "worker crashed: exit code 0xC0000409" plus the crash banner with this change. Full parallel suite on Windows matches the main-built baseline (no new failures); Linux is behaviorally unchanged. Note for the record: the proof is Windows-only (POSIX exit codes are 8-bit, the new test skips there).

@coderabbitai

coderabbitai Bot commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Cross-platform crash handling

Layer / File(s) Summary
Fastfail testing API
src/js/internal-for-testing.ts, src/runtime/api/crash_handler_jsc.rs, test/cli/test/parallel.test.ts
The crash-handler testing API exposes fastfail. The binding aborts through platform-specific paths. POSIX tests verify SIGABRT crash reporting and sibling-worker abortion.
Raw Windows exit status
src/spawn/process.rs, src/runtime/cli/test/parallel/Coordinator.rs, test/cli/test/parallel.test.ts
Windows preserves raw 32-bit process statuses. The coordinator classifies fatal NTSTATUS values and reports high-bit values in hexadecimal. Windows tests verify this behavior.
Worker abort and panic propagation
src/runtime/cli/test/parallel/Coordinator.rs
Windows abort and panic paths force-terminate workers and sibling workers instead of using signal-based termination.

Possibly related PRs

  • oven-sh/bun#36175: Both changes modify parallel worker abort and termination behavior.
  • oven-sh/bun#36232: Both changes modify parallel crash-worker handling and coordinator behavior.
  • oven-sh/bun#36237: Both changes modify parallel crash-worker termination handling.

Suggested reviewers: jarred-sumner, dylan-conway

🚥 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 summarizes the primary change: aborting parallel tests when a Windows worker exits with a fatal NTSTATUS.
Description check ✅ Passed The description explains the problem, fix, verification steps, platform coverage, and expected behavior in sufficient detail.

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

@github-actions github-actions Bot added the claude label Aug 7, 2026
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
… 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.
Comment thread src/runtime/api/crash_handler_jsc.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
@robobun

robobun commented Aug 7, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:30 AM PT - Aug 7th, 2026

✅ @robobun, your commit d442075649121272271c9fb6b50ebf6588855cf8 passed in Build #90102! 🎉


🧪   To try this PR locally:

bunx bun-pr 37129

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

bun-37129 --bun

Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Worker.rs Outdated
Comment thread src/spawn/process.rs Outdated
Comment thread src/runtime/api/crash_handler_jsc.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Coordinator.rs Outdated
Comment thread src/runtime/cli/test/parallel/Worker.rs Outdated
Comment thread src/spawn/process.rs Outdated
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

Addressed the review finding about sibling termination in 7b9d545: the abort path killed siblings with signal 1, which libuv-win rejects with ENOSYS (it only maps SIGQUIT/SIGTERM/SIGKILL/SIGINT to TerminateProcess), so on Windows the newly reachable abort would have waited on a busy sibling instead of stopping it. Both abort paths now use SIGKILL, and the Windows test hangs its sibling file (like the POSIX test) so the termination is exercised: the run ends immediately with the banner last and the sibling accounted as "aborted: sibling worker panicked". Verified on windows-x64.

…orrect 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).
Comment thread src/runtime/api/crash_handler_jsc.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/runtime/cli/test/parallel/Coordinator.rs
Comment thread src/spawn/process.rs
@robobun

robobun commented Aug 7, 2026

Copy link
Copy Markdown
Collaborator Author

FYI: the underlying fastfail behind the 0xC0000409 worker deaths this PR classifies is identified and fixed in #37140 (worker_threads argv/execArgv cross-thread AtomString abort). The triage improvement here is still valuable on its own.

@Jarred-Sumner
Jarred-Sumner merged commit 11e3f2b into main Aug 7, 2026
54 checks passed
@Jarred-Sumner
Jarred-Sumner deleted the farm/0f3cf2df/parallel-windows-ntstatus-crash branch August 7, 2026 21:28
springmin pushed a commit to springmin/bun that referenced this pull request Aug 7, 2026
…fatal NTSTATUS (oven-sh#37129)

## Problem

In [Buildkite build 89973](https://buildkite.com/bun/bun/builds/89973),
both Windows test lanes (x64 and aarch64) printed, for one file of the
parallel bucket:

```
✗ test\js\node\zlib\zlib-estimated-size-gc.test.ts (worker crashed: exit code 9)
```

with zero per-test output under the file header. The file then passed
6/6 when retried alone, so the event was recorded as a flaky test and
forgotten.

"Exit code 9" was really NTSTATUS `0xC0000409`
(STATUS_STACK_BUFFER_OVERRUN, i.e. `__fastfail`: what UCRT `abort()`,
Rust aborts in native addons, and /GS stack checks raise). The libuv
exit callback narrows the 32-bit `GetExitCodeProcess` value to `u8`, and
`0xC0000409 & 0xFF == 9`.

Two gaps compound into the masking, and the second is the one this PR is
about. The coordinator classifies a worker death as a panic (which
aborts the whole run, the behavior added in oven-sh#30216) only by fatal
signal. Windows never delivers a signal, so a native fault there was
indistinguishable from `process.exit(N)`: the file got marked failed, a
fresh worker was spawned, and the run carried on, which in CI turns a
genuine native crash into a "flaky" retry. The `is_panic_status` comment
described exactly this limitation. And since the printed exit code was
the truncated one, the log did not even identify the crash.

(oven-sh#33720 separately widens the JS-visible `Exited::code` to match Node.
This PR keeps `code` untouched, so it has no JS-visible effect and no
dependency on that change; it stores the raw value alongside, and the
two fields meet at the same struct, so whichever lands second reconciles
them there.)

## Fix

- `Exited` gains a `#[cfg(windows)] raw: u32` carrying the untruncated
`GetExitCodeProcess` DWORD, set at its single Windows construction site
(the libuv exit callback).
- `is_panic_status` also treats a fixed allowlist of fatal NTSTATUS exit
codes as panics, mirroring the POSIX fatal-signal list: access
violation, in-page error, illegal/privileged instruction, the float and
integer faults, stack overflow, heap corruption, `__fastfail`, CRT
fatals, assertion failure. An allowlist rather than `code >= 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) intentionally stays a per-file failure, like SIGKILL on
POSIX.
- `describe_status` prints the untruncated code, in hex for
NTSTATUS-range values: `worker crashed: exit code 0xC0000409`.
- The abort path's sibling termination used `kill(1)`, which libuv-win
rejects with ENOSYS (it only maps SIGQUIT/SIGTERM/SIGKILL/SIGINT to
`TerminateProcess`), so the newly reachable Windows abort would have
waited on a busy sibling instead of stopping it. Both abort paths now
use SIGKILL.
- New `crash_handler.fastfail` binding in `bun:internal-for-testing`:
dies like foreign native code with the crash handler provably out of the
way on both platforms — `__fastfail` on Windows (kernel-terminated with
`0xC0000409`, no VEH/SEH dispatch) and a raw SIGABRT on POSIX
(dispositions reset first). Every existing crash-trigger binding routes
through the crash handler, which exits with code 3 on Windows.

A crash that does reach Bun's Windows crash handler still exits with
code 3, which genuinely cannot be told apart from `process.exit(3)`;
that case keeps the old behavior and is recognizable by its banner in
the captured worker stderr.

## Verification

The fix caught the real crash live: in this PR's own CI ([build
90096](https://buildkite.com/bun/bun/builds/90096), windows 2019 x64),
the same zlib file's worker died again and the batch log now shows

```
✗ test\js\node\zlib\zlib-estimated-size-gc.test.ts (worker crashed: exit code 0xC0000409)

error: a test worker process crashed with exit code 0xC0000409 while running test\js\node\zlib\zlib-estimated-size-gc.test.ts.
This indicates a bug in Bun or in a native addon, not in the test itself. Aborting.
```

instead of `exit code 9` and a silent continue. (The underlying zlib
crash is reported separately.)

Tests, mirroring the existing POSIX segfault test (hung sibling +
crashing file):

- Windows (`skipIf(!isWindows)`): a fastfail worker must produce the hex
report, the banner, the abort, and the terminated sibling accounted as
`aborted: sibling worker panicked`. Verified on windows-x64: fails on
the released canary, passes with this build; with the classification
short-circuited, the run reproduces the CI logs' exact `worker crashed:
exit code 9` signature and carries on.
- POSIX (`skipIf(isWindows)`): the same fixture dies by raw SIGABRT and
must abort identically via the fatal-signal list. Fails on the released
canary, passes with this build on linux x64 (debug+ASAN).

`bun bd test test/cli/test/parallel.test.ts` matches the main-built
baseline on both platforms (the pre-existing failures on Windows debug
builds are assertion noise reported separately; Windows CI lanes run
release builds and are unaffected), and the existing `process.exit(7)`
mid-run test still reports `exit code 7` and continues on both.

<!-- robobun:evidence:begin -->

---

**no test proof** · iteration 0 · Platform-specific test(s) that do not
run on this machine. Deferring to CI, which covers all platforms:
test/cli/test/parallel.test.ts

<!-- robobun:evidence:end -->
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.

2 participants