Skip to content

[JSC] Windows: emit a .pdata/.xdata record over the LLInt and vmEntry* code - #442

Open
robobun wants to merge 2 commits into
mainfrom
farm/39f3ba0a/llint-windows-unwind-info
Open

[JSC] Windows: emit a .pdata/.xdata record over the LLInt and vmEntry* code#442
robobun wants to merge 2 commits into
mainfrom
farm/39f3ba0a/llint-windows-unwind-info

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Problem

  • On Windows (x64 and ARM64) the code offlineasm emits into the image, jsc_llint_begin..jsc_llint_end (every LLInt opcode handler, vmEntryToJavaScript, vmEntryToNative and the other vmEntry* trampolines, the wasm interpreter), has no RUNTIME_FUNCTION: offlineasm emits no .seh_* directives, so nothing lands in .pdata for it.
  • RtlLookupFunctionEntry consults only the image's static .pdata for a PC inside a loaded image, so the dynamic function table registerJITUnwindInfo registers for the JIT pool (JSC(windows): register unwind info for the fixed JIT pool #315) cannot cover it; ExecutableAllocator.cpp has been carrying this as a noted follow-up since then.
  • Consequences: every unwind-table based walk stops or derails at the first interpreter frame. RtlCaptureStackBackTrace (what WTF's StackTrace and Bun's non-fault crash reports use) returns nothing below the JIT thunk that called into native code; RtlVirtualUnwind based walkers (Bun's fault reports, ETW/WPA, WinDbg, minidump tooling) treat the PC as a leaf and pop a CallFrame slot as the return address, losing the JS frames, vmEntryToJavaScript and everything that entered JS (crash_handler(windows): unwind through LLInt and vmEntryToJavaScript frames and stop the walk at non-code PCs bun#38789 has the traces); and an exception that propagates up through an LLInt frame without a JIT frame below it (a C++ slow path called from LLInt, or jitless mode) has no language-specific handler, so SEH dispatch has to get through the same broken unwind to reach anything.

Fix

  • LowLevelInterpreter.cpp assembles, right after the LLIntAssembly.h include and under OS(WINDOWS) && ENABLE(JIT), one .pdata entry covering jsc_llint_begin..jsc_llint_end plus the .xdata it points at:
    • x64: UNWIND_INFO version 1, UNW_FLAG_EHANDLER, prolog 4, frame register rbp, codes UWOP_SET_FPREG @4 and UWOP_PUSH_NONVOL rbp @1. These are the bytes JITUnwindRecord::unwindInfo holds for the pool (09 04 02 05 04 03 01 50), and also exactly what LLVM emits for a push rbp; mov rbp, rsp function with an @except handler.
    • ARM64: header with the real length, X = 1, one code word set_fp, save_fplr_x 16, end, nop, i.e. arm64XdataHeader / arm64JITUnwindCodes with the actual length; again byte-identical to LLVM's own output for that prologue.
    • Both point at jscJITSEHHandler, which loses static and gains a C name so the record can name it; it is in the same image, so no thunk is needed (the pool records need one only because a dynamic table's RVAs are relative to the table base).
  • Why one record over the whole range is correct: every function in the range enters through functionPrologue (push fp/lr, fp = sp) and keeps the frame pointer on a CallFrame, whose first two slots are the caller's frame pointer and the return address. That is precisely what these unwind codes restore, and it is the same argument JSC(windows): register unwind info for the fixed JIT pool #315 already makes for the pool, whose code has the same prologue; the two records compose, so a walk goes JIT -> LLInt -> vmEntryToJavaScript -> the C++ caller (whose own .pdata takes over; rbp/fp is restored by the codes, which is the only register C++ unwind info can depend on). The range starts with the jsc_llint_begin and llintPCRangeStart trap instructions, so the 4-byte prologue the record claims never contains a real PC. Precision is the same as the pool records (and V8's / SpiderMonkey's equivalents): a PC sitting exactly on a prologue instruction inside the range unwinds one frame short, which is what the pool already accepts.
  • Why it is data and not .seh_* directives: LLVM cannot compute an ARM64 SEH function length across alignment directives ([AArch64] UnwindInfo cannot be emitted if inline asm with align or fill are used llvm/llvm-project#47432, the bug the existing -fno-unwind-tables for LowLevelInterpreterLib works around); wrapping the offlineasm output in .seh_proc/.seh_endproc dies with LLVM ERROR: Failed to evaluate function length in SEH unwind info in LLVM 21. The .long in the hand-written record is an ordinary data fixup, resolved after layout.
  • The RVAs are written .long symbol@IMGREL rather than .rva symbol. Both assemble to the same ADDR32NB relocation, but LTO finds the symbols a module's asm references by parsing the asm with a recording streamer, and .rva operands never reach it: the first revision used .rva, and the windows-amd64-lto preview job was the one job that failed. Reproduced outside the tree by compiling the block with /clang:-flto=thin and linking with lld-link: undefined symbol: jscJITSEHHandler, referenced by ...(.xdata) (the handler gets internalized). With @IMGREL the bitcode's symbol table lists U jscJITSEHHandler and the same LTO link succeeds on both x64 and ARM64, with the records intact in the linked image; non-LTO objects are byte-identical to the .rva version.
  • ARM64's FunctionLength is 18 bits of instructions (1 MB); the blob is ~400 KB today (jsc_llint_end - jsc_llint_begin is 401,200 bytes in a Windows x64 debug build of Bun and 404,884 on Windows ARM64 debug, measured by the tests below). Instead of letting the field overflow silently into the flag bits one day, a never-executed adr xzr, jsc_llint_begin - 4 is assembled right after jsc_llint_end: ADR reaches exactly 2^18 instructions, so the build fails with fixup value out of range on that line once the range stops fitting in one record (checked at both sides of the boundary with llvm-mc: 0xFFFFC bytes assembles and encodes FunctionLength = 0x3FFFF, 0x100000 fails).
  • Behaviour change for embedders: the callback passed to setJITExceptionHandlerWin is now also invoked when dispatch reaches an LLInt / vmEntry* frame. It is the same callback with the same arguments, and only UNW_FLAG_EHANDLER is set (no unwind-phase calls), as for the pool. No API change; the header comment says so.
  • Verified:
    • The exact block from this file (extracted and compiled with clang-cl --target={x86_64,aarch64}-pc-windows-msvc /clang:-masm=att, the flags the real build uses, around a stand-in blob full of .balign/.align directives) produces a RUNTIME_FUNCTION from jsc_llint_begin to jsc_llint_end with ADDR32NB relocations, llvm-readobj --unwind decodes it as described above, and the .xdata bytes match what LLVM emits from .seh_* directives for the same prologue on both architectures. .seh_proc around the same stand-in fails with the [JSC] Use code blocks syntax for Test262 documents WebKit/WebKit#47432 error on ARM64. The same block compiled with /clang:-flto=thin /clang:-fno-split-lto-unit and linked with lld-link links on both architectures with the handler resolved.
    • Preview builds: dbb960f3 (.rva) failed only windows-amd64-lto; 644fda92 (@IMGREL, the current head) built every lane and published autobuild-preview-pr-442-644fda92.
    • On real Windows, Windows Server 2019 x64 and Windows 11 ARM64, debug builds of Bun with Windows: unwind through LLInt and vmEntry* frames (WebKit bump) bun#39101's tests (test/cli/run/run-crash-handler.test.ts), first on the current prebuilt f0f60fd2, then on this preview; results identical on both architectures:
      • RtlLookupFunctionEntry on jsc_llint_begin and on jsc_llint_end - 4: null and null before; after, the same entry for both, BeginAddress == jsc_llint_begin, and nothing at jsc_llint_end itself (on x64 the entry's EndAddress is exactly jsc_llint_end; on ARM64 the word after the range is the adr, which has no entry, so the 18-bit length is exact). Range size 401,200 bytes (x64) / 404,884 (ARM64).
      • A RtlCaptureContext + RtlLookupFunctionEntry + RtlVirtualUnwind walk started in a host function called through five interpreted functions: before, 3 frames (host function, host-call thunk in the pool, first LLInt return address, where the lookup fails); after, 50 frames: 7 frames at the interpreter's call return point, vmEntryToJavaScript's return point, 40 frames of JSC/Bun C++ and Rust below it, then kernel32 and ntdll.
      • The handler: BUN_JSC_useJIT=0 (no pool, so no pool record anywhere in the chain) with a fault inside ntdll called from interpreted JS: before, no output and exit code 0xC0000005 (dispatch derails on the LLInt frame and the unhandled-exception filter is never reached); after, the crash report from the callback installed through setJITExceptionHandlerWin.
      • The rest of that file is unaffected: 12 pass / 16 skip on each machine after, including the existing pool-handler tests from JSC(windows): register unwind info for the fixed JIT pool #315's companion.
  • Companion and ordering: Windows: unwind through LLInt and vmEntry* frames (WebKit bump) bun#39101 (draft) pins this PR's preview build and carries the tests above; it gets repointed at the merged sha once this lands. It supersedes the LLInt part of crash_handler(windows): unwind through LLInt and vmEntryToJavaScript frames and stop the walk at non-code PCs bun#38789, Bun's interim frame-pointer step over exactly these frames, which becomes dead once the pin lands (noted there); the rest of that PR is independent of this one.

Background

  • .pdata / .xdata: on 64-bit Windows every non-leaf function needs a RUNTIME_FUNCTION in the image's .pdata section (x64: begin RVA, end RVA, unwind info RVA; ARM64: begin RVA plus unwind info RVA, the length lives in the unwind info) pointing at an .xdata record that lists, in reverse prologue order, how to undo the prologue. RtlLookupFunctionEntry finds the record for a PC; RtlVirtualUnwind applies it; RtlCaptureStackBackTrace, SEH dispatch and every profiler/debugger are built on those two. A function with no record is assumed to be a leaf whose return address is at [rsp] (in lr on ARM64). Records can also carry an exception-handler RVA (UNW_FLAG_EHANDLER / the X bit); SEH dispatch calls that handler when the exception reaches a frame of the function.
  • Dynamic function tables (RtlAddGrowableFunctionTable, used by registerJITUnwindInfo) cover memory outside any image; for a PC inside an image the lookup goes straight to that image's static table, which is why this has to be emitted at build time.
  • offlineasm output: LLIntAssembly.h is one __asm__ statement whose first and last labels are jsc_llint_begin / jsc_llint_end (OFFLINE_ASM_BEGIN / OFFLINE_ASM_END in this file, originally for lldb). It is generated GNU-syntax assembly assembled by clang as module-level inline asm, which is also where the .cfi_* block above the include comes from; this PR's block is the SEH counterpart of that block.
  • functionPrologue (LowLevelInterpreter.asm; preserveCallerPCAndCFR is the same sequence) is push cfr; move sp, cfr on x64 and push cfr, lr; move sp, cfr (stp fp, lr, [sp, #-16]!; mov fp, sp) on ARM64. cfr is rbp / x29 and points at the current CallFrame, whose layout starts with callerFrame and returnPC. Every function in the range, including doVMEntry and the wasm interpreter (InPlaceInterpreter.asm is part of the same output), enters through one of these two macros, and AssemblyHelpers::emitFunctionPrologue emits the same sequence in every JIT tier, which is what makes one set of unwind codes describe both the pool and this range.

The LLInt opcode handlers and the vmEntry* trampolines are linked into
the image's .text with no RUNTIME_FUNCTION, because offlineasm emits no
.seh_* directives. The Windows unwinder consults only the image's static
.pdata for an in-image PC, so the dynamic function table registered for
the JIT pool cannot help, and every unwind-table based stack walk
(RtlVirtualUnwind, RtlCaptureStackBackTrace, SEH dispatch, ETW, WinDbg)
stops or derails at the first interpreter frame.

Every function in jsc_llint_begin..jsc_llint_end keeps the frame pointer
on a CallFrame, the frame shape the JIT pool's unwind info already
describes, so LowLevelInterpreter.cpp now assembles one RUNTIME_FUNCTION
over that range with the same unwind codes registerJITUnwindInfo
hand-encodes, and with the same language-specific handler, which gets a
C name so the record can address it directly.

The record is written out as data rather than with .seh_* directives
because LLVM cannot compute an ARM64 SEH function length across the
alignment directives in the offlineasm output (llvm/llvm-project#47432).
On ARM64 the 18-bit FunctionLength field is guarded by an ADR whose
reach matches it, so the build fails instead of emitting a bad record if
the interpreter ever outgrows one entry.
@coderabbitai

coderabbitai Bot commented Aug 15, 2026

Copy link
Copy Markdown

Warning

Review limit reached

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

Next review available in: 29 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: e5f9ec70-a520-4b9a-b6ab-7710616fff4f

📥 Commits

Reviewing files that changed from the base of the PR and between 592a255 and 644fda9.

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

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

@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 reviewed this PR and didn't find any bugs. Given that it hand-encodes Windows .pdata/.xdata unwind records and wires a language-specific SEH handler over the entire LLInt/vmEntry range on two architectures, and the real-Windows verification in the companion Bun PR is still pending, a human look would still be worthwhile.

Checked that the x64 UNWIND_INFO bytes and the ARM64 header/codes match what registerJITUnwindInfo already builds for the JIT pool (JITUnwindRecord::unwindInfo, arm64XdataHeader/arm64JITUnwindCodes).
Checked that dropping static on jscJITSEHHandler in favour of extern "C" keeps the same body (still returns ExceptionContinueSearch when no callback is set) and that the ENABLE(JIT) guard on the new asm matches the guard around the handler's definition.
Checked the ARM64 adr xzr overflow guard and FunctionLength shift/flag-bit layout against the 18-bit limit described in the comments.

Extended reasoning...

Overview

This PR emits a static Windows .pdata/.xdata RUNTIME_FUNCTION record covering the offlineasm-generated code range jsc_llint_begin..jsc_llint_end (LLInt opcode handlers, vmEntryToJavaScript/vmEntryToNative, wasm in-place interpreter) so that RtlLookupFunctionEntry, RtlVirtualUnwind, RtlCaptureStackBackTrace, SEH dispatch, and out-of-process profilers/debuggers can unwind through interpreter frames. It does this via hand-written module-level inline assembly in LowLevelInterpreter.cpp for both x86_64 and ARM64 (data .byte/.long/.rva rather than .seh_* directives, to work around llvm/llvm-project#47432 on ARM64). jscJITSEHHandler in ExecutableAllocator.cpp loses static and gains extern "C" so the record can reference it by name, and comments in ExecutableAllocator.{h,cpp} are updated to reflect that the embedder's setJITExceptionHandlerWin callback now also fires for LLInt/vmEntry frames.

Security risks

The change registers a language-specific SEH handler over a large region of executable image code. If the unwind codes were wrong, the worst realistic outcome is a mis-unwound stack during crash reporting or SEH dispatch — not attacker-controlled control flow, since the record is static, read-only image data and the handler is the existing jscJITSEHHandler which just forwards to an embedder-set callback or returns ExceptionContinueSearch. Making jscJITSEHHandler externally visible (extern "C", no longer file-static) slightly widens the symbol surface but does not change what it does. I don't see an injection, auth, or data-exposure surface here.

Level of scrutiny

High. This is low-level, platform-ABI-specific work: hand-encoded unwind opcodes for two CPU architectures, a build-time overflow guard implemented as a never-executed adr xzr instruction, and a behaviour change to when the embedder's SEH callback is invoked. A single wrong nibble in the .xdata bytes could break unwinding or SEH dispatch on one architecture only, and the companion Bun PR's Windows x64 / Windows 11 ARM64 tests are described as not yet run ("Results will be added here once the preview build is up"). This is well outside the "simple, mechanical, obvious" bar for auto-approval.

Other factors

The encoding is internally consistent with the existing dynamic-table path in ExecutableAllocator.cpp (the x64 bytes 09 04 02 05 04 03 01 50 match JITUnwindRecord::unwindInfo; the ARM64 header expression and code word E1 81 E4 E3 match arm64XdataHeader/arm64JITUnwindCodes), and the ENABLE(JIT) guard on the new asm block lines up with the guard around jscJITSEHHandler's definition, so there is no obvious link-time hole. The PR description is thorough and cites the LLVM bug that forces the hand-written approach. Still, given the domain (Windows SEH internals across two ISAs) and the pending real-hardware verification, a human reviewer with Windows unwinding expertise should sign off.

LTO collects the symbols a module's inline asm references by parsing it
with a recording streamer, and .rva operands never reach it. With .rva a
-flto build internalized jscJITSEHHandler and lld-link failed with an
undefined symbol referenced from .xdata (the windows-amd64-lto build).
.long symbol@IMGREL assembles to the same ADDR32NB relocation but goes
through an ordinary data expression, which the scan records.

@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 reviewed this PR and didn't find any bugs. Because it hand-encodes Windows SEH unwind data (.pdata/.xdata bytes) for both x64 and ARM64, changes jscJITSEHHandler linkage, and broadens when the embedder SEH callback fires, a human look would still be worthwhile — and the description notes preview-build test results are still pending.

What was reviewed:

  • Cross-checked the x64 UNWIND_INFO bytes and ARM64 .xdata header/codes against JITUnwindRecord / arm64XdataHeader / arm64JITUnwindCodes in ExecutableAllocator.cpp — they match.
  • Confirmed jscJITSEHHandler is only otherwise referenced via &jscJITSEHHandler in the same TU, so staticextern "C" doesn't break existing callers.
  • Checked the adr xzr overflow guard sits after jsc_llint_end (preceded by a trap spacer per OFFLINE_ASM_END), so it's unreachable as claimed.
Extended reasoning...

Overview

This PR adds a static .pdata/.xdata RUNTIME_FUNCTION covering the offlineasm-generated LLInt/vmEntry* code range (jsc_llint_begin..jsc_llint_end) on Windows x64 and ARM64, so RtlLookupFunctionEntry/RtlVirtualUnwind can unwind through interpreter frames. It changes jscJITSEHHandler from file-static to extern "C" so the hand-written .xdata can reference it by RVA, updates the header comment on setJITExceptionHandlerWin, and moves an existing follow-up comment. The unwind bytes mirror the existing dynamic-table encoding for the JIT pool from #315.

Security risks

Low but non-trivial. Registering a language-specific SEH handler over a large code range means malformed unwind data could redirect exception dispatch. The bytes are constant and match the already-shipping JIT-pool encoding, and the handler defaults to ExceptionContinueSearch, so no new attack surface beyond what #315 already introduced. No user-controlled input reaches the encoding.

Level of scrutiny

High. This is hand-encoded platform ABI binary data embedded via inline asm, with architecture-specific bit-packing, LTO-specific relocation spelling (@IMGREL vs .rva), and a clever build-time overflow guard. Errors here would manifest as broken stack walks, SEH misdispatch, or link failures on specific toolchain configurations — hard to catch without the Windows CI matrix. The author's verification is unusually thorough, but they explicitly note that results against the preview build are still pending.

Other factors

  • The change is additive and Windows-only (guarded by OS(WINDOWS) && ENABLE(JIT) && (CPU(X86_64) || CPU(ARM64))); other platforms are unaffected.
  • There is a semantic behaviour change for embedders: the setJITExceptionHandlerWin callback now also fires for LLInt/vmEntry* frames. The header comment documents it, but a human should confirm this is desirable for Bun's crash-reporting integration.
  • The companion Bun PR with the actual runtime tests is referenced but its results against this build aren't in yet.
  • No prior human or bot review on this PR beyond a rate-limited CodeRabbit notice.

@github-actions

Copy link
Copy Markdown

Preview Builds

Commit Release Date
644fda92 autobuild-preview-pr-442-644fda92 2026-08-15 11:21:42 UTC

robobun added a commit to oven-sh/bun that referenced this pull request Aug 15, 2026
The offlineasm code linked into bun.exe (LLInt, vmEntryToJavaScript and
the other vmEntry* trampolines) had no .pdata entry, so every unwind
table based stack walk stopped or derailed at the first interpreter
frame: fault reports lost everything below the JS frames, non-fault
reports ended at the JIT thunk, and a fault propagating out of an
interpreted call with no JIT frame below it had no SEH catch point.

oven-sh/WebKit#442 makes LowLevelInterpreter.cpp emit one .pdata/.xdata
record over jsc_llint_begin..jsc_llint_end carrying the JIT pool's
unwind codes and handler. This bumps WEBKIT_VERSION to it, updates the
comments that described LLInt as uncovered, and adds Windows-only test
hooks (jscInternals.llintCodeRange, jscInternals.unwindCurrentStack)
plus tests that look both ends of the range up with
RtlLookupFunctionEntry, walk through interpreted frames with
RtlVirtualUnwind, and check that a jitless fault under an interpreted
frame is reported through the record's handler.
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.

1 participant