Skip to content

crash_handler(windows): walk the fault CONTEXT via RtlVirtualUnwind - #35074

Merged
dylan-conway merged 11 commits into
mainfrom
farm/c335f089/windows-fault-context-unwind
Jul 23, 2026
Merged

dylan-conway merged 11 commits into
mainfrom
farm/c335f089/windows-fault-context-unwind

Conversation

@robobun

@robobun robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

handle_segfault_windows reads ExceptionAddress for frame 0 but then captures the rest of the stack from inside the handler with RtlCaptureStackBackTrace, trimming the handler's own frames by scanning for an address within 256 bytes of the fault PC. That trim only works when RtlCaptureStackBackTrace can unwind through KiUserExceptionDispatcher back to the faulting frame. When it cannot, the capture is just [fault PC, capture_from_context, crash_handler, handle_segfault_windows, ntdll] and none of the bun callers are recorded.

Observed in Sentry (BUN-3K2N and siblings): 1180 events in the last 30 days on bun@1.4.0+* builds where capture_from_context is visible in the stack, 88% Windows ARM64 / 12% x64. Example trace for a fault inside CRYPTSP.dll:

<anonymous>                                     CRYPTSP.dll        (fault PC)
bun_core::debug::capture_from_context           debug.rs:344
bun_crash_handler::draft::crash_handler         lib.rs:1123
bun_crash_handler::draft::handle_segfault_windows lib.rs:2065
<anonymous>                                     ntdll.dll

On Windows Server 2019 x64 the old path happens to work (RtlCaptureStackBackTrace does unwind through the dispatcher there), so this is not reproducible on every Windows version.

Fix

Mirror the POSIX path: seed the walk from the saved register context instead of from inside the handler. The VEH receives EXCEPTION_POINTERS::ContextRecord; pass that through TraceSeed::Fault and walk with RtlLookupFunctionEntry + RtlVirtualUnwind. The handler's frames are never in the chain, and faults in external DLLs unwind into their bun callers regardless of Windows version or architecture. This is the same approach Crashpad, Breakpad and the Sentry native SDK use.

  • src/windows_sys/externs.rs: add CONTEXT (x64 and ARM64 layouts with static size/offset assertions), RUNTIME_FUNCTION, RtlLookupFunctionEntry, RtlVirtualUnwind, UNW_FLAG_NHANDLER.
  • src/bun_core/debug.rs: rewrite the Windows branch of capture_from_context to copy the CONTEXT and loop RtlLookupFunctionEntry / RtlVirtualUnwind, with leaf-function handling for both arches.
  • src/crash_handler/lib.rs: pass info.ContextRecord through instead of fp: 0.
  • src/runtime/api/crash_handler_jsc.rs: add a segfaultInDll test hook that faults inside ntdll!RtlFillMemory so the test has a fault PC outside bun.exe.

Verification

Built on Windows x64, ran segfaultInDll, and symbolized the captured addresses:

frame0  ntdll!RtlFillMemory                                (fault PC)
frame1  crash_handler_jsc::js_bindings::js_segfault_in_dll (the bun caller)
frame2  __jsc_host_js_segfault_in_dll::{closure#0}
frame3  host_fn_result::{closure#0}
frame4  to_js_host_call
frame5  host_fn_result
frame6  __jsc_host_js_segfault_in_dll
frame7-8 JIT
frame9  llint_entry

No capture_from_context / crash_handler / handle_segfault_windows frames. cargo check clean on x86_64-pc-windows-msvc, aarch64-pc-windows-msvc, and the host.

The new test is Windows-only; on Server 2019 both the old and new code produce a correct trace for this case, so the test acts as a regression guard for the new path rather than a fail-before proof. The fail-before evidence is the Sentry data above.

Touches the same files as #32871 (frameless leaf handling) but is orthogonal; whichever lands second will need a small rebase.


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

@coderabbitai

coderabbitai Bot commented Jul 22, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

Changes

Windows crash handling now seeds stack walking from the exception CONTEXT, uses Win32 unwind metadata, tightens memory checks, adds a DLL fault test hook, and validates caller-frame recovery on Windows.

Windows crash unwinding

Layer / File(s) Summary
Windows unwind and memory ABI
src/windows_sys/externs.rs
Adds Windows CONTEXT, RUNTIME_FUNCTION, unwind API declarations, and page protection constants.
Context-seeded stack walking
src/bun_core/debug.rs, src/crash_handler/lib.rs
Uses exception context data with RtlLookupFunctionEntry and RtlVirtualUnwind, including leaf-function fallback handling and stricter readable-page checks.
DLL fault trigger and validation
src/runtime/api/crash_handler_jsc.rs, test/cli/run/run-crash-handler.test.ts
Adds the segfaultInDll hook and verifies Windows debug traces recover Bun caller frames.

Possibly related PRs

  • oven-sh/bun#34814: Directly overlaps in passing Windows exception-context data into TraceSeed::Fault for stack walking.

Suggested reviewers: jarred-sumner

🚥 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 captures the main change: Windows crash handling now unwinds from the saved CONTEXT via RtlVirtualUnwind.
Description check ✅ Passed The description covers the problem, fix, and verification with detailed Windows testing, even though it doesn't use the template's exact headings.

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

@robobun

robobun commented Jul 22, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 1:06 AM PT - Jul 23rd, 2026

❌ @robobun, your commit d3098e4 has 2 failures in Build #78416 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35074

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

bun-35074 --bun

Comment thread src/bun_core/debug.rs Outdated
Comment thread src/bun_core/debug.rs Outdated
Comment thread test/cli/run/run-crash-handler.test.ts Outdated
Comment thread src/runtime/api/crash_handler_jsc.rs
Comment thread src/bun_core/debug.rs Outdated
Comment thread src/bun_core/debug.rs Outdated
Comment thread src/bun_core/debug.rs

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

b0a0151 resolves the two prior-round findings: the sp-advance guard is gone (so ARM64 zero-stack unwinds and directly-recursive stacks are no longer truncated), and is_valid_memory now gates the x64 leaf [Rsp] deref. I re-checked termination after dropping the guard — next_pc == 0, the ARM64 Lr == control_pc leaf check, and the n < out.len() cap together bound every path, and the earlier null-call / recursion cases stay fixed. Remaining item is the inline nit on is_valid_memory's Windows predicate.

Extended reasoning...

b0a0151 addressed both open findings from the previous round. I verified the new loop cannot spin (x64 leaf always advances Rsp; ARM64 leaf breaks on Lr == control_pc; RtlVirtualUnwind termination is Pc == 0; n < out.len() is the hard cap) and does not reintroduce the earlier null-call or recursive-frame truncations. The CONTEXT layouts are pinned by static size/offset asserts. The one remaining nit — is_valid_memory on Windows passes MEM_RESERVE/PAGE_GUARD — is narrow and non-blocking; leaving the approve/merge call to a human given this is hand-rolled Windows unwinder FFI verified only on x64.

Comment thread src/bun_core/debug.rs Outdated
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Windows 11 ARM64 verification (build 10.26100)

Before (main's RtlCaptureStackBackTrace + tolerance-trim), fault inside ntdll!RtlFillMemory, symbolized:

[0] ntdll                           (fault PC)
[1] capture_from_context            debug.rs:351   <- handler
[2] crash_handler                   lib.rs:1125    <- handler
[3] handle_segfault_windows         lib.rs:2060    <- handler
[4-6] ntdll dispatch (3 frames)
[7] __jsc_host_js_segfault_in_dll::{closure}       <- first real caller
...

6 junk frames; the 256-byte tolerance scan found no match so the trim never fired.

After (b0a0151, RtlVirtualUnwind from ContextRecord):

[0] ntdll                           (fault PC)
[1] js_segfault_in_dll              crash_handler_jsc.rs:116
[2] __jsc_host_js_segfault_in_dll::{closure}
[3] host_fn_result::{closure}
[4] to_js_host_call
[5] host_fn_result
[6] __jsc_host_js_segfault_in_dll
[7] JIT

No handler or ntdll-dispatch frames. run-crash-handler.test.ts: 5 pass, 0 fail on ARM64.

On Windows Server 2019 x64 (10.17763), the old tolerance-trim path happens to work for this case because RtlCaptureStackBackTrace there unwinds through KiUserExceptionDispatcher and captures an address near the fault PC. The new path produces the same correct result there without depending on that behavior.

@Jarred-Sumner

Copy link
Copy Markdown
Collaborator

@robobun address all comments

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@test/cli/run/run-crash-handler.test.ts`:
- Around line 147-157: Update the frame-count assertion in the crash-handler
test to require at least 7 captured frames, matching the six frames inspected by
frameAddrs.slice(1, 7). Keep the existing high-address grouping assertion
unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: e53ce5da-a903-4bca-bcd3-8edbdd7f1835

📥 Commits

Reviewing files that changed from the base of the PR and between 12b30c3 and c6244ae.

📒 Files selected for processing (5)
  • src/bun_core/debug.rs
  • src/crash_handler/lib.rs
  • src/runtime/api/crash_handler_jsc.rs
  • src/windows_sys/externs.rs
  • test/cli/run/run-crash-handler.test.ts

Comment thread test/cli/run/run-crash-handler.test.ts Outdated
Comment thread src/bun_core/debug.rs Outdated
Comment thread src/runtime/api/crash_handler_jsc.rs
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

@Jarred-Sumner all review comments addressed as of a390bd2:

finding commit
remove top-of-loop control_pc == 0 (breaks null-call) 3c0d586
next_pc == control_pc truncates recursive stacks 3c0d586 (superseded by b0a0151)
stale create_empty_object(global, 8) hint 3c0d586
test hi(0) != hi(1) can false-red under HEASLR 3c0d586
sp <= prev_sp breaks ARM64 leaf step ec9cccd (superseded by b0a0151)
ARM64 RtlVirtualUnwind can leave Sp unchanged b0a0151 (drop sp-advance guard; Pc == 0 + buffer cap only)
validate Rsp before x64 leaf deref b0a0151
is_valid_memory Windows predicate too permissive c6244ae (MEM_COMMIT + readable prot, no PAGE_GUARD/PAGE_NOACCESS)
test frame-count guard >= 6 vs slice(1,7) 2417a88
x64 leaf *(Rsp as *const u64) alignment a390bd2 (Rsp & 7 != 0 check)
fixture-crash.js usage string stale a390bd2

Verified at 2417a88 (last functional change before the two nits in a390bd2):

  • Windows 11 ARM64 (10.26100): run-crash-handler.test.ts 5 pass / 0 fail
  • Windows Server 2019 x64 (10.17763): new test passes

Overlaps with #34814 at TraceSeed::Fault / handle_segfault_windows; whichever lands second needs a small rebase.

Comment thread test/cli/run/run-crash-handler.test.ts Outdated

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings — 069e6f7 addressed the last open nit (test now asserts max−min span < 2 GiB instead of grouping by high-32, which removes the HEASLR straddle flake). Deferring to a human for the final sign-off: this is a rewrite of the Windows VEH stack walker with hand-rolled CONTEXT layouts and arch-gated unsafe code, and the ARM64 path can only be exercised on hardware CI doesn't cover on every lane.

Checked this round: the new span assertion still fails-before on the ARM64 repro (ntdll-dispatch frames at 4–6 span the >10 GiB EXE↔ntdll gap) and cannot false-red intra-image; the CONTEXT size/offset static asserts pin both arch layouts; the tightened is_valid_memory predicate has no other Windows callers whose semantics change (the only other caller is MemoryAccessor::read's fallback, which wants the same tightening).

Extended reasoning...

Overview

Rewrites the Windows branch of capture_from_context (src/bun_core/debug.rs) from RtlCaptureStackBackTrace + tolerance-trim to RtlLookupFunctionEntry + RtlVirtualUnwind seeded from the fault CONTEXT. Adds hand-rolled CONTEXT structs for x64 and ARM64 plus RUNTIME_FUNCTION and the two Rtl externs in src/windows_sys/externs.rs (with size_of/offset_of static asserts). Passes ContextRecord through TraceSeed::Fault in src/crash_handler/lib.rs. Adds a segfaultInDll test hook and a Windows-debug-only regression test.

Security risks

None. The crash handler runs after a fatal fault, on process-local state only; no external input is parsed and no privilege boundary is crossed. The one raw deref (*(Rsp as *const u64)) is now gated on 8-alignment + VirtualQuery commit/protection checks, and a nested fault is bounded by PANIC_STAGE.

Level of scrutiny

High. This is arch-cfg-gated unsafe code that runs inside a VEH handler on a crashing thread, with hand-rolled Win32 ABI struct layouts for two architectures. A layout or termination-condition mistake degrades every Windows crash report. It has been through five review rounds (11 findings, all addressed and verified on Win11 ARM64 + Server 2019 x64), so the remaining risk is the class of things only real Windows execution catches — which is exactly why a human with Windows context should approve.

Other factors

All prior inline threads are resolved; the last one (4 GiB-window grouping in the test) was addressed in 069e6f7 by switching to a max−min span bound, which was one of the two fixes I suggested. The bug-hunting system found nothing new this run. The PR description flags an overlap with #34814 that whichever lands second will need to rebase — a maintainer should sequence that.

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

CI (build 77635): all Windows lanes green (x64 build+test, aarch64 build+test). Remaining failures are all tagged [flaky] (also failing on main) and unrelated to this diff:

  • webview-chrome.test.ts (debian x64, animation timing)
  • test-gc-http-client-timeout.js (ubuntu x64, GC stall)
  • test-fs-promises-file-handle-readFile.js (debian x64-asan, FileHandle GC)
  • test-fs-read-stream-pos.js (windows x64, passed on retry)
  • dev-server-ssr-100.test.ts (darwin aarch64, next.js integration)

Ready for merge.

@dylan-conway

Copy link
Copy Markdown
Member

@robobun resolve all the conflicts

robobun and others added 9 commits July 23, 2026 03:15
handle_segfault_windows previously captured the stack from inside the VEH
handler with RtlCaptureStackBackTrace and then trimmed by scanning for an
address near the fault PC. That trim only works when RtlCaptureStackBackTrace
can unwind through KiUserExceptionDispatcher back to the faulting frame,
which is Windows-version dependent. When it cannot (observed in Sentry for
a fault inside CRYPTSP.dll on a 1.4.0 build), the trace is just the handler's
own frames plus the fault PC, and none of the bun callers are recorded.

This mirrors the POSIX path: seed the walk from the saved register context
instead of from inside the handler. On Windows that is the EXCEPTION_POINTERS
ContextRecord the VEH receives, walked with RtlLookupFunctionEntry +
RtlVirtualUnwind. The handler's frames are never in the chain and faults in
external DLLs unwind into their bun callers.

Adds CONTEXT / RUNTIME_FUNCTION / RtlLookupFunctionEntry / RtlVirtualUnwind
to bun_windows_sys with static size/offset assertions for both x64 and arm64,
and a segfaultInDll test hook that faults inside ntdll.dll so the Windows
test can assert the captured trace reaches the bun callers.
…e ENTRIES.len(), tighten test assertion

- Remove the top-of-loop control_pc==0 check so a fault with Rip/Pc==0
  (indirect call through a null function pointer) falls into the leaf
  branch and recovers the caller from [Rsp]/Lr instead of returning [0].
- Replace the next_pc==control_pc stuck-unwind guard with a stack-pointer
  advance check: two directly-recursive frames share a return PC but not
  an Sp, so comparing PCs truncated recursive stacks after two frames.
- Pass ENTRIES.len() to create_empty_object so the inline-capacity hint
  can't drift.
- Test: assert frames 1..7 all share one high-32 value. The old path left
  three ntdll-dispatch frames at indices 4-6 on Windows 11 ARM64 so this
  set has two members there; the new path keeps frames 1..7 in bun's
  image. Drops the hi(0)!=hi(1) assertion, which could false-red when
  HEASLR places ntdll and bun in the same 4 GiB window.
The ARM64 leaf step (Pc = Lr) correctly leaves Sp unchanged, so a
top-of-loop sp <= prev_sp check truncated the walk one iteration later.
Check sp-advance only after RtlVirtualUnwind (the only path that can get
stuck), and guard the ARM64 leaf branch against Lr == control_pc instead.
On ARM64 RtlVirtualUnwind can legitimately leave Sp unchanged (fault at
prolog offset 0, or a .pdata-bearing zero-stack leaf: bl/blr writes Lr
without pushing). The sp_after <= sp_before check truncated the trace to
[fault PC] in that case; confirmed on Windows 11 ARM64 where ntdll's
RtlFillMemory is a zero-stack leaf. Match the reference RtlVirtualUnwind
loop: terminate on next_pc == 0 and the buffer cap only.

Also guard the x64 leaf-branch [Rsp] deref with is_valid_memory(), same
convention as StackIterator::next on the POSIX side.
State != MEM_FREE passes MEM_RESERVE (no backing) and MEM_COMMIT pages
with PAGE_NOACCESS or PAGE_GUARD (the stack guard page after
EXCEPTION_STACK_OVERFLOW), so the guard did not actually make the
following deref safe. Require MEM_COMMIT with a readable protection and
no PAGE_NOACCESS/PAGE_GUARD bit, matching Crashpad/Breakpad's
SafeReadMemory. Also benefits MemoryAccessor::read's existing fallback.
The >> 32n grouping can false-red when bun.exe straddles a 4 GiB boundary
(HEASLR aligns the EXE base to 64 KiB, not 4 GiB). The suggested >> 36n
widening loses the fail-before signal entirely: on the Windows 11 ARM64
before/after data in this PR, ntdll (0x7ffd...) and bun.exe (0x7ff6...)
share the same high-36 value. Assert the invariant directly: the span of
frames 1..6 fits inside one image (< 2 GiB), vs the >10 GiB gap to the
system-DLL region when ntdll-dispatch frames are interleaved.
@robobun
robobun force-pushed the farm/c335f089/windows-fault-context-unwind branch from 069e6f7 to c365892 Compare July 23, 2026 03:17
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Rebased onto main (c365892). Conflicts were mechanical:

Re-verified on Windows 11 ARM64 at c365892: run-crash-handler.test.ts passes (5/5 of the tests that run on Windows).

… Pc nor Sp changed

On x64 RtlVirtualUnwind sets Rip = 0 at end-of-chain; on ARM64 it can leave
the CONTEXT unchanged instead. rust-lang/backtrace-rs handles this with
ip == prev_ip && sp == prev_sp. Safe for the cases an Sp-only or Pc-only
check truncates: ARM64 zero-stack unwinds change Pc (to Lr) but not Sp, and
directly-recursive frames share Pc but never Sp.
@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Prior art comparison

Crashpad and Breakpad are out-of-process: they snapshot raw stack memory and unwind offline in the processor, so neither contains an in-process RtlVirtualUnwind loop. The in-process references are:

leaf (no .pdata) x64 leaf ARM64 termination Rsp guard
this PR [Rsp]; Rsp+=8 + align + VirtualQuery Pc=Lr + Lr==Pc guard pc==0 or (pc==prev && sp==prev) + cap yes
CoreCLR VirtualUnwindLeafCallFrame [Rsp]; Rsp+=8 Pc=Lr per-frame, caller-driven no
backtrace-rs win64.rs break break ip==0 or (ip==prev && sp==prev) n/a
BoringSSL abi_test.cc (vendored) [Rsp]; Rsp+=8; error if below frame 0 n/a Rip==0 no
Go sehhandler break break entry==0 or establisher_frame outside stack bounds n/a
LLVM sanitizer uses StackWalk64 (dbghelp), not RtlVirtualUnwind

The leaf handling matches CoreCLR (the most complete reference). backtrace-rs and Go don't do manual leaf unwind because Rust/Go emit .pdata for everything; we need it for faults in JIT/external DLLs. The Rsp alignment + VirtualQuery guard before the leaf deref is extra defense not in any reference.

One change from the research (3d160d8): adopted backtrace-rs's (ip==prev && sp==prev) end-of-chain check. backtrace-rs's source comment: on ARM64, RtlVirtualUnwind at end-of-chain can leave the CONTEXT unchanged rather than setting Pc=0 like x64. Requiring both to match is safe for the cases an Sp-only or Pc-only check truncates: ARM64 zero-stack unwinds change Pc (to Lr) but not Sp, and directly-recursive frames share Pc but never Sp.

dylan-conway
dylan-conway previously approved these changes Jul 23, 2026
…n block

The windows-sys-link-cfg source lint from #35084 requires every #[link]
in bun_windows_sys to use the cfg_attr form. The block is already
#[cfg(windows)] (CONTEXT only exists there), so the cfg_attr is redundant
but matches the enforced pattern.
@dylan-conway
dylan-conway enabled auto-merge (squash) July 23, 2026 04:25

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

No new findings on the post-rebase revision. All 11 prior review threads are addressed; the span-based test assertion (c365892) and the backtrace-rs (pc==prev && sp==prev) end-of-chain check (3d160d8) both look correct. Deferring the final call to a human — this is hand-rolled Windows unwinding FFI (CONTEXT layouts, RtlVirtualUnwind loop) in the crash handler path, and while it's been verified on both x64 and ARM64, it warrants maintainer sign-off.

What was reviewed

  • CONTEXT struct layouts pinned by static size_of/offset_of asserts against winnt.h values for both arches
  • x64 leaf deref now guarded by 8-alignment + tightened is_valid_memory (MEM_COMMIT + readable + !GUARD/!NOACCESS)
  • Termination check matches backtrace-rs; n < out.len() remains the hard cap
  • Rebase merge (abort/trap entries, cfg_attr(windows, link), ENTRIES.len() capacity hint) — checked each conflict site
Extended reasoning...

Overview

Rewrites the Windows branch of capture_from_context in src/bun_core/debug.rs to walk the stack via RtlLookupFunctionEntry + RtlVirtualUnwind seeded from the fault CONTEXT, replacing the previous RtlCaptureStackBackTrace-from-inside-the-handler + tolerance-trim approach. Adds CONTEXT (x64 + ARM64), RUNTIME_FUNCTION, M128A, PAGE_* constants, and the two Rtl externs to src/windows_sys/externs.rs. Passes ContextRecord through TraceSeed::Fault in src/crash_handler/lib.rs. Adds a segfaultInDll test hook that faults inside ntdll!RtlFillMemory, plus a Windows-only debug-build test asserting frames 1..7 span < 2 GiB (i.e. all in bun.exe, not interleaved with ntdll dispatch frames). Tightens is_valid_memory on Windows to require MEM_COMMIT + readable protection + no PAGE_GUARD/PAGE_NOACCESS.

Security risks

None identified. This is diagnostic-only code that runs after a fatal fault, on the way to process termination. The one raw deref (*(ctx.Rsp as *const u64) in the x64 leaf path) is now guarded by alignment + VirtualQuery commit/protection checks, and a nested fault is bounded by PANIC_STAGE to a degraded abort rather than a hang or corruption. The new FFI struct layouts are pinned by compile-time size_of/offset_of assertions matching winnt.h, so an ABI mismatch would fail the build rather than corrupt memory at runtime.

Level of scrutiny

High. The crash handler is exactly the code that must not itself crash, and this replaces its Windows stack-walk with hand-rolled FFI against undocumented-in-parts ntdll surface. That said, the approach mirrors CoreCLR / backtrace-rs (per the prior-art table posted in-thread), the struct layouts have static assertions, and the author manually verified on Windows Server 2019 x64 and Windows 11 ARM64 with before/after symbolized traces. CI passed on both Windows lanes. This has been through five automated review rounds with 11 findings all addressed.

Other factors

  • Two maintainers (Jarred-Sumner, dylan-conway) have engaged but neither has approved yet; dylan-conway's last action was requesting a rebase, which was done.
  • Since my last inline finding (08:01Z on 07-22), the changes were: test assertion switched to a 2 GiB span check (addressing my HEASLR-straddle nit correctly — my suggested >> 36n was wrong and the author explained why), the end-of-chain check adopted backtrace-rs's (pc==prev && sp==prev) (safer than pc-only for ARM64), a mechanical rebase over #35084/#34771, and a lint fix.
  • The new test is isWindows && isDebug-gated so it only runs on the two Windows CI lanes; on Server 2019 the old code also passed, so it's a regression guard for the new path rather than a fails-before proof (the fails-before evidence is Sentry data + the ARM64 manual run in-thread).
  • Overlaps with #32871 and #34814 at the same call sites; whichever lands second needs a small rebase.

Not approving because this is not a simple/mechanical change — it's low-level platform FFI in a must-not-fail path — but I have no outstanding concerns.

@robobun

robobun commented Jul 23, 2026

Copy link
Copy Markdown
Collaborator Author

Build 78416: Source lints pass. All 16 Windows test shards (x64 + aarch64) passed; run-crash-handler.test.ts green on both arches. Remaining failures are all Linux/macOS and unrelated to this cfg(windows) diff:

  • worker-transfer-terminate-stress.test.ts (debian x64-asan SIGABRT, main break)
  • test-fs-promises-file-handle-readFile.js (debian x64, main break, 3rd build in a row)
  • webview-chrome.test.ts [flaky], require-cache.test.ts [flaky]

Ready to merge.

@dylan-conway
dylan-conway disabled auto-merge July 23, 2026 08:18
@dylan-conway
dylan-conway merged commit 892b1da into main Jul 23, 2026
50 of 52 checks passed
@dylan-conway
dylan-conway deleted the farm/c335f089/windows-fault-context-unwind branch July 23, 2026 08:18
robobun added a commit that referenced this pull request Jul 23, 2026
…C unwind info

Replaces the previous SCOPE_TABLE walk heuristic with a deterministic design:

- VEH returns CONTINUE_SEARCH when the fault PC is outside bun.exe's image
  (Go's isgoexception, CoreCLR's RhpVectoredExceptionHandler). Stack overflow
  is always claimed since no foreign __except recovers from it and dispatch
  itself costs guard-page stack.
- JSC now registers RtlAddGrowableFunctionTable unwind info for its JIT pool
  (oven-sh/WebKit#315) with a language-specific SEH handler that routes to
  Bun__crashHandlerFromJSCFrame; that's the deterministic catch point for
  unguarded foreign faults under JIT frames. LLInt is not covered (Windows
  only consults static .pdata for in-module PCs; needs offlineasm .seh_*
  emission, follow-up).
- SetUnhandledExceptionFilter as the remaining backstop.
- All three handlers seed capture_from_context with the fault CONTEXT so the
  RtlVirtualUnwind walk from #35074 applies to each.
- WebKit bumped to autobuild-preview-pr-315-ed1c14e9.

Four Windows tests: IsBadReadPtr survives (SEH-guarded probe), RtlFillMemory
crash-reports (unguarded), RtlLookupFunctionEntry resolves a JIT PC (validates
the hand-encoded unwind bytes), JIT-warm then FFI fault after clearing UEF
still reports (isolates the JSC handler).

The CRYPTSP 0xE8 sentinel + NULL-hProv analysis and V8/SpiderMonkey/
python-etwtrace prior art are in oven-sh/WebKit#315.
dylan-conway pushed a commit that referenced this pull request Jul 23, 2026
…C unwind info (#35083)

## What

Bun's Vectored Exception Handler intercepts every first-chance access
violation process-wide and treats it as fatal. Windows system code and
injected third-party DLLs (AV/EDR hooks, BeyondTrust PGHook.dll,
virtualization guest tools) deliberately raise AVs inside
`__try`/`__except` as part of normal operation; VEH runs before SEH, so
Bun kills the process for what the callee was about to recover from.

Sentry groups [BUN-3PJM](https://bun-p9.sentry.io/issues/7573578898/),
[BUN-2V6E](https://bun-p9.sentry.io/issues/7403380795/),
[BUN-3K05](https://bun-p9.sentry.io/issues/7559009286/),
[BUN-3K2N](https://bun-p9.sentry.io/issues/7559259017/) are all this one
crash (~18.8k events, 1,299 machines): BeyondTrust's `PGHook.dll` hooks
`MoveFileExW`, passes a `NULL` `HCRYPTPROV` to `CryptCreateHash`,
`CRYPTSP.dll` validates via `cmp [rcx+0E8h], 11111111h` under SEH, and
Bun's VEH reports the `0xE8` probe as a segfault.

Fixes #34055, #30327, #24394, #20816, #32403, #11898, #10056.

## Fix

Three handlers, each deterministic, no heuristic parsing:

1. **VEH** (`handle_segfault_windows`): only claim the fault when
`ExceptionAddress` is inside `bun.exe`'s own image. Otherwise return
`CONTINUE_SEARCH` so frame-based SEH can run. Matches Go's
[`isgoexception`](https://github.com/golang/go/blob/master/src/runtime/signal_windows.go)
and CoreCLR NativeAOT's
[`RhpVectoredExceptionHandler`](https://github.com/dotnet/runtime/blob/main/src/coreclr/nativeaot/Runtime/EHHelpers.cpp).
Stack overflow is always claimed here: no foreign `__except` recovers
from it in practice, and SEH dispatch itself costs guard-page stack.

2. **JSC SEH handler** (`Bun__crashHandlerFromJSCFrame`, via
`JSC::setJITExceptionHandlerWin`): JSC now registers
`RtlAddGrowableFunctionTable` unwind info for its fixed JIT pool
([oven-sh/WebKit#315](oven-sh/WebKit#315)), with
a language-specific handler. When SEH dispatch reaches a JIT frame with
an unhandled fault, that handler calls this function, which
crash-reports. This is the deterministic catch point for unguarded
faults under JIT frames, on real Windows and on Wine. LLInt is not yet
covered: it lives in image `.text` and Windows only consults static
`.pdata` for in-module PCs, so covering it needs build-time `.seh_*`
emission in offlineasm (follow-up; see the comment in
`ExecutableAllocator.cpp`).

3. **UEF** (`handle_unhandled_exception_windows`, via
`SetUnhandledExceptionFilter`): backstop for anything no SEH handler
claimed and no JIT frame caught.

All three seed `capture_from_context` with the fault `CONTEXT`, so
#35074's `RtlVirtualUnwind` walk applies to each.

## Verification

Repro (canary `5b98630ac`, Server 2019):
```sh
bun -e "require('bun:ffi').dlopen('kernel32.dll',{IsBadReadPtr:{args:['usize','usize'],returns:'i32'}}).symbols.IsBadReadPtr(0xE8,8)"
```
Before: `panic(main thread): Segmentation fault at address 0xE8`. After:
exits 0.

Four Windows tests in `run-crash-handler.test.ts`:
- `IsBadReadPtr(0xE8, 8)` survives (SEH-guarded probe)
- `RtlFillMemory(0xE8, 8, 0)` still crash-reports (unguarded)
- `RtlLookupFunctionEntry` returns non-null for a JIT pool PC (validates
the WebKit-side unwind-info registration)
- JIT-warm a function (`jitPolicyScale=0`) then
`SetUnhandledExceptionFilter(0)` and fault via FFI from inside it; crash
is still reported, isolating `jscJITSEHHandler` as the catch point

## Prior art

V8
[`RegisterNonABICompliantCodeRange`](https://github.com/v8/v8/blob/main/src/diagnostics/unwinding-info-win64.cc),
SpiderMonkey
[`RegisterExecutableMemory`](https://searchfox.org/firefox-main/source/js/src/jit/ProcessExecutableMemory.cpp),
[microsoft/python-etwtrace](https://github.com/microsoft/python-etwtrace/blob/main/src/etwtrace/_etwtrace.c),
and Steve Dower's guidance in
[python/cpython#126910](python/cpython#126910)
("`RtlAddGrowableFunctionTable` is actually the only one that works")
all converge on this design. Go issue
[golang/go#56082](golang/go#56082) describes
the exact VEH-vs-SEH failure class.

---------

Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com>
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.

3 participants