diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 73ca2ef17..bfce8c327 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 383ea407d..aa582e059 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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); diff --git a/packages/compiler/src/backend/llvm/expr-callbacks.ts b/packages/compiler/src/backend/llvm/expr-callbacks.ts index 50180e2f6..3bba43060 100644 --- a/packages/compiler/src/backend/llvm/expr-callbacks.ts +++ b/packages/compiler/src/backend/llvm/expr-callbacks.ts @@ -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) diff --git a/packages/compiler/src/backend/llvm/expr-island.ts b/packages/compiler/src/backend/llvm/expr-island.ts index 29708f020..c29b3312c 100644 --- a/packages/compiler/src/backend/llvm/expr-island.ts +++ b/packages/compiler/src/backend/llvm/expr-island.ts @@ -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) diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 7f84cf069..71d78f4e9 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -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)`, diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index c4a25e9f3..632bbb6ac 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15079,6 +15079,12 @@ ], "diags": [] }, + "/tests/corpus/4431-exception-pending-contexts.ts": { + "order": [ + "/tests/corpus/4431-exception-pending-contexts.ts" + ], + "diags": [] + }, "/tests/corpus/array-sort-runtime-comparator.ts": { "order": [ "/tests/corpus/array-sort-runtime-comparator.ts" diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index b5e065484..3c698f242 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -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 diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 0ae39d498..630d7d2c3 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -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 */ diff --git a/tests/corpus/4431-exception-pending-contexts.ts b/tests/corpus/4431-exception-pending-contexts.ts new file mode 100644 index 000000000..591fd140c --- /dev/null +++ b/tests/corpus/4431-exception-pending-contexts.ts @@ -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 { + await Promise.resolve(); +} + +async function failsLater(id: number): Promise { + await tick(); + if (id % 2 === 1) throw new Bad(id); + await tick(); + return id * 10; +} + +async function finallyAcrossAwait(id: number): Promise { + 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 { + 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 { + 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)); +});