Skip to content

Windows: unwind through LLInt and vmEntry* frames (WebKit bump) - #39101

Draft
robobun wants to merge 1 commit into
mainfrom
farm/39f3ba0a/llint-windows-unwind-info
Draft

Windows: unwind through LLInt and vmEntry* frames (WebKit bump)#39101
robobun wants to merge 1 commit into
mainfrom
farm/39f3ba0a/llint-windows-unwind-info

Conversation

@robobun

@robobun robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Draft until oven-sh/WebKit#442 merges: WEBKIT_VERSION points at that PR's preview build (autobuild-preview-pr-442-644fda92) so CI runs against it, and gets repointed at the merged sha before this is ready for review.

Problem

  • On Windows, every stack walk that uses unwind tables stops or derails at the first interpreter frame. The code offlineasm links into bun.exe (jsc_llint_begin..jsc_llint_end: all LLInt opcode handlers, vmEntryToJavaScript and the other vmEntry* trampolines, 401,200 bytes on x64 / 404,884 on ARM64 in a debug build) has no RUNTIME_FUNCTION in .pdata, and for a PC inside an image RtlLookupFunctionEntry consults only that image's static table, so the dynamic table JSC registers for the JIT pool (crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083) cannot cover it. JSC's ExecutableAllocator.cpp, bun's ZigGlobalObject.cpp (next to the setJITExceptionHandlerWin call) and crash_handler/lib.rs (the VEH's comment) all carried this as a pending note.
  • What that does in bun: a fault report's walk (capture_from_context, RtlVirtualUnwind) treats the LLInt PC as a leaf and pops a CallFrame slot as the return address, so the JS frames, vmEntryToJavaScript and everything that entered JS are missing or garbage (traces in crash_handler(windows): unwind through LLInt and vmEntryToJavaScript frames and stop the walk at non-code PCs #38789); non-fault reports (RtlCaptureStackBackTrace) end at the JIT thunk; ETW/WPA, WinDbg and minidumps lose the same frames; and a fault propagating out of a host function called by the interpreter with no JIT frame below it (jitless mode, C++ slow paths) has no SEH catch point: dispatch derails on the LLInt frame, the unhandled-exception filter is never reached, and the process dies with exit code 0xC0000005 and no report.
  • Measured with this branch's tests on the current pin (f0f60fd2), Windows Server 2019 x64 and Windows 11 ARM64 debug builds, identical on both: RtlLookupFunctionEntry returns null for both ends of the range; a table-driven walk started in a host function called through five interpreted functions returns 3 frames (the host function, JSC's host-call thunk, the first LLInt return address) and stops; BUN_JSC_useJIT=0 bun fixture-crash.js segfaultInDll prints nothing and exits 0xC0000005.

Fix

  • scripts/build/deps/webkit.ts: bump to [JSC] Windows: emit a .pdata/.xdata record over the LLInt and vmEntry* code WebKit#442. LowLevelInterpreter.cpp now assembles one .pdata entry over jsc_llint_begin..jsc_llint_end carrying the same unwind codes and the same language-specific handler (jscJITSEHHandler) that registerJITUnwindInfo gives the JIT pool. It is correct for the whole range because everything in it keeps the frame pointer on a CallFrame, whose first two slots are the caller's frame pointer and the return address, which is exactly what those codes restore; the encoding, the ARM64 length guard and the LTO detail are argued in that PR.
  • No runtime code in bun changes. setJITExceptionHandlerWin keeps its name and bun keeps calling it; its callback, Bun__crashHandlerFromJSCFrame, is now also reached from LLInt frames with the same arguments (EHANDLER only, like the pool). capture_from_context needs no change: the ".pdata is always emitted" its doc comment relies on is now true. The three pending-note comments are updated.
  • JSCTestingHelpers.cpp (Windows only, bun:internal-for-testing, next to the existing startOfFixedExecutableMemoryPool probe): jscInternals.llintCodeRange() returns the two labels the WebKit record is built from; jscInternals.unwindCurrentStack() does the RtlCaptureContext + RtlLookupFunctionEntry + RtlVirtualUnwind walk that capture_from_context, SEH dispatch and debuggers do and stops at the first PC without an entry. It is native rather than FFI because the walk has to happen while the frames it reads are live; a CONTEXT captured through FFI describes frames that are gone by the time JS looks at it (tried first, it reads reused stack).
  • Tests in test/cli/run/run-crash-handler.test.ts, in the existing Windows describe block, all three failing on the old pin and passing on the preview on both architectures (12 pass / 16 skip for the file on each; 17 pass / 10 skip on Linux, where they skip):
    • RtlLookupFunctionEntry resolves JSC JIT pool and LLInt PCs (extends the crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083 test): jsc_llint_begin and jsc_llint_end - 4 resolve to the same entry, its BeginAddress is jsc_llint_begin, jsc_llint_end itself resolves to nothing, the range is over 100 KB. Old pin: both LLInt lookups null. Preview: all true; on x64 the entry's EndAddress is exactly jsc_llint_end, on ARM64 jsc_llint_end (the instruction after the range) has no entry, so the 18-bit length is exact.
    • RtlVirtualUnwind walks through LLInt frames: the walk above through five once-run functions must report at least 8 frames in the range and at least 2 below it. Old pin: 1 and 0. Preview, both architectures: 50 frames, 8 in the range (7 at the interpreter's call return point, then vmEntryToJavaScript's), 40 bun frames below them, then kernel32/ntdll. The assertions are lower bounds on purpose: the invariant is that the walk gets through, not how many frames bun's module loader has.
    • jitless: fault under an interpreted frame crash-reports via the LLInt handler: BUN_JSC_useJIT=0 plus the existing segfaultInDll fixture. With the JIT off there is no pool and no pool handler, so the report can only come from the LLInt record's handler; this is the one behaviour the record adds beyond unwinding. Old pin: empty stderr, exit 0xC0000005. Preview: Segmentation fault at address 0xDEADBEEF on both. (bun:ffi needs the JIT, so this uses the fixture and cannot clear the UEF like the JIT test above it; the old-pin result shows the UEF is not what reports it.)
    • RtlCaptureStackBackTrace is deliberately not asserted on: on ARM64 it follows the frame-pointer chain and passes without the entry (checked), so it would not exercise the unwind codes.
  • Not provable on the Linux gate: the WebKit change is OS(WINDOWS)-only and the tests skip off Windows, the same situation as crash_handler(windows): walk the fault CONTEXT via RtlVirtualUnwind #35074 / crash_handler(windows): let foreign first-chance AVs reach SEH via JSC unwind info #35083.
  • Relationship to crash_handler(windows): unwind through LLInt and vmEntryToJavaScript frames and stop the walk at non-code PCs #38789 (open): its unwind_frame_pointer step exists to get bun's own walker past exactly these frames and becomes unnecessary once this lands (its doc comment says so); its frame-0 leaf validation and non-code-PC stop remain useful on their own. Neither PR needs the other to build; whichever lands second should be adjusted accordingly, and I have left a note there.

Background

  • .pdata / .xdata: 64-bit Windows requires every non-leaf function to have a RUNTIME_FUNCTION (x64: begin and end RVAs; ARM64: begin RVA, length in the unwind record) pointing at unwind codes that say how to undo its prologue. RtlLookupFunctionEntry finds the record for a PC and RtlVirtualUnwind applies it to a register CONTEXT; RtlCaptureStackBackTrace, SEH dispatch, ETW and debuggers are built on those two. A PC with no record is assumed to be in a leaf whose return address is at [rsp] (in lr on ARM64), which is wrong for an interpreter frame. A record may also name an exception handler, which SEH dispatch calls when an exception reaches one of that function's frames; the pool's records, and now the LLInt record, name JSC's handler, which forwards to the callback bun installs with setJITExceptionHandlerWin.
  • The VEH (handle_segfault_windows) claims faults whose PC is inside bun.exe directly and lets foreign ones go to SEH dispatch, which is why the tests fault inside ntdll (segfaultInDll calls RtlFillMemory on a bad address).
  • jsc_llint_begin / jsc_llint_end: labels LowLevelInterpreter.cpp emits immediately before and after the generated interpreter assembly. CallFrame is JSC's JS stack frame; in LLInt and every JIT tier the frame-pointer register points at it and its first two slots are callerFrame and returnPC, which is why one set of unwind codes describes the pool and the interpreter alike.
  • Jitless mode (BUN_JSC_useJIT=0): JSC runs everything in LLInt and reserves no JIT pool, so host functions are called straight from interpreter code and there is no pool record anywhere in the chain.

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

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author

Status: draft, pinned to the preview build of oven-sh/WebKit#442 (autobuild-preview-pr-442-644fda92); repoint to the merged sha and mark ready once that PR lands.

Reproduced: with this branch's three new tests in test/cli/run/run-crash-handler.test.ts against the current pin f0f60fd2, on Windows Server 2019 x64 and Windows 11 ARM64 debug builds: both LLInt lookups null, the RtlVirtualUnwind walk stops after 3 frames with 1 interpreter frame, and the jitless segfaultInDll run prints nothing and exits 0xC0000005. Against the preview build all three pass on both (file: 12 pass / 16 skip each).

Fix: oven-sh/WebKit#442 (one .pdata/.xdata record over the offlineasm code) plus this PR's pin bump, comment updates, test hooks and tests.

@robobun

robobun commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 11:05 AM PT - Aug 15th, 2026

@robobun, your commit 6384c82 has some failures in Build #98241 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 39101

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

bun-39101 --bun

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.

1 participant