Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion scripts/build/deps/webkit.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* for local mode. Override via `--webkit-version=<hash>` 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.
Expand Down
12 changes: 6 additions & 6 deletions src/crash_handler/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)]
Expand Down
89 changes: 89 additions & 0 deletions src/jsc/bindings/JSCTestingHelpers.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@
#include "ZigGlobalObject.h"

#if OS(WINDOWS)
#include <JavaScriptCore/ArgList.h>
#include <JavaScriptCore/ExecutableAllocator.h>
#include <JavaScriptCore/JSBigInt.h>
#include <JavaScriptCore/JSGlobalObjectInlines.h>
#include <windows.h>
#endif

namespace Bun {
Expand Down Expand Up @@ -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<uint64_t>(JSC::startOfFixedExecutableMemoryPool<uintptr_t>()))));
}

// 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<uint64_t>(reinterpret_cast<uintptr_t>(&jsc_llint_begin)));
RETURN_IF_EXCEPTION(scope, {});
JSValue end = JSBigInt::makeHeapBigIntOrBigInt32(globalObject, static_cast<uint64_t>(reinterpret_cast<uintptr_t>(&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<uint64_t>(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<ArrayAllocationProfile*>(nullptr), pcs)));
}
#endif

JSC::JSValue createJSCTestingHelpers(Zig::GlobalObject* globalObject)
Expand All @@ -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;
Expand Down
8 changes: 4 additions & 4 deletions src/jsc/bindings/ZigGlobalObject.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
143 changes: 127 additions & 16 deletions test/cli/run/run-crash-handler.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -269,31 +270,47 @@ 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],
returns: FFIType.pointer,
},
});
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,
Expand All @@ -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);
});

Expand Down Expand Up @@ -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", () => {
Expand Down
Loading