diff --git a/scripts/build/deps/webkit.ts b/scripts/build/deps/webkit.ts index 0b1c956da743..6a3fa0f8e3e2 100644 --- a/scripts/build/deps/webkit.ts +++ b/scripts/build/deps/webkit.ts @@ -3,7 +3,7 @@ * for local mode. Override via `--webkit-version=` to test a branch. * From https://github.com/oven-sh/WebKit releases. */ -export const WEBKIT_VERSION = "f0f60fd2324817dae9656d8bf2fcae25ceaccc37"; +export const WEBKIT_VERSION = "autobuild-preview-pr-442-644fda92"; /** * WebKit (JavaScriptCore) — the JS engine. diff --git a/src/crash_handler/lib.rs b/src/crash_handler/lib.rs index a5cb8c8966ea..466f43a26012 100644 --- a/src/crash_handler/lib.rs +++ b/src/crash_handler/lib.rs @@ -2015,10 +2015,10 @@ mod draft { // `ERROR_INVALID_PARAMETER`. Treating that first-chance exception as // fatal kills the process for what the callee was about to recover // from. So only take over here when the faulting instruction is inside - // Bun's own image; for foreign code, let SEH dispatch proceed. JSC - // registers unwind info for its JIT pool with a language-specific - // handler (LLInt is pending build-time offlineasm .seh_* emission) - // that routes back to `Bun__crashHandlerFromJSCFrame`, and + // Bun's own image; for foreign code, let SEH dispatch proceed. JSC's + // unwind info for its JIT pool and for LLInt carries a + // language-specific handler that routes back to + // `Bun__crashHandlerFromJSCFrame`, and // `handle_unhandled_exception_windows` reports anything that still // goes unhandled. Stack overflow is always claimed here: no foreign // `__except` recovers from it in practice, and SEH dispatch itself @@ -2042,8 +2042,8 @@ mod draft { ); } - /// Called from JSC's `jscJITSEHHandler` when SEH dispatch reaches a JIT - /// frame with an unhandled exception. Reports the crash if the reason is + /// Called from JSC's `jscJITSEHHandler` when SEH dispatch reaches a JIT or + /// LLInt frame with an unhandled exception. Reports the crash if the reason is /// one we classify; otherwise continues the search so an outer handler /// (or UEF) can claim it. #[cfg(windows)] diff --git a/src/jsc/bindings/JSCTestingHelpers.cpp b/src/jsc/bindings/JSCTestingHelpers.cpp index caca7766c736..9a0c3a8d4c32 100644 --- a/src/jsc/bindings/JSCTestingHelpers.cpp +++ b/src/jsc/bindings/JSCTestingHelpers.cpp @@ -7,8 +7,11 @@ #include "ZigGlobalObject.h" #if OS(WINDOWS) +#include #include #include +#include +#include #endif namespace Bun { @@ -62,6 +65,82 @@ JSC_DEFINE_HOST_FUNCTION(jsFunctionStartOfFixedExecutableMemoryPool, auto scope = DECLARE_THROW_SCOPE(vm); RELEASE_AND_RETURN(scope, JSValue::encode(JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(JSC::startOfFixedExecutableMemoryPool())))); } + +// The labels JSC's LowLevelInterpreter.cpp puts around the offlineasm output +// (LLInt, vmEntryToJavaScript and friends). The .pdata record JSC emits for that +// code on Windows covers exactly this range. +extern "C" { +void jsc_llint_begin(); +void jsc_llint_end(); +} + +// Returns { begin, end } of the offlineasm code range as bigints. +JSC_DEFINE_HOST_FUNCTION(jsFunctionLLIntCodeRange, + (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + JSValue begin = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(reinterpret_cast(&jsc_llint_begin))); + RETURN_IF_EXCEPTION(scope, {}); + JSValue end = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(reinterpret_cast(&jsc_llint_end))); + RETURN_IF_EXCEPTION(scope, {}); + JSObject* range = JSC::constructEmptyObject(globalObject); + range->putDirect(vm, JSC::Identifier::fromString(vm, "begin"_s), begin); + range->putDirect(vm, JSC::Identifier::fromString(vm, "end"_s), end); + return JSValue::encode(range); +} + +// Walks the current stack with unwind tables only, the way the crash handler +// walks a fault's (capture_from_context in src/bun_core/debug.rs) and SEH +// dispatch and debuggers do, stopping at the first PC without a +// RUNTIME_FUNCTION. Returns the PCs as bigints, innermost first. Native rather +// than FFI so that the frames being read (JSC's host-call thunk, the JS +// callers, vmEntryToJavaScript, the C++ that entered JS) are still live. +JSC_DEFINE_HOST_FUNCTION(jsFunctionUnwindCurrentStack, + (JSGlobalObject * globalObject, CallFrame*)) +{ + auto& vm = JSC::getVM(globalObject); + auto scope = DECLARE_THROW_SCOPE(vm); + + CONTEXT context; + RtlCaptureContext(&context); + + MarkedArgumentBuffer pcs; + for (unsigned i = 0; i < 64; i++) { +#if CPU(X86_64) + DWORD64 pc = context.Rip; + DWORD64 sp = context.Rsp; +#else + DWORD64 pc = context.Pc; + DWORD64 sp = context.Sp; +#endif + if (!pc) + break; + JSValue value = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast(pc)); + RETURN_IF_EXCEPTION(scope, {}); + pcs.append(value); + + DWORD64 imageBase = 0; + PRUNTIME_FUNCTION entry = RtlLookupFunctionEntry(pc, &imageBase, nullptr); + if (!entry) + break; + PVOID handlerData = nullptr; + DWORD64 establisherFrame = 0; + RtlVirtualUnwind(UNW_FLAG_NHANDLER, imageBase, pc, entry, &context, &handlerData, &establisherFrame, nullptr); +#if CPU(X86_64) + bool unchanged = context.Rip == pc && context.Rsp == sp; +#else + bool unchanged = context.Pc == pc && context.Sp == sp; +#endif + if (unchanged) + break; + } + if (pcs.hasOverflowed()) [[unlikely]] { + throwOutOfMemoryError(globalObject, scope); + return {}; + } + RELEASE_AND_RETURN(scope, JSValue::encode(JSC::constructArray(globalObject, static_cast(nullptr), pcs))); +} #endif JSC::JSValue createJSCTestingHelpers(Zig::GlobalObject* globalObject) @@ -85,6 +164,16 @@ JSC::JSValue createJSCTestingHelpers(Zig::GlobalObject* globalObject) vm, globalObject, JSC::Identifier::fromString(vm, "startOfFixedExecutableMemoryPool"_s), 0, jsFunctionStartOfFixedExecutableMemoryPool, ImplementationVisibility::Public, NoIntrinsic, JSC::PropertyAttribute::DontDelete | 0); + + object->putDirectNativeFunction( + vm, globalObject, JSC::Identifier::fromString(vm, "llintCodeRange"_s), 0, + jsFunctionLLIntCodeRange, ImplementationVisibility::Public, NoIntrinsic, + JSC::PropertyAttribute::DontDelete | 0); + + object->putDirectNativeFunction( + vm, globalObject, JSC::Identifier::fromString(vm, "unwindCurrentStack"_s), 0, + jsFunctionUnwindCurrentStack, ImplementationVisibility::Public, NoIntrinsic, + JSC::PropertyAttribute::DontDelete | 0); #endif return object; diff --git a/src/jsc/bindings/ZigGlobalObject.cpp b/src/jsc/bindings/ZigGlobalObject.cpp index ca0613b6d856..b498c037836a 100644 --- a/src/jsc/bindings/ZigGlobalObject.cpp +++ b/src/jsc/bindings/ZigGlobalObject.cpp @@ -376,10 +376,10 @@ extern "C" void JSCInitialize(const char* envp[], size_t envc, void (*onCrash)(c #if OS(WINDOWS) && (CPU(X86_64) || CPU(ARM64)) // JSC::initialize() registered unwind info + a language-specific SEH - // handler for the JIT pool. Route that handler to the crash reporter - // so a hardware fault under a JIT frame is reported deterministically - // at the JSC boundary. LLInt is not yet covered (needs build-time - // offlineasm .seh_* emission). + // handler for the JIT pool; the LLInt code linked into this image + // carries the same handler in its static .pdata. Route that handler to + // the crash reporter so a hardware fault under a JS frame is reported + // deterministically at the JSC boundary. JSC::setJITExceptionHandlerWin(&Bun__crashHandlerFromJSCFrame); #endif }); // end std::call_once lambda diff --git a/test/cli/run/run-crash-handler.test.ts b/test/cli/run/run-crash-handler.test.ts index 4d436bbedc7b..ddd074c08b0c 100644 --- a/test/cli/run/run-crash-handler.test.ts +++ b/test/cli/run/run-crash-handler.test.ts @@ -243,9 +243,10 @@ describe.if(isWindows)("Windows VEH handler and first-chance faults in external // `RtlFillMemory` has no `__try`/`__except` around its store. With the VEH // now returning CONTINUE_SEARCH for out-of-image PCs, the catch point is - // JSC's jscJITSEHHandler (registered for JIT frames), which routes to - // Bun__crashHandlerFromJSCFrame, or UEF. This exercises that the crash is - // still reported and the report carries the fault address. + // JSC's jscJITSEHHandler (the handler of both the JIT pool's and LLInt's + // unwind info), which routes to Bun__crashHandlerFromJSCFrame, or UEF. This + // exercises that the crash is still reported and the report carries the + // fault address. test("unguarded fault still crash-reports", async () => { await using proc = Bun.spawn({ cmd: [ @@ -269,18 +270,20 @@ describe.if(isWindows)("Windows VEH handler and first-chance faults in external expect(exitCode).not.toBe(0); }); - // Validate WebKit's registerJITUnwindInfo against the actual unwinder: - // RtlLookupFunctionEntry must return a RUNTIME_FUNCTION for a JIT pool PC. - // This is the smoke test for the hand-encoded UNWIND_INFO / .xdata bytes. - // LLInt PCs are not covered here: LLInt lives in image .text and Windows - // only consults static .pdata for in-module PCs; that needs build-time - // .seh_* emission in offlineasm (follow-up). - test("RtlLookupFunctionEntry resolves JSC JIT pool PCs", async () => { + // Validate WebKit's hand-encoded unwind info against the actual unwinder. + // registerJITUnwindInfo registers a dynamic function table for the JIT pool; + // LowLevelInterpreter.cpp emits a static .pdata entry over the offlineasm + // code in bun's own image (jsc_llint_begin..jsc_llint_end: LLInt, + // vmEntryToJavaScript, ...), which a dynamic table cannot cover because + // Windows consults only the image's static .pdata for an in-image PC. + // RtlLookupFunctionEntry must return a RUNTIME_FUNCTION for a PC in either, + // and the LLInt entry must be one entry spanning exactly that range. + test("RtlLookupFunctionEntry resolves JSC JIT pool and LLInt PCs", async () => { await using proc = Bun.spawn({ cmd: [ bunExe(), "-e", - `const { dlopen, FFIType, ptr } = require("bun:ffi"); + `const { dlopen, FFIType, ptr, read } = require("bun:ffi"); const { symbols } = dlopen("ntdll.dll", { RtlLookupFunctionEntry: { args: [FFIType.u64, FFIType.pointer, FFIType.pointer], @@ -288,12 +291,26 @@ describe.if(isWindows)("Windows VEH handler and first-chance faults in external }, }); const { jscInternals } = require("bun:internal-for-testing"); - const pool = jscInternals.startOfFixedExecutableMemoryPool(); const imageBase = new BigUint64Array(1); - const jitEntry = symbols.RtlLookupFunctionEntry(pool + 0x100n, ptr(imageBase), null); + const lookup = pc => { + imageBase[0] = 0n; + const entry = symbols.RtlLookupFunctionEntry(pc, ptr(imageBase), null); + // RUNTIME_FUNCTION starts with BeginAddress (an RVA) on x64 and ARM64 alike. + return entry === null ? null : { entry, begin: imageBase[0] + BigInt(read.u32(entry, 0)) }; + }; + const pool = jscInternals.startOfFixedExecutableMemoryPool(); + const { begin, end } = jscInternals.llintCodeRange(); + const first = lookup(begin); + const last = lookup(end - 4n); + const past = lookup(end); console.log(JSON.stringify({ - pool: pool.toString(16), - jitEntry: jitEntry === null ? "null" : "ok", + jitEntry: lookup(pool + 0x100n) !== null, + llintSize: Number(end - begin), + firstResolves: first !== null, + lastResolves: last !== null, + oneEntry: first !== null && last !== null && first.entry === last.entry, + entryBeginsAtRangeStart: first !== null && first.begin === begin, + endIsExclusive: past === null || first === null || past.entry !== first.entry, }));`, ], env: noReportEnv, @@ -303,7 +320,79 @@ describe.if(isWindows)("Windows VEH handler and first-chance faults in external expect(stderr).toBe(""); const out = JSON.parse(stdout.trim()); - expect(out.jitEntry).toBe("ok"); + // The interpreter is a few hundred KB of code; a tiny range would mean the + // labels no longer bracket it and the assertions below prove nothing. + expect(out.llintSize).toBeGreaterThan(100 * 1024); + expect(out).toMatchObject({ + jitEntry: true, + firstResolves: true, + lastResolves: true, + oneEntry: true, + entryBeginsAtRangeStart: true, + endIsExclusive: true, + }); + expect(exitCode).toBe(0); + }); + + // The unwind codes in the LLInt entry, applied by the real unwinder. + // jscInternals.unwindCurrentStack() (JSCTestingHelpers.cpp) does the walk + // bun's crash handler does from a fault CONTEXT (capture_from_context in + // src/bun_core/debug.rs), and the one SEH dispatch, ETW and debuggers do: + // RtlLookupFunctionEntry + RtlVirtualUnwind, stopping at the first PC that + // has no RUNTIME_FUNCTION. Without the LLInt entry that is the first + // interpreter frame. (RtlCaptureStackBackTrace would not show this on ARM64, + // where it follows the frame-pointer chain instead; and the walk has to run + // inside the call chain, while the frames it reads are still live, which is + // why it is a native helper rather than FFI calls from JS.) + // + // Each function here runs exactly once, so they all execute in LLInt; their + // return addresses, the module body's and vmEntryToJavaScript's lie in the + // offlineasm range, and the frames after the last of those are the C++ that + // entered JS. + test("RtlVirtualUnwind walks through LLInt frames", async () => { + const depth = 5; + await using proc = Bun.spawn({ + cmd: [ + bunExe(), + "-e", + `const { jscInternals } = require("bun:internal-for-testing"); + const { begin, end } = jscInternals.llintCodeRange(); + // The calls are deliberately not in tail position: in strict code JSC + // implements a tail call by dropping the caller's frame. + let capture = () => { + const pcs = jscInternals.unwindCurrentStack(); + return pcs; + }; + for (let i = 0; i < ${depth}; i++) { + const next = capture; + capture = () => { + const pcs = next(); + return pcs; + }; + } + const pcs = capture(); + const isInterpreter = pc => pc >= begin && pc < end; + const lastInterpreterFrame = pcs.findLastIndex(isInterpreter); + console.log(JSON.stringify({ + count: pcs.length, + interpreterFrames: pcs.filter(isInterpreter).length, + framesBelowInterpreter: lastInterpreterFrame === -1 ? 0 : pcs.length - 1 - lastInterpreterFrame, + }));`, + ], + env: noReportEnv, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stderr).toBe(""); + const out = JSON.parse(stdout.trim()); + // The depth wrappers, the innermost closure and the module body are LLInt + // frames, and vmEntryToJavaScript is in the range too. Unfixed, the walk + // stops at the first of them, so this is 1. + expect(out.interpreterFrames).toBeGreaterThanOrEqual(depth + 3); + // JSC::Interpreter and bun's module evaluation below vmEntryToJavaScript; + // unfixed, the walk never gets there. + expect(out.framesBelowInterpreter).toBeGreaterThanOrEqual(2); expect(exitCode).toBe(0); }); @@ -344,6 +433,28 @@ describe.if(isWindows)("Windows VEH handler and first-chance faults in external expect(stdout).not.toContain("SHOULD NOT REACH"); expect(exitCode).not.toBe(0); }); + + // The handler side of the LLInt unwind info. With the JIT off there is no + // pool and no pool handler: the interpreter calls the host function directly, + // so the only JSC frame between the fault and the C++ that entered JS is an + // LLInt one, and the report can only come from the handler named by the + // LLInt entry. segfaultInDll faults inside ntdll, which the VEH (in-image + // faults only) leaves to SEH dispatch; without the entry, dispatch derails + // on that frame and the process dies with no report at all. (bun:ffi needs + // the JIT, hence the fixture, and no clearing of the UEF the way the test + // above does.) + test("jitless: fault under an interpreted frame crash-reports via the LLInt handler", async () => { + await using proc = Bun.spawn({ + cmd: [bunExe(), path.join(import.meta.dir, "fixture-crash.js"), "segfaultInDll"], + env: { ...noReportEnv, BUN_JSC_useJIT: "0" }, + stdio: ["ignore", "pipe", "pipe"], + }); + const [stdout, stderr, exitCode] = await Promise.all([proc.stdout.text(), proc.stderr.text(), proc.exited]); + + expect(stdout).toBe(""); + expect(stderr).toContain("Segmentation fault at address 0xDEADBEEF"); + expect(exitCode).not.toBe(0); + }); }); test.if(process.platform === "darwin")("macOS has the assumed image offset", () => {