From d5ac31f812f173b6c758e014be6488406313d1d4 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 00:22:04 -0500 Subject: [PATCH 1/3] Raise through cached unwind plans on x86-64 Linux scr_unwind_raise walks the stack with plans cached per return address. The first raise through a return address finds the function's FDE through the .eh_frame_hdr of the object that contains it, runs the CFI up to the call, and finds the call site's landing pad in the LSDA; the plan records the frame's CFA rule, where its callee-saved registers are saved, and the landing pad. Later raises look the plans up, restore the registers, and jump to the first landing pad. Plans are inserted under a lock and read without one. Landing pads continue the unwind with scr_unwind_resume instead of `resume`, so the walk goes on from their frame. A frame whose CFI, pointer encodings or personality the plans do not express, or a return address without an FDE, leaves the unwind to libgcc's forced unwind from the frame the walk started at, as before. Other Linux targets keep libgcc's unwinder; their scr_unwind_resume calls _Unwind_Resume. bench:runtime gains layered-errors, a workload whose errors are thrown below small functions that hold references and caught per entry in a loop, and the corpus gains unwind-landing-pad-chains, which pins landing pads that continue the unwind, including in frames LLVM inlines into a caller whose handler takes the exception. --- benchmarks/runtime/README.md | 1 + .../runtime/workloads/layered-errors.json | 6 + .../runtime/workloads/layered-errors.ts | 95 +++ packages/compiler/src/backend/llvm/blocks.ts | 12 +- .../src/backend/llvm/emit-function.ts | 7 +- packages/compiler/src/backend/llvm/emitter.ts | 4 +- .../src/backend/llvm/unwind-abi.test.ts | 27 +- .../compiler/src/backend/llvm/unwind-abi.ts | 43 +- .../corpus/unwind-landing-pad-chains.ts.json | 6 + packages/runtime/src/scr_unwind.c | 713 +++++++++++++++++- packages/runtime/test/test_unwind.c | 165 ++++ packages/runtime/test/test_unwind_frames.S | 101 +++ packages/runtime/test/test_unwind_frames.ll | 131 ++++ packages/runtime/test/unwind.test.ts | 48 ++ scripts/zero-cost-compare.mjs | 5 +- tests/corpus/unwind-landing-pad-chains.ts | 104 +++ .../unwind-landing-pad-chains.ts.json | 1 + 17 files changed, 1438 insertions(+), 31 deletions(-) create mode 100644 benchmarks/runtime/workloads/layered-errors.json create mode 100644 benchmarks/runtime/workloads/layered-errors.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/unwind-landing-pad-chains.ts.json create mode 100644 packages/runtime/test/test_unwind.c create mode 100644 packages/runtime/test/test_unwind_frames.S create mode 100644 packages/runtime/test/test_unwind_frames.ll create mode 100644 packages/runtime/test/unwind.test.ts create mode 100644 tests/corpus/unwind-landing-pad-chains.ts create mode 100644 tests/harness/corpus-costs/unwind-landing-pad-chains.ts.json diff --git a/benchmarks/runtime/README.md b/benchmarks/runtime/README.md index f3a444ea98..149c348d04 100644 --- a/benchmarks/runtime/README.md +++ b/benchmarks/runtime/README.md @@ -21,6 +21,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea | `json-records` | cpu | `JSON.stringify`, typed `JSON.parse`, Map aggregation | | `json-unions` | cpu | `JSON.parse` into discriminated unions: an event log with eight kinds and nested expression trees with nine, evaluated after the cast | | `keyed-option-writes` | cpu | class methods whose names are also keys of option records written through element accesses: `enabled[name] = true` over a union of literal names and `totals["count"] = n` | +| `layered-errors` | cpu | validation errors thrown below a stack of small functions that hold strings and arrays, and caught per entry in a loop: about half of 640,000 entries fail | | `line-reader` | cpu | `readline` over a file stream: 'line' events for a 67 MB access log (every 50th line not ASCII), with a status count, a path tally and byte totals | | `log-lines` | cpu | 200k immediate `console.log`/`process.stdout.write` lines into the runner's pipe | | `log-summary`, `inventory-report` | cpu | the multi-module applications from `benchmarks/builds`, on 300k-line inputs | diff --git a/benchmarks/runtime/workloads/layered-errors.json b/benchmarks/runtime/workloads/layered-errors.json new file mode 100644 index 0000000000..70971552c4 --- /dev/null +++ b/benchmarks/runtime/workloads/layered-errors.json @@ -0,0 +1,6 @@ +{ + "entry": "workloads/layered-errors.ts", + "args": [ + "1" + ] +} diff --git a/benchmarks/runtime/workloads/layered-errors.ts b/benchmarks/runtime/workloads/layered-errors.ts new file mode 100644 index 0000000000..bf2ff581cb --- /dev/null +++ b/benchmarks/runtime/workloads/layered-errors.ts @@ -0,0 +1,95 @@ +// A configuration loader whose validation errors are thrown at the bottom +// of a stack of small layers and caught per entry at the top: each layer +// holds strings and arrays while the error passes through it. +class ConfigError extends Error { + readonly key: string; + constructor(key: string, message: string) { + super(message); + this.key = key; + } +} + +let seed = 4242; +function random(): number { + seed = (Math.imul(seed, 1103515245) + 12345) >>> 0; + return seed / 4294967296; +} + +function fail(key: string, text: string): never { + throw new ConfigError(key, `invalid value "${text}" for ${key}`); +} +function digits(key: string, text: string): number { + let value = 0; + for (let i = 0; i < text.length; i++) { + const c = text.charCodeAt(i); + if (c < 48 || c > 57) fail(key, text); + value = value * 10 + (c - 48); + } + return value; +} +function parseNumber(key: string, text: string): number { + return text.length === 0 ? fail(key, text) : digits(key, text); +} +function checkRange(key: string, text: string, max: number): number { + const value = parseNumber(key, text); + return value > max ? fail(key, text) : value; +} +function coerce(key: string, raw: string, max: number): number { + const trimmed = raw.trim(); + const parts = [key, trimmed]; + const value = checkRange(key, trimmed, max); + return value + parts.length; +} +function resolveValue(key: string, raw: string, defaults: Map): number { + const text = raw === "default" ? (defaults.get(key) ?? "") : raw; + const label = "value:" + key; + return coerce(key, text, 100000) + label.length; +} +function loadEntry(section: string, entry: string, defaults: Map): number { + const eq = entry.indexOf("="); + const key = section + "." + entry.slice(0, eq); + const path = [section, key]; + return resolveValue(key, entry.slice(eq + 1), defaults) + path.length; +} +function loadEntryWithContext(section: string, entry: string, defaults: Map): number { + const context = `${section}/${entry.length}`; + return loadEntry(section, entry, defaults) + context.length; +} + +function makeEntry(i: number): string { + const r = random(); + const name = "k" + (i % 97); + if (r < 0.3) return `${name}=x${i}`; + if (r < 0.4) return `${name}=`; + if (r < 0.5) return `${name}=${200000 + i}`; + if (r < 0.55) return `${name}=default`; + return `${name}= ${Math.floor(random() * 90000)} `; +} + +const rounds = Number(process.argv[2] ?? "1"); +const defaults = new Map(); +for (let i = 0; i < 97; i += 2) defaults.set(`net.k${i}`, String(i * 10)); +const sections = ["net", "db", "cache", "auth"]; +const entries: string[] = []; +for (let i = 0; i < 4000; i++) entries.push(makeEntry(i)); + +let loaded = 0; +let sum = 0; +let failed = 0; +const failedKeys = new Map(); +for (let round = 0; round < rounds * 40; round++) { + for (const section of sections) { + for (const entry of entries) { + try { + sum += loadEntryWithContext(section, entry, defaults); + loaded++; + } catch (error) { + if (!(error instanceof ConfigError)) throw error; + failed++; + failedKeys.set(error.key, (failedKeys.get(error.key) ?? 0) + 1); + } + } + } +} +console.log(`loaded ${loaded} failed ${failed} sum ${sum} distinct failing keys ${failedKeys.size}`); +console.log(`net.k0 failures ${failedKeys.get("net.k0") ?? 0}`); diff --git a/packages/compiler/src/backend/llvm/blocks.ts b/packages/compiler/src/backend/llvm/blocks.ts index 3ae65cb0f0..2aef040a19 100644 --- a/packages/compiler/src/backend/llvm/blocks.ts +++ b/packages/compiler/src/backend/llvm/blocks.ts @@ -97,12 +97,22 @@ export class BlockBuilder { if (s === UNWIND_RESUME) { const exception = this.tmp(); this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`); - s = `resume { ptr, i32 } ${exception}`; + this.resumeUnwind(exception); + return; } this.cur.term = ` ${this.withDebugLocation(s)}`; } } + /** Ends the block by continuing an unwind with `exception`, the value + * of a landing pad (UNWIND_RESUME_DECLARATION). */ + resumeUnwind(exception: string): void { + const token = this.tmp(); + this.line(`${token} = extractvalue { ptr, i32 } ${exception}, 0`); + this.line(`call void @scr_unwind_resume(ptr ${token})`); + this.terminate("unreachable"); + } + br(label: string, attachment = ""): void { this.terminate(`br label %${label}${attachment}`); } diff --git a/packages/compiler/src/backend/llvm/emit-function.ts b/packages/compiler/src/backend/llvm/emit-function.ts index 02c12305c5..aab4459e0c 100644 --- a/packages/compiler/src/backend/llvm/emit-function.ts +++ b/packages/compiler/src/backend/llvm/emit-function.ts @@ -62,7 +62,7 @@ import { emitIntegerClone, emitIntegerDispatcher } from "./integer-clones.js"; import { beginFunctionLoops, LOOPS } from "./loop-state.js"; import { runningExtremumUpdates } from "./running-extrema.js"; import { findIntegerOrNaNLocals } from "./wrapping-sums.js"; -import { UNWIND_PERSONALITY, UNWIND_SLOT } from "./unwind-abi.js"; +import { UNWIND_PERSONALITY, UNWIND_RESUME_DECLARATION, UNWIND_SLOT } from "./unwind-abi.js"; export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { const B = new BlockBuilder(); @@ -681,7 +681,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string { emitter.privateGlobalSlots.clear(); // Landing pads (emitDirectCall) need the personality. const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : ""; - if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`); + if (emitter.usesLandingPads) { + emitter.declare(`declare i32 @__gcc_personality_v0(...)`); + emitter.declare(UNWIND_RESUME_DECLARATION); + } let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${personality}${debug} { ; ${fn.name}\n${B.render()}\n}`; if (int32Abi) body += "\n" + emitter.emitInt32AbiWrapper(fn, symbol, int32Abi); if (emitter.integerClones.members.has(fn.name)) { diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e25d78e53d..9c2d3bfe64 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -160,6 +160,7 @@ import { UNWIND_PERSONALITY, UNWIND_RAISE, UNWIND_RAISE_DECLARATION, + UNWIND_RESUME_DECLARATION, UNWIND_RESUME, UNWIND_SLOT, UnwindAbiViolation, @@ -6110,7 +6111,8 @@ export class LlEmitter { const exception = B.tmp(); B.line(`${exception} = landingpad { ptr, i32 } cleanup`); for (const index of borrowed) this.releaseFrame(`%p${index}`, fn.params[index]!.type); - B.terminate(`resume { ptr, i32 } ${exception}`); + this.declare(UNWIND_RESUME_DECLARATION); + B.resumeUnwind(exception); B.startBlock(ok); } else B.line(ret === "void" ? call : `%result = ${call}`); // A borrowed result is reachable from the parameters: own it first. A diff --git a/packages/compiler/src/backend/llvm/unwind-abi.test.ts b/packages/compiler/src/backend/llvm/unwind-abi.test.ts index bcef1c3796..da490324b7 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.test.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.test.ts @@ -93,7 +93,9 @@ test("a method of a class outside any hierarchy is on the unwinding ABI; closure // closure over mid is a pointer target and returns with the flag. const add = body(ir, "Counter_add"); expect(add).toMatch(/invoke double @sc_b?f_[^(]*mid/); - expect(add).toMatch(/resume \{ ptr, i32 \} %[\w.]+/); + expect(add).toMatch(/call void @scr_unwind_resume\(ptr %[\w.]+\)\n\s+unreachable/); + expect(add).not.toMatch(/\bresume \{/); + expect(ir).toContain("declare void @scr_unwind_resume(ptr) cold"); expect(add).not.toContain("@scr_unwind_raise"); const closures = ir .split(/\n(?=define )/) @@ -212,7 +214,10 @@ test("an invoked wrapper of a raising function resumes the unwind; its address m expect(out).toContain( "%r = invoke double @sc_bf_body.i32(i32 %i) to label %ub.ok.0 unwind label %ub.lp.0", ); - expect(out).toContain("resume { ptr, i32 } %ub.v.0"); + expect(out).toContain( + "%ub.e.0 = extractvalue { ptr, i32 } %ub.v.0, 0\n call void @scr_unwind_resume(ptr %ub.e.0)\n unreachable", + ); + expect(out).toContain("declare void @scr_unwind_resume(ptr) cold"); expect(() => bridgePlainUnwindCalls([ `${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`, @@ -277,11 +282,27 @@ test("calls that cannot unwind are marked nounwind", () => { " unreachable", "}", ].join("\n"); - const [out] = bridgePlainUnwindCalls([raising]); + const resuming = [ + "define internal void @sc_bf_resumes(ptr %b) #0 personality ptr @__gcc_personality_v0 {", + "entry:", + " invoke void @sc_bf_raises(ptr %b) to label %ok unwind label %lp", + "ok:", + " ret void", + "lp:", + " %e = landingpad { ptr, i32 } cleanup", + " %t = extractvalue { ptr, i32 } %e, 0", + " call void @scr_unwind_resume(ptr %t)", + " unreachable", + "}", + ].join("\n"); + const [out] = bridgePlainUnwindCalls([[raising, resuming].join("\n")]); expect(out).toContain("%n = call i64 @scr_bytes_length(ptr %b) nounwind ; length"); expect(out).toContain("call void @scr_bytes_release(ptr %b) nounwind, !dbg !4"); expect(out).toContain("%r = call ptr %b(ptr %b) nounwind"); expect(out).toContain(" call void @scr_unwind_raise()\n"); + // The resume unwinds: where LLVM inlines the function at an invoke, the + // call becomes an invoke of the caller's landing pad. + expect(out).toContain(" call void @scr_unwind_resume(ptr %t)\n"); }); test("functions without a plain call of a raising function change only their call lines", () => { diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index fdd0a2f18b..2b13a06662 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -76,9 +76,11 @@ function recursiveFunctions(edges: ReadonlyMap): Set< * than a `call`'s callee, and not called from an async or generator body * (coroutine bodies keep plain calls). * - * A native unwind costs about 2 µs a raise and 0.1 µs a frame, against a - * few nanoseconds for a flag test, so the functions an exception is likely to - * cross many of keep the flag: those with a `throw` or rethrow of their own + * A native unwind costs about 0.05 µs a raise and 10 to 25 ns a frame on + * x86-64 (scr_unwind.c's cached plans) and about 2 µs a raise and 0.1 µs a + * frame through libgcc's unwinder, against a few nanoseconds for a flag + * test, so the functions an exception is likely to cross many of keep the + * flag: those with a `throw` or rethrow of their own * and those up to THROW_LEVELS calls above one (a handler near the throw * takes it without a native unwind), and the recursive ones (deep stacks * are recursions). */ @@ -254,8 +256,8 @@ export function unwindAbiFunctions( * its own takes the exception. */ export const UNWIND_RAISE = "call void @scr_unwind_raise()\n unreachable"; /** What the unwind of a landing pad ends with when no handler of the - * function takes the exception: the unwind goes on (`resume`) with the - * exception the landing pad stored in UNWIND_SLOT. The block builder + * function takes the exception: the unwind goes on (scr_unwind_resume) with + * the exception the landing pad stored in UNWIND_SLOT. The block builder * expands it (BlockBuilder.terminate). */ export const UNWIND_RESUME = "resume { ptr, i32 } "; /** The function's slot for the exception its landing pads take. */ @@ -264,13 +266,17 @@ export const UNWIND_SLOT = "%unw.exc"; * would call the sanitizer's no-return hook before it, which fails on fiber * stacks; scr_unwind_raise clears the unwound frames' shadow itself. */ export const UNWIND_RAISE_DECLARATION = "declare void @scr_unwind_raise() cold"; +/** scr_unwind_resume's declaration. Landing pads call it with the + * exception they took to continue the unwind (BlockBuilder.resumeUnwind); + * like scr_unwind_raise, it is not `noreturn`. */ +export const UNWIND_RESUME_DECLARATION = "declare void @scr_unwind_resume(ptr) cold"; /** The personality of functions with landing pads: the landing pads are - * cleanups that scr_unwind_raise's forced unwind runs. */ + * cleanups that scr_unwind_raise's unwind runs. */ export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0"; -/** An unwind leaving the function: a raise, or a landing pad's `resume`. */ -const UNWINDS = /call void @scr_unwind_raise\(\)|\n\s+resume /; +/** An unwind leaving the function: a raise, or a landing pad's resume. */ +const UNWINDS = /call void @scr_unwind_(?:raise\(\)|resume\()/; /** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */ const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/; @@ -307,15 +313,16 @@ function argumentsEnd(line: string, open: number): number { return close; } -/** A call that cannot unwind, marked so: only scr_unwind_raise and the - * raising functions propagate an unwind (runtime code and every function - * reached through a pointer return normally), so every other call is - * `nounwind`, and the inliner keeps it a call where it inlines a raising - * function at an invoke. */ +/** A call that cannot unwind, marked so: only scr_unwind_raise, + * scr_unwind_resume and the raising functions propagate an unwind (runtime + * code and every function reached through a pointer return normally), so + * every other call is `nounwind`, and the inliner keeps it a call where it + * inlines a raising function at an invoke. */ function markNounwind(line: string): string { const call = line.indexOf("call "); const callee = CALLEE.exec(line.slice(call)); - if (callee === null || callee[0] === "@scr_unwind_raise(") return line; + if (callee === null || callee[0] === "@scr_unwind_raise(" || callee[0] === "@scr_unwind_resume(") + return line; const close = argumentsEnd(line, call + callee.index + callee[0].length - 1); return close >= line.length ? line @@ -446,6 +453,7 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] { } if (violations.size > 0) throw new UnwindAbiViolation(violations); let personality = false; + let resumes = false; let counter = 0; const out = split.map((chunks) => chunks @@ -525,8 +533,11 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] { result.push( `${pad}:`, `${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`, - `${indent}resume { ptr, i32 } %ub.v.${n}`, + `${indent}%ub.e.${n} = extractvalue { ptr, i32 } %ub.v.${n}, 0`, + `${indent}call void @scr_unwind_resume(ptr %ub.e.${n})`, + `${indent}unreachable`, ); + resumes = true; result.push(`${ok}:`); } else if (m[2] !== undefined) { const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim(); @@ -585,5 +596,7 @@ export function bridgePlainUnwindCalls(parts: string[]): string[] { ); if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)"))) out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`; + if (resumes && !out.some((part) => part.includes(UNWIND_RESUME_DECLARATION))) + out[0] = `${UNWIND_RESUME_DECLARATION}\n${out[0]}`; return out; } diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/unwind-landing-pad-chains.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/unwind-landing-pad-chains.ts.json new file mode 100644 index 0000000000..84c925f1ed --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/unwind-landing-pad-chains.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/unwind-landing-pad-chains.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_unwind.c b/packages/runtime/src/scr_unwind.c index 50474e82e4..f75ee39906 100644 --- a/packages/runtime/src/scr_unwind.c +++ b/packages/runtime/src/scr_unwind.c @@ -1,6 +1,24 @@ /* scr_unwind_raise: the native unwind of the unwinding exception ABI of - * emitted functions (the compiler's unwind-abi.ts). Linux (glibc and musl) - * only; elsewhere this unit is empty. */ + * emitted functions (the compiler's unwind-abi.ts), and scr_unwind_resume, + * which emitted landing pads call to continue it. Linux (glibc and musl) + * only; elsewhere this unit is empty. + * + * Emitted landing pads are cleanups: they release the frame's references + * and then either take the exception (a handler of theirs, or a frame on + * the pending-flag ABI that returns with it pending) or resume the unwind. + * So the unwind is a single pass through every landing pad; the exception + * itself stays in the active cell. + * + * On x86-64 the unwind walks with plans cached per return address. The + * first raise through a return address finds the function's FDE through + * the .eh_frame_hdr of the object that contains it, runs the CFI up to the + * call, and finds the call site's landing pad in the LSDA. The plan records + * how to compute the frame's CFA, where each callee-saved register is + * saved, and the landing pad; later raises only look plans up. A landing + * pad runs with rax holding the raise's token and rdx 0 (a cleanup). When a + * frame's FDE uses CFI or encodings the plans do not express, or a return + * address has no FDE, the unwind goes on from the frame the walk started at + * with libgcc's forced unwind, as on other Linux targets. */ #include "scr_runtime.h" #if defined(__linux__) && !defined(__ANDROID__) && !defined(__wasi__) @@ -16,6 +34,7 @@ * which link it in. */ #pragma weak _Unwind_ForcedUnwind #pragma weak _Unwind_GetCFA +#pragma weak _Unwind_Resume #if defined(__SANITIZE_ADDRESS__) #define SCR_UNWIND_ASAN 1 @@ -34,11 +53,6 @@ static SCR_TL uintptr_t scr_unwind_low; #endif -/* Emitted landing pads are cleanups: they release the frame's references - * and then either take the exception (a handler of theirs, or a frame on - * the pending-flag ABI that returns with it pending) or resume the unwind. - * So the unwind is forced, a single pass through every landing pad; the - * exception itself stays in the active cell. */ static _Unwind_Reason_Code scr_unwind_stop(int version, _Unwind_Action actions, _Unwind_Exception_Class cls, struct _Unwind_Exception *exc, @@ -69,7 +83,10 @@ static _Unwind_Reason_Code scr_unwind_stop(int version, _Unwind_Action actions, static SCR_TL struct _Unwind_Exception scr_unwind_exception; -void scr_unwind_raise(void) { +/* libgcc's forced unwind, from the caller of the function that calls this. + * It does not return; it is not declared noreturn, so sanitized builds call + * no sanitizer hook before it (see scr_unwind_stop). */ +static __attribute__((noinline)) void scr_unwind_forced(void) { struct _Unwind_Exception *exc = &scr_unwind_exception; memset(exc, 0, sizeof *exc); exc->exception_class = 0x5343524950540000ull; /* "SCRIPT\0\0" */ @@ -80,4 +97,684 @@ void scr_unwind_raise(void) { fputs("scriptc: the native unwinder could not raise an exception\n", stderr); abort(); } + +#if defined(__x86_64__) +#include + +/* DWARF register numbers. */ +enum { + SCR_UNW_RBX = 3, + SCR_UNW_RBP = 6, + SCR_UNW_RSP = 7, + SCR_UNW_R12 = 12, + SCR_UNW_R13 = 13, + SCR_UNW_R14 = 14, + SCR_UNW_R15 = 15, + SCR_UNW_RA = 16, + SCR_UNW_REGS = 17 +}; + +/* The callee-saved registers a plan restores, in the order of its bits. */ +static const uint8_t scr_unwind_callee[6] = {SCR_UNW_RBX, SCR_UNW_RBP, SCR_UNW_R12, + SCR_UNW_R13, SCR_UNW_R14, SCR_UNW_R15}; + +typedef struct { + uint64_t pc; /* the return address the plan is for (0: an empty slot) */ + uint64_t pad; /* the call site's landing pad, or 0 */ + int32_t cfa_off; /* the CFA is cfa_reg's value plus cfa_off */ + uint8_t cfa_reg; /* SCR_UNW_RSP or SCR_UNW_RBP */ + uint8_t saved; /* bit i: scr_unwind_callee[i] is saved at CFA + off[i] * 8 */ + int8_t off[6]; + uint8_t fallback; /* the frame is left to libgcc's unwinder */ +} ScrUnwindPlan; + +_Static_assert(sizeof(ScrUnwindPlan) == 32, "a plan is half a cache line"); + +#define SCR_UNWIND_PLAN_BITS 14 +#define SCR_UNWIND_PLAN_SLOTS (1u << SCR_UNWIND_PLAN_BITS) +static ScrUnwindPlan scr_unwind_plans[SCR_UNWIND_PLAN_SLOTS]; +static const ScrUnwindPlan scr_unwind_fallback_plan = {.fallback = 1}; +static size_t scr_unwind_plan_count; +/* Inserts take this lock; lookups take none. A plan is published by the + * release store of its pc. */ +static int scr_unwind_lock; + +/* Reads a value in DWARF pointer encoding enc. datarel is the base of + * datarel values (0 where they do not occur). Sets *bad for an encoding the + * plans do not read, which includes indirect values. Like libgcc, a zero + * value stays zero. */ +static uint64_t scr_unwind_read(const uint8_t **p, uint8_t enc, uint64_t datarel, int *bad) { + const uint8_t *start = *p; + uint64_t v = 0; + switch (enc & 0x0f) { + case 0x00: /* absptr */ + case 0x04: /* udata8 */ + case 0x0c: /* sdata8 */ + memcpy(&v, *p, 8); + *p += 8; + break; + case 0x01: { /* uleb128 */ + unsigned shift = 0; + uint8_t b; + do { + b = *(*p)++; + if (shift < 64) v |= (uint64_t)(b & 0x7f) << shift; + shift += 7; + } while (b & 0x80); + break; + } + case 0x02: { + uint16_t x; + memcpy(&x, *p, 2); + *p += 2; + v = x; + break; + } + case 0x03: { + uint32_t x; + memcpy(&x, *p, 4); + *p += 4; + v = x; + break; + } + case 0x0a: { + int16_t x; + memcpy(&x, *p, 2); + *p += 2; + v = (uint64_t)(int64_t)x; + break; + } + case 0x0b: { + int32_t x; + memcpy(&x, *p, 4); + *p += 4; + v = (uint64_t)(int64_t)x; + break; + } + default: /* sleb128 and unknown formats */ + *bad = 1; + return 0; + } + if (v == 0) return 0; + switch (enc & 0x70) { + case 0x00: + break; + case 0x10: /* pcrel */ + v += (uint64_t)(uintptr_t)start; + break; + case 0x30: /* datarel */ + if (datarel == 0) *bad = 1; + v += datarel; + break; + default: + *bad = 1; + return 0; + } + if (enc & 0x80) *bad = 1; /* indirect: read only through scr_unwind_skip */ + return v; +} + +static uint64_t scr_unwind_uleb(const uint8_t **p) { + int bad = 0; + return scr_unwind_read(p, 0x01, 0, &bad); +} + +static int64_t scr_unwind_sleb(const uint8_t **p) { + int64_t v = 0; + unsigned shift = 0; + uint8_t b; + do { + b = *(*p)++; + if (shift < 64) v |= (int64_t)((uint64_t)(b & 0x7f) << shift); + shift += 7; + } while (b & 0x80); + if (shift < 64 && (b & 0x40)) v |= (int64_t)(~(uint64_t)0 << shift); + return v; +} + +/* A search for the loaded object that contains pc, and its .eh_frame_hdr + * search table. */ +typedef struct { + uint64_t pc; + const uint8_t *hdr; + const uint8_t *table; + size_t count; +} ScrUnwindObject; + +static int scr_unwind_find_object(struct dl_phdr_info *info, size_t size, void *data) { + (void)size; + ScrUnwindObject *object = data; + int contains = 0; + const uint8_t *h = NULL; + for (int i = 0; i < info->dlpi_phnum; i++) { + const ElfW(Phdr) *phdr = &info->dlpi_phdr[i]; + uint64_t start = (uint64_t)info->dlpi_addr + phdr->p_vaddr; + if (phdr->p_type == PT_LOAD && object->pc >= start && object->pc - start < phdr->p_memsz) + contains = 1; + else if (phdr->p_type == PT_GNU_EH_FRAME) + h = (const uint8_t *)(uintptr_t)start; + } + if (!contains) return 0; + /* Version 1 with the table LLVM's and GNU's linkers write: datarel + * sdata4 pairs, sorted by initial location. */ + if (h != NULL && h[0] == 1 && h[1] != 0xff && h[2] != 0xff && h[3] == 0x3b) { + int bad = 0; + const uint8_t *p = h + 4; + (void)scr_unwind_read(&p, h[1], (uint64_t)(uintptr_t)h, &bad); + uint64_t count = scr_unwind_read(&p, h[2], (uint64_t)(uintptr_t)h, &bad); + if (!bad) { + object->hdr = h; + object->table = p; + object->count = (size_t)count; + } + } + return 1; +} + +/* The FDE whose initial location is the greatest one at or below pc in + * the table of the object that contains pc. */ +static const uint8_t *scr_unwind_find_fde(uint64_t pc) { + ScrUnwindObject object = {.pc = pc}; + dl_iterate_phdr(scr_unwind_find_object, &object); + if (object.table == NULL) return NULL; + uint64_t base = (uint64_t)(uintptr_t)object.hdr; + size_t lo = 0, hi = object.count; + while (lo < hi) { + size_t mid = lo + (hi - lo) / 2; + int32_t start; + memcpy(&start, object.table + mid * 8, 4); + if (base + (uint64_t)(int64_t)start <= pc) lo = mid + 1; + else hi = mid; + } + if (lo == 0) return NULL; + int32_t fde; + memcpy(&fde, object.table + (lo - 1) * 8 + 4, 4); + return (const uint8_t *)(uintptr_t)(base + (uint64_t)(int64_t)fde); +} + +#define SCR_UNWIND_UNSAVED INT32_MIN + +typedef struct { + uint64_t cfa_reg; + int64_t cfa_off; + int32_t saved[SCR_UNW_REGS]; /* CFA-relative offsets, or SCR_UNWIND_UNSAVED */ + uint64_t args_size; +} ScrUnwindRules; + +/* Whether the plans track register reg: the callee-saved registers, the + * stack pointer and the return address. */ +static int scr_unwind_tracked(uint64_t reg) { + return reg == SCR_UNW_RBX || reg == SCR_UNW_RBP || reg == SCR_UNW_RSP || + (reg >= SCR_UNW_R12 && reg <= SCR_UNW_RA); +} + +static void scr_unwind_save(ScrUnwindRules *r, uint64_t reg, int64_t off, int *bad) { + if (reg >= SCR_UNW_REGS) return; /* vector and other registers stay as they are */ + if (off < INT32_MIN + 1 || off > INT32_MAX) *bad = 1; + else r->saved[reg] = (int32_t)off; +} + +/* Runs CFI instructions up to the location pc. Sets *bad for an + * instruction the plans do not express. */ +static void scr_unwind_run_cfi(const uint8_t *p, const uint8_t *end, uint64_t code_align, + int64_t data_align, uint64_t loc, uint64_t pc, ScrUnwindRules *r, + const ScrUnwindRules *initial, int *bad) { + ScrUnwindRules stack[8]; + int depth = 0; + while (p < end && loc <= pc && !*bad) { + uint8_t op = *p++; + uint8_t low = op & 0x3f; + switch (op & 0xc0) { + case 0x40: /* advance_loc */ + loc += low * code_align; + continue; + case 0x80: /* offset */ + scr_unwind_save(r, low, (int64_t)scr_unwind_uleb(&p) * data_align, bad); + continue; + case 0xc0: /* restore */ + if (low < SCR_UNW_REGS) r->saved[low] = initial->saved[low]; + continue; + default: + break; + } + switch (op) { + case 0x00: /* nop */ + break; + case 0x02: /* advance_loc1 */ + loc += *p++ * code_align; + break; + case 0x03: { /* advance_loc2 */ + uint16_t d; + memcpy(&d, p, 2); + p += 2; + loc += d * code_align; + break; + } + case 0x04: { /* advance_loc4 */ + uint32_t d; + memcpy(&d, p, 4); + p += 4; + loc += d * code_align; + break; + } + case 0x05: { /* offset_extended */ + uint64_t reg = scr_unwind_uleb(&p); + scr_unwind_save(r, reg, (int64_t)scr_unwind_uleb(&p) * data_align, bad); + break; + } + case 0x06: { /* restore_extended */ + uint64_t reg = scr_unwind_uleb(&p); + if (reg < SCR_UNW_REGS) r->saved[reg] = initial->saved[reg]; + break; + } + case 0x07: /* undefined */ + case 0x08: { /* same_value */ + uint64_t reg = scr_unwind_uleb(&p); + if (op == 0x07 && scr_unwind_tracked(reg)) *bad = 1; + else if (reg < SCR_UNW_REGS) r->saved[reg] = SCR_UNWIND_UNSAVED; + break; + } + case 0x09: { /* register */ + uint64_t reg = scr_unwind_uleb(&p); + (void)scr_unwind_uleb(&p); + if (scr_unwind_tracked(reg)) *bad = 1; + break; + } + case 0x0a: /* remember_state */ + if (depth == 8) *bad = 1; + else stack[depth++] = *r; + break; + case 0x0b: { /* restore_state: the CFA rule and args size stay */ + if (depth == 0) { + *bad = 1; + break; + } + uint64_t args_size = r->args_size; + *r = stack[--depth]; + r->args_size = args_size; + break; + } + case 0x0c: /* def_cfa */ + r->cfa_reg = scr_unwind_uleb(&p); + r->cfa_off = (int64_t)scr_unwind_uleb(&p); + break; + case 0x0d: /* def_cfa_register */ + r->cfa_reg = scr_unwind_uleb(&p); + break; + case 0x0e: /* def_cfa_offset */ + r->cfa_off = (int64_t)scr_unwind_uleb(&p); + break; + case 0x11: { /* offset_extended_sf */ + uint64_t reg = scr_unwind_uleb(&p); + scr_unwind_save(r, reg, scr_unwind_sleb(&p) * data_align, bad); + break; + } + case 0x12: /* def_cfa_sf */ + r->cfa_reg = scr_unwind_uleb(&p); + r->cfa_off = scr_unwind_sleb(&p) * data_align; + break; + case 0x13: /* def_cfa_offset_sf */ + r->cfa_off = scr_unwind_sleb(&p) * data_align; + break; + case 0x2e: /* GNU_args_size */ + r->args_size = scr_unwind_uleb(&p); + break; + default: /* expressions, val_offset, set_loc and vendor instructions */ + *bad = 1; + break; + } + } +} + +/* The landing pad of the call site that contains pc in an LSDA, or 0. */ +static uint64_t scr_unwind_lsda_pad(const uint8_t *p, uint64_t func_start, uint64_t pc, int *bad) { + uint8_t lpstart_enc = *p++; + uint64_t lpstart = func_start; + if (lpstart_enc != 0xff) lpstart = scr_unwind_read(&p, lpstart_enc, 0, bad); + uint8_t ttype_enc = *p++; + if (ttype_enc != 0xff) (void)scr_unwind_uleb(&p); + uint8_t cs_enc = *p++; + uint64_t cs_len = scr_unwind_uleb(&p); + const uint8_t *cs_end = p + cs_len; + while (p < cs_end && !*bad) { + uint64_t start = scr_unwind_read(&p, cs_enc, 0, bad); + uint64_t len = scr_unwind_read(&p, cs_enc, 0, bad); + uint64_t pad = scr_unwind_read(&p, cs_enc, 0, bad); + (void)scr_unwind_uleb(&p); /* action */ + if (pc < func_start + start) return 0; + if (pc < func_start + start + len) return pad ? lpstart + pad : 0; + } + return 0; +} + +/* The personality routine of emitted functions; the walk reads their + * LSDAs itself. Frames with any other personality are left to libgcc. */ +extern _Unwind_Reason_Code __gcc_personality_v0(int, _Unwind_Action, _Unwind_Exception_Class, + struct _Unwind_Exception *, + struct _Unwind_Context *); +#pragma weak __gcc_personality_v0 + +/* Builds the plan of return address ra. */ +static void scr_unwind_build(ScrUnwindPlan *plan, uint64_t ra) { + memset(plan, 0, sizeof *plan); + plan->fallback = 1; + uint64_t pc = ra - 1; /* within the call instruction */ + const uint8_t *fde = scr_unwind_find_fde(pc); + if (fde == NULL) return; + uint32_t fde_len; + memcpy(&fde_len, fde, 4); + if (fde_len == 0 || fde_len == 0xffffffffu) return; + const uint8_t *fde_end = fde + 4 + fde_len; + int32_t cie_off; + memcpy(&cie_off, fde + 4, 4); + if (cie_off == 0) return; + const uint8_t *cie = fde + 4 - cie_off; + uint32_t cie_len; + memcpy(&cie_len, cie, 4); + if (cie_len == 0 || cie_len == 0xffffffffu) return; + const uint8_t *cie_end = cie + 4 + cie_len; + const uint8_t *q = cie + 8; /* past the length and the CIE id */ + uint8_t version = *q++; + if (version != 1 && version != 3) return; + const char *aug = (const char *)q; + q += strlen(aug) + 1; + uint64_t code_align = scr_unwind_uleb(&q); + int64_t data_align = scr_unwind_sleb(&q); + uint64_t ra_reg = version == 1 ? *q++ : scr_unwind_uleb(&q); + if (ra_reg != SCR_UNW_RA) return; + int bad = 0; + uint8_t fde_enc = 0x00, lsda_enc = 0xff; + if (aug[0] == 'z') { + uint64_t aug_len = scr_unwind_uleb(&q); + const uint8_t *aug_end = q + aug_len; + for (const char *a = aug + 1; *a != '\0'; a++) { + if (*a == 'R') { + fde_enc = *q++; + } else if (*a == 'L') { + lsda_enc = *q++; + } else if (*a == 'P') { + uint8_t enc = *q++; + uint64_t slot = scr_unwind_read(&q, enc & 0x7f, 0, &bad); + uint64_t personality = slot; + if ((enc & 0x80) && slot != 0) memcpy(&personality, (const void *)(uintptr_t)slot, 8); + if (personality != (uint64_t)(uintptr_t)&__gcc_personality_v0) bad = 1; + } else { + return; /* signal frames and unknown augmentations */ + } + } + q = aug_end; + } else if (aug[0] != '\0') { + return; + } + if (bad) return; + ScrUnwindRules initial = {.cfa_reg = SCR_UNW_RSP, .cfa_off = 8}; + for (int i = 0; i < SCR_UNW_REGS; i++) initial.saved[i] = SCR_UNWIND_UNSAVED; + scr_unwind_run_cfi(q, cie_end, code_align, data_align, 0, UINT64_MAX, &initial, &initial, &bad); + const uint8_t *f = fde + 8; + uint64_t func_start = scr_unwind_read(&f, fde_enc, 0, &bad); + uint64_t func_len = scr_unwind_read(&f, fde_enc & 0x0f, 0, &bad); + if (bad || pc < func_start || pc - func_start >= func_len) return; + const uint8_t *lsda = NULL; + if (aug[0] == 'z') { + uint64_t aug_len = scr_unwind_uleb(&f); + const uint8_t *aug_end = f + aug_len; + if (lsda_enc != 0xff && aug_len != 0) + lsda = (const uint8_t *)(uintptr_t)scr_unwind_read(&f, lsda_enc, 0, &bad); + f = aug_end; + } + ScrUnwindRules r = initial; + scr_unwind_run_cfi(f, fde_end, code_align, data_align, func_start, pc, &r, &initial, &bad); + if (bad || r.args_size != 0) return; + if (r.cfa_reg != SCR_UNW_RSP && r.cfa_reg != SCR_UNW_RBP) return; + if (r.cfa_off < INT32_MIN || r.cfa_off > INT32_MAX) return; + if (r.saved[SCR_UNW_RA] != -8) return; + uint8_t saved = 0; + for (int i = 0; i < 6; i++) { + int32_t o = r.saved[scr_unwind_callee[i]]; + if (o == SCR_UNWIND_UNSAVED) continue; + if (o % 8 != 0 || o / 8 < INT8_MIN || o / 8 > INT8_MAX) return; + saved |= (uint8_t)(1u << i); + plan->off[i] = (int8_t)(o / 8); + } + uint64_t pad = lsda ? scr_unwind_lsda_pad(lsda, func_start, pc, &bad) : 0; + if (bad) return; + plan->cfa_reg = (uint8_t)r.cfa_reg; + plan->cfa_off = (int32_t)r.cfa_off; + plan->saved = saved; + plan->pad = pad; + plan->fallback = 0; +} + +static uint64_t scr_unwind_hash(uint64_t ra) { + return (ra * 0x9E3779B97F4A7C15ull) >> (64 - SCR_UNWIND_PLAN_BITS); +} + +static __attribute__((noinline)) const ScrUnwindPlan *scr_unwind_insert(uint64_t ra) { + while (__atomic_exchange_n(&scr_unwind_lock, 1, __ATOMIC_ACQUIRE)) + while (__atomic_load_n(&scr_unwind_lock, __ATOMIC_RELAXED)) __builtin_ia32_pause(); + const ScrUnwindPlan *result = &scr_unwind_fallback_plan; + uint64_t h = scr_unwind_hash(ra); + ScrUnwindPlan *slot = &scr_unwind_plans[h]; + while (slot->pc != 0 && slot->pc != ra) { + h = (h + 1) & (SCR_UNWIND_PLAN_SLOTS - 1); + slot = &scr_unwind_plans[h]; + } + if (slot->pc == ra) { + result = slot; + } else if (scr_unwind_plan_count < SCR_UNWIND_PLAN_SLOTS / 4 * 3) { + /* A full table leaves further return addresses to libgcc. */ + scr_unwind_plan_count++; + ScrUnwindPlan built; + scr_unwind_build(&built, ra); + built.pc = 0; + *slot = built; + __atomic_store_n(&slot->pc, ra, __ATOMIC_RELEASE); + result = slot; + } + __atomic_store_n(&scr_unwind_lock, 0, __ATOMIC_RELEASE); + return result; +} + +static const ScrUnwindPlan *scr_unwind_plan(uint64_t ra) { + uint64_t h = scr_unwind_hash(ra); + for (;;) { + const ScrUnwindPlan *plan = &scr_unwind_plans[h]; + uint64_t pc = __atomic_load_n(&plan->pc, __ATOMIC_ACQUIRE); + if (pc == ra) return plan; + if (pc == 0) return scr_unwind_insert(ra); + h = (h + 1) & (SCR_UNWIND_PLAN_SLOTS - 1); + } +} + +/* Restores the callee-saved registers and the stack pointer of regs + * (indexed by DWARF number) and jumps to the landing pad with rax holding + * the token and rdx 0. */ +static _Noreturn __attribute__((naked)) void scr_unwind_install(const uint64_t *regs, uint64_t pad, + const void *token) { + __asm__ volatile("movq %rdx, %rax\n\t" + "movq %rsi, %r11\n\t" + "movq 24(%rdi), %rbx\n\t" + "movq 48(%rdi), %rbp\n\t" + "movq 96(%rdi), %r12\n\t" + "movq 104(%rdi), %r13\n\t" + "movq 112(%rdi), %r14\n\t" + "movq 120(%rdi), %r15\n\t" + "movq 56(%rdi), %rsp\n\t" + "xorl %edx, %edx\n\t" + "jmpq *%r11\n\t"); +} + +/* Identifies a walk's raise to the landing pads it runs: they pass it to + * scr_unwind_resume. The thrown value is in the active cell. */ +static const char scr_unwind_token; + +#ifdef SCR_UNWIND_TESTING +/* The walk's raises and resumes that went on with libgcc's unwinder, and + * the frames whose stack pointer or callee-saved registers differ from + * libgcc's for the same return address. */ +size_t scr_unwind_test_fallbacks; +size_t scr_unwind_test_mismatches; + +typedef struct { + uint64_t ip, cfa, regs[6]; +} ScrUnwindTestFrame; + +typedef struct { + ScrUnwindTestFrame frames[512]; + int count, next; +} ScrUnwindTestTrace; + +static _Thread_local ScrUnwindTestTrace scr_unwind_test_trace; + +static _Unwind_Reason_Code scr_unwind_test_record(struct _Unwind_Context *context, void *arg) { + ScrUnwindTestTrace *trace = arg; + if (trace->count == 512) return _URC_END_OF_STACK; + ScrUnwindTestFrame *frame = &trace->frames[trace->count++]; + frame->ip = _Unwind_GetIP(context); + frame->cfa = _Unwind_GetCFA(context); + for (int i = 0; i < 6; i++) frame->regs[i] = _Unwind_GetGR(context, scr_unwind_callee[i]); + return _URC_NO_REASON; +} + +/* Records libgcc's frames from here, before the walk changes anything. */ +static void scr_unwind_test_start(void) { + scr_unwind_test_trace.count = 0; + scr_unwind_test_trace.next = 0; + _Unwind_Backtrace(scr_unwind_test_record, &scr_unwind_test_trace); +} + +/* Compares the walk's frame, regs at its call, with libgcc's: libgcc's + * CFA there is the callee's, the frame's stack pointer at the call. */ +static void scr_unwind_test_check(const uint64_t *regs) { + ScrUnwindTestTrace *trace = &scr_unwind_test_trace; + while (trace->next < trace->count && trace->frames[trace->next].ip != regs[SCR_UNW_RA]) + trace->next++; + if (trace->next == trace->count) { + if (trace->count < 512) __atomic_add_fetch(&scr_unwind_test_mismatches, 1, __ATOMIC_RELAXED); + return; + } + const ScrUnwindTestFrame *frame = &trace->frames[trace->next++]; + int same = frame->cfa == regs[SCR_UNW_RSP]; + for (int i = 0; i < 6; i++) same &= frame->regs[i] == regs[scr_unwind_callee[i]]; + if (!same) __atomic_add_fetch(&scr_unwind_test_mismatches, 1, __ATOMIC_RELAXED); +} + +#define SCR_UNWIND_FALLBACK() __atomic_add_fetch(&scr_unwind_test_fallbacks, 1, __ATOMIC_RELAXED) +#define SCR_UNWIND_TEST_START() scr_unwind_test_start() +#define SCR_UNWIND_TEST_CHECK(regs) scr_unwind_test_check(regs) +#else +#define SCR_UNWIND_FALLBACK() ((void)0) +#define SCR_UNWIND_TEST_START() ((void)0) +#define SCR_UNWIND_TEST_CHECK(regs) ((void)0) +#endif + +/* Walks from the frame regs describes, a caller's registers at its call, + * and installs the first frame with a landing pad. Returns when a frame is + * left to libgcc, before anything has been installed. */ +static __attribute__((no_sanitize("address"))) void scr_unwind_walk(uint64_t *regs) { + SCR_UNWIND_TEST_START(); + for (;;) { + const ScrUnwindPlan *plan = scr_unwind_plan(regs[SCR_UNW_RA]); + if (plan->fallback) return; + SCR_UNWIND_TEST_CHECK(regs); + if (plan->pad != 0) { +#ifdef SCR_UNWIND_ASAN + /* The frames below the landing pad's never return: clear their + * redzones' shadow, up to the pad frame's stack pointer. */ + uintptr_t sp; + __asm__ volatile("movq %%rsp, %0" : "=r"(sp)); + if (regs[SCR_UNW_RSP] > sp) + __asan_unpoison_memory_region((void *)sp, regs[SCR_UNW_RSP] - sp); +#endif + scr_unwind_install(regs, plan->pad, &scr_unwind_token); + } + uint64_t cfa = regs[plan->cfa_reg] + (uint64_t)(int64_t)plan->cfa_off; + const uint64_t *frame = (const uint64_t *)(uintptr_t)cfa; + for (unsigned m = plan->saved; m != 0; m &= m - 1) { + int i = __builtin_ctz(m); + regs[scr_unwind_callee[i]] = frame[plan->off[i]]; + } + regs[SCR_UNW_RA] = frame[-1]; + regs[SCR_UNW_RSP] = cfa; + } +} + +/* Called by the entry stubs below with their caller's registers. */ +__attribute__((visibility("hidden"), used)) void scr_unwind_walk_raise(uint64_t *regs) { + scr_unwind_walk(regs); +} + +__attribute__((visibility("hidden"), used)) void scr_unwind_walk_resume(uint64_t *regs, + const void *exc) { + if (exc == &scr_unwind_token) scr_unwind_walk(regs); +} + +/* Where the entry stubs go when the walk returns, with the stack as it was + * at their entry: the caller is the emitted frame. */ +__attribute__((visibility("hidden"), used, no_sanitize("address"))) void +scr_unwind_raise_slow(void) { + SCR_UNWIND_FALLBACK(); + scr_unwind_forced(); +} + +__attribute__((visibility("hidden"), used, no_sanitize("address"))) void +scr_unwind_resume_slow(void *exc) { + /* A landing pad that libgcc's unwind ran passes libgcc's exception. */ + if (exc != &scr_unwind_token && _Unwind_Resume != NULL) _Unwind_Resume(exc); + SCR_UNWIND_FALLBACK(); + scr_unwind_forced(); +} + +/* The entry stubs store the caller's callee-saved registers, stack pointer + * after the return and return address into a register array on the stack, + * indexed by DWARF number (17 slots: 136 bytes, which keeps the stack + * 16-byte aligned at the call). */ +#define SCR_UNWIND_CAPTURE \ + "subq $136, %rsp\n\t" \ + ".cfi_adjust_cfa_offset 136\n\t" \ + "movq %rbx, 24(%rsp)\n\t" \ + "movq %rbp, 48(%rsp)\n\t" \ + "movq %r12, 96(%rsp)\n\t" \ + "movq %r13, 104(%rsp)\n\t" \ + "movq %r14, 112(%rsp)\n\t" \ + "movq %r15, 120(%rsp)\n\t" \ + "movq 136(%rsp), %rax\n\t" \ + "movq %rax, 128(%rsp)\n\t" \ + "leaq 144(%rsp), %rax\n\t" \ + "movq %rax, 56(%rsp)\n\t" + +__attribute__((naked)) void scr_unwind_raise(void) { + __asm__ volatile(SCR_UNWIND_CAPTURE "movq %rsp, %rdi\n\t" + "call scr_unwind_walk_raise\n\t" + "addq $136, %rsp\n\t" + ".cfi_adjust_cfa_offset -136\n\t" + "jmp scr_unwind_raise_slow\n\t"); +} + +/* Landing pads continue the unwind here with the exception they took. */ +__attribute__((naked)) void scr_unwind_resume(void *exc) { + __asm__ volatile(SCR_UNWIND_CAPTURE "movq %rdi, 40(%rsp)\n\t" + "movq %rdi, %rsi\n\t" + "movq %rsp, %rdi\n\t" + "call scr_unwind_walk_resume\n\t" + "movq 40(%rsp), %rdi\n\t" + "addq $136, %rsp\n\t" + ".cfi_adjust_cfa_offset -136\n\t" + "jmp scr_unwind_resume_slow\n\t"); +} + +#else + +void scr_unwind_raise(void) { + scr_unwind_forced(); + __builtin_unreachable(); +} + +/* Landing pads continue the unwind here with the exception they took. */ +void scr_unwind_resume(void *exc) { + if (_Unwind_Resume != NULL) _Unwind_Resume(exc); + scr_unwind_forced(); +} + +#endif #endif diff --git a/packages/runtime/test/test_unwind.c b/packages/runtime/test/test_unwind.c new file mode 100644 index 0000000000..5edabcde4f --- /dev/null +++ b/packages/runtime/test/test_unwind.c @@ -0,0 +1,165 @@ +/* Raises through the synthetic frames of test_unwind_frames.ll and + * test_unwind_frames.S with scr_unwind.c's walk: landing pads run inner + * first, the catching frame sees its callee-saved registers restored, every + * frame the walk steps through has the stack pointer and callee-saved + * registers libgcc's unwinder computes for it, and only frames with CFI the + * plans do not express go to libgcc's unwinder. + * Each case runs three times (the first builds plans, the others reuse + * them), and again from several threads at once. */ +#include +#include +#include +#include +#include + +typedef void Frame(void **chain); +extern Frame ir_raise, ir_plain, ir_plain_fp, ir_alloca, ir_pad, ir_pad_fp; +extern Frame asm_remember, asm_rbp, asm_expr; +extern int64_t ir_catch(void **chain, int64_t a, int64_t b, int64_t c, int64_t d, int64_t e, + int64_t f); +extern size_t scr_unwind_test_fallbacks; +extern size_t scr_unwind_test_mismatches; + +static _Thread_local void **cleanup_base; +static _Thread_local int cleanups[512]; +static _Thread_local int cleanup_count; + +/* A landing pad of the frame at chain[-1] ran. */ +void test_cleanup(void **chain) { + if (cleanup_count < 512) cleanups[cleanup_count] = (int)(chain - cleanup_base) - 1; + cleanup_count++; +} + +void test_touch(void *p) { memset(p, 0x5a, 16); } + +void test_return(void **chain) { (void)chain; } + +static int64_t checksum(int64_t a, int64_t b, int64_t c, int64_t d, int64_t e, int64_t f) { + uint64_t s = (uint64_t)a * 3 ^ (uint64_t)b * 5; + s += (uint64_t)c * 7; + s ^= (uint64_t)d * 11; + s += (uint64_t)e * 13; + s ^= (uint64_t)f * 17; + return (int64_t)(s & INT64_MAX); +} + +/* Writes over the stack the unwound frames used: in sanitized builds, a + * redzone the unwind left poisoned would be reported here. */ +static __attribute__((noinline)) void scrub(void) { + volatile char big[32768]; + memset((char *)big, 0, sizeof big); +} + +static void fail(const char *name, const char *what) { + fprintf(stderr, "%s: %s\n", name, what); + exit(1); +} + +/* Runs frames[0..n) below ir_catch. A case that ends in test_return + * raises nothing. Returns the case's fallbacks to libgcc. */ +static size_t run(const char *name, Frame *const *frames, int n, int64_t seed) { + void *chain[512]; + int expected[512]; + int pads = 0; + if (n >= 511) fail(name, "too many frames"); + for (int i = 0; i < n; i++) chain[i] = (void *)frames[i]; + chain[n] = NULL; + int raises = frames[n - 1] == ir_raise; + if (raises) + for (int i = n - 1; i >= 0; i--) + if (frames[i] == ir_pad || frames[i] == ir_pad_fp) expected[pads++] = i; + size_t before = __atomic_load_n(&scr_unwind_test_fallbacks, __ATOMIC_RELAXED); + cleanup_base = chain; + cleanup_count = 0; + int64_t a = seed, b = seed ^ 0x1234, c = seed + 77, d = seed * 31, e = ~seed, f = seed << 3; + int64_t got = ir_catch(chain, a, b, c, d, e, f); + int64_t want = checksum(a, b, c, d, e, f) | (raises ? INT64_MIN : 0); + if (got != want) fail(name, "the catching frame's values or raise flag differ"); + if (cleanup_count != pads) fail(name, "a different number of landing pads ran"); + for (int i = 0; i < pads; i++) + if (cleanups[i] != expected[i]) fail(name, "landing pads ran out of order"); + scrub(); + if (__atomic_load_n(&scr_unwind_test_mismatches, __ATOMIC_RELAXED) != 0) + fail(name, "a frame's stack pointer or callee-saved registers differ from libgcc's"); + return __atomic_load_n(&scr_unwind_test_fallbacks, __ATOMIC_RELAXED) - before; +} + +typedef struct { + const char *name; + Frame *frames[16]; + int count; + size_t fallbacks; /* raises or resumes that go to libgcc */ +} Case; + +/* Sanitized builds realign a frame with a dynamic alloca through r10 and + * describe it with DWARF expressions, which go to libgcc. */ +#if defined(__SANITIZE_ADDRESS__) +#define ALLOCA_FALLBACKS 1 +#elif defined(__has_feature) +#if __has_feature(address_sanitizer) +#define ALLOCA_FALLBACKS 1 +#endif +#endif +#ifndef ALLOCA_FALLBACKS +#define ALLOCA_FALLBACKS 0 +#endif + +static const Case cases[] = { + {"returns", {ir_pad, ir_plain, test_return}, 3, 0}, + {"raise", {ir_raise}, 1, 0}, + {"plain frames", {ir_plain, ir_plain_fp, ir_plain, ir_raise}, 4, 0}, + {"dynamic alloca", {ir_pad, ir_alloca, ir_pad, ir_raise}, 4, ALLOCA_FALLBACKS}, + {"landing pads", {ir_pad, ir_plain, ir_pad_fp, ir_pad, ir_raise}, 5, 0}, + {"remember and rbp", {asm_remember, ir_pad, asm_rbp, asm_remember, ir_raise}, 5, 0}, + {"libgcc above a pad", {asm_expr, ir_pad, ir_raise}, 3, 1}, + {"libgcc between pads", {ir_pad, asm_expr, ir_pad_fp, ir_raise}, 4, 1}, + {"libgcc at the raise", {ir_pad, asm_expr, ir_raise}, 3, 1}, +}; +#define CASES (sizeof cases / sizeof cases[0]) + +static void run_cases(int64_t seed) { + for (size_t i = 0; i < CASES; i++) { + size_t fallbacks = run(cases[i].name, cases[i].frames, cases[i].count, seed + (int64_t)i); + if (fallbacks != cases[i].fallbacks) fail(cases[i].name, "a different number of fallbacks"); + } +} + +/* A deep recursion of every frame kind. */ +static void run_deep(int64_t seed) { + static Frame *const kinds[] = {ir_pad, ir_plain, asm_remember, asm_rbp, ir_pad_fp, ir_plain_fp}; + Frame *frames[301]; + for (int i = 0; i < 300; i++) frames[i] = kinds[i % 6]; + frames[300] = ir_raise; + if (run("deep", frames, 301, seed) != 0) fail("deep", "a fallback to libgcc"); +} + +static void *thread_main(void *arg) { + int64_t seed = (int64_t)(intptr_t)arg; + for (int round = 0; round < 200; round++) { + for (size_t i = 0; i < CASES; i++) { + if (cases[i].fallbacks != 0) continue; /* the shared counter is checked alone */ + if (run(cases[i].name, cases[i].frames, cases[i].count, seed + round) != 0) + fail(cases[i].name, "a fallback to libgcc in a thread"); + } + if (round % 20 == 0) run_deep(seed); + } + return NULL; +} + +/* With an argument, the threads run first, so they build the plans + * concurrently. */ +int main(int argc, char **argv) { + (void)argv; + if (argc == 1) + for (int round = 0; round < 3; round++) { + run_cases(round * 1000); + run_deep(round); + } + pthread_t threads[8]; + for (int i = 0; i < 8; i++) + if (pthread_create(&threads[i], NULL, thread_main, (void *)(intptr_t)(i * 7919)) != 0) + fail("threads", "pthread_create failed"); + for (int i = 0; i < 8; i++) pthread_join(threads[i], NULL); + puts("unwind checks passed"); + return 0; +} diff --git a/packages/runtime/test/test_unwind_frames.S b/packages/runtime/test/test_unwind_frames.S new file mode 100644 index 0000000000..af46685927 --- /dev/null +++ b/packages/runtime/test/test_unwind_frames.S @@ -0,0 +1,101 @@ +/* Frames for test_unwind.c with CFI that compilers emit rarely. Like the + * frames in test_unwind_frames.ll, each takes a chain and calls its first + * function with the rest. */ + .text + +/* Saves rbx and r12, and leaves through an early epilogue under + * remember_state before its call, so the call's rules come from + * restore_state. */ + .globl asm_remember + .type asm_remember,@function +asm_remember: + .cfi_startproc + pushq %rbx + .cfi_def_cfa_offset 16 + .cfi_offset %rbx, -16 + pushq %r12 + .cfi_def_cfa_offset 24 + .cfi_offset %r12, -24 + subq $8, %rsp + .cfi_def_cfa_offset 32 + movq (%rdi), %rax + testq %rax, %rax + jne 1f + .cfi_remember_state + addq $8, %rsp + .cfi_def_cfa_offset 24 + popq %r12 + .cfi_def_cfa_offset 16 + .cfi_restore %r12 + popq %rbx + .cfi_def_cfa_offset 8 + .cfi_restore %rbx + retq + .cfi_restore_state +1: + movabsq $0x5151515151515151, %rbx + movabsq $0x5252525252525252, %r12 + addq $8, %rdi + callq *%rax + addq $8, %rsp + .cfi_def_cfa_offset 24 + popq %r12 + .cfi_def_cfa_offset 16 + popq %rbx + .cfi_def_cfa_offset 8 + retq + .cfi_endproc + .size asm_remember, .-asm_remember + +/* An rbp frame that saves r13 and r14 below rbp and moves the stack + * pointer by an amount that depends on the chain's address. */ + .globl asm_rbp + .type asm_rbp,@function +asm_rbp: + .cfi_startproc + pushq %rbp + .cfi_def_cfa_offset 16 + .cfi_offset %rbp, -16 + movq %rsp, %rbp + .cfi_def_cfa_register %rbp + pushq %r13 + pushq %r14 + .cfi_offset %r13, -24 + .cfi_offset %r14, -32 + movq %rdi, %rax + andq $0x70, %rax + subq %rax, %rsp + movabsq $0x5353535353535353, %r13 + movabsq $0x5454545454545454, %r14 + movq (%rdi), %rax + addq $8, %rdi + callq *%rax + leaq -16(%rbp), %rsp + popq %r14 + popq %r13 + popq %rbp + .cfi_def_cfa %rsp, 8 + retq + .cfi_endproc + .size asm_rbp, .-asm_rbp + +/* Defines its CFA with a DWARF expression (rsp + 16), which the plans do + * not express: raises through it go to libgcc's unwinder. */ + .globl asm_expr + .type asm_expr,@function +asm_expr: + .cfi_startproc + pushq %rbx + .cfi_escape 0x0f, 0x02, 0x77, 0x10 + .cfi_offset %rbx, -16 + movabsq $0x5555555555555555, %rbx + movq (%rdi), %rax + addq $8, %rdi + callq *%rax + popq %rbx + .cfi_def_cfa %rsp, 8 + retq + .cfi_endproc + .size asm_expr, .-asm_expr + + .section .note.GNU-stack,"",@progbits diff --git a/packages/runtime/test/test_unwind_frames.ll b/packages/runtime/test/test_unwind_frames.ll new file mode 100644 index 0000000000..08415e7253 --- /dev/null +++ b/packages/runtime/test/test_unwind_frames.ll @@ -0,0 +1,131 @@ +; Frames for test_unwind.c, shaped like emitted code on the unwinding +; exception ABI. Every frame takes a chain: a null-terminated array of frame +; functions, of which it calls the first with the rest. Each clobbers the +; callee-saved registers, so its prologue saves them and its CFI says where. +target triple = "x86_64-unknown-linux-gnu" + +declare void @scr_unwind_raise() cold +declare void @scr_unwind_resume(ptr) cold +declare i32 @__gcc_personality_v0(...) +declare void @test_cleanup(ptr) +declare void @test_touch(ptr) + +; Clobbers every callee-saved register the frame pointer leaves free. +define private void @clobber() alwaysinline { + call void asm sideeffect "movq $$0x6161616161616161, %rbx\0Amovq $$0x6262626262626262, %r12\0Amovq $$0x6363636363636363, %r13\0Amovq $$0x6464646464646464, %r14\0Amovq $$0x6565656565656565, %r15", "~{rbx},~{r12},~{r13},~{r14},~{r15}"() + ret void +} + +; A local that escapes, so sanitized builds give the frame redzones. +define private void @local() alwaysinline sanitize_address { + %buf = alloca [48 x i8], align 16 + call void @test_touch(ptr %buf) + ret void +} + +define void @ir_raise(ptr %chain) sanitize_address { + call void @local() + call void @clobber() + call void @scr_unwind_raise() + unreachable +} + +define void @ir_plain(ptr %chain) sanitize_address { + call void @local() + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + call void @clobber() + call void %fn(ptr %rest) + ret void +} + +define void @ir_plain_fp(ptr %chain) sanitize_address "frame-pointer"="all" { + call void @local() + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + call void @clobber() + call void %fn(ptr %rest) + ret void +} + +; A frame whose stack pointer moves by a runtime amount: its CFA is rbp's. +define void @ir_alloca(ptr %chain) sanitize_address { + %bits = ptrtoint ptr %chain to i64 + %n = and i64 %bits, 112 + %size = add i64 %n, 16 + %dyn = alloca i8, i64 %size, align 16 + call void @test_touch(ptr %dyn) + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + call void @clobber() + call void %fn(ptr %rest) + ret void +} + +; A frame with a landing pad: it records its chain position and continues +; the unwind. The chain position is live across the call. +define void @ir_pad(ptr %chain) sanitize_address personality ptr @__gcc_personality_v0 { +entry: + call void @local() + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + call void @clobber() + invoke void %fn(ptr %rest) to label %ok unwind label %lp +ok: + ret void +lp: + %e = landingpad { ptr, i32 } cleanup + call void @test_cleanup(ptr %chain) + %token = extractvalue { ptr, i32 } %e, 0 + call void @scr_unwind_resume(ptr %token) + unreachable +} + +define void @ir_pad_fp(ptr %chain) sanitize_address "frame-pointer"="all" personality ptr @__gcc_personality_v0 { +entry: + call void @local() + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + call void @clobber() + invoke void %fn(ptr %rest) to label %ok unwind label %lp +ok: + ret void +lp: + %e = landingpad { ptr, i32 } cleanup + call void @test_cleanup(ptr %chain) + %token = extractvalue { ptr, i32 } %e, 0 + call void @scr_unwind_resume(ptr %token) + unreachable +} + +; Takes the exception, as a pending-flag frame does: returns a checksum of +; six values live across the call, which hold the callee-saved registers, +; and whether the call raised in bit 63. +define i64 @ir_catch(ptr %chain, i64 %a, i64 %b, i64 %c, i64 %d, i64 %e, i64 %f) sanitize_address personality ptr @__gcc_personality_v0 { +entry: + call void @local() + %x1 = mul i64 %a, 3 + %x2 = mul i64 %b, 5 + %x3 = mul i64 %c, 7 + %x4 = mul i64 %d, 11 + %x5 = mul i64 %e, 13 + %x6 = mul i64 %f, 17 + %fn = load ptr, ptr %chain + %rest = getelementptr ptr, ptr %chain, i64 1 + invoke void %fn(ptr %rest) to label %ok unwind label %lp +ok: + br label %done +lp: + %l = landingpad { ptr, i32 } cleanup + br label %done +done: + %raised = phi i64 [ 0, %ok ], [ -9223372036854775808, %lp ] + %s1 = xor i64 %x1, %x2 + %s2 = add i64 %s1, %x3 + %s3 = xor i64 %s2, %x4 + %s4 = add i64 %s3, %x5 + %s5 = xor i64 %s4, %x6 + %s6 = and i64 %s5, 9223372036854775807 + %r = or i64 %s6, %raised + ret i64 %r +} diff --git a/packages/runtime/test/unwind.test.ts b/packages/runtime/test/unwind.test.ts new file mode 100644 index 0000000000..2e75de1eb5 --- /dev/null +++ b/packages/runtime/test/unwind.test.ts @@ -0,0 +1,48 @@ +import { execFile } from "node:child_process"; +import { mkdir } from "node:fs/promises"; +import { join } from "node:path"; +import { promisify } from "node:util"; +import { expect, test } from "vitest"; + +const execFileAsync = promisify(execFile); +const testDir = import.meta.dirname; + +// scr_unwind.c walks with cached plans on x86-64 Linux only. +test.runIf(process.platform === "linux" && process.arch === "x64")( + "the unwinder runs landing pads in order, restores callee-saved registers and falls back to libgcc", + async () => { + const buildDir = join(testDir, "build"); + await mkdir(buildDir, { recursive: true }); + const frames = [join(testDir, "test_unwind_frames.ll"), join(testDir, "test_unwind_frames.S")]; + // The frames also run from a shared library, whose FDEs are in its own + // .eh_frame_hdr. + const library = join(buildDir, "libtest_unwind_frames.so"); + await execFileAsync("clang", ["-O1", "-shared", "-fPIC", "-o", library, ...frames]); + for (const build of ["plain", "asan", "shared"] as const) { + const bin = join(buildDir, `test_unwind_${build}`); + await execFileAsync("clang", [ + "-std=c11", + "-O1", + "-Wall", + "-Wextra", + "-D_GNU_SOURCE", + "-DSCR_UNWIND_TESTING", + ...(build === "asan" ? ["-fsanitize=address"] : []), + "-pthread", + "-o", + bin, + join(testDir, "test_unwind.c"), + ...(build === "shared" ? [library, `-Wl,-rpath,${buildDir}`] : frames), + join(testDir, "../src/scr_unwind.c"), + // The library's landing pads and the program share libgcc_s's unwinder. + ...(build === "shared" ? ["-shared-libgcc"] : ["-lgcc_eh"]), + ]); + const env = { ...process.env, ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1" }; + for (const args of [[], ["threads"]]) { + const run = await execFileAsync(bin, args, { env }); + expect(run.stdout).toBe("unwind checks passed\n"); + } + } + }, + 120_000, +); diff --git a/scripts/zero-cost-compare.mjs b/scripts/zero-cost-compare.mjs index 8d612db243..40f7287fbb 100644 --- a/scripts/zero-cost-compare.mjs +++ b/scripts/zero-cost-compare.mjs @@ -415,7 +415,9 @@ export function nodeIslandSymbols(names) { * whose parameters the lib types `any`: captures a match can leave unset * and results the checker types `any`. bench/record-untyped-writes, also * added later, writes typed records through `unknown` and `Object.assign` - * views. Every other bench workload is fully typed and must link none. The gate and + * views. bench/layered-errors, also added later, constructs and throws + * Error subclass instances, as bench/validate-errors does. Every other bench + * workload is fully typed and must link none. The gate and * tests/harness/bench-dynamic-tier.test.ts enforce that, and the test also * fails when a listed workload stops linking them, so remove a workload here * when a leak fix makes its binary clean. */ @@ -440,6 +442,7 @@ export const DYNAMIC_WORKLOADS = new Set([ "bench/throw-chains", "bench/regex-replacers", "bench/record-untyped-writes", + "bench/layered-errors", ]); /** Names of the functions (text and weak symbols) in `nm` output. Mach-O diff --git a/tests/corpus/unwind-landing-pad-chains.ts b/tests/corpus/unwind-landing-pad-chains.ts new file mode 100644 index 0000000000..ba605586dc --- /dev/null +++ b/tests/corpus/unwind-landing-pad-chains.ts @@ -0,0 +1,104 @@ +// Exceptions thrown below functions that hold references and propagate by +// unwinding: landing pads release a frame's strings and arrays and continue +// the unwind, also where the frame is inlined into a caller whose own handler +// takes the exception, and through methods, closures and deep chains. +function leaf(n: number): number { + if (n % 3 === 0) throw new RangeError("leaf " + n); + return n * 2; +} +function first(n: number): number { + return leaf(n) + 1; +} +function second(n: number): number { + return first(n) * 3; +} +function third(n: number): number { + return second(n) - 2; +} +function holder(n: number): number { + const parts: string[] = ["a" + n]; + const r = third(n); + parts.push("b"); + return r + parts.length; +} +function labelled(n: number): number { + const label = "u" + n; + return holder(n) + label.length; +} +function deeper(n: number, depth: number): number { + const name = "d" + depth; + if (depth === 0) return labelled(n) + name.length; + return deeper(n, depth - 1) + name.length; +} +class Counter { + total = 0; + add(n: number): void { + this.total += labelled(n); + } +} + +function caught(n: number): string { + try { + return "ok " + labelled(n); + } catch (e) { + return "caught " + (e as Error).message; + } +} + +const out: string[] = []; +for (let i = 0; i < 7; i++) out.push(caught(i)); +console.log(out.join(", ")); + +const counter = new Counter(); +let failures = 0; +for (let round = 0; round < 2000; round++) { + for (let i = 0; i < 6; i++) { + try { + counter.add(i); + } catch { + failures++; + } + } +} +console.log("counter", counter.total, "failures", failures); + +let deep = 0; +let deepFailures = 0; +for (let i = 0; i < 300; i++) { + try { + deep += deeper(i, i % 40); + } catch (e) { + deepFailures++; + if (i < 10) console.log((e as Error).name, (e as Error).message); + } +} +console.log("deep", deep, "failures", deepFailures); + +const viaClosure = (n: number): number => labelled(n) + 1; +let closureFailures = 0; +for (let i = 0; i < 30; i++) { + try { + viaClosure(i); + } catch { + closureFailures++; + } +} +console.log("closure failures", closureFailures); + +function finallyOrder(n: number): string { + const log: string[] = []; + try { + try { + log.push("try"); + labelled(n); + log.push("after"); + } finally { + log.push("finally"); + } + } catch (e) { + log.push("catch " + (e as Error).message); + } + return log.join(" > "); +} +console.log(finallyOrder(3)); +console.log(finallyOrder(4)); diff --git a/tests/harness/corpus-costs/unwind-landing-pad-chains.ts.json b/tests/harness/corpus-costs/unwind-landing-pad-chains.ts.json new file mode 100644 index 0000000000..97b189b08f --- /dev/null +++ b/tests/harness/corpus-costs/unwind-landing-pad-chains.ts.json @@ -0,0 +1 @@ +{ "plain": 4100, "san": 69700 } From d3e8ab41ef49a7be77e0bdeee30e97a7819667af Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 00:36:18 -0500 Subject: [PATCH 2/3] Publish unwind plans without a plain write to their pc A new plan was copied into its slot as a whole struct, which wrote the slot's pc with a plain store while other threads' lookups read it atomically: a data race. The insert now copies the fields after pc and publishes pc only with the release store. The unwinder test also runs its threaded cases under ThreadSanitizer, which reports the race without this change. --- packages/runtime/src/scr_unwind.c | 6 ++++-- packages/runtime/test/unwind.test.ts | 13 ++++++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/packages/runtime/src/scr_unwind.c b/packages/runtime/src/scr_unwind.c index f75ee39906..339f85088b 100644 --- a/packages/runtime/src/scr_unwind.c +++ b/packages/runtime/src/scr_unwind.c @@ -566,8 +566,10 @@ static __attribute__((noinline)) const ScrUnwindPlan *scr_unwind_insert(uint64_t scr_unwind_plan_count++; ScrUnwindPlan built; scr_unwind_build(&built, ra); - built.pc = 0; - *slot = built; + /* Lookups read pc atomically, so the plan's other fields are written + * first and pc only by the release store that publishes them. */ + memcpy((char *)slot + sizeof slot->pc, (const char *)&built + sizeof built.pc, + sizeof *slot - sizeof slot->pc); __atomic_store_n(&slot->pc, ra, __ATOMIC_RELEASE); result = slot; } diff --git a/packages/runtime/test/unwind.test.ts b/packages/runtime/test/unwind.test.ts index 2e75de1eb5..95fc5a0fd5 100644 --- a/packages/runtime/test/unwind.test.ts +++ b/packages/runtime/test/unwind.test.ts @@ -18,7 +18,7 @@ test.runIf(process.platform === "linux" && process.arch === "x64")( // .eh_frame_hdr. const library = join(buildDir, "libtest_unwind_frames.so"); await execFileAsync("clang", ["-O1", "-shared", "-fPIC", "-o", library, ...frames]); - for (const build of ["plain", "asan", "shared"] as const) { + for (const build of ["plain", "asan", "tsan", "shared"] as const) { const bin = join(buildDir, `test_unwind_${build}`); await execFileAsync("clang", [ "-std=c11", @@ -28,6 +28,7 @@ test.runIf(process.platform === "linux" && process.arch === "x64")( "-D_GNU_SOURCE", "-DSCR_UNWIND_TESTING", ...(build === "asan" ? ["-fsanitize=address"] : []), + ...(build === "tsan" ? ["-fsanitize=thread"] : []), "-pthread", "-o", bin, @@ -37,8 +38,14 @@ test.runIf(process.platform === "linux" && process.arch === "x64")( // The library's landing pads and the program share libgcc_s's unwinder. ...(build === "shared" ? ["-shared-libgcc"] : ["-lgcc_eh"]), ]); - const env = { ...process.env, ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1" }; - for (const args of [[], ["threads"]]) { + const env = { + ...process.env, + ASAN_OPTIONS: "detect_leaks=0:halt_on_error=1", + TSAN_OPTIONS: "halt_on_error=1", + }; + // ThreadSanitizer checks the threads' concurrent plan inserts and + // lookups; the libgcc fallback cases run in the other builds. + for (const args of build === "tsan" ? [["threads"]] : [[], ["threads"]]) { const run = await execFileAsync(bin, args, { env }); expect(run.stdout).toBe("unwind checks passed\n"); } From 195c38e6712a0503ebb31d225b3d1b7b0f685df6 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 28 Oct 2026 01:26:22 -0500 Subject: [PATCH 3/3] Declare scr_unwind_resume in the runtime header The backend declares scr_unwind_resume, so scr_exception.h carries its prototype, as the runtime ABI test requires. The unwind-ABI pattern names both entry points in full, so the test reads no partial symbol from it. --- packages/compiler/src/backend/llvm/unwind-abi.ts | 2 +- packages/runtime/src/scr_exception.h | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/unwind-abi.ts b/packages/compiler/src/backend/llvm/unwind-abi.ts index 2b13a06662..2c0721a133 100644 --- a/packages/compiler/src/backend/llvm/unwind-abi.ts +++ b/packages/compiler/src/backend/llvm/unwind-abi.ts @@ -276,7 +276,7 @@ export const UNWIND_RESUME_DECLARATION = "declare void @scr_unwind_resume(ptr) c export const UNWIND_PERSONALITY = "personality ptr @__gcc_personality_v0"; /** An unwind leaving the function: a raise, or a landing pad's resume. */ -const UNWINDS = /call void @scr_unwind_(?:raise\(\)|resume\()/; +const UNWINDS = /call void @scr_unwind_raise\(\)|call void @scr_unwind_resume\(/; /** A pending-flag test: the thread's exception word, the active cell, or the runtime query. */ const PENDING_TEST = /@scr_exc_flag\b|%exc\.cell\b|@scr_exc_active\b|@scr_exc_pending\(/; diff --git a/packages/runtime/src/scr_exception.h b/packages/runtime/src/scr_exception.h index 168b2fe2d7..6c5f83358c 100644 --- a/packages/runtime/src/scr_exception.h +++ b/packages/runtime/src/scr_exception.h @@ -129,6 +129,8 @@ _Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR * functions, unwind-abi.ts). Linux (glibc and musl) only; it never * returns. */ SCR_COLD _Noreturn void scr_unwind_raise(void); +/* Continues the unwind from a landing pad with the exception it took. */ +SCR_COLD void scr_unwind_resume(void *exc); SCR_COLD void scr_throw_f64(double v); SCR_COLD void scr_throw_bool(bool v); SCR_COLD void scr_throw_str(ScrStr *v); /* takes ownership */