Propagate exceptions by unwinding from directly called functions on Linux (#2115)

This commit is contained in:
Chris Tate
2026-10-24 17:15:36 -05:00
committed by GitHub
21 changed files with 1276 additions and 26 deletions
+1
View File
@@ -31,6 +31,7 @@ Application-shaped workloads for measuring the speed of compiled executables. Ea
| `regex-logs` | cpu | regex `exec` with groups, `test`, global `replace` |
| `region-builds` | cpu | a watch-mode build loop over `LifetimeRegion`: each build binds a program in a new region and releases the previous one, which a cache keeps referenced for two builds |
| `template-render` | cpu | template literals, escaping, `map`/`join` |
| `throw-chains` | cpu | exceptions caught 2, 5 and 20 recursive calls above the throw, through direct calls and through a callback, and a backtracking parser that throws on a failed alternative |
| `thread-slots`, `thread-slots-cache` | cpu | worker threads reading one published type graph from an `InternTable` and keeping per-thread state for each type they visit: a `ThreadSlots` family, or a direct-mapped cache by id in front of a `Map` by id (the `-cache` entry); under Node.js both are per thread |
| `typed-array-kernels` | cpu | element-wise loops that run to one typed array's length and index others at the same position: `Float64Array` axpy, `Float32Array` blend, `Uint8Array` mask, wrapping `Int32Array` add, `Uint8ClampedArray` brighten, RGBA to gray, a row-major `Float32Array` matrix-vector product, and `reduce`/`some`/`map` callbacks |
| `typed-array-scan`, `typed-array-scan-workers` | cpu | element-by-element scan loops over `Uint8Array`, `Uint16Array`, `Int32Array` and `Float64Array`; the second entry is a worker-enabled executable (it can start a worker), so each access also checks for shared storage |
@@ -0,0 +1,6 @@
{
"entry": "workloads/throw-chains.ts",
"args": [
"1"
]
}
@@ -0,0 +1,196 @@
// Exceptions that cross many frames: throws from helpers below a recursion,
// caught 2, 5 and 20 recursive calls up (direct calls, then calls through a
// callback), with each frame holding an array across its call, and a
// backtracking parser that uses exceptions for control flow.
class ParseError extends Error {
readonly at: number;
constructor(at: number, message: string) {
super(message);
this.at = at;
}
}
/** A thrown value that is not an Error (no stack to capture). */
class Stop {
readonly code: number;
constructor(code: number) {
this.code = code;
}
}
const held: number[][] = [];
function fail(n: number, plain: boolean): number {
if (plain) throw new Stop(n);
throw new RangeError("chain " + n);
}
function check(n: number, plain: boolean): number {
return (n & 3) === 0 ? fail(n, plain) : n;
}
function validate(n: number, plain: boolean): number {
return check(n, plain) + (n & 1);
}
function bottom(n: number, plain: boolean): number {
const box = [n, -1];
const r = validate(n, plain);
held[0] = box;
return r + box.length;
}
function walk(n: number, depth: number, plain: boolean): number {
const box = [n, depth];
const r = depth === 0 ? bottom(n, plain) : walk(n, depth - 1, plain);
held[depth] = box;
return r + box.length;
}
function chains(rounds: number): string {
const out: string[] = [];
for (const depth of [2, 5, 20])
for (const plain of [false, true]) {
let caught = 0;
let sum = 0;
for (let i = 0; i < rounds; i++) {
try {
sum += walk(i, depth - 2, plain);
} catch (error) {
caught++;
sum += error instanceof Stop ? error.code & 7 : 1;
}
}
out.push(`depth ${depth} ${plain ? "object" : "error"}: caught ${caught} sum ${sum}`);
}
return out.join("\n");
}
// The same chains through a callback: each level visits the next with a
// closure, as tree visitors do.
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
const box = [n, depth];
const r = depth === 0 ? bottom(n, false) : each(n, depth - 1);
held[depth] = box;
return r + box.length;
}
function visits(rounds: number): string {
const out: string[] = [];
const each = (n: number, depth: number): number => visit(n, depth, each);
for (const depth of [2, 5, 20]) {
let caught = 0;
let sum = 0;
for (let i = 0; i < rounds; i++) {
try {
sum += visit(i, depth - 2, each);
} catch (error) {
caught++;
sum += error instanceof RangeError ? 1 : 0;
}
}
out.push(`visit depth ${depth}: caught ${caught} sum ${sum}`);
}
return out.join("\n");
}
// expr := term (("+" | "-") term)*; term := atom ("*" atom)*;
// atom := number | "(" expr ")" | name "(" expr ("," expr)* ")" | name.
// A call and a name start alike: the parser tries the call first and
// backtracks when it fails.
let text = "";
let pos = 0;
function peek(): string {
return pos < text.length ? text[pos]! : "";
}
function expect(c: string): void {
if (peek() !== c) throw new ParseError(pos, `expected ${c}`);
pos++;
}
function name(): string {
const start = pos;
while (pos < text.length && text.charCodeAt(pos) >= 97 && text.charCodeAt(pos) <= 122) pos++;
if (pos === start) throw new ParseError(pos, "expected a name");
return text.slice(start, pos);
}
function call(): number {
const callee = name();
expect("(");
let value = expr() + callee.length;
while (peek() === ",") {
pos++;
value += expr();
}
expect(")");
return value;
}
function atom(): number {
const c = peek();
if (c >= "0" && c <= "9") {
let value = 0;
while (peek() >= "0" && peek() <= "9") value = value * 10 + (text.charCodeAt(pos++) - 48);
return value;
}
if (c === "(") {
pos++;
const value = expr();
expect(")");
return value;
}
const start = pos;
try {
return call();
} catch (error) {
if (!(error instanceof ParseError)) throw error;
pos = start;
return name().length;
}
}
function term(): number {
let value = atom();
while (peek() === "*") {
pos++;
value = (value * atom()) % 1000003;
}
return value;
}
function expr(): number {
let value = term();
while (peek() === "+" || peek() === "-") {
const op = text[pos++];
value = op === "+" ? value + term() : value - term();
}
return value;
}
let seed = 4242;
function random(): number {
seed = (Math.imul(seed, 1103515245) + 12345) >>> 0;
return seed / 4294967296;
}
function source(depth: number): string {
const r = random();
if (depth > 6 || r < 0.25) return String(Math.floor(random() * 100));
if (r < 0.45) return "abcdefgh".slice(0, 1 + Math.floor(random() * 8));
if (r < 0.6) return `(${source(depth + 1)})`;
if (r < 0.8) return `${source(depth + 1)}${random() < 0.5 ? "+" : "*"}${source(depth + 1)}`;
return `f${"xyz".slice(0, Math.floor(random() * 3))}(${source(depth + 1)},${source(depth + 1)})`;
}
function parse(count: number): string {
let total = 0;
let failed = 0;
for (let i = 0; i < count; i++) {
// Every tenth input lacks its last character, which fails deep inside it.
const full = source(0);
text = i % 10 === 0 ? full.slice(0, -1) : full;
pos = 0;
try {
total = (total + expr()) % 1000003;
if (pos !== text.length) throw new ParseError(pos, "trailing input");
} catch (error) {
if (!(error instanceof ParseError)) throw error;
failed++;
}
}
return `parsed ${count}: total ${total} failed ${failed}`;
}
const scale = Number(process.argv[2] ?? "1");
console.log(chains(Math.floor(60000 * scale)));
console.log(visits(Math.floor(60000 * scale)));
console.log(parse(Math.floor(40000 * scale)));
+13 -1
View File
@@ -9,6 +9,7 @@
* code after it); here the lines are DROPPED instead, and emitStmts skips
* whole statements once the current block is terminated, so no dropped
* definition can leak into a later block. */
import { UNWIND_RESUME, UNWIND_SLOT } from "./unwind-abi.js";
export class BlockBuilder {
private readonly blocks: { label: string; lines: string[]; term: string | null }[] = [];
@@ -86,7 +87,18 @@ export class BlockBuilder {
terminate(s: string): void {
if (this.cur.term === null) {
if (this.returnEpilogue && s.startsWith("ret ")) this.line(this.returnEpilogue);
if (
this.returnEpilogue &&
(s.startsWith("ret ") ||
s.startsWith("call void @scr_unwind_raise()") ||
s === UNWIND_RESUME)
)
this.line(this.returnEpilogue);
if (s === UNWIND_RESUME) {
const exception = this.tmp();
this.line(`${exception} = load { ptr, i32 }, ptr ${UNWIND_SLOT}`);
s = `resume { ptr, i32 } ${exception}`;
}
this.cur.term = ` ${this.withDebugLocation(s)}`;
}
}
@@ -56,11 +56,14 @@ 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";
export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const B = new BlockBuilder();
emitter.B = B;
emitter.currentFunctionName = fn.name;
emitter.currentUnwindAbi = emitter.unwindAbi.has(fn.name);
emitter.usesLandingPads = false;
emitter.findInt32Allocations(fn);
emitter.currentTier = fn.tier;
// Fiber switches restore the active exception cell before control
@@ -597,6 +600,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
B.terminate(cleanup.terminator);
}
if (emitter.usesLandingPads && emitter.currentUnwindAbi)
B.entryAllocas.push(`${UNWIND_SLOT} = alloca { ptr, i32 }`);
if (emitter.logArgSlots > 0) {
B.entryAllocas.push(`%logargs = alloca [${emitter.logArgSlots} x %ScrLogArg]`);
}
@@ -637,7 +642,10 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const debug = emitter.debugScope === null ? "" : ` !dbg ${emitter.debugScope}`;
const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name);
emitter.privateGlobalSlots.clear();
let body = `define internal ${ret} @${symbol}${int32Abi ? INT32_ABI_SUFFIX : ""}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
// Landing pads (emitDirectCall) need the personality.
const personality = emitter.usesLandingPads ? ` ${UNWIND_PERSONALITY}` : "";
if (emitter.usesLandingPads) emitter.declare(`declare i32 @__gcc_personality_v0(...)`);
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)) {
body += "\n" + emitIntegerClone(fn, emitter, emitter.stackChecks?.has(fn.name) === true);
+127 -8
View File
@@ -131,6 +131,16 @@ import {
type FfiCallbackAdapter,
} from "../ffi-callbacks.js";
import { computeMayThrow } from "../may-throw.js";
import {
UNWIND_PERSONALITY,
UNWIND_RAISE,
UNWIND_RAISE_DECLARATION,
UNWIND_RESUME,
UNWIND_SLOT,
UnwindAbiViolation,
bridgePlainUnwindCalls,
unwindAbiFunctions,
} from "./unwind-abi.js";
import {
mangleAsyncInlineAwaitCall,
mangleAsyncSpawn,
@@ -442,6 +452,12 @@ export interface ForkPrimitives {
export interface LlvmTargetOptions {
/** C ABI target; omitted direct emission uses the host ABI. */
targetTriple?: string;
/** Linux (glibc and musl) executables: functions reached only by direct
* calls propagate exceptions by unwinding the native stack (unwind-abi.ts). */
unwindAbi?: boolean;
/** Functions kept on the pending-flag ABI under `unwindAbi`: the ones an
* earlier emission found breaking its rules (UnwindAbiViolation). */
unwindExcluded?: ReadonlySet<string>;
/** Emit the function bodies in this many contiguous ranges, each by its
* own emitter, and merge them in function order (emit-journal.ts). The
* output is the same as one emitter's. Debug builds (`debugSources`)
@@ -595,16 +611,36 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
return new LlEmitter(emitted, options, null, prepared?.mod === emitted ? prepared : null);
}
/** Emits the module's parts, emitting it again with the functions an
* UnwindAbiViolation names on the pending-flag ABI. */
function emitModuleText(input: IrModule, options: LlvmTargetOptions): string[] {
for (let excluded = options.unwindExcluded ?? new Set<string>(); ;) {
const emitter = moduleEmitter(input, { ...options, unwindExcluded: excluded });
try {
return emitter.emitParts();
} catch (error) {
if (!(error instanceof UnwindAbiViolation)) throw error;
const names = new Set(excluded);
for (const name of emitter.unwindAbi)
for (const symbol of [mangleFunction(name), mangleBorrowedFunction(name)])
if (error.symbols.has(symbol) || error.symbols.has(symbol + INT32_ABI_SUFFIX))
names.add(name);
if (names.size === excluded.size) throw error;
excluded = names;
}
}
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
return moduleEmitter(input, options).emit();
return emitModuleText(input, options).join("\n");
}
/** emitLlvmModule's text as parts that concatenate to it, without the copy
* that joining them makes: a large program's text is 150 MB. */
export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] {
return moduleEmitter(input, options)
.emitParts()
.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
return emitModuleText(input, options).flatMap((part, index) =>
index === 0 ? [part] : ["\n", part],
);
}
/** Keep large translation units below the host's single-string limit. */
@@ -614,7 +650,7 @@ export function emitLlvmModuleSource(
): string | readonly string[] {
// IR validation, contracts and the frontend's disposal run after lowering without checks.
relieveHeapPressure(true);
const parts = moduleEmitter(input, options).emitParts();
const parts = emitModuleText(input, options);
const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1));
if (length <= 256 * 1024 * 1024) return parts.join("\n");
return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
@@ -1258,6 +1294,10 @@ export class LlEmitter {
/** May-throw analysis (computeMayThrow): pending
* checks are emitted only after calls that can actually raise. */
readonly mayThrow: Set<string>;
/** The functions that propagate exceptions by unwinding (unwind-abi.ts). */
readonly unwindAbi: Set<string>;
/** Whether the function being emitted is one of unwindAbi. */
currentUnwindAbi = false;
readonly indirectMayThrow: boolean;
/** Worker programs: the functions that poll for termination on entry
* (workerTerminationPolls); null outside worker programs. */
@@ -1725,6 +1765,16 @@ export class LlEmitter {
nodes: this.nodeIndex,
});
this.mayThrow = mt.fns;
this.unwindAbi =
options.unwindAbi === true &&
!this.wasi &&
mod.lib === undefined &&
options.debugSources === undefined &&
(options.targetTriple
? /-linux-(?:gnu|musl)/.test(options.targetTriple)
: process.platform === "linux")
? unwindAbiFunctions(mod, this.mayThrow, options.unwindExcluded)
: new Set();
this.workerEntryPolls = mt.workerEntryPolls ?? null;
this.stackChecks = mt.stackChecks ?? null;
// A stackless call's eager prefix borrows its caller's stack; the
@@ -1860,6 +1910,9 @@ export class LlEmitter {
: undefined,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.stackCallbacks = new StackCallbacks(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
@@ -2139,7 +2192,8 @@ export class LlEmitter {
return this.emitParts().join("\n");
}
emitParts(): string[] {
return emitParts(this);
const parts = emitParts(this);
return this.unwindAbi.size > 0 ? bridgePlainUnwindCalls(parts) : parts;
}
/** The dispose-observer gates the emitted frees and record writers test
@@ -3249,6 +3303,13 @@ export class LlEmitter {
terminator = `br label %${target.label}`;
} else if (this.currentCoro !== null) {
terminator = `br label %${this.currentCoro.finalLabel}`;
} else if (this.currentUnwindAbi && this.inLandingPad) {
// The unwind that entered the landing pad goes on to the caller's.
terminator = UNWIND_RESUME;
} else if (this.currentUnwindAbi) {
// The caller's invoke takes the exception at its landing pad.
this.declare(UNWIND_RAISE_DECLARATION);
terminator = UNWIND_RAISE;
} else {
const t = this.llType(this.currentReturnType);
terminator =
@@ -3279,6 +3340,7 @@ export class LlEmitter {
passThrough !== undefined &&
!target &&
this.currentCoro === null &&
!this.currentUnwindAbi &&
!this.currentInt32Result
) {
const t = this.llType(this.currentReturnType);
@@ -3465,6 +3527,46 @@ export class LlEmitter {
B.startBlock(B.newLabel("exc.dead"));
}
/** Emits `line`, a `call` of `callee`, and reports whether the call
* needs no pending check after it: a call of an unwinding-ABI function
* becomes an `invoke` whose landing pad runs this frame's unwind (the
* same releases and handler as emitPendingCheck's unwind path), and the
* normal path continues in a fresh block. Coroutine bodies keep plain
* calls (unwindAbiFunctions excludes their callees). */
emitDirectCall(line: string, callee: string): boolean {
const B = this.B;
if (!this.unwindAbi.has(callee) || this.currentCoro !== null || B.isTerminated()) {
B.line(line);
return false;
}
const ok = B.newLabel("inv.ok");
const pad = B.newLabel("inv.lp");
this.usesLandingPads = true;
// A trailing comment goes after the invoke's labels.
const comment = line.indexOf(" ; ");
const text = comment < 0 ? line : line.slice(0, comment);
B.terminate(
`${text.replace(/^((?:%[\w.]+ = )?)call /, "$1invoke ")} to label %${ok} unwind label %${pad}`,
);
B.startBlock(pad);
const exception = B.tmp();
B.line(`${exception} = landingpad { ptr, i32 } cleanup`);
if (this.currentUnwindAbi) B.line(`store { ptr, i32 } ${exception}, ptr ${UNWIND_SLOT}`);
this.inLandingPad = true;
this.emitUnwind();
this.inLandingPad = false;
B.startBlock(ok);
return true;
}
/** Whether emitUnwind runs for a landing pad, whose unwind goes on with
* `resume` instead of raising again. */
private inLandingPad = false;
/** Whether the function being emitted has a landing pad (its definition
* needs the personality). */
usesLandingPads = false;
/** A recursive function's entry check: below the running stack's guard,
* throw Node's RangeError and unwind like any other pending exception. */
emitStackCheck(): void {
@@ -5238,7 +5340,22 @@ export class LlEmitter {
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
const ret = this.llType(fn.returnType);
const call = `call ${ret} @${mangleBorrowedFunction(fn.name)}(${params.join(", ")})`;
B.line(ret === "void" ? call : `%result = ${call}`);
// The owned entry of an unwinding-ABI body releases the borrowed
// parameters on the unwind too, then resumes it to its caller.
const unwinding = this.unwindAbi.has(fn.name) && kept.size === 0;
if (unwinding) {
const ok = B.newLabel("adapt.ok");
const pad = B.newLabel("adapt.lp");
B.terminate(
`${ret === "void" ? "" : "%result = "}invoke ${call.slice(5)} to label %${ok} unwind label %${pad}`,
);
B.startBlock(pad);
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}`);
B.startBlock(ok);
} else B.line(ret === "void" ? call : `%result = ${call}`);
// A borrowed result is reachable from the parameters: own it first. A
// throwing body returns a null dummy, which needs no owner (and string
// or array retains do not accept NULL).
@@ -5264,7 +5381,9 @@ export class LlEmitter {
B.terminate(ret === "void" ? "ret void" : `ret ${ret} ${result}`);
const symbol =
kept.size > 0 ? `${mangleBorrowedFunction(fn.name)}.virtual` : mangleFunction(fn.name);
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS} {\n${B.render()}\n}`;
if (unwinding) this.declare(`declare i32 @__gcc_personality_v0(...)`);
const personality = unwinding ? ` ${UNWIND_PERSONALITY}` : "";
return `define internal ${ret} @${symbol}(${params.join(", ")}) ${FN_ATTRS}${personality} {\n${B.render()}\n}`;
}
canBorrowCallArgument(value: IrExpr): boolean {
return canBorrowCallArgument(this, value);
@@ -244,38 +244,46 @@ export function emitCallExpr(
? INTEGER_DISPATCH_SUFFIX
: "";
const target = `@${borrowed ? mangleBorrowedFunction(e.callee) : host.callTarget(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}${dispatch}`;
// A call of an unwinding-ABI callee is an invoke (emitDirectCall):
// its landing pad unwinds, so no pending check follows it.
if (e.type.kind === "void") {
B.line(`call void ${target}(${argList})`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
const invoked = host.emitDirectCall(`call void ${target}(${argList})`, e.callee);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (borrowed) host.releaseTemps(host.frames.pop()!);
return { name: "", type: e.type };
}
const t = B.tmp();
if (abi?.union) {
B.line(`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
const invoked = host.emitDirectCall(
`${t} = call ${SCALAR_UNION_RESULT} ${target}(${argList})`,
e.callee,
);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (borrowed) host.releaseTemps(host.frames.pop()!);
if (stackResult) return host.stackScalarUnion(t, e.type);
return host.own({ name: host.boxScalarUnion(t, e.type), type: e.type });
}
if (abi?.result) {
B.line(`${t} = call i32 ${target}(${argList})`);
const invoked = host.emitDirectCall(`${t} = call i32 ${target}(${argList})`, e.callee);
const out = host.int32Result(t, e.type);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (borrowed) host.releaseTemps(host.frames.pop()!);
return out;
}
B.line(`${t} = call ${host.llType(e.type)} ${target}(${argList})`);
const invoked = host.emitDirectCall(
`${t} = call ${host.llType(e.type)} ${target}(${argList})`,
e.callee,
);
let out: LlValue;
if (borrowed && host.borrowedReturns.has(e.callee)) {
// A borrowed-return body hands back +0 (a null dummy when it threw):
// test the exception first, then own the result before the
// argument snapshots that keep it reachable are released.
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
out = host.own({ name: host.retainFrame(t, e.type), type: e.type });
} else {
out = host.own({ name: t, type: e.type });
if (host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck(out);
}
if (borrowed) {
host.toFrame(out);
@@ -1366,10 +1374,11 @@ export function emitBorrowedResultCall(host: LlvmEmitterContext, e: ExprOf<"call
)
.join(", ");
const t = host.B.tmp();
host.B.line(
const invoked = host.emitDirectCall(
`${t} = call ${host.llType(e.type)} @${mangleBorrowedFunction(e.callee)}${abi ? INT32_ABI_SUFFIX : ""}(${argList}) ; borrowed result`,
e.callee,
);
if (host.mayThrow.has(e.callee)) host.emitPendingCheck();
if (!invoked && host.mayThrow.has(e.callee)) host.emitPendingCheck();
return { name: t, type: e.type };
}
@@ -0,0 +1,285 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import { validateModule } from "../../ir/validate.js";
import { emitLlvmModule } from "./emitter.js";
import { UnwindAbiViolation, bridgePlainUnwindCalls } from "./unwind-abi.js";
async function moduleFor(source: string) {
const dir = await mkdtemp(join(tmpdir(), "scriptc-unwind-abi-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
const module = deserializeModule(await readFile(output, "utf8"));
expect(validateModule(module)).toEqual([]);
return module;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The definition of the source function `name` (its i32 or borrowing entry when it has one). */
function body(ir: string, name: string): string {
return new RegExp(
`^define internal [^\\n]*@sc_b?f_[^\\n(]*${name}(?:\\.i32)?\\([^]*?^}`,
"m",
).exec(ir)![0];
}
const SOURCE = `
function leaf(n: number): number {
if (n === 3) throw new RangeError("three");
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 mid(n: number): number {
const parts: string[] = ["a" + n];
const r = third(n);
parts.push("b");
return r + parts.length;
}
class Counter {
total = 0;
add(n: number): void { this.total += mid(n); }
}
function outer(n: number): string {
try { return "ok " + mid(n); } catch (e) { return "caught " + (e as Error).message; }
}
const counter = new Counter();
for (let i = 0; i < 5; i++) { console.log(outer(i)); try { counter.add(i); } catch { console.log("add", i); } }
const f = (x: number) => mid(x);
console.log(f(1), counter.total);
`;
const LINUX = { targetTriple: "x86_64-unknown-linux-gnu", unwindAbi: true } as const;
test("directly called functions raise, and their callers invoke them without a pending check", async () => {
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
// leaf throws: the exception starts on the pending flag, and the three
// calls above it test the flag too; mid raises it on.
for (const name of ["leaf", "first", "second", "third"])
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
const mid = body(ir, "mid");
expect(mid).toMatch(/= call double @sc_b?f_[^(]*third\(/);
expect(mid).toContain("call void @scr_unwind_raise()");
// outer invokes mid; the landing pad takes the exception to its handler.
const outer = body(ir, "outer");
expect(outer).toMatch(
/= invoke double @sc_b?f_[^(]*mid[^\n]* to label %inv\.ok\d+ unwind label %inv\.lp\d+/,
);
expect(outer).toMatch(/inv\.lp\d+:\n\s+%t\d+ = landingpad \{ ptr, i32 \} cleanup/);
expect(outer).toContain("personality ptr @__gcc_personality_v0");
expect(ir).toContain("declare i32 @__gcc_personality_v0(...)");
expect(ir).toContain("declare void @scr_unwind_raise() cold");
});
test("a method of a class outside any hierarchy is on the unwinding ABI; closures keep the flag", async () => {
const ir = emitLlvmModule(await moduleFor(SOURCE), LINUX);
// Counter.add invokes mid and resumes the unwind from its landing pad; the
// 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).not.toContain("@scr_unwind_raise");
const closures = ir
.split(/\n(?=define )/)
.filter((def) => /^define internal [^\n]*@sc_b?f_[^\n(]*fn_/.test(def));
expect(closures.length).toBeGreaterThan(0);
for (const def of closures) expect(def).not.toContain("call void @scr_unwind_raise()");
});
test("recursive functions keep the pending flag, also when they recurse through a callback", async () => {
const source = `
function leaf(n: number): number {
if (n < 0) throw new RangeError("negative");
return n;
}
function one(n: number): number { return leaf(n) + 1; }
function two(n: number): number { return one(n) + 1; }
function three(n: number): number { return two(n) + 1; }
function four(n: number): number {
const parts: string[] = ["a" + n];
const r = three(n);
return r + parts.length;
}
function walk(n: number, depth: number): number {
const parts: string[] = ["w" + n];
const r = depth === 0 ? four(n) : walk(n, depth - 1);
return r + parts.length;
}
function visit(n: number, depth: number, each: (n: number, depth: number) => number): number {
const parts: string[] = ["v" + n];
const r = depth === 0 ? four(n) : each(n, depth - 1);
return r + parts.length;
}
const each = (n: number, depth: number): number => visit(n, depth, each);
for (let i = -1; i < 2; i++) {
try { console.log(walk(i, 3), visit(i, 3, each)); } catch (e) { console.log((e as Error).message); }
}
`;
const ir = emitLlvmModule(await moduleFor(source), LINUX);
expect(body(ir, "four")).toContain("call void @scr_unwind_raise()");
for (const name of ["walk", "visit"]) {
expect(body(ir, name)).not.toContain("@scr_unwind_raise");
expect(body(ir, name)).toMatch(/invoke double @sc_b?f_[^(]*four/);
}
});
test("without the option, and off Linux, emission keeps the pending-flag ABI", async () => {
const mod = await moduleFor(SOURCE);
for (const options of [{}, { targetTriple: "arm64-apple-macosx14.0.0", unwindAbi: true }]) {
const ir = emitLlvmModule(mod, options);
expect(ir).not.toContain("invoke ");
expect(ir).not.toContain("@scr_unwind_raise");
expect(ir).not.toContain("personality");
}
});
test("bridgePlainUnwindCalls keeps the pending-flag contract at plain calls of raising functions", () => {
const raising = [
"define internal ptr @sc_bf_raises(ptr %p) #0 { ; raises",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const caller = [
"define internal ptr @sc_f_adapter(ptr %p0) #0 {",
"entry:",
" %result = call ptr @sc_bf_raises(ptr %p0) ; borrowed result",
" br label %next",
"next:",
" %v = phi ptr [ %result, %entry ]",
" ret ptr %v",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([`${raising}\n${caller}`]);
expect(out).toContain(
"%result.ub = invoke ptr @sc_bf_raises(ptr %p0) to label %ub.n.0 unwind label %ub.lp.0",
);
expect(out).toContain("%result = phi ptr [ %result.ub, %ub.n.0 ], [ null, %ub.lp.0 ]");
// The split block's successor now comes from its last part.
expect(out).toContain("%v = phi ptr [ %result, %ub.ok.0 ]");
expect(out).toMatch(
/define internal ptr @sc_f_adapter\(ptr %p0\) #0 personality ptr @__gcc_personality_v0 \{/,
);
expect(out).toContain("declare i32 @__gcc_personality_v0(...)");
});
test("an invoked wrapper of a raising function resumes the unwind; its address must not escape", () => {
const raising = [
"define internal double @sc_bf_body.i32(i32 %a) #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const wrapper = [
"define internal double @sc_bf_body(double %a) #0 {",
"entry:",
" %i = fptosi double %a to i32",
" %r = call double @sc_bf_body.i32(i32 %i)",
" ret double %r",
"}",
].join("\n");
const caller = [
"define internal double @sc_bf_caller(double %a) #0 personality ptr @__gcc_personality_v0 {",
"entry:",
" %r = invoke double @sc_bf_body(double %a) to label %ok unwind label %lp",
"lp:",
" %x = landingpad { ptr, i32 } cleanup",
" call void @scr_unwind_raise()",
" unreachable",
"ok:",
" ret double %r",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([[raising, wrapper, caller].join("\n")]);
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(() =>
bridgePlainUnwindCalls([
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
]),
).toThrow(UnwindAbiViolation);
try {
bridgePlainUnwindCalls([
`${raising}\n@table = internal constant [1 x ptr] [ptr @sc_bf_body.i32]`,
]);
} catch (error) {
expect([...(error as UnwindAbiViolation).symbols]).toEqual(["sc_bf_body.i32"]);
}
});
test("an invoked function that never tests the pending flag needs no raise", () => {
const raising = [
"define internal void @sc_bf_raises() #0 {",
"entry:",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const leaf = [
"define internal double @sc_bf_leaf() #0 {",
"entry:",
" ret double 1.0",
"}",
].join("\n");
const caller = (callee: string) =>
[
"define internal double @sc_bf_caller() #0 personality ptr @__gcc_personality_v0 {",
"entry:",
` %r = invoke double @${callee}() to label %ok unwind label %lp`,
"lp:",
" %x = landingpad { ptr, i32 } cleanup",
" call void @scr_unwind_raise()",
" unreachable",
"ok:",
" ret double %r",
"}",
].join("\n");
expect(() =>
bridgePlainUnwindCalls([[raising, leaf, caller("sc_bf_leaf")].join("\n")]),
).not.toThrow();
const testing = leaf.replace(
" ret double 1.0",
" %f = load i32, ptr @scr_exc_flag\n ret double 1.0",
);
expect(() =>
bridgePlainUnwindCalls([[raising, testing, caller("sc_bf_leaf")].join("\n")]),
).toThrow(UnwindAbiViolation);
});
test("calls that cannot unwind are marked nounwind", () => {
const raising = [
"define internal void @sc_bf_raises(ptr %b) #0 {",
"entry:",
" %n = call i64 @scr_bytes_length(ptr %b) ; length",
" call void @scr_bytes_release(ptr %b), !dbg !4",
" %r = call ptr %b(ptr %b)",
" call void @scr_unwind_raise()",
" unreachable",
"}",
].join("\n");
const [out] = bridgePlainUnwindCalls([raising]);
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");
});
@@ -0,0 +1,491 @@
/* The functions that propagate exceptions by unwinding instead of the
* pending flag (see unwindAbiFunctions). */
import type { IrExpr, IrModule } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
/** How many calls above a `throw` functions keep the pending flag. */
const THROW_LEVELS = 3;
/** The call graph's node for calls through values. */
const ANY_FUNCTION = " any function";
/** The nodes of `edges` on a cycle (a recursion), by Tarjan's algorithm
* without recursion of its own: a large module's call chains are deeper
* than the host stack. */
function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<string> {
const index = new Map<string, number>();
const low = new Map<string, number>();
const stack: string[] = [];
const onStack = new Set<string>();
const result = new Set<string>();
let counter = 0;
const enter = (name: string): { name: string; next: number } => {
index.set(name, counter);
low.set(name, counter++);
stack.push(name);
onStack.add(name);
return { name, next: 0 };
};
for (const root of edges.keys()) {
if (index.has(root)) continue;
const work = [enter(root)];
while (work.length > 0) {
const top = work[work.length - 1]!;
const out = edges.get(top.name) ?? [];
if (top.next < out.length) {
const callee = out[top.next++]!;
if (!index.has(callee)) work.push(enter(callee));
else if (onStack.has(callee))
low.set(top.name, Math.min(low.get(top.name)!, index.get(callee)!));
continue;
}
work.pop();
const parent = work[work.length - 1];
if (parent !== undefined)
low.set(parent.name, Math.min(low.get(parent.name)!, low.get(top.name)!));
if (low.get(top.name) !== index.get(top.name)) continue;
const members: string[] = [];
let member: string;
do {
member = stack.pop()!;
onStack.delete(member);
members.push(member);
} while (member !== top.name);
if (members.length > 1 || out.includes(top.name)) for (const m of members) result.add(m);
}
}
return result;
}
/** The may-throw functions whose exceptions reach their callers by
* unwinding the native stack (scr_unwind_raise) instead of returning with
* the pending flag set. Their callers call them with `invoke`, so the call
* needs no flag test; the landing pad runs the frame's unwind.
*
* A function qualifies only when every way it can be reached is a direct
* `call` from emitted code that can lower it as an `invoke`: it is not
* async or a generator, not a constructor, not a method a virtual call can
* reach or one a vtable holds (an overridden method), not a closure
* target, not the entry or a library export, not named by any node other
* 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
* 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). */
export function unwindAbiFunctions(
mod: IrModule,
mayThrow: ReadonlySet<string>,
excluded: ReadonlySet<string> = new Set(),
): Set<string> {
const names = new Set(mod.functions.map((fn) => fn.name));
const blocked = new Set<string>(excluded);
blocked.add(mod.entry);
// The runtime calls the error property readers through the pointers the
// module installs (emit-module.ts).
blocked.add("%error.name.read");
blocked.add("%error.message.read");
for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName);
// Methods a virtual call can reach, and the overridden methods of class
// hierarchies (whose vtables list them), are called through pointers; the
// others are called directly, like functions. A virtual call reaches its listed
// targets, or else the method in its receiver class's hierarchy; a
// receiver that is no class of the module (an interface) may reach any
// class's method of that name.
const classes = new Map((mod.classes ?? []).map((cls) => [cls.name, cls]));
const anyClass = new Set<string>();
for (const fn of mod.functions)
everyStmtList(fn.body, {
expr: (e: IrExpr) => {
if (e.kind !== "virtualCall") return true;
if (e.targets !== undefined) for (const target of e.targets) blocked.add(target);
else if (classes.has(e.className)) blocked.add(`%${e.className}.${e.method}`);
else anyClass.add(e.method);
return true;
},
stmt: () => true,
});
// A hierarchy's vtables hold the methods declared by more than one of its
// classes (an override and what it overrides).
const rootOf = (name: string): string => {
let cls = classes.get(name);
while (cls?.base !== undefined && classes.has(cls.base)) cls = classes.get(cls.base);
return cls?.name ?? name;
};
const declarers = new Map<string, number>();
for (const cls of mod.classes ?? [])
for (const m of cls.methods ?? []) {
const key = `${rootOf(cls.name)}\0${m}`;
declarers.set(key, (declarers.get(key) ?? 0) + 1);
}
for (const cls of mod.classes ?? []) {
blocked.add(`%${cls.name}.constructor`);
blocked.add(`%${cls.name}.construct`);
const root = rootOf(cls.name);
for (const m of cls.methods ?? [])
if (anyClass.has(m) || declarers.get(`${root}\0${m}`)! > 1) blocked.add(`%${cls.name}.${m}`);
}
// Any function a node names other than as a call's callee may be reached
// through a pointer.
const referenced = new Set<string>();
const noteNames = (node: object, skip: string | undefined): void => {
for (const key in node) {
if (key === skip) continue;
const value = (node as Record<string, unknown>)[key];
if (typeof value === "string" && names.has(value)) referenced.add(value);
}
};
// The call graph, for the functions near a throw and the recursive ones.
// Calls through values (closures, class values, dynamic calls, callbacks
// passed to the runtime) reach ANY_FUNCTION, which reaches every function
// a value can hold.
const throws = new Set<string>();
const callers = new Map<string, string[]>();
const callees = new Map<string, string[]>();
const edge = (from: string, to: string): void => {
let list = callers.get(to);
if (list === undefined) callers.set(to, (list = []));
list.push(from);
let out = callees.get(from);
if (out === undefined) callees.set(from, (out = []));
out.push(to);
};
const methodsNamed = new Map<string, string[]>();
for (const cls of mod.classes ?? [])
for (const m of cls.methods ?? []) {
let list = methodsNamed.get(m);
if (list === undefined) methodsNamed.set(m, (list = []));
list.push(`%${cls.name}.${m}`);
}
for (const fn of mod.functions) {
const coroutine = fn.async === true || fn.generator !== undefined;
everyStmtList(fn.body, {
expr: (e: IrExpr) => {
const kind = e.kind as string;
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
if (e.kind === "call") {
edge(fn.name, e.callee);
// The entry's callees (the module initializers) run once: on the
// flag they keep landing pads out of the top-level code inlined
// into the entry.
if (coroutine || fn.name === mod.entry) blocked.add(e.callee);
noteNames(e, "callee");
return true;
}
noteNames(e, undefined);
if (e.kind === "virtualCall")
for (const target of e.targets ?? methodsNamed.get(e.method) ?? []) edge(fn.name, target);
else if (e.kind === "new") {
edge(fn.name, `%${e.className}.constructor`);
edge(fn.name, `%${e.className}.construct`);
} else if (kind === "callValue" || kind === "dynCall" || kind === "newValue")
edge(fn.name, ANY_FUNCTION);
else if ("args" in e) {
const args: unknown = e.args;
if (Array.isArray(args))
for (const arg of args as IrExpr[])
if (arg.type.kind === "func") edge(fn.name, ANY_FUNCTION);
}
return true;
},
stmt: (s) => {
const kind = s.kind as string;
if (kind === "throw" || kind === "rethrow") throws.add(fn.name);
noteNames(s, undefined);
return true;
},
});
}
for (const name of referenced) {
blocked.add(name);
edge(ANY_FUNCTION, name);
}
// A throw starts on the pending flag, and so do the functions up to
// THROW_LEVELS calls above it. The union narrowing helpers' throws (a
// failed narrowing) don't count for their callers.
for (const name of throws) blocked.add(name);
let level = [...throws].filter((name) => !name.startsWith("%union."));
const near = new Set(level);
for (let i = 0; i < THROW_LEVELS; i++) {
const next: string[] = [];
for (const name of level)
for (const caller of callers.get(name) ?? [])
if (!near.has(caller)) {
near.add(caller);
next.push(caller);
}
level = next;
}
for (const name of near) blocked.add(name);
for (const name of recursiveFunctions(callees)) blocked.add(name);
const result = new Set<string>();
for (const fn of mod.functions) {
if (!mayThrow.has(fn.name) || blocked.has(fn.name)) continue;
if (fn.async === true || fn.generator !== undefined) continue;
result.add(fn.name);
}
return result;
}
/** What an unwinding-ABI function's unwind ends with when no handler of
* 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
* expands it (BlockBuilder.terminate). */
export const UNWIND_RESUME = "resume { ptr, i32 } <unwind slot>";
/** The function's slot for the exception its landing pads take. */
export const UNWIND_SLOT = "%unw.exc";
/** scr_unwind_raise's declaration. It is not `noreturn`: sanitized builds
* 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";
/** The personality of functions with landing pads: the landing pads are
* cleanups that scr_unwind_raise's forced 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 /;
/** 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\(/;
/** Functions emission found breaking the unwinding ABI's rules
* (bridgePlainUnwindCalls), by symbol: the caller emits the module again
* with them on the pending-flag ABI. */
export class UnwindAbiViolation extends Error {
readonly symbols: ReadonlySet<string>;
constructor(symbols: ReadonlySet<string>) {
super(`unwinding ABI: ${[...symbols].slice(0, 3).join(", ")} must keep the pending flag`);
this.symbols = symbols;
}
}
const DEFINE_NAME = /^define [^@\n]*@([\w.$%-]+)\(/;
const PLAIN_CALL = /^(\s*)(%[\w.$-]+) = call (.*?)@([\w.$%-]+)\(|^(\s*)call (.*?)@([\w.$%-]+)\(/;
const RETURN_ATTRIBUTES =
/\b(?:zeroext|signext|noalias|nonnull|noundef|inreg|dereferenceable(?:_or_null)?\(\d+\)|align \d+)\s+/g;
/** A call instruction's line. */
const CALL_LINE = /^\s*(?:%[\w.$-]+ = )?(?:(?:tail|musttail|notail) )?call /;
/** A call's callee (a function or a register) and the parenthesis that
* opens its arguments. */
const CALLEE = /[@%][\w.$%-]+\(/;
/** The index of the parenthesis that closes the argument list opened at `open`. */
function argumentsEnd(line: string, open: number): number {
let depth = 0;
let close = open;
for (; close < line.length; close++) {
const c = line.charAt(close);
if (c === "(") depth++;
else if (c === ")" && --depth === 0) break;
}
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. */
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;
const close = argumentsEnd(line, call + callee.index + callee[0].length - 1);
return close >= line.length
? line
: `${line.slice(0, close + 1)} nounwind${line.slice(close + 1)}`;
}
/** The value a bridged call yields on its landing pad: the dummy a
* pending-flag callee returns with an exception pending. */
function dummyValue(type: string): string {
if (type === "ptr") return "null";
if (type === "double" || type === "float") return "0.0";
if (/^i\d+$/.test(type)) return "0";
return "zeroinitializer";
}
/** Rewrites every remaining plain call of an unwinding-ABI function (one
* that raises, scr_unwind_raise, or resumes an unwind) in `parts`, the module's text, into an
* invoke whose landing pad continues after the call with the exception
* pending and the call's dummy result: the pending-flag contract those
* call sites were emitted for, whichever lowering emitted them. Calls of
* these functions that emitDirectCall lowered are invokes already. */
export function bridgePlainUnwindCalls(parts: string[]): string[] {
const raisers = new Set<string>();
const split = parts.map((part) => part.split(/\n(?=define )/));
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
if (m !== null && UNWINDS.test(chunk)) raisers.add(m[1]!);
}
if (raisers.size === 0) return parts;
// An invoke's callee must raise or never test the pending flag: one that
// returned with the flag pending would pass the exception by its invoking
// caller unseen. A wrapper an invoke reaches (an int32 or borrowed-ABI
// entry forwarding to the body) propagates its callee's unwind instead
// (`resume`), so it raises too. A raising function must be reached only by
// calls: a pointer to it (a vtable or table entry, a runtime callback)
// could be called without a landing pad. Functions that break either rule
// go back to the pending-flag ABI (UnwindAbiViolation).
const violations = new Set<string>();
const invoked = new Set<string>();
const testsFlag = new Set<string>();
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
if (m !== null && PENDING_TEST.test(chunk)) testsFlag.add(m[1]!);
for (const c of chunk.matchAll(/ invoke [^@\n]*@([\w.$%-]+)\(/g)) invoked.add(c[1]!);
}
const resumers = new Set<string>();
for (let grew = true; grew;) {
grew = false;
for (const chunks of split)
for (const chunk of chunks) {
const m = DEFINE_NAME.exec(chunk);
const name = m?.[1];
if (name === undefined || raisers.has(name) || !invoked.has(name)) continue;
if (![...chunk.matchAll(/ call [^@\n]*@([\w.$%-]+)\(/g)].some((c) => raisers.has(c[1]!)))
continue;
if (testsFlag.has(name)) continue;
raisers.add(name);
resumers.add(name);
grew = true;
}
}
for (const name of invoked) if (!raisers.has(name) && testsFlag.has(name)) violations.add(name);
for (const chunks of split)
for (const chunk of chunks)
for (const line of chunk.split("\n")) {
if (line.startsWith("define ")) continue;
for (const m of line.matchAll(/@([\w.$%-]+)/g)) {
if (!raisers.has(m[1]!)) continue;
const at = m.index!;
if (
/(?:call|invoke) [^@]*$/.test(line.slice(0, at)) &&
line.charAt(at + m[0].length) === "("
)
continue;
violations.add(m[1]!);
}
}
if (violations.size > 0) throw new UnwindAbiViolation(violations);
let personality = false;
let counter = 0;
const out = split.map((chunks) =>
chunks
.map((chunk) => {
if (!DEFINE_NAME.test(chunk) || !chunk.includes("call ")) return chunk;
const lines = chunk.split("\n");
const resume = resumers.has(DEFINE_NAME.exec(chunk)![1]!);
const result: string[] = [];
// The label a block's terminator now follows, for phi operands.
const moved = new Map<string, string>();
let block = "";
let changed = false;
for (let i = 0; i < lines.length; i++) {
const line = lines[i]!;
const label = /^([\w.$-]+):/.exec(line);
if (label !== null) block = label[1]!;
const m = PLAIN_CALL.exec(line);
const callee = m === null ? undefined : (m[4] ?? m[7]);
if (m === null || callee === undefined || !raisers.has(callee)) {
const marked = CALL_LINE.test(line) ? markNounwind(line) : line;
changed ||= marked !== line;
result.push(marked);
continue;
}
// The call's text from its callee to the end of the argument list.
const close = argumentsEnd(line, line.indexOf(`@${callee}(`) + callee.length + 1);
const head = line.slice(line.indexOf("call ") + 5, close + 1);
const tail = line.slice(close + 1);
const meta = /,\s*!/.exec(tail);
const attrs = (
meta === null ? tail.replace(/;.*$/, "") : tail.slice(0, meta.index)
).trimEnd();
const dbg = meta === null ? "" : tail.slice(meta.index).replace(/\s;.*$/, "");
const n = counter++;
if (block === "") {
result.splice(1, 0, "entry.ub:");
block = "entry.ub";
}
const indent = m[1] ?? m[5] ?? " ";
const ok = `ub.ok.${n}`;
const pad = `ub.lp.${n}`;
if (resume) {
// The wrapper holds nothing to release: the unwind goes on to
// its caller's landing pad.
const assign = m[2] !== undefined ? `${m[2]} = ` : "";
result.push(
`${indent}${assign}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}resume { ptr, i32 } %ub.v.${n}`,
);
result.push(`${ok}:`);
} else if (m[2] !== undefined) {
const type = (m[3] ?? "").replace(RETURN_ATTRIBUTES, "").trim();
const normal = `ub.n.${n}`;
result.push(
`${indent}${m[2]}.ub = invoke ${head}${attrs} to label %${normal} unwind label %${pad}${dbg}`,
);
result.push(`${normal}:`, `${indent}br label %${ok}`);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}br label %${ok}`,
);
result.push(
`${ok}:`,
`${indent}${m[2]} = phi ${type} [ ${m[2]}.ub, %${normal} ], [ ${dummyValue(type)}, %${pad} ]`,
);
} else {
result.push(
`${indent}invoke ${head}${attrs} to label %${ok} unwind label %${pad}${dbg}`,
);
result.push(
`${pad}:`,
`${indent}%ub.v.${n} = landingpad { ptr, i32 } cleanup`,
`${indent}br label %${ok}`,
);
result.push(`${ok}:`);
}
moved.set(block, ok);
changed = true;
}
if (!changed) return chunk;
if (moved.size === 0) return result.join("\n");
personality = true;
const header = result[0]!;
const brace = header.lastIndexOf(" {");
if (!header.includes("personality") && brace >= 0) {
// Before the function's metadata attachments (`!dbg`), if any.
const dbg = header.slice(0, brace).search(/\s!dbg !\d+$/);
const at = dbg >= 0 ? dbg : brace;
result[0] = `${header.slice(0, at)} ${UNWIND_PERSONALITY}${header.slice(at)}`;
}
// A split block's successors now come from its last part.
if (moved.size > 0)
for (let i = 0; i < result.length; i++) {
const line = result[i]!;
if (!line.includes(" phi ")) continue;
result[i] = line.replace(/, %([\w.$-]+) \]/g, (all, name: string) => {
const to = moved.get(name);
return to === undefined ? all : `, %${to} ]`;
});
}
return result.join("\n");
})
.join("\n"),
);
if (personality && !out.some((part) => part.includes("@__gcc_personality_v0(...)")))
out[0] = `declare i32 @__gcc_personality_v0(...)\n${out[0]}`;
return out;
}
@@ -43,6 +43,7 @@ import {
cacheTargetIdentity,
isZigDriver,
isMuslTarget,
type CcDriver,
} from "./driver.js";
import {
resolveProgramShardMergeIdentity,
@@ -821,6 +822,7 @@ export async function compileCInternal(
// generated/native input that references inflate symbols. Cross
// and targetless Zig builds use vendored zlib objects in the input section above.
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
// glibc keeps libm separate from libc. This must trail the generated
// program and every native FFI input because GNU ld resolves archives
// from left to right.
@@ -1068,6 +1070,7 @@ export async function compileCInternal(
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
];
@@ -1080,6 +1083,7 @@ export async function compileCInternal(
...(mbedtls && targetPlatform(driver) === "win32" ? ["-lbcrypt"] : []),
...(tlsCa && targetPlatform(driver) === "win32" ? ["-lcrypt32"] : []),
...(((opts.zlib ?? false) || nativeFetch) && !isZigDriver(driver) ? ["-lz"] : []),
...unwinderLinkArgs(driver),
...driver.linkArgs,
...executableLinkFlags,
];
@@ -1689,3 +1693,11 @@ export async function compileC(opts: CcOptions): Promise<void> {
await rm(`${opts.outPath}.dSYM`, { recursive: true, force: true });
}
}
/** Linux runtimes raise through the native unwinder (scr_unwind_raise):
* Zig's libunwind, or the toolchain's libgcc_eh. Both are static archives,
* so programs load no shared library for them. */
function unwinderLinkArgs(driver: CcDriver): string[] {
if (targetPlatform(driver) !== "linux") return [];
return isZigDriver(driver) ? ["-lunwind"] : ["-lgcc_eh"];
}
@@ -53,6 +53,7 @@ export const EXECUTABLE_RUNTIME_SOURCES = [
"scr_object.c",
"scr_union.c",
"scr_exception.c",
"scr_unwind.c",
"scr_error.c",
"scr_console.c",
"scr_lib.c",
+1
View File
@@ -875,6 +875,7 @@ async function prepareExecutableInput(
pointerBits: buildPlatform === "wasi" ? 32 : 64,
wasi: buildPlatform === "wasi",
runtimeAbiMarker: useRuntimePack,
unwindAbi: buildPlatform === "linux",
objectAudit: opts.sanitize === true,
lowRegion: lowRegionTarget(buildPlatform, opts.sanitize === true),
...(opts.sanitize === true ? { wasmBoundsChecks: true } : {}),
+1
View File
@@ -408,6 +408,7 @@ export function prepareNativeExecutable(
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
runtimeAbiMarker: outputKind === "obj" || outputKind === "exe",
unwindAbi: outputKind === "exe" && toolchain.target.platform === "linux",
objectAudit: options.sanitize === true,
lowRegion: lowRegionTarget(toolchain.target.platform, options.sanitize === true),
functionWorkers: emitWorkers(),
@@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({
},
...toolchain,
compileFlags: ["-ffunction-sections", "-fdata-sections"],
systemLibraries: [{ name: "m", predicate: true }],
// scr_unwind_raise: the static libgcc_eh, which loads no shared library.
systemLibraries: [
{ name: "m", predicate: true },
{ name: "gcc_eh", predicate: true },
],
});
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({
},
targetArgs: ["-target", "aarch64-linux-musl"],
compileFlags: ["-ffunction-sections", "-fdata-sections"],
systemLibraries: [{ name: "m", predicate: true }],
// scr_unwind_raise: Zig's libunwind.
systemLibraries: [
{ name: "m", predicate: true },
{ name: "unwind", predicate: true },
],
});
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -24,6 +24,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({
},
...toolchain,
compileFlags: ["-ffunction-sections", "-fdata-sections"],
systemLibraries: [{ name: "m", predicate: true }],
// scr_unwind_raise: the static libgcc_eh, which loads no shared library.
systemLibraries: [
{ name: "m", predicate: true },
{ name: "gcc_eh", predicate: true },
],
});
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -26,6 +26,10 @@ process.env.SCRIPTC_RUNTIME_PACK_CONFIG = JSON.stringify({
},
targetArgs: ["-target", "x86_64-linux-musl"],
compileFlags: ["-ffunction-sections", "-fdata-sections"],
systemLibraries: [{ name: "m", predicate: true }],
// scr_unwind_raise: Zig's libunwind.
systemLibraries: [
{ name: "m", predicate: true },
{ name: "unwind", predicate: true },
],
});
await import("../../runtime-pack-common/scripts/build.mjs");
@@ -30,6 +30,7 @@ export const BASE_RUNTIME_SOURCES = [
"scr_object.c",
"scr_union.c",
"scr_exception.c",
"scr_unwind.c",
"scr_error.c",
"scr_console.c",
"scr_lib.c",
+5
View File
@@ -123,6 +123,11 @@ static inline ScrExcCell *scr_exc_swap_cell_inline(ScrExcCell *cell) {
_Static_assert(offsetof(ScrExcCell, kind) == 0 && sizeof(ScrExcKind) == 4 && SCR_EXC_NONE == 0,
"the LLVM emitter's inline pending check loads an i32 kind at offset 0");
/* Unwinds the native stack to the nearest landing pad of emitted code with
* the exception pending in the active cell (the unwinding ABI of emitted
* functions, unwind-abi.ts). Linux (glibc and musl) only; it never
* returns. */
SCR_COLD _Noreturn void scr_unwind_raise(void);
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 */
+83
View File
@@ -0,0 +1,83 @@
/* 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. */
#include "scr_runtime.h"
#if defined(__linux__) && !defined(__ANDROID__) && !defined(__wasi__)
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unwind.h>
/* Weak, so the runtime unit alone does not link the unwinder into every
* program: programs that raise reference the personality routine from
* their landing pads, and its unit references the unwinder's entry points,
* which link it in. */
#pragma weak _Unwind_ForcedUnwind
#pragma weak _Unwind_GetCFA
#if defined(__SANITIZE_ADDRESS__)
#define SCR_UNWIND_ASAN 1
#elif defined(__has_feature)
#if __has_feature(address_sanitizer)
#define SCR_UNWIND_ASAN 1
#endif
#endif
#ifdef SCR_UNWIND_ASAN
#include <sanitizer/asan_interface.h>
#endif
#ifdef SCR_UNWIND_ASAN
/* The lowest stack address the unwind has not cleared yet. The unwind
* outlives the frame that started it: landing pads resume it. */
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,
struct _Unwind_Context *context, void *arg) {
(void)version;
(void)cls;
(void)exc;
#ifdef SCR_UNWIND_ASAN
/* The frames this unwind passes never return, so it clears their
* redzones' shadow itself. Emitted code calls scr_unwind_raise as an
* ordinary function: the sanitizer's hook before no-return calls needs
* stack bounds, which fiber stacks do not report. */
uintptr_t cfa = _Unwind_GetCFA(context);
if (cfa > scr_unwind_low) {
__asan_unpoison_memory_region((void *)scr_unwind_low, cfa - scr_unwind_low);
scr_unwind_low = cfa;
}
#else
(void)context;
#endif
(void)arg;
if (actions & _UA_END_OF_STACK) {
fputs("scriptc: an exception unwound past the program's outermost frame\n", stderr);
abort();
}
return _URC_NO_REASON;
}
static SCR_TL struct _Unwind_Exception scr_unwind_exception;
void scr_unwind_raise(void) {
struct _Unwind_Exception *exc = &scr_unwind_exception;
memset(exc, 0, sizeof *exc);
exc->exception_class = 0x5343524950540000ull; /* "SCRIPT\0\0" */
#ifdef SCR_UNWIND_ASAN
scr_unwind_low = (uintptr_t)__builtin_frame_address(0);
#endif
if (_Unwind_ForcedUnwind != NULL) _Unwind_ForcedUnwind(exc, scr_unwind_stop, NULL);
fputs("scriptc: the native unwinder could not raise an exception\n", stderr);
abort();
}
#endif
+4 -1
View File
@@ -395,7 +395,9 @@ export function dynamicTierSymbols(names) {
* bench/channel-messages and bench/worker-messages, added later, link them
* through worker messages:
* node:worker_threads values are dyn objects. bench/json-unions, added
* later too, decodes JSON.parse trees. bench/dynamic-methods and
* later too, decodes JSON.parse trees. bench/throw-chains, also added
* later, tests caught values with `instanceof`, as bench/validate-errors
* does. bench/dynamic-methods and
* bench/dynamic-properties are untyped JavaScript. 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
@@ -417,6 +419,7 @@ export const DYNAMIC_WORKLOADS = new Set([
"bench/log-summary",
"bench/inventory-report",
"bench/json-unions",
"bench/throw-chains",
]);
/** Names of the functions (text and weak symbols) in `nm` output. Mach-O