Windows: unwind through LLInt and vmEntry* frames (WebKit bump) - #39101
Windows: unwind through LLInt and vmEntry* frames (WebKit bump)#39101robobun wants to merge 1 commit into
Conversation
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.
|
Status: draft, pinned to the preview build of oven-sh/WebKit#442 ( Reproduced: with this branch's three new tests in test/cli/run/run-crash-handler.test.ts against the current pin Fix: oven-sh/WebKit#442 (one |
|
Updated 11:05 AM PT - Aug 15th, 2026
❌ @robobun, your commit 6384c82 has some failures in 🧪 To try this PR locally: bunx bun-pr 39101That installs a local version of the PR into your bun-39101 --bun |
Problem
jsc_llint_begin..jsc_llint_end: all LLInt opcode handlers,vmEntryToJavaScriptand the othervmEntry*trampolines, 401,200 bytes on x64 / 404,884 on ARM64 in a debug build) has noRUNTIME_FUNCTIONin.pdata, and for a PC inside an imageRtlLookupFunctionEntryconsults 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'sExecutableAllocator.cpp, bun'sZigGlobalObject.cpp(next to thesetJITExceptionHandlerWincall) andcrash_handler/lib.rs(the VEH's comment) all carried this as a pending note.capture_from_context,RtlVirtualUnwind) treats the LLInt PC as a leaf and pops aCallFrameslot as the return address, so the JS frames,vmEntryToJavaScriptand 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 code0xC0000005and no report.f0f60fd2), Windows Server 2019 x64 and Windows 11 ARM64 debug builds, identical on both:RtlLookupFunctionEntryreturns 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 segfaultInDllprints nothing and exits0xC0000005.Fix
scripts/build/deps/webkit.ts: bump to [JSC] Windows: emit a .pdata/.xdata record over the LLInt and vmEntry* code WebKit#442.LowLevelInterpreter.cppnow assembles one.pdataentry overjsc_llint_begin..jsc_llint_endcarrying the same unwind codes and the same language-specific handler (jscJITSEHHandler) thatregisterJITUnwindInfogives the JIT pool. It is correct for the whole range because everything in it keeps the frame pointer on aCallFrame, 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.setJITExceptionHandlerWinkeeps 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_contextneeds no change: the ".pdatais 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 existingstartOfFixedExecutableMemoryPoolprobe):jscInternals.llintCodeRange()returns the two labels the WebKit record is built from;jscInternals.unwindCurrentStack()does theRtlCaptureContext+RtlLookupFunctionEntry+RtlVirtualUnwindwalk thatcapture_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).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_beginandjsc_llint_end - 4resolve to the same entry, itsBeginAddressisjsc_llint_begin,jsc_llint_enditself resolves to nothing, the range is over 100 KB. Old pin: both LLInt lookups null. Preview: all true; on x64 the entry'sEndAddressis exactlyjsc_llint_end, on ARM64jsc_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, thenvmEntryToJavaScript'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=0plus the existingsegfaultInDllfixture. 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, exit0xC0000005. Preview:Segmentation fault at address 0xDEADBEEFon 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.)RtlCaptureStackBackTraceis 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.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.unwind_frame_pointerstep 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 aRUNTIME_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.RtlLookupFunctionEntryfinds the record for a PC andRtlVirtualUnwindapplies it to a registerCONTEXT;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](inlron 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 withsetJITExceptionHandlerWin.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 (segfaultInDllcallsRtlFillMemoryon a bad address).jsc_llint_begin/jsc_llint_end: labelsLowLevelInterpreter.cppemits immediately before and after the generated interpreter assembly.CallFrameis JSC's JS stack frame; in LLInt and every JIT tier the frame-pointer register points at it and its first two slots arecallerFrameandreturnPC, which is why one set of unwind codes describes the pool and the interpreter alike.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.