Skip to content

JSC(windows): register unwind info for the fixed JIT pool - #315

Merged
dylan-conway merged 8 commits into
mainfrom
robobun/win-jit-unwind-info
Jul 23, 2026
Merged

JSC(windows): register unwind info for the fixed JIT pool#315
dylan-conway merged 8 commits into
mainfrom
robobun/win-jit-unwind-info

Conversation

@robobun

@robobun robobun commented Jul 22, 2026

Copy link
Copy Markdown
Collaborator

Problem

On Windows (x64 and ARM64), JavaScriptCore's JIT code has no RUNTIME_FUNCTION entries: the JIT pool is anonymous memory, so RtlLookupFunctionEntry returns NULL for any JIT PC and frame-based SEH dispatch falls back to a leaf-frame pop that is wrong past the prologue and derails.

This means:

  • RtlCaptureStackBackTrace stops at the first JIT frame
  • SetUnhandledExceptionFilter is unreachable for any fault under a JIT frame
  • ETW/WPA and WinDbg/VS cannot unwind through JIT

Bun's crash handler currently uses a Vectored Exception Handler as a workaround, but VEH runs before frame-based SEH and so intercepts first-chance exceptions that foreign DLLs handle themselves (CRYPTSP.dll, injected AV/EDR hooks). See oven-sh/bun#35083.

Change

Register a dynamic function table with RtlAddGrowableFunctionTable covering the fixed JIT pool at JSC::initialize() time. The first page(s) of the reservation are carved out for the RUNTIME_FUNCTION array, unwind info, and a handler thunk, since all three must live at pool-relative RVAs.

The unwind info describes the uniform prologue every JIT tier emits (AssemblyHelpers::emitFunctionPrologue: push rbp; mov rbp, rsp on x64, stp fp, lr, [sp, #-16]!; mov fp, sp on ARM64), so rbp/fp-chain unwinding is valid for the whole range. CallerFrameAndPC ({callerFrame @[fp+0], returnPC @[fp+8]}) matches the native layout.

The unwind info also registers an UNW_FLAG_EHANDLER language-specific handler (jscJITSEHHandler) that calls an embedder-settable callback and defaults to ExceptionContinueSearch. Embedders that want a deterministic catch point at the JIT boundary can install one via JSC::setJITExceptionHandlerWin.

The growable API (not RtlAddFunctionTable) is used so out-of-process stack walkers see the entry too.

x64

One 12-byte RUNTIME_FUNCTION covers the whole pool. Unwind codes: UWOP_PUSH_NONVOL rbp; UWOP_SET_FPREG rbp, 0. Pool thunk: mov rax, imm64; jmp rax. Reference.

ARM64

FunctionLength is 18 bits of instruction count (~1 MB per entry), so the 512 MB pool is chunked. Full-size chunks share one .xdata; a shorter tail uses a second. Unwind codes: set_fp; save_fplr_x 1; end. Pool thunk: ldr x16, #8; br x16; .quad handler. Reference.

LLInt

LLInt and vmEntryToJavaScript are emitted by offlineasm into .text without .seh_* directives (ARM64 builds the LLInt TU with -fno-unwind-tables), so they have no .pdata of their own. A dynamic function table cannot cover them: RtlLookupFunctionEntry for a PC inside a loaded module consults only that module's static .pdata (verified on Windows 10/11 x64 and ARM64 via Bun CI). V8 solves this at build time by emitting .pdata/.xdata for its embedded builtins (platform-embedded-file-writer-win.cc); the JSC equivalent is offlineasm emitting .seh_* directives, which is a separate change. A comment pointing at that follow-up is left in ExecutableAllocator.cpp.

Prior art

V8 RegisterNonABICompliantCodeRange and SpiderMonkey RegisterExecutableMemory (Bug 1530552; Bug 1843842 "Always use RtlAddGrowableFunctionTable on Windows") do the same for their JIT code. CoreCLR codeman.cpp uses the growable API for managed code. microsoft/python-etwtrace and the guidance in python/cpython#126910 ("RtlAddGrowableFunctionTable is actually the only one that works") follow the same pattern. Wine fully implements RtlAddGrowableFunctionTable and calls the language handler.

Scope

No runtime overhead: the table is consulted only during exception dispatch and stack capture. JSC's Wasm fault handler (WTF::SignalsWin.cpp) uses VEH, which runs before function-table lookup, so it is unaffected. VMTraps is polling on Windows.

End-to-end verification (crash from inside a warm JIT frame after clearing UEF, RtlLookupFunctionEntry on a JIT pool PC) is in oven-sh/bun#35083.

…JIT pool

On Windows x64 JavaScriptCore's JIT code has no RUNTIME_FUNCTION entries, so
RtlLookupFunctionEntry returns NULL for any JIT PC and frame-based SEH
dispatch cannot unwind through a JIT frame. This breaks
RtlCaptureStackBackTrace at the first JIT frame and makes
SetUnhandledExceptionFilter unreachable for faults under JIT.

Reserve the first page of the fixed executable pool for a single
RUNTIME_FUNCTION + UNWIND_INFO covering the rest of the pool and register it
with RtlAddFunctionTable. The unwind codes describe the push rbp; mov rbp, rsp
prologue every JIT tier emits (AssemblyHelpers::emitFunctionPrologue and
LLInt functionPrologue), so rbp-chain unwinding is valid for the whole range.
An EHANDLER thunk jumps to jscJITSEHHandler, which by default returns
ExceptionContinueSearch; embedders can install a language-specific handler via
JSC::setJITExceptionHandlerWin.

This mirrors V8's RegisterNonABICompliantCodeRange
(src/diagnostics/unwinding-info-win64.cc).
@coderabbitai

coderabbitai Bot commented Jul 22, 2026

Copy link
Copy Markdown

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: 6 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: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 33e09a0f-db33-4eff-9cdb-c7e7fb1d6124

📥 Commits

Reviewing files that changed from the base of the PR and between c9296e3 and ed1c14e.

📒 Files selected for processing (3)
  • Source/JavaScriptCore/PlatformJSCOnly.cmake
  • Source/JavaScriptCore/jit/ExecutableAllocator.cpp
  • Source/JavaScriptCore/jit/ExecutableAllocator.h

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

ARM64 .xdata encodes FunctionLength in 18 bits of instruction count, so one
RUNTIME_FUNCTION covers at most ~1 MB; the 512 MB pool needs an entry per
chunk. All full-size chunks share one .xdata, the tail chunk a second. The
unwind codes describe stp fp, lr, [sp, #-16]!; mov fp, sp (set_fp,
save_fplr_x 1, end). The handler thunk is ldr x16, #8; br x16; .quad
jscJITSEHHandler.

Also drops the Chromium-style k-prefixed constants in the x64 path in favour
of the UNW_FLAG_EHANDLER winnt.h macro and inline values.
@robobun robobun changed the title JSC(win-x64): register unwind info for the fixed JIT pool JSC(windows): register unwind info for the fixed JIT pool Jul 22, 2026
robobun added 2 commits July 22, 2026 18:13
Both are set once during single-threaded JSC::initialize() before any thread
that could read them exists; matches the m_bytesAllocated pattern in this
file.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I didn't find any bugs, but this hand-encodes Windows unwind-info ABI structures and machine-code thunks for two architectures and shifts the JIT pool base — worth a human look.

What was reviewed:

  • x64 UNWIND_INFO layout, unwind-code ordering, and DWORD alignment against the MS spec
  • ARM64 .xdata header bit-packing, save_fplr_x encoding, and 1 MB chunk-array sizing vs maxEntries
  • RtlAddFunctionTable failure path — record page stays outside startExecutableMemory, so the RX page can't be handed to the JIT allocator
  • Interaction with g_jscConfig.startExecutableMemory / isJITPC and the x64 1 GB pool vs size > UINT32_MAX guard
Extended reasoning...

Overview

This PR adds registerJITUnwindInfo() in ExecutableAllocator.cpp, which carves the first page(s) out of the fixed JIT reservation on Windows x64/ARM64, writes a RUNTIME_FUNCTION table plus hand-packed unwind info and an absolute-jump thunk into it, flips the page to PAGE_EXECUTE_READ, and registers it with RtlAddFunctionTable. It then advances reservation.base/size so g_jscConfig.startExecutableMemory points past the record. The header exposes setJITExceptionHandlerWin / hasJITUnwindInfoWin for embedders. Two separate implementations exist for x64 (single 12-byte entry) and ARM64 (~512 entries covering ~1 MB each).

Security risks

The change writes an executable thunk into JIT memory and installs a process-wide SEH language handler for the entire JIT range. The thunk address and unwind data are written once at startup and then made read-only, and the handler defaults to ExceptionContinueSearch, so this is not obviously exploitable. But it is security-adjacent: a mis-encoded unwind record could derail SEH dispatch in new ways, and the embedder callback is stored in a plain relaxed atomic (not in g_jscConfig). Nothing here looks wrong, but it's the kind of surface a reviewer with Windows SEH experience should sign off on.

Level of scrutiny

High. This is ~200 lines of new platform-ABI code that hand-encodes binary structures documented only in Microsoft's spec pages: x64 unwind codes with reverse-prologue ordering and packed opInfo nibbles, ARM64 .xdata headers with 18-bit function-length fields and byte-stream unwind opcodes, and raw instruction bytes for two thunks. It also mutates the JIT pool bounds inside initializeJITPageReservation(), which every JIT allocation depends on. None of this is mechanical or pattern-following within this codebase.

Other factors

The PR description is excellent and cites V8/Go/tinycc prior art doing the same thing, which raises confidence. The bug hunter found nothing, and the one candidate raised (RX page left in the pool on RtlAddFunctionTable failure) was ruled out because the early-return leaves base/size unmodified so the record page never enters the allocator range. I spot-checked the x64 unwind codes (UWOP_PUSH_NONVOL rbp at offset 1, UWOP_SET_FPREG at offset 4, frameRegister=5), the ARM64 save_fplr_x encoding for #-16 (0x81), the maxEntries ceiling-division, and that the x64 pool size (1 GB) passes the size > UINT32_MAX guard — all look correct. Still, correctness here hinges on byte-level ABI conformance that really wants a Windows-experienced human reviewer and ideally a manual stack-walk test on both architectures.

@github-actions

github-actions Bot commented Jul 22, 2026

Copy link
Copy Markdown

Preview Builds

Commit Release Date
ed1c14e9 autobuild-preview-pr-315-ed1c14e9 2026-07-23 09:29:05 UTC
d23a372e autobuild-preview-pr-315-d23a372e 2026-07-23 04:26:48 UTC
cadde6ac autobuild-preview-pr-315-cadde6ac 2026-07-22 19:00:46 UTC

robobun added 2 commits July 22, 2026 19:24
…ee the entry

RtlAddFunctionTable works for in-process SEH and RtlVirtualUnwind, but
out-of-process stack walkers (ETW, WPA, WinDbg) read the growable-table
list from the target process. This is why V8's RtlAddFunctionTable path is
the 'exception handling only' Win7 fallback and SpiderMonkey's
RegisterExecutableMemory (js/src/jit/ProcessExecutableMemory.cpp) calls the
growable API under the comment 'Required for enabling Stackwalking on
windows using external tools'.

RtlAddGrowableFunctionTable writes into the record region, so write-protect
only after it returns.
vmEntryToJavaScript and the LLInt bytecode handlers are emitted by
offlineasm into .text without .seh_* directives (and ARM64 builds the LLInt
TU with -fno-unwind-tables), so they have no static .pdata. Every JS stack
has at least one such frame, so SEH / RtlCaptureStackBackTrace would still
derail there even with the JIT pool covered.

Register a second growable table over [llintPCRangeStart, llintPCRangeEnd)
(and the wasm IPInt range) using the same prologue unwind codes. The unwind
info lives in image .data so RUNTIME_FUNCTION.UnwindData can address it as
an image-base-relative RVA; the handler is in image .text so no thunk is
needed. Two fixed .data slots cover both callers.
Comment thread Source/JavaScriptCore/jit/ExecutableAllocator.cpp Outdated
Comment thread Source/JavaScriptCore/runtime/InitializeThreading.cpp Outdated
Comment thread Source/JavaScriptCore/jit/ExecutableAllocator.cpp Outdated
- Link ntdll for RtlAddGrowableFunctionTable (kernel32 has no forwarder,
  unlike RtlAddFunctionTable).
- registerImageUnwindInfoWin: pass imageBase as RangeBase. RVAs are
  image-base-relative, and RangeBase is what RtlLookupFunctionEntry returns
  as *ImageBase and resolves all RVAs against; the previous rangeStart base
  never matched so the LLInt table was dead.
- Use &__ImageBase instead of GetModuleHandleW(nullptr) so the base is
  correct if JSC is ever linked as a DLL.
- Drop the separate wasmIPInt registration: those labels sit between
  llintPCRangeStart/End in LowLevelInterpreter.asm, so the LLInt
  registration already covers them. One .data slot now suffices.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks — d23a372 addresses all three of my earlier notes (RangeBase now imageBase, __ImageBase in place of GetModuleHandleW(nullptr), and the redundant IPInt registration dropped). I didn't find anything further, but this is hand-rolled Windows SEH unwind info + machine-code thunks that carve pages out of the JIT reservation, so it's worth a human pass.

Checked this round: the x64/ARM64 UNWIND_INFO/.xdata encodings against the MS docs and the JIT prologue shape; that registerJITUnwindInfo only advances base/size after RtlAddGrowableFunctionTable succeeds so a failure leaves the pool untouched; that the record page is committed before placement-new and write-protected only after registration; and that the ARM64 chunking math and RELEASE_ASSERT(entryCount <= maxEntries) hold for both pool and image paths.

Extended reasoning...

Overview

Adds Windows x64/ARM64 dynamic function-table registration for JSC-generated code so RtlLookupFunctionEntry / SEH / stack walkers can unwind through JIT and LLInt frames. Touches ExecutableAllocator.{h,cpp} (~300 new lines: UNWIND_INFO/.xdata structs, x64 mov rax,imm64; jmp rax and ARM64 ldr x16,#8; br x16 thunks, registerJITUnwindInfo carving the first page(s) out of the fixed JIT pool, registerImageUnwindInfoWin for the LLInt range), InitializeThreading.cpp (one call site), and PlatformJSCOnly.cmake (link ntdll).

Prior review

My previous run flagged three issues; d23a372 fixes all of them and the current diff reflects that: RtlAddGrowableFunctionTable in the image path now takes imageBase / imageBase + end; the image-base is derived from the linker-provided __ImageBase symbol; and only the LLInt range is registered (with a comment noting it encloses IPInt), so g_imageUnwindInfo shrank to a single x64 slot / two ARM64 xdata slots (full + tail) for one range.

Security risks

The change writes an executable thunk into the first page of the JIT reservation and registers a language-specific SEH handler for all JIT/LLInt frames. The handler defaults to ExceptionContinueSearch and only defers to an embedder-set callback, so behavior is inert until Bun opts in. The record page is committed RW, populated, FlushInstructionCache'd, then flipped to PAGE_EXECUTE_READ — no lingering RWX. Still, this is executable-memory / exception-dispatch plumbing on a critical init path, which is exactly the kind of code that benefits from a second human set of eyes.

Level of scrutiny

High. This is platform-ABI-level code (byte-exact unwind-code encodings, hand-assembled thunks, RVA arithmetic) inserted into initializeJITPageReservation, which every Windows JSC process runs once. It mutates reservation.base/reservation.size before g_jscConfig.startExecutableMemory is set, so a mistake here could corrupt JIT allocation globally. The failure surface (bad unwind data confusing SEH or out-of-process walkers) is also hard to test automatically.

Other factors

The design closely mirrors V8's RegisterNonABICompliantCodeRange and SpiderMonkey's RegisterExecutableMemory, which is reassuring. The PR notes end-to-end verification lands with the consuming Bun change, so runtime testing depends on that follow-up. Given the complexity, criticality of the touched path, and Windows-only nature (harder to CI-verify), deferring to a human reviewer rather than auto-approving.

robobun added a commit to oven-sh/bun that referenced this pull request Jul 23, 2026
…C unwind info

Replaces the 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)
- JSC now registers RtlAddGrowableFunctionTable unwind info for its JIT pool
  and LLInt range (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 JSC frames
- SetUnhandledExceptionFilter as the remaining backstop
- WebKit bumped to autobuild-preview-pr-315-d23a372e

Four Windows tests: IsBadReadPtr survives (SEH-guarded probe), RtlFillMemory
crash-reports (unguarded), RtlLookupFunctionEntry resolves both a JIT and an
LLInt PC (validates the hand-encoded unwind bytes), JIT-warm then FFI fault
still reports.

The exact CRYPTSP 0xE8 sentinel + NULL-hProv repro and V8/SpiderMonkey/
python-etwtrace prior art are in the WebKit PR.
RtlLookupFunctionEntry for a PC inside a loaded module consults only that
module's static .pdata; dynamic growable tables are for anonymous memory. The
LLInt table therefore never resolved (verified on Windows 10/11 x64 and ARM64
via Bun CI build 78474).

V8 solves this at build time by emitting .pdata/.xdata for its embedded
builtins (platform-embedded-file-writer-win.cc). The JSC equivalent is
offlineasm emitting .seh_* directives; that belongs in a separate change. A
comment pointing at it is left where the dynamic registration used to be.

The JIT pool registration (anonymous memory) is validated working on both
arches and stays.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for the follow-ups — dropping registerImageUnwindInfoWin entirely (ed1c14e) moots all three of my earlier comments, and I found no issues in the remaining JIT-pool-only registration. That said, ~250 lines of hand-encoded UNWIND_INFO/.xdata, hand-assembled thunks, and a carve-out that shifts the JIT pool base warrant a human look from someone familiar with Windows SEH and JSC's executable allocator.

What was reviewed:

  • x64 UNWIND_INFO layout and unwind codes against the MS x64 exception-handling ABI; ARM64 .xdata header bitfields and set_fp/save_fplr_x/end byte encodings.
  • Pool carve-out ordering — base/size are adjusted before g_jscConfig.startExecutableMemory is set, so isJITPC and the downstream allocators never see the record page(s).
  • Failure paths (size > UINT32_MAX, RtlAddGrowableFunctionTable non-zero) degrade to pre-PR behaviour without corrupting the reservation.
Extended reasoning...

Overview

The PR adds Windows-only (OS(WINDOWS) && (CPU(X86_64) || CPU(ARM64))) dynamic function-table registration for the fixed JIT pool so that RtlLookupFunctionEntry/RtlVirtualUnwind/SEH can walk through JIT frames. It carves the first page(s) out of the JIT reservation to hold a RUNTIME_FUNCTION array, hand-built UNWIND_INFO/.xdata describing the uniform push rbp; mov rbp, rsp / stp fp,lr; mov fp,sp prologue, and a small hand-assembled thunk that tail-calls a C++ language handler. It also links ntdll and exposes setJITExceptionHandlerWin/hasJITUnwindInfoWin for the embedder.

Since my previous review the author dropped the registerImageUnwindInfoWin path (commits d23a372, ed1c14e) after determining that dynamic tables cannot cover PCs inside a loaded module's static .pdata domain — the new trailing comment records that the LLInt fix belongs in offlineasm as a separate change. All three of my earlier findings targeted that removed path and are now moot; no new issues were found in the surviving JIT-pool path.

Security risks

The change writes an executable thunk into RWX JIT memory and installs a language-specific SEH handler. These are not new attack surface in the sense that the JIT pool is already RWX and JSC already runs arbitrary generated code from it; the thunk is written once at init, then flipped to PAGE_EXECUTE_READ. The default handler returns ExceptionContinueSearch, so no exceptions are swallowed unless an embedder opts in via setJITExceptionHandlerWin. I don't see an injection or auth surface here, but hand-encoded ABI structures consumed by the kernel/ntdll unwinder are exactly the kind of thing where a byte-level mistake produces hard-to-diagnose misbehaviour, which argues for a second pair of eyes.

Level of scrutiny

High. This is not mechanical: it hand-encodes two platform ABIs (x64 UNWIND_INFO, ARM64 .xdata) byte-for-byte, hand-assembles two machine-code thunks, and mutates reservation.base/reservation.size before the rest of initializeJITPageReservation and FixedVMPoolExecutableAllocator consume them. None of that is checkable by the compiler and the PR notes end-to-end verification lands with the consuming Bun change. It mirrors well-trodden V8/SpiderMonkey code, but a reviewer familiar with Windows SEH internals and the JSC allocator should confirm the encodings and the carve-out interaction (e.g. with JUMP_ISLANDS region math on ARM64).

Other factors

No tests accompany the change (verification is deferred to the Bun-side PR). Prior review feedback has been addressed by removing the offending code rather than patching it, which simplifies the current diff. The change is entirely #if OS(WINDOWS)-gated so non-Windows builds are unaffected.

robobun added a commit to oven-sh/bun 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
dylan-conway merged commit a40d462 into main Jul 23, 2026
43 checks passed
robobun added a commit to oven-sh/bun that referenced this pull request Jul 23, 2026
dylan-conway pushed a commit to oven-sh/bun 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>
liooil pushed a commit to liooil/poly that referenced this pull request Aug 7, 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

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants