JSC(windows): register unwind info for the fixed JIT pool - #315
Conversation
…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).
|
Warning Review limit reached
Next review available in: 6 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the 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 configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (3)
Comment |
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.
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.
There was a problem hiding this comment.
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_xencoding, and 1 MB chunk-array sizing vsmaxEntries RtlAddFunctionTablefailure path — record page stays outsidestartExecutableMemory, so the RX page can't be handed to the JIT allocator- Interaction with
g_jscConfig.startExecutableMemory/isJITPCand the x64 1 GB pool vssize > UINT32_MAXguard
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.
Preview Builds
|
…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.
- 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.
There was a problem hiding this comment.
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.
…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.
There was a problem hiding this comment.
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/endbyte encodings. - Pool carve-out ordering —
base/sizeare adjusted beforeg_jscConfig.startExecutableMemoryis set, soisJITPCand the downstream allocators never see the record page(s). - Failure paths (
size > UINT32_MAX,RtlAddGrowableFunctionTablenon-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.
…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.
…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>
…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>
Problem
On Windows (x64 and ARM64), JavaScriptCore's JIT code has no
RUNTIME_FUNCTIONentries: the JIT pool is anonymous memory, soRtlLookupFunctionEntryreturnsNULLfor 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:
RtlCaptureStackBackTracestops at the first JIT frameSetUnhandledExceptionFilteris unreachable for any fault under a JIT frameBun'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
RtlAddGrowableFunctionTablecovering the fixed JIT pool atJSC::initialize()time. The first page(s) of the reservation are carved out for theRUNTIME_FUNCTIONarray, 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, rspon x64,stp fp, lr, [sp, #-16]!; mov fp, spon 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_EHANDLERlanguage-specific handler (jscJITSEHHandler) that calls an embedder-settable callback and defaults toExceptionContinueSearch. Embedders that want a deterministic catch point at the JIT boundary can install one viaJSC::setJITExceptionHandlerWin.The growable API (not
RtlAddFunctionTable) is used so out-of-process stack walkers see the entry too.x64
One 12-byte
RUNTIME_FUNCTIONcovers the whole pool. Unwind codes:UWOP_PUSH_NONVOL rbp; UWOP_SET_FPREG rbp, 0. Pool thunk:mov rax, imm64; jmp rax. Reference.ARM64
FunctionLengthis 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
vmEntryToJavaScriptare emitted by offlineasm into.textwithout.seh_*directives (ARM64 builds the LLInt TU with-fno-unwind-tables), so they have no.pdataof their own. A dynamic function table cannot cover them:RtlLookupFunctionEntryfor 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/.xdatafor 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 inExecutableAllocator.cpp.Prior art
V8
RegisterNonABICompliantCodeRangeand SpiderMonkeyRegisterExecutableMemory(Bug 1530552; Bug 1843842 "Always use RtlAddGrowableFunctionTable on Windows") do the same for their JIT code. CoreCLRcodeman.cppuses the growable API for managed code. microsoft/python-etwtrace and the guidance in python/cpython#126910 ("RtlAddGrowableFunctionTableis actually the only one that works") follow the same pattern. Wine fully implementsRtlAddGrowableFunctionTableand 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.VMTrapsis polling on Windows.End-to-end verification (crash from inside a warm JIT frame after clearing UEF,
RtlLookupFunctionEntryon a JIT pool PC) is in oven-sh/bun#35083.