Inline the pending-exception test in helpers and runtime units

main's emitPendingCheck (#785) already tests `scr_exc_active->kind`
inline after every may-throw call in executables. The helper and adapter
bodies the backend generates (dyn check helpers, async/generator
trampolines, inline-async wrappers, FFI callback adapters, island and
stream adapters, IPC message thunks, dynamic-key record stores) still
called `scr_exc_pending()`, an opaque call into the prebuilt runtime,
and so did every runtime C unit. On the tsc-ts self-check that function
was about 4% of self time.

Those sites now share pendingTestLines, which follows emitPendingCheck's
policy: executables load the active cell and compare its i32 kind
inline; libraries and worker programs keep the runtime call (workers
also observe cross-thread termination there). Runtime units outside
thread-instanced libraries and worker executables get the same inline
test through a scr_runtime.h macro; the exported scr_exc_pending
definition stays for those lanes and for emitted slow paths. A static
assert pins the i32 kind at offset 0.

This replays the work item's inline-check commit on top of #785: its own
emitPendingCheck rewrite is dropped in favor of main's.

Adds corpus 4431 pinning exception propagation across fiber switches,
generators, sort/map callbacks, and runtime JSON failures.
This commit is contained in:
Malte Ubl
2026-10-09 11:59:27 -05:00
committed by GitHub
parent 918024b688
commit 8d1969a313
9 changed files with 203 additions and 24 deletions
+3 -2
View File
@@ -86,6 +86,8 @@ export interface DynHost extends WalkerHost {
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
classSubtypes(className: string): readonly string[];
/** emitter.ts's pendingTestLines: the inline pending-exception test. */
pendingTestLines(dest: string): string[];
}
const FN_ATTRS = "#0";
@@ -484,9 +486,8 @@ export class LlDyn {
/** Emit the standard in-helper pending check: on a pending exception
* run `cleanup` and return `dummy`. */
private pendingBail(B: BlockBuilder, hint: string, cleanup: () => void, dummy: string): void {
this.host.declare(`declare zeroext i1 @scr_exc_pending()`);
const p = B.tmp();
B.line(`${p} = call zeroext i1 @scr_exc_pending()`);
for (const line of this.host.pendingTestLines(p)) B.line(line.trimStart());
const lu = B.newLabel(`${hint}.u`);
const lk = B.newLabel(`${hint}.k`);
B.condBr(p, lu, lk);
+31 -11
View File
@@ -819,6 +819,7 @@ export class LlEmitter {
)
.map((meta) => meta.def.name);
},
pendingTestLines: (dest) => this.pendingTestLines(dest),
};
this.walkers = new LlWalkers(this.shapeHost);
this.dyn = new LlDyn(this.shapeHost);
@@ -916,7 +917,6 @@ export class LlEmitter {
const globals: string[] = [];
const defs: string[] = [];
if (this.ffiCallbackAdapters.size === 0) return { globals, defs };
this.declare(`declare zeroext i1 @scr_exc_pending()`);
this.declare(`declare void @scr_trap(ptr)`);
const expired = this.cstr(
"scriptc: native callback invoked outside its call-scoped lifetime\n",
@@ -1081,7 +1081,7 @@ export class LlEmitter {
` call void @scr_trap(ptr ${adapter.callback.lifetime === "call" ? expired : released})`,
` unreachable`,
`ready:`,
` %pending = call zeroext i1 @scr_exc_pending()`,
...this.pendingTestLines("%pending"),
` br i1 %pending, label %skip, label %invoke`,
`skip:`,
` ret ${ffiCallbackDummyLl(cb)}`,
@@ -1519,8 +1519,9 @@ export class LlEmitter {
const topPendingReleases = asyncEntry ? globalReleaseLines("gp") : [];
const loopReportedReleases = runsLoop ? globalReleaseLines("gq") : [];
// main's epilogues read the flag / the loop entry points — declared
// HERE, before the extern block flushes (a pending check usually
// declared the flag already; the Set dedupes).
// HERE, before the extern block flushes. They run once, so they keep
// the out-of-line scr_exc_pending call; per-call checks inline the
// test (pendingTestLines).
if (entryMayThrow || runsLoop) this.declare(`declare zeroext i1 @scr_exc_pending()`);
if (runsLoop) {
this.declare(`declare zeroext i1 @scr_loop_run(ptr)`);
@@ -2745,7 +2746,6 @@ export class LlEmitter {
this.declare(`declare void @free(ptr)`);
this.declare(`declare ptr @malloc(${this.sizeType})`);
this.declare(`declare zeroext i1 @scr_exc_pending()`);
const tr: string[] = [
`define internal void @${mangleTrampoline(fn.name)}(ptr %self, ptr %ap) ${FN_ATTRS} {`,
@@ -2899,7 +2899,6 @@ export class LlEmitter {
this.declare(`declare void @scr_async_inline_enter(ptr)`);
this.declare(`declare ptr @scr_async_inline_leave(ptr)`);
this.declare(`declare void @scr_promise_reject_pending(ptr)`);
this.declare(`declare zeroext i1 @scr_exc_pending()`);
if (lifted) {
this.declare(`declare ptr @scr_closure_retain_v(ptr)`);
this.declare(`declare void @scr_closure_release(ptr)`);
@@ -2915,7 +2914,7 @@ export class LlEmitter {
` call void @scr_async_inline_enter(ptr %frame)`,
retTy === "void" ? ` ${bodyCall}` : ` %r = ${bodyCall}`,
...(lifted ? [` call void @scr_closure_release(ptr %a0)`] : []),
` %pend = call zeroext i1 @scr_exc_pending()`,
...this.pendingTestLines("%pend"),
` %p = call ptr @scr_async_inline_leave(ptr %frame)`,
` br i1 %pend, label %thrown, label %clean`,
`clean:`,
@@ -2985,7 +2984,7 @@ export class LlEmitter {
this.declare(`declare void @scr_closure_release(ptr)`);
}
tr.push(
` %pend = call zeroext i1 @scr_exc_pending()`,
...this.pendingTestLines("%pend"),
` br i1 %pend, label %thrown, label %clean`,
`clean:`,
` %pr = call ptr @scr_fiber_promise(ptr %self)`,
@@ -3105,7 +3104,7 @@ export class LlEmitter {
} else {
tr.push(
` %g = call ptr @scr_gen_of_fiber(ptr %self)`,
` %pend = call zeroext i1 @scr_exc_pending()`,
...this.pendingTestLines("%pend"),
` br i1 %pend, label %thrown, label %clean`,
`clean:`,
);
@@ -3540,6 +3539,28 @@ export class LlEmitter {
this.B.br(cleanup.label);
}
/** Instructions (two-space indented) that set the i1 `dest` to "an
* exception is pending in the active context" — scr_exc_pending()'s
* meaning — for the helper and adapter bodies that test it outside
* emitPendingCheck. Executables read the runtime's exported active-cell
* pointer and compare its i32 kind (offset 0) against SCR_EXC_NONE inline,
* as emitPendingCheck does; the out-of-line call would be opaque to LLVM
* because the runtime links as prebuilt objects. Libraries and worker
* programs keep the runtime call, which is emitPendingCheck's path there
* too (workers also observe cross-thread termination in it). */
pendingTestLines(dest: string): string[] {
if (this.mod.lib !== undefined || this.mod.workers === true) {
this.declare(`declare zeroext i1 @scr_exc_pending()`);
return [` ${dest} = call zeroext i1 @scr_exc_pending()`];
}
this.declare(`@scr_exc_active = external global ptr`);
return [
` ${dest}.cell = load ptr, ptr @scr_exc_active`,
` ${dest}.kind = load i32, ptr ${dest}.cell`,
` ${dest} = icmp ne i32 ${dest}.kind, 0`,
];
}
/** The emitter contract for exceptions: after EVERY call that can throw
* (per the may-throw analysis), test the pending flag and unwind. The
* call's result temp must join its frame BEFORE this runs so the unwind
@@ -5224,9 +5245,8 @@ export class LlEmitter {
B.line(`call void @scr_dyn_release(ptr ${v.name})`);
// Mismatched write: TypeError pending, field untouched — the
// statement-level check below unwinds.
this.declare(`declare zeroext i1 @scr_exc_pending()`);
const pend = B.tmp();
B.line(`${pend} = call zeroext i1 @scr_exc_pending()`);
for (const line of this.pendingTestLines(pend)) B.line(line.trimStart());
const lw = B.newLabel("rks.w");
B.condBr(pend, join, lw);
B.startBlock(lw);
@@ -568,9 +568,8 @@ export function ipcMessageThunkFor(
} else {
const helper = host.dyn.dynCheckHelper(param);
d.push(` %value = call ${ty} @${helper}(ptr %message, ptr null)`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
d.push(
` %bad = call zeroext i1 @scr_exc_pending()`,
...host.pendingTestLines("%bad"),
` br i1 %bad, label %fail, label %invoke`,
`fail:`,
...(isRefCounted(param)
@@ -653,7 +653,6 @@ export function islandTypedAdapter(
`cont${b}:`,
);
};
host.declare(`declare zeroext i1 @scr_exc_pending()`);
fn.params.forEach((p, i) => {
d.push(
` %ap${i} = getelementptr inbounds ptr, ptr %argv, i64 ${i}`,
@@ -742,7 +741,7 @@ export function islandTypedAdapter(
`cont${b3}:`,
` %cv${i} = call ${host.llType(p)} @${host.dyn.dynCheckHelper(p)}(ptr %dp${i}, ptr null)`,
` call void @scr_dyn_release(ptr %dp${i})`,
` %pe${i} = call zeroext i1 @scr_exc_pending()`,
...host.pendingTestLines(`%pe${i}`),
);
const b4 = blk++;
d.push(
@@ -852,7 +851,7 @@ export function islandTypedAdapter(
d.push(
` %jbuf = alloca %ScrJsonBuf`,
` %rv = call ${host.llType(fn.ret)} %fn(${passed.join(", ")})`,
` %rpend = call zeroext i1 @scr_exc_pending()`,
...host.pendingTestLines("%rpend"),
` br i1 %rpend, label %jfail, label %jok`,
`jfail:`,
...(isRefCounted(fn.ret)
@@ -70,7 +70,6 @@ export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): stri
host.dynPromiseAdapters.set(key, sym);
host.declare(`declare ptr @scr_promise_payload_ref(ptr)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
host.declare(`declare void @scr_promise_reject_pending(ptr)`);
const B = new BlockBuilder();
const dyn = B.tmp();
@@ -83,7 +82,7 @@ export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): stri
: `${value} = call ${host.llType(inner)} @${host.dyn.dynCheckHelper(inner)}(ptr ${dyn}, ptr null)`,
);
B.line(`call void @scr_dyn_release_v(ptr ${dyn})`);
B.line(`${pending} = call zeroext i1 @scr_exc_pending()`);
for (const line of host.pendingTestLines(pending)) B.line(line.trimStart());
const fail = B.newLabel("sra.fail");
const ok = B.newLabel("sra.ok");
B.condBr(pending, fail, ok);
@@ -210,7 +209,6 @@ export function streamTypedRefCommitAdapter(
const commit = `${snapshot}_commit`;
host.declare(`declare ptr @scr_dyn_obj_get(ptr, ptr, ${host.sizeType})`);
host.declare(`declare ptr @scr_dyn_undefined()`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
const lines = [
`define internal void @${commit}(ptr %target, ptr %d) ${FN_ATTRS} { ; commit unknown class ${typeKey(t)}`,
`entry:`,
@@ -304,7 +302,7 @@ export function streamTypedRefCommitAdapter(
` %${input} = select i1 %${missing}, ptr %${undef}, ptr %${raw}`,
` %${next} = call ${field.type.kind === "bool" ? "zeroext " : ""}${checkTy} @${host.dyn.dynCheckHelper(field.type)}(ptr %${input}, ptr null)`,
...(symbolGlobal ? [` call void @scr_dyn_release_v(ptr %${raw})`] : []),
` %${pending} = call zeroext i1 @scr_exc_pending()`,
...host.pendingTestLines(`%${pending}`),
` br i1 %${pending}, label %done, label %${store}`,
`${store}:`,
` %f${index}_ptr = getelementptr inbounds %${classStructSym(t.className)}, ptr %target, i64 0, i32 ${fieldIndex}`,
@@ -360,7 +358,7 @@ export function streamTypedRefCommitAdapter(
` %bag = call ptr @scr_dyn_new_obj()`,
` %bag_copy = call ptr @scr_dyn_copy_property_descriptors(ptr %bag, ptr %d)`,
` call void @scr_dyn_release_v(ptr %bag_copy)`,
` %bag_pending = call zeroext i1 @scr_exc_pending()`,
...host.pendingTestLines("%bag_pending"),
` br i1 %bag_pending, label %bag_fail, label %bag_keys`,
`bag_fail:`,
` call void @scr_dyn_release_v(ptr %bag)`,
@@ -15079,6 +15079,12 @@
],
"diags": []
},
"<repo>/tests/corpus/4431-exception-pending-contexts.ts": {
"order": [
"<repo>/tests/corpus/4431-exception-pending-contexts.ts"
],
"diags": []
},
"<repo>/tests/corpus/array-sort-runtime-comparator.ts": {
"order": [
"<repo>/tests/corpus/array-sort-runtime-comparator.ts"
+3 -1
View File
@@ -122,7 +122,9 @@ ScrStr *scr_stack_capture(void) {
#define scr_exc_release_fn (SCR_EXC_CUR()->release_fn)
#define scr_exc_trace_fn (SCR_EXC_CUR()->trace_fn)
bool scr_exc_pending(void) {
/* Parenthesized: scr_runtime.h maps runtime-internal calls to an inline
* test with a function-like macro; this keeps the exported symbol. */
bool(scr_exc_pending)(void) {
#ifdef SCR_WORKERS
if (scr_context_checkpoint()) return true;
#endif
+12
View File
@@ -2666,6 +2666,18 @@ ScrExcCell *scr_exc_current_cell(void); /* the ACTIVE cell */
bool scr_exc_pending(void);
#if !((defined(SCR_LIB) && defined(SCR_THREAD_INSTANCES)) || defined(SCR_WORKERS))
/* Runtime units test the active cell's kind inline, as emitted exception
* polls do (scr_exc_active is never NULL outside thread-instanced libraries
* and worker executables, which also poll cross-thread termination in
* scr_exc_pending). The out-of-line definition in scr_exception.c stays
* for those lanes and for emitted code's slow paths. */
static inline bool scr_exc_pending_inline(void) { return scr_exc_active->kind != SCR_EXC_NONE; }
#define scr_exc_pending() scr_exc_pending_inline()
#endif
_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");
void scr_throw_f64(double v);
void scr_throw_bool(bool v);
void scr_throw_str(ScrStr *v); /* takes ownership */
@@ -0,0 +1,142 @@
// The pending-exception test after may-throw calls reads the ACTIVE
// exception context (one per fiber). These cases interleave throws with
// fiber switches, generators, runtime callbacks, and runtime-internal
// failures so a check that read the wrong context would either miss an
// exception or unwind a frame that never threw.
class Bad extends Error {
readonly code: number;
constructor(code: number) {
super(`bad ${code}`);
this.code = code;
}
}
function mayThrow(n: number): number {
if (n % 7 === 3) throw new Bad(n);
return n * 2;
}
// Many checks on the hot path, rare unwinds.
function sumChecked(limit: number): number {
let sum = 0;
let caught = 0;
for (let i = 0; i < limit; i++) {
try {
sum += mayThrow(i);
} catch (e) {
if (e instanceof Bad) caught += e.code;
else throw e;
}
}
return sum * 1000 + caught;
}
console.log("loop", sumChecked(200));
// Exceptions across awaits: each fiber owns its own pending state, and a
// throw in one fiber must not be observed by another that resumes first.
async function tick(): Promise<void> {
await Promise.resolve();
}
async function failsLater(id: number): Promise<number> {
await tick();
if (id % 2 === 1) throw new Bad(id);
await tick();
return id * 10;
}
async function finallyAcrossAwait(id: number): Promise<string> {
const log: string[] = [];
try {
try {
log.push(`try${id}`);
await failsLater(id);
log.push("resumed");
} finally {
await tick();
log.push("finally");
}
} catch (e) {
log.push(e instanceof Bad ? `caught ${e.code}` : "other");
}
return log.join(",");
}
// Generators run on their own fibers too.
function* numbers(): Generator<number, string, unknown> {
yield mayThrow(1);
try {
yield mayThrow(3);
} catch (e) {
yield e instanceof Bad ? -e.code : 0;
}
return "done";
}
function drain(): string {
const out: string[] = [];
const it = numbers();
for (let r = it.next(); ; r = it.next()) {
if (r.done) {
out.push(String(r.value));
break;
}
out.push(String(r.value));
}
return out.join(" ");
}
async function main(): Promise<void> {
const results = await Promise.all([0, 1, 2, 3].map((id) => finallyAcrossAwait(id)));
for (const r of results) console.log("async", r);
// Two fibers in flight; the second throws while the first is suspended.
const pending = [failsLater(4), failsLater(5)];
for (const p of pending) {
try {
console.log("settled", await p);
} catch (e) {
console.log("rejected", e instanceof Bad ? e.code : "other");
}
}
console.log("gen", drain());
// A throw from a sort comparator unwinds through the sort loop.
const values = [5, 3, 9, 1, 7];
try {
values.sort((a, b) => {
if (a === 9 || b === 9) throw new Bad(9);
return a - b;
});
} catch (e) {
console.log("sort", e instanceof Bad ? e.code : "other");
}
// A throw from an array callback mid-iteration.
try {
const mapped = [1, 2, 3, 4].map((x) => mayThrow(x));
console.log("map", mapped.join(","));
} catch (e) {
console.log("map caught", e instanceof Bad ? e.code : "other");
}
// Runtime-internal failures (the runtime's own pending checks).
for (const text of ['{"a": [1, 2, {"b": true}]}', '{"a": [1, 2,', "[1, 2, 3]"]) {
try {
const parsed = JSON.parse(text) as unknown;
console.log("json", JSON.stringify(parsed));
} catch (e) {
console.log("json error", e instanceof SyntaxError);
}
}
// After everything above, nothing may still be pending.
await tick();
console.log("after", sumChecked(10));
}
main().catch((e: unknown) => {
console.log("main failed", e instanceof Error ? e.message : String(e));
});