Skip to content

crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info - #35083

Merged
dylan-conway merged 3 commits into
mainfrom
farm/4e61abff/veh-skip-external-modules
Jul 23, 2026
Merged

crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info#35083
dylan-conway merged 3 commits into
mainfrom
farm/4e61abff/veh-skip-external-modules

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

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, BUN-2V6E, BUN-3K05, BUN-3K2N 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 and CoreCLR NativeAOT's RhpVectoredExceptionHandler. 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), 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):

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, SpiderMonkey RegisterExecutableMemory, microsoft/python-etwtrace, and Steve Dower's guidance in python/cpython#126910 ("RtlAddGrowableFunctionTable is actually the only one that works") all converge on this design. Go issue golang/go#56082 describes the exact VEH-vs-SEH failure class.

@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 4 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: d4baa2ae-1440-4e8c-bebf-8ada83169c26

📥 Commits

Reviewing files that changed from the base of the PR and between 85161d3 and 76c80a8.

📒 Files selected for processing (8)
  • scripts/build/deps/webkit.ts
  • src/bun_core/Global.rs
  • src/crash_handler/lib.rs
  • src/jsc/bindings/JSCTestingHelpers.cpp
  • src/jsc/bindings/ZigGlobalObject.cpp
  • src/sys/windows/mod.rs
  • src/windows_sys/externs.rs
  • test/cli/run/run-crash-handler.test.ts

Walkthrough

Changes

WebKit revision

Layer / File(s) Summary
WebKit revision update
scripts/build/deps/webkit.ts
Updates WEBKIT_VERSION to a new autobuild preview tag used in prebuilt artifact URL, destination, and identity calculations.

Windows crash handling

Layer / File(s) Summary
Windows exception APIs and image range
src/windows_sys/externs.rs, src/sys/windows/mod.rs
Adds Win32 exception-filter declarations and executable PE image-range detection.
Windows handler installation and classification
src/crash_handler/lib.rs, src/bun_core/Global.rs
Installs and clears the unhandled-exception filter, classifies Windows exceptions, filters VEH handling by executable range, and reports recognized faults.
JSC fault integration and validation
src/jsc/bindings/ZigGlobalObject.cpp, src/jsc/bindings/JSCTestingHelpers.cpp, test/cli/run/run-crash-handler.test.ts
Connects JSC JIT faults to the crash handler, exposes runtime address helpers, and adds Windows crash-handling tests.

Possibly related PRs

  • oven-sh/bun#34373: Updates the same WebKit version constant and prebuilt artifact selection path.
  • oven-sh/bun#34814: Modifies adjacent Windows crash-handler entry points and fault payload handling.
  • oven-sh/bun#35074: Overlaps with the Windows VEH fault classification and recording path.

Suggested reviewers: dylan-conway, 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 is specific and matches the Windows crash-handler change to let foreign AVs reach SEH.
Description check ✅ Passed The description covers the PR's purpose and verification, with all required information present despite different headings.

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

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 2:21 PM PT - Jul 23rd, 2026

@robobun, your commit 76c80a8 has 2 failures in Build #78875 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 35083

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

bun-35083 --bun

@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

Reproduced on Windows x64 (Server 2019):

bun -e "require('bun:ffi').dlopen('kernel32.dll',{IsBadReadPtr:{args:['usize','usize'],returns:'i32'}}).symbols.IsBadReadPtr(0xE8,8)"

canary 5b98630ac: panic(main thread): Segmentation fault at address 0xE8, exit 3
this branch: prints SURVIVED rc=1, exit 0

The unguarded counterpart (ntdll!RtlFillMemory(0xE8, 8, 0)) still crash-reports on this branch, confirming napi/bun:ffi crash coverage is unchanged. Both cases are in the new tests.

test/cli/run/run-crash-handler.test.ts on Windows: 6 pass / 0 fail. Linux: 9 pass / 3 skip / 0 fail. rust:check-all clean across all six targets. Intel SDE verify-baseline behaves the same as main.

The new tests are describe.if(isWindows), so the Linux gate sees them skip on both the stashed and unstashed builds; the fail-before proof lives in the Windows CI lane.

All review threads resolved. The diff is entirely #[cfg(windows)] Rust plus a Windows-only test; all Windows CI lanes are green. CI red on build 77686 is unrelated POSIX-lane flake: no-orphans.test.ts (darwin, pre-existing on main), watch-many-dirs/test-gc-http-client-timeout/webview-chrome/in-process-cron (Linux, all passed on retry). Ready for a maintainer look.

@github-actions

Copy link
Copy Markdown
Contributor

Found 5 issues this PR may fix:

  1. Segfault at 0x3C — nProtect GameGuard (npggNT64.des) injection kills any Bun process on Windows 11 #34055 - nProtect GameGuard (npggNT64.des) injects via SetWindowsHookEx; its first-chance SEH exception is intercepted by Bun's VEH and treated as fatal
  2. Segfault when Sandboxie SbieDll.dll is injected into Bun process #30327 - Sandboxie's SbieDll.dll hooks syscalls via injection; Bun's VEH catches its internal first-chance exception as a crash
  3. Segmentation Fault when running K7 TotalSecurity AV on Windows #24394 - K7 TotalSecurity AV injects K7CrvrEx64.dll; all top crash frames are in the AV DLL, not Bun
  4. Segmentation Fault when running Digital Guardian AV on Windows #20816 - Digital Guardian AV injects dgapi64.dll; the VEH intercepts its own first-chance exception and treats it as fatal
  5. Claude code desktop crush #32403 - Trend Micro injects TmUmEvt64.dll and tmmon64.dll; the VEH catches Trend Micro's first-chance exception as a Bun crash

If this is helpful, copy the block below into the PR description to auto-close these issues on merge.

Fixes #34055
Fixes #30327
Fixes #24394
Fixes #20816
Fixes #32403

🤖 Generated with Claude Code

Comment thread src/crash_handler/lib.rs 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.

The UEF backstop in 8cbdcfb addresses the napi/ffi coverage gap from the earlier review — thanks. Beyond the inline findings, I also checked whether GetModuleHandleExW(FROM_ADDRESS) on the VEH continue-path can deadlock under the loader lock — it takes the PEB loader lock read-side but the continue path only runs for out-of-image PCs and matches the existing StackLine::from_address usage, so ruled out.

Extended reasoning...

The author pushed commit 8cbdcfb ("wip: add UEF backstop") in response to the earlier review, adding SetUnhandledExceptionFilter(handle_unhandled_windows) so unguarded external-DLL faults still reach crash_handler on second-chance dispatch. That closes the regression I flagged. The two remaining inline findings (UEF has no test; sibling teardown in raise_ignoring_panic_handler_raw not updated) are new to this revision and posted separately. A finder also raised a potential loader-lock deadlock from calling GetModuleHandleExW inside the VEH; verifiers ruled it out — the call only fires on the out-of-image continue path and mirrors the pre-existing symbolication call pattern. Not approving: crash-handler code, "wip:" commit prefix, and REVIEW.md requires the load-bearing UEF clause to break at least one test when deleted.

Comment thread src/crash_handler/lib.rs Outdated
Comment thread src/crash_handler/lib.rs
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/windows_sys/externs.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
@robobun
robobun force-pushed the farm/4e61abff/veh-skip-external-modules branch from 2469a92 to 6c6ef82 Compare July 22, 2026 07:16
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
Comment thread src/sys/windows/mod.rs Outdated
@robobun

robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator Author

I arrived at the same root cause independently while investigating BUN-3PJM / BUN-2V6E / BUN-3K05 (same crash, different stack depths; ~18.8k events total, 1,299 unique machines). A few additions that might be useful for the description or review:

PGHook.dll identity. It is BeyondTrust Privilege Management for Windows (formerly Avecto Defendpoint / Privilege Guard), not Parallels. Confirmed via a module list in FarGroup/FarManager#1115:

C:\Program Files\Avecto\Privilege Guard Client\PGHook.dll | BeyondTrust Privilege Management Hook | 21.5.106.0

Same DLL shows up in adoptium/adoptium-support#429 and git-for-windows/git#4830. The fix is identical either way.

Why 0xE8 specifically. From dumpbin /disasm cryptsp.dll, the internal HCRYPTPROV struct has a magic sentinel 0x11111111 at offset +0xE8 (and a refcount at +0xF0). Every Crypt* export begins its handle validation with:

cmp  dword ptr [rcx+0E8h], 11111111h   ; no NULL check first

dumpbin /unwindinfo cryptsp.dll shows EHANDLER on every one of these functions. PGHook.dll calls CryptAcquireContext, it fails, hProv stays NULL, and the unchecked NULL goes straight to CryptCreateHash/CryptReleaseContext. Without a VEH in the way the call just returns FALSE / ERROR_INVALID_PARAMETER.

Standalone repro (no BeyondTrust required), verified on Server 2019:

AddVectoredExceptionHandler(0, veh);      // veh records then returns CONTINUE_SEARCH
CryptCreateHash((HCRYPTPROV)0, CALG_SHA_256, 0, 0, &hh);
// -> veh observes AV at 0xE8, SEH recovers, call returns FALSE gle=87

Same call with a VEH that treats AV as fatal (current bun) kills the process at 0xE8. Fail-before on bun 1.4.0-canary.1 (5b98630ac) via bun:ffi:

before
panic(main thread): Segmentation fault at address 0xE8
 /1.4.0/ea25b98630gCokggigDCWcryptsp.dllo+PCWcryptsp.dll4wN_A2AwO

Prior art for the PC-in-own-image check:

  • Go isgoexception: "Only handle exception if executing instructions in Go binary (not Windows library code)" then CONTINUE_SEARCH. Shipping since ~2015.
  • CoreCLR NativeAOT RhpVectoredExceptionHandler: PalGetModuleBounds(hRuntimeModule, &lower, &upper) then only fatal inside.
  • Crashpad uses SetUnhandledExceptionFilter as primary; VEH only for STATUS_HEAP_CORRUPTION.
  • Rust std's VEH always returns CONTINUE_SEARCH.
  • golang/go#56082 describes this exact failure class.

I had a simpler variant (bounds-only check + UEF backstop) on farm/5242eb28/veh-continue-search-foreign-av; the .pdata SCOPE_TABLE walk here is strictly better since it keeps unguarded napi/ffi crashes fatal and sidesteps the JIT-frame UEF reachability problem. Standing down in favor of this PR.

@robobun
robobun force-pushed the farm/4e61abff/veh-skip-external-modules branch from 7dbe576 to 16e9b0b Compare July 23, 2026 05:09
@robobun robobun changed the title crash_handler(windows): let external-DLL first-chance exceptions reach SEH crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info Jul 23, 2026
Comment thread src/crash_handler/lib.rs
Comment thread src/crash_handler/lib.rs
Comment thread scripts/build/deps/webkit.ts Outdated

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

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
src/crash_handler/lib.rs (1)

2067-2074: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Guard ExceptionInformation[1] behind NumberParameters >= 2.

EXCEPTION_ACCESS_VIOLATION only defines ExceptionInformation[1] when at least two parameters are present; malformed or software-raised records can leave it undefined and misclassify the crash.

Suggested change
            bun_sys::windows::EXCEPTION_ACCESS_VIOLATION => {
+                if record.NumberParameters < 2 {
+                    return None;
+                }
                 CrashReason::SegmentationFault(record.ExceptionInformation[1])
             }
🤖 Prompt for 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.

In `@src/crash_handler/lib.rs` around lines 2067 - 2074, In
classify_exception_windows, guard access to record.ExceptionInformation[1] for
EXCEPTION_ACCESS_VIOLATION with record.NumberParameters >= 2; otherwise avoid
constructing SegmentationFault from undefined data and preserve the existing
fallback classification behavior.

Source: Coding guidelines

🤖 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.

Outside diff comments:
In `@src/crash_handler/lib.rs`:
- Around line 2067-2074: In classify_exception_windows, guard access to
record.ExceptionInformation[1] for EXCEPTION_ACCESS_VIOLATION with
record.NumberParameters >= 2; otherwise avoid constructing SegmentationFault
from undefined data and preserve the existing fallback classification behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 760ee263-9380-4622-92bd-b66dc266876b

📥 Commits

Reviewing files that changed from the base of the PR and between a5cac60 and 85161d3.

📒 Files selected for processing (2)
  • src/crash_handler/lib.rs
  • src/sys/windows/mod.rs
💤 Files with no reviewable changes (1)
  • src/sys/windows/mod.rs

Comment thread src/crash_handler/lib.rs Outdated
Comment thread src/crash_handler/lib.rs Outdated
Comment thread src/crash_handler/lib.rs Outdated
Comment thread src/crash_handler/lib.rs
Comment thread src/crash_handler/lib.rs
Comment thread src/jsc/bindings/ZigGlobalObject.cpp

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

Confirmed afab2b0 correctly addresses all six prior nits: the disposition::ExceptionContinueSearch split, .take(24) scan depth, EXCEPTION_UNWIND guard + WINDOWS_SEGFAULT_HANDLE.is_null() teardown gate in Bun__crashHandlerFromJSCFrame, the StackOverflow exemption from the out-of-image gate, and the JIT-frame test now clearing UEF via FFI to isolate the JSC handler. Two remaining nits inline; still needs a human look given the cross-repo dependency (WEBKIT_VERSION is on a preview autobuild tag pending WebKit#315 merge).

Extended reasoning...

afab2b0 is a fix-up commit responding to my prior review round. I walked each of the six earlier findings against the current diff: the bun_sys::windows::disposition module with the two-enum warning comment is present and used; capture_from_context scans 24 frames with the updated comment naming the UEF path; Bun__crashHandlerFromJSCFrame now gates on both EXCEPTION_UNWIND and the null VEH handle; the VEH's out-of-image check exempts CrashReason::StackOverflow; and the fourth Windows test calls SetUnhandledExceptionFilter(0) before faulting so it fails if setJITExceptionHandlerWin is deleted. All landed as suggested. The two new inline findings this run are cosmetic (a missing ThrowScope in Windows-only test-helper C++, and four comments still claiming LLInt unwind-info coverage that commit 8f0c63e's own message says was dropped). Not approving: this is process-wide Windows exception handling with a paired oven-sh/WebKit change still on a preview tag.

Comment thread src/jsc/bindings/JSCTestingHelpers.cpp
Comment thread scripts/build/deps/webkit.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 this pass — the last two rounds of nits (afab2b0, 707846d) landed cleanly. Deferring to a human for the merge decision: this is a redesign of the Windows crash-handling architecture (VEH → JSC SEH → UEF) coupled to oven-sh/WebKit#315, and WEBKIT_VERSION still points at the preview tag autobuild-preview-pr-315-ed1c14e9, which per the earlier thread needs bumping to the merged main-branch hash before landing.

What was reviewed:

  • VEH out-of-image gate, EXCEPTION_DISPOSITION vs filter-constant enum split, unwind-phase / teardown guards in Bun__crashHandlerFromJSCFrame.
  • exe_image_range() PE-header offsets (e_lfanew at 0x3C, SizeOfImage at NT+4+20+56) — match winnt.h for IMAGE_NT_HEADERS64.
  • Throw-scope discipline in jsFunctionStartOfFixedExecutableMemoryPool; LLInt comment drift across the four sites.
  • The four Windows tests: subprocess-drain pattern, SetUnhandledExceptionFilter(0) isolation of the JSC handler, and RtlLookupFunctionEntry smoke test.
Extended reasoning...

Overview

This PR reworks Bun's Windows crash handler from a single greedy Vectored Exception Handler into three cooperating handlers: (1) a VEH that only claims faults whose PC is inside bun.exe's own image, (2) a language-specific SEH handler wired into JSC's JIT-pool unwind info via the companion oven-sh/WebKit#315, and (3) a SetUnhandledExceptionFilter backstop. The goal is to stop Bun's VEH from killing the process on first-chance access violations that injected DLLs (BeyondTrust PGHook, CRYPTSP handle probes, EDR agents) raise-and-recover under their own __try/__except. Nine files touched: crash_handler/lib.rs (core logic), sys/windows/mod.rs and windows_sys/externs.rs (new Win32 surface: exe_image_range, SetUnhandledExceptionFilter, GetModuleHandleW, EXCEPTION_DISPOSITION constants), bun_core/Global.rs and debug.rs (teardown symmetry, deeper dispatcher-frame trim), ZigGlobalObject.cpp (setJITExceptionHandlerWin wiring), JSCTestingHelpers.cpp (test hook), webkit.ts (version bump), and four new Windows tests.

Security risks

Low. The change narrows which first-chance exceptions Bun claims; the concern is observability regression (missing a crash report), not privilege escalation or data exposure. exe_image_range() reads the process's own mapped PE header at fixed offsets — not attacker-controlled input. The EXCEPTION_UNWIND and post-teardown guards in Bun__crashHandlerFromJSCFrame prevent re-entry during unwind/teardown.

Level of scrutiny

High. Crash handling is process-wide, runs at fault time on a possibly-corrupted stack, and errors here either silently drop crash telemetry or spuriously kill user processes. The design went through ~12 iterations on this thread, pivoting from a heuristic SCOPE_TABLE-walking predicate to the deterministic image-range + JSC-unwind-info approach. That evolution is well-reasoned and matches Go/CoreCLR/V8/SpiderMonkey prior art, but it's exactly the kind of architectural decision a maintainer should sign off on.

Other factors

  • Cross-repo dependency not finalized: WEBKIT_VERSION = "autobuild-preview-pr-315-ed1c14e9" is a preview-PR artifact tag. The author acknowledged (2026-07-23T05:39:43Z) it will be bumped to the merged commit hash before landing — that hasn't happened yet, so a fresh clone would fetch an ephemeral release. This alone blocks auto-approval.
  • Windows-only tests: all four new tests are describe.if(isWindows); the PR evidence marker says they didn't run locally and defer to CI. Windows CI results should be green before merge.
  • Prior review rounds: I raised ~20 inline findings across earlier revisions; every one is now marked resolved and the fixes are visible in the current diff (stack-overflow exemption, ExceptionContinueSearch named constant, unwind-phase guard, teardown-null gate, .take(24) trim depth, throw scope on the BigInt helper, LLInt comment sync). The bug-hunting system found nothing new this run.

…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.
@robobun
robobun force-pushed the farm/4e61abff/veh-skip-external-modules branch from 707846d to 4566938 Compare July 23, 2026 19:56
Comment on lines +167 to +179
// The Windows crash handler is a Vectored Exception Handler, which sees every
// first-chance exception process-wide before frame-based SEH does. Third-party
// DLLs injected into the process (AV/EDR agents such as BeyondTrust's
// PGHook.dll, virtualization guest tools, shell extensions) routinely raise
// and then handle access violations under SEH as part of normal operation.
// The VEH must let those through rather than treating them as a fatal crash.
// `IsBadReadPtr` is the canonical example: it probes its argument inside a
// `__try`/`__except` in kernel32, so the AV it raises is inside a system DLL
// and is immediately swallowed by that DLL's own SEH.
//
// See https://github.com/oven-sh/bun/issues/10056 (Carbon Black),
// https://github.com/oven-sh/bun/issues/11898 (Trend Micro).
describe.if(isWindows)("Windows VEH handler and first-chance faults in external DLLs", () => {

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.

🟡 🟡 nit (comment accuracy — same "update every consumer atomically" class as the four sites fixed in 707846d; this is a fifth sibling that sweep missed): the pre-existing test immediately above at :130-165 (segfault inside a system DLL captures the bun callers, added by #35074 / 892b1da) faults via RtlFillMemory(0xDEADBEEF, ...) with the PC in ntdll.dll, and its comment at :130-135 states "the VEH handler must walk the stack from the fault CONTEXT record" — but this PR's new out-of-image gate (lib.rs:2122-2130) makes the VEH return CONTINUE_SEARCH for exactly this fault; the CONTEXT walk now happens from Bun__crashHandlerFromJSCFrame/UEF instead. The test's assertions still hold (all three entry points feed the same fault CONTEXT to capture_from_context), so this is prose-only. Suggest rewording :130-135 to name the actual entry point, e.g. "the crash handler must walk the stack from the fault CONTEXT record (RtlVirtualUnwind) — post-#35083 the VEH declines this ntdll-PC fault and dispatch reaches Bun__crashHandlerFromJSCFrame/UEF, which seed the same walk". Separately, no remaining test exercises the VEH's own CONTEXT-walk path for an in-image fault — worth a follow-up if that property matters independently.

Extended reasoning...

What this is

The pre-existing test test.if(isWindows && isDebug)("Windows: segfault inside a system DLL captures the bun callers") at run-crash-handler.test.ts:136-165 was added by the immediately-preceding PR #35074 / commit 892b1da. It faults via js_segfault_in_dll (src/runtime/api/crash_handler_jsc.rs:103-118) → RtlFillMemory(0xDEADBEEF, ...), so the faulting instruction is inside ntdll.dll. Its explanatory comment at :130-135 states the property under test:

Windows: the VEH handler must walk the stack from the fault CONTEXT record (RtlVirtualUnwind), not from inside the handler. When the fault is in an external DLL the old RtlCaptureStackBackTrace path could stop at KiUserExceptionDispatcher …

This PR changes handle_segfault_windows so that when ExceptionAddress is outside bun.exe's image range (and the reason isn't StackOverflow), the VEH returns EXCEPTION_CONTINUE_SEARCH (src/crash_handler/lib.rs:2122-2130). An ntdll PC is outside bun.exe's image, so post-PR the VEH declines this test's fault. The crash instead routes via SEH dispatch → JSC's jscJITSEHHandlerBun__crashHandlerFromJSCFrame, or falls through to handle_unhandled_exception_windows (UEF). The comment now names the wrong handler.

Why this is the same class as 707846d

Commit 707846d in this PR ("Addressed" the earlier claude review at 2026-07-23T09:15) swept four stale "LLInt range" mentions across webkit.ts, lib.rs, ZigGlobalObject.cpp, and this test file after commit 8f0c63e changed what WebKit#315 registers. That was exactly REVIEW.md's "When changing output/defaults/messages, grep the suite for assertions on the old behavior and update them in the same PR" / "one source of truth; update every consumer atomically". The :130-135 comment is a fifth site describing pre-PR routing that the same sweep should have caught — it just lives immediately above the diff hunk rather than inside it, so it's easy to miss.

Step-by-step: routing before vs after

Pre-PR (892b1da, when the comment was written):

  1. js_segfault_in_dllRtlFillMemory(0xDEADBEEF, 8, 0) faults at a rep stosb in ntdll.
  2. KiUserExceptionDispatcherRtlDispatchExceptionRtlpCallVectoredHandlershandle_segfault_windows.
  3. VEH matches EXCEPTION_ACCESS_VIOLATION, calls crash_handler(SegmentationFault(0xDEADBEEF), TraceSeed::Fault { pc, fp: info.ContextRecord }).
  4. capture_from_context walks via RtlVirtualUnwind from the fault CONTEXT → the test's ≥7-frame / span-<2³¹ assertions pass.

Post-PR (this change):

  1. Same fault, same KiUserExceptionDispatcher → VEH.
  2. handle_segfault_windows computes pc = record.ExceptionAddress (in ntdll), loads WINDOWS_EXE_IMAGE_BASE..END, sees !(base..end).contains(&pc), returns CONTINUE_SEARCH (lib.rs:2127-2130). VEH does not walk anything.
  3. RtlDispatchException proceeds to frame-based dispatch. The FFI trampoline / native-call thunk sits in JSC's fixed executable memory pool, whose UNWIND_INFO (WebKit#315) names jscJITSEHHandler as language-specific handler → Bun__crashHandlerFromJSCFrame(record, _, context, _) (lib.rs:2146-2179).
  4. That handler calls crash_handler(SegmentationFault(0xDEADBEEF), TraceSeed::Fault { pc, fp: context as usize })same TraceSeed::Fault shape, and per MSDN the PEXCEPTION_ROUTINE's ContextRecord during search phase is the original fault CONTEXT.
  5. capture_from_context walks the same fault CONTEXT via RtlVirtualUnwind → the test's assertions still pass, for the same underlying reason.

So the property the test validates (fault-CONTEXT-seeded RtlVirtualUnwind walk recovers bun callers) is still exercised — just via a different entry point than the comment names.

Why the assertions are unaffected (why this is nit, not normal)

All three Windows entry points converge on identical inputs to the same sink:

  • VEH (lib.rs:2136): TraceSeed::Fault { pc, fp: info.ContextRecord as usize }
  • Bun__crashHandlerFromJSCFrame (lib.rs:2177): TraceSeed::Fault { pc, fp: context as usize }
  • UEF (lib.rs:2197): TraceSeed::Fault { pc, fp: info.ContextRecord as usize }

and capture_from_context treats fp as *const CONTEXT and RtlVirtualUnwind-walks from it regardless of caller. The test's frame-count (≥7) and address-span (<2³¹) assertions measure the output of that walk, not which handler invoked it. So no assertion is stale — only the prose.

The original bug report also raised a speculative "test may fail if SEH dispatch derails at LLInt frames lacking .pdata". That concern applies equally to this PR's own new unguarded fault still crash-reports and unguarded fault from inside a JIT-compiled frame tests (same FFI→ntdll→SEH-dispatch shape), and host-function calls go through a JIT-pool native-call thunk that WebKit#315's growable table covers, so jscJITSEHHandler should fire before dispatch reaches any .pdata-less LLInt asm frame. The PR body explicitly defers Windows verification to CI, which will catch all of these together if that assumption is wrong.

Secondary observation (coverage gap, not a bug)

Post-PR, the only case where the VEH itself seeds capture_from_context from the fault CONTEXT is an in-image fault (PC inside bun.exe). This test no longer exercises that path, and no other test does — the segfault fixture faults inside bun.exe but doesn't assert trace quality on Windows. If validating the VEH-specific CONTEXT-walk property matters independently of the JSC-SEH/UEF paths, an in-image variant (or repointing js_segfault_in_dll at an in-image address) would restore it. This is optional follow-up, not a defect.

Suggested fix

Update the comment at :130-135 to something like:

// Windows: the crash handler must walk the stack from the fault CONTEXT
// record (RtlVirtualUnwind), not from inside the handler. When the fault is
// in an external DLL the old RtlCaptureStackBackTrace path could stop at
// KiUserExceptionDispatcher on some Windows versions, leaving only the
// handler's own frames. Post-#35083 the VEH declines out-of-image PCs; this
// fault reaches capture_from_context via Bun__crashHandlerFromJSCFrame/UEF,
// which seed the same fault-CONTEXT walk.

Comment on lines +179 to +180
describe.if(isWindows)("Windows VEH handler and first-chance faults in external DLLs", () => {
test("SEH-guarded probe survives", async () => {

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.

🟡 These four tests each spawn an independent Bun subprocess with no shared state, so they should use test.concurrent(...) instead of plain test(...) — REVIEW.md's "Tests reviewers reject" section calls for "test.concurrent for independent subprocess suites", and this file already applies that convention in the SIGABRT/SIGTRAP describe below (test.concurrent.each / test.skipIf(isASAN).concurrent.each). The JIT-warmup test in particular runs 10000 iterations under debug JSC with useConcurrentJIT: 0, so serial execution adds noticeable wall-clock on the Windows lane.

Extended reasoning...

What this is

The four new tests inside describe.if(isWindows)("Windows VEH handler and first-chance faults in external DLLs", ...) at test/cli/run/run-crash-handler.test.ts:179 each spawn an independent Bun subprocess and use plain test(...):

  1. "SEH-guarded probe survives" — spawns bun -e with an IsBadReadPtr FFI probe
  2. "unguarded fault still crash-reports" — spawns bun -e with RtlFillMemory
  3. "RtlLookupFunctionEntry resolves JSC JIT pool PCs" — spawns bun -e calling jscInternals.startOfFixedExecutableMemoryPool() + FFI
  4. "unguarded fault from inside a JIT-compiled frame ..." — spawns bun -e with a 10000-iteration warmup loop under BUN_JSC_jitPolicyScale=0 / BUN_JSC_useConcurrentJIT=0

Each is a self-contained await using proc = Bun.spawn(...) + Promise.all([stdout.text(), stderr.text(), exited]) on its own pipes, spreading the shared noReportEnv per call. There is no shared server, port, temp directory, or mutable module-level state between them.

Why the repo convention applies

REVIEW.md's "Tests reviewers reject" → "Copy harness conventions exactly" section says:

test.concurrent for independent subprocess suites … Keep tests fast (~1s per test; debug+ASAN runs 10-100x slower); a new file over ~10s on the default lane gets scrutinized for test.concurrent and staying serial needs a stated reason. A correct but slow test still gets changes-requested.

And this file already applies that convention locally: the describe.if(isPosix)("SIGABRT/SIGTRAP are caught by the crash handler", ...) block immediately below uses test.concurrent.each(...), test.skipIf(isASAN).concurrent.each(...), and test.concurrent(...) for the identical shape (independent Bun.spawn + await, no shared state). So test.concurrent is the established local idiom for this pattern in this file, and the new block diverges from it without a stated reason.

Step-by-step: why serial execution is measurable here

  1. Test 4 sets BUN_JSC_jitPolicyScale: "0" and BUN_JSC_useConcurrentJIT: "0", then runs for (let i = 0; i < 10000; i++) hot(i) to force hot() up the JIT tiers synchronously. Under a debug JSC build (which is what the Windows debug lane runs), disabling the concurrent JIT means each tier-up compilation blocks the main thread; 10000 iterations under debug JSC is on the order of seconds, not milliseconds.
  2. Tests 1-3 each pay full process-startup cost (debug bun.exe + FFI dlopen of kernel32/ntdll + JSC init). CLAUDE.md notes debug+ASAN builds run 10-100× slower than release.
  3. Serially, the describe's wall-clock is t1 + t2 + t3 + t4. With test.concurrent, it collapses to roughly max(t1..t4)t4 (the JIT-warmup test), since all four subprocesses can run in parallel on the multi-core Windows CI runner.
  4. This describe is Windows-only, so the wall-clock lands entirely on the (already slowest) Windows CI lane.

Why this is nit-severity

Nothing is functionally wrong — the tests are correct, hermetic, and will pass. This is purely a test-hygiene / CI-wall-clock concern that the repo's own review checklist flags. Other serial subprocess blocks also exist in this file (the "terminal signal reflects the crash cause" test.each and the "automatic crash reporter" for-loop), so this is not uniquely deviant; but those are pre-existing, whereas these four are newly added in this PR alongside a sibling block that already uses .concurrent.

Suggested fix

Change the four test(...) calls to test.concurrent(...):

describe.if(isWindows)("Windows VEH handler and first-chance faults in external DLLs", () => {
  test.concurrent("SEH-guarded probe survives", async () => { ... });
  test.concurrent("unguarded fault still crash-reports", async () => { ... });
  test.concurrent("RtlLookupFunctionEntry resolves JSC JIT pool PCs", async () => { ... });
  test.concurrent("unguarded fault from inside a JIT-compiled frame ...", async () => { ... });
});

No shared state exists to make this unsafe: each test's await using proc owns its own subprocess and pipes, noReportEnv is spread per-call (test 4's extra BUN_JSC_* keys go into a fresh object), and there are no beforeEach/afterEach hooks in the describe.

@dylan-conway
dylan-conway merged commit 79ee451 into main Jul 23, 2026
7 of 34 checks passed
@dylan-conway
dylan-conway deleted the farm/4e61abff/veh-skip-external-modules branch July 23, 2026 21:51
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.

Segfault at 0x3C — nProtect GameGuard (npggNT64.des) injection kills any Bun process on Windows 11

3 participants