From b7309201dc4549970f7f70e32aa6f2862c1302bc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 21:42:14 -0700 Subject: [PATCH 1/8] Skip worker entry polls in stack-checked functions A stack-checked synchronous function may throw, so every caller tests the pending state after calling it, and a descent that never returns ends at the stack guard, whose RangeError unwinds through those tests. A terminate request keeps the alert word raised, so the next test stops the thread; the entry poll only added a rejoining slow path that kept arguments live across a call in every recursive function. Async and generator bodies and the module entry keep their polls. --- .../compiler/src/backend/may-throw.test.ts | 32 +++++++++++++++++++ packages/compiler/src/backend/may-throw.ts | 27 +++++++++++++++- packages/compiler/test/worker-threads.test.ts | 6 ++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 4a07f8227..fee54361c 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -437,6 +437,38 @@ test("stack checks cover recursive components and recursion through reentrant op expect(computeMayThrow(mod).fns.size).toBe(0); }); +test("stack-checked synchronous functions skip the worker entry poll", () => { + const asyncSelf: IrFunction = { ...fn("asyncSelf", [call("asyncSelf")], []), async: true }; + const mod: IrModule = { + ...moduleWith( + fn("caller", [call("caller"), call("self"), call("left"), call("walker")], []), + fn("self", [call("self")], []), + fn("left", [call("right")], []), + fn("right", [call("left")], []), + fn("walker", [callClosure("leaf")], []), + fn("leaf", [exprStmt(value)], []), + asyncSelf, + ), + workers: true, + }; + const answer = computeMayThrow(mod, { stackChecks: true }); + expect([...answer.stackChecks!].sort()).toEqual(["asyncSelf", "caller", "left", "right", "self"]); + // Callers test after every call of a stack-checked function, and its + // overflow unwinds through those tests, so only the module entry, async + // bodies and unbounded functions without a stack check poll on entry. + expect([...answer.workerEntryPolls!].sort()).toEqual(["asyncSelf", "caller", "walker"]); + expect(answer.fns.has("self") && answer.fns.has("left") && answer.fns.has("right")).toBe(true); + // Without stack checks (WASI, Windows, libraries) every cycle keeps its poll. + expect([...computeMayThrow(mod).workerEntryPolls!].sort()).toEqual([ + "asyncSelf", + "caller", + "left", + "right", + "self", + "walker", + ]); +}); + test("class members are reachable from reentrant operations", () => { const mod: IrModule = { ...moduleWith( diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 21d0fb900..55effebad 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -368,11 +368,36 @@ export function computeMayThrow( return { fns: may, indirect, - ...(terminations ? { workerEntryPolls: terminations.entry } : {}), + ...(terminations ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackChecks) } : {}), ...(stackChecks ? { stackChecks } : {}), }; } +/** The termination polls a worker program places on function entry. A + * stack-checked synchronous function needs none: it may throw (the stack + * guard seeds the fixpoint), so every caller tests the pending state after + * the call, and a descent that never returns ends at the stack guard, whose + * RangeError unwinds through those tests. An outstanding terminate request + * keeps the alert word nonzero, so the first test that runs afterwards (a + * post-call test, a loop poll or the unwinding) stops the thread; dropping + * the entry poll only delays the stop by bounded work, which Node's + * asynchronous terminate allows. The entry poll's slow path rejoins the + * body, so every polled function kept its arguments live across a call. */ +function entryPolls( + mod: IrModule, + entry: Set, + stackChecks: Set | null, +): Set { + if (stackChecks === null) return entry; + // Calling an async or generator function never tests the caller's pending + // state, and the module entry has no caller: those keep their polls. + const keep = new Set( + mod.functions.filter((fn) => fn.async || fn.generator !== undefined).map((fn) => fn.name), + ); + keep.add(mod.entry); + return new Set([...entry].filter((name) => keep.has(name) || !stackChecks.has(name))); +} + /** Statement and expression kinds that cannot reenter scriptc code except * through the direct call edges recorded below (`call`, `new`, * `virtualCall`), and cannot block in the runtime. Everything else, such as diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 25df45995..2f70de9e3 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -248,9 +248,9 @@ console.log(mix(1, 2), spin(5)); }; // A bounded helper neither polls on entry nor makes its callers poll. expect(body("sc_f_mix")).not.toContain("@scr_exc_alert"); - // Recursion polls on entry: the inline test reads the process-wide - // alert word (no thread-local access), and only the slow path calls into - // the runtime. + // Recursion tests after its recursive calls (its stack check replaces + // the entry poll): the inline test reads the process-wide alert word (no + // thread-local access), and only the slow path calls into the runtime. expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); From ad99adc1a08f4a0a0d73d864608f4fb606517c47 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 21:43:28 -0700 Subject: [PATCH 2/8] Skip worker loop polls when every iteration tests the pending state Worker loops poll the exception alert every 64 iterations so that a loop calling nothing that tests still observes a terminate request. A loop whose condition, update or do-while condition evaluates a call followed by a pending test, or whose body evaluates one before any statement that can continue the loop, observes the request through that test. Corpus native-worker-terminate-tested-loops terminates workers spinning in each shape, including a continue path that keeps its poll. --- .oxlintrc.jsonc | 1 + packages/compiler/src/backend/llvm/emitter.ts | 33 ++++- .../compiler/src/backend/loop-polls.test.ts | 94 ++++++++++++ packages/compiler/src/backend/loop-polls.ts | 107 ++++++++++++++ .../test/ts7/baselines/order-parity.json | 6 + .../native-worker-terminate-tested-loops.ts | 138 ++++++++++++++++++ 6 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/backend/loop-polls.test.ts create mode 100644 packages/compiler/src/backend/loop-polls.ts create mode 100644 tests/corpus/native-worker-terminate-tested-loops.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 58ab0455d..7de4019ae 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -268,6 +268,7 @@ "files": [ "packages/compiler/src/frontend/lowering/**", "packages/compiler/src/backend/llvm/**", + "packages/compiler/src/backend/loop-polls.test.ts", "packages/compiler/src/ir/**", "packages/compiler/src/library/*.test.ts", "packages/compiler/test/**", diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..91c0b6baa 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -28,6 +28,7 @@ import { undefinedArmTag, } from "../../ir/analysis.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; +import { loopTestsEveryIteration } from "../loop-polls.js"; import type { IrBytesElem, IrExpr, @@ -3974,8 +3975,30 @@ export class LlEmitter { B.startBlock(ok); } - private workerLoopBudget(): string | null { + /** Whether the emitter follows this call with a pending test (the + * conditions in expr-calls.ts). */ + private callTestsPending(e: IrExpr): boolean { + switch (e.kind) { + case "call": + return this.mayThrow.has(e.callee); + case "new": + return this.mayThrow.has(`%${e.className}.constructor`); + case "virtualCall": + return this.mayThrowMethods.has(e.method); + case "callValue": + return this.indirectMayThrow; + default: + return false; + } + } + + private workerLoopBudget( + loop: Extract, + ): string | null { if (!this.mod.workers) return null; + // A loop that runs a pending test on every iteration observes a + // terminate request through it; a separate poll would only add work. + if (loopTestsEveryIteration(loop, (e) => this.callTestsPending(e))) return null; const slot = this.B.slot(); this.B.entryAllocas.push(`${slot} = alloca i32 ; worker cancellation budget`); this.B.line(`store i32 0, ptr ${slot}`); @@ -5930,7 +5953,7 @@ export class LlEmitter { break; } case "while": { - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); const lc = B.newLabel("loop.c"); const lb = B.newLabel("loop.b"); const le = B.newLabel("loop.e"); @@ -5955,7 +5978,7 @@ export class LlEmitter { break; } case "doWhile": { - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); // Body first (runs at least once); continue jumps to the CONDITION. const lb = B.newLabel("loop.b"); const lc = B.newLabel("loop.c"); @@ -6124,7 +6147,7 @@ export class LlEmitter { }; const lu = s.update || capturedLets.length > 0 ? B.newLabel("loop.u") : lc; freshenBindings(); - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); B.br(lc); B.startBlock(lc); this.emitWorkerLoopCheck(workerBudget); @@ -6227,7 +6250,7 @@ export class LlEmitter { const lb = B.newLabel("fof.b"); const lu = B.newLabel("fof.u"); const le = B.newLabel("fof.e"); - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); B.br(lc); B.startBlock(lc); this.emitWorkerLoopCheck(workerBudget); diff --git a/packages/compiler/src/backend/loop-polls.test.ts b/packages/compiler/src/backend/loop-polls.test.ts new file mode 100644 index 000000000..b3ecd5e71 --- /dev/null +++ b/packages/compiler/src/backend/loop-polls.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrStmt } from "../ir/ir.js"; +import { loopTestsEveryIteration } from "./loop-polls.js"; + +const loc = { file: "loops.ts", start: 0, end: 1 }; +const call = (callee: string): IrExpr => ({ kind: "call", callee, args: [], type: F64, loc }); +const stmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); +const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; +const tested = (e: IrExpr): boolean => e.kind === "call" && e.callee === "throws"; +const whileLoop = (body: IrStmt[], cond: IrExpr = yes, labels?: string[]): IrStmt => ({ + kind: "while", + cond, + body, + loc, + ...(labels ? { labels } : {}), +}); +const answer = (loop: IrStmt): boolean => + loopTestsEveryIteration(loop as Parameters[0], tested); +const ifThen = (body: IrStmt[]): IrStmt => ({ + kind: "if", + cond: yes, + then: body, + else_: null, + loc, +}); +const sum = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op: "+", + left, + right, + type: F64, + loc, +}); + +test("an unconditional tested call in the body or condition replaces the poll", () => { + expect(answer(whileLoop([stmt(call("throws"))]))).toBe(true); + expect(answer(whileLoop([stmt(sum(call("pure"), call("throws")))]))).toBe(true); + expect(answer(whileLoop([], { kind: "toBool", operand: call("throws"), type: BOOL, loc }))).toBe( + true, + ); + // Untested calls and calls behind a condition or short circuit do not. + expect(answer(whileLoop([stmt(call("pure"))]))).toBe(false); + expect(answer(whileLoop([ifThen([stmt(call("throws"))])]))).toBe(false); + const shortCircuit: IrExpr = { + kind: "logical", + op: "&&", + left: yes, + right: call("throws"), + type: BOOL, + loc, + }; + expect(answer(whileLoop([stmt(shortCircuit)]))).toBe(false); +}); + +test("a continue before the tested call keeps the poll", () => { + const skip: IrStmt = { kind: "continue", loc }; + expect(answer(whileLoop([ifThen([skip]), stmt(call("throws"))]))).toBe(false); + // After the call, or bound to a nested loop, a continue is harmless. + expect(answer(whileLoop([stmt(call("throws")), ifThen([skip])]))).toBe(true); + expect(answer(whileLoop([whileLoop([skip]), stmt(call("throws"))]))).toBe(true); + // A labeled continue out of a nested loop still skips this iteration. + const labeled: IrStmt = { kind: "continue", label: "outer", loc }; + expect(answer(whileLoop([whileLoop([labeled]), stmt(call("throws"))], yes, ["outer"]))).toBe( + false, + ); +}); + +test("for updates and do-while conditions run after every continue", () => { + const skip: IrStmt = { kind: "continue", loc }; + const forLoop: IrStmt = { + kind: "for", + init: null, + cond: null, + update: stmt(call("throws")), + body: [ifThen([skip])], + loc, + }; + expect(answer(forLoop)).toBe(true); + const doWhile: IrStmt = { + kind: "doWhile", + body: [ifThen([skip])], + cond: { kind: "toBool", operand: call("throws"), type: BOOL, loc }, + loc, + }; + expect(answer(doWhile)).toBe(true); + const forOf: IrStmt = { + kind: "forOf", + localId: "x", + iterable: { kind: "varRef", localId: "xs", type: { kind: "array", elem: F64 }, loc }, + body: [stmt({ kind: "call", callee: "throws", args: [], type: VOID, loc })], + loc, + } as IrStmt; + expect(answer(forOf)).toBe(true); +}); diff --git a/packages/compiler/src/backend/loop-polls.ts b/packages/compiler/src/backend/loop-polls.ts new file mode 100644 index 000000000..9fc70a2bb --- /dev/null +++ b/packages/compiler/src/backend/loop-polls.ts @@ -0,0 +1,107 @@ +/* Which worker-program loops can skip their termination poll. */ +import type { IrExpr, IrStmt } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; + +type Loop = Extract; + +/** Expression kinds whose operands all evaluate whenever the expression does + * (no short-circuit, deferred or conditional operand). */ +const EAGER_EXPRS: ReadonlySet = new Set([ + "call", + "new", + "virtualCall", + "callValue", + "bin", + "unary", + "toBool", + "strConcat", + "strEq", + "strCmp", + "assignExpr", + "fieldGet", + "fieldIncDec", + "arrayGet", + "recordGet", + "upcast", + "downcast", + "unionWrap", + "dynFrom", +]); + +/** Statement kinds whose expressions all evaluate whenever the statement + * does. */ +const EAGER_STMTS: ReadonlySet = new Set([ + "varDecl", + "assign", + "exprStmt", + "arraySet", + "bytesSet", + "fieldSet", + "recordSet", + "return", + "throw", +]); + +/** True when every iteration of `loop` evaluates a call that the emitter + * follows with a pending test (`tested`). Worker loops poll for termination + * every 64 iterations (emitWorkerLoopCheck) so that a loop whose body calls + * nothing that tests still observes a terminate request; a pending test reads + * the same alert word, so a loop that always runs one needs no extra poll. + * The tested call must be unconditional: in the condition (while, for), in + * the update or the do-while condition (both run after `continue` too), or + * in the body before any statement that can `continue` this loop. */ +export function loopTestsEveryIteration(loop: Loop, tested: (call: IrExpr) => boolean): boolean { + const evaluates = (e: IrExpr): boolean => { + if (tested(e)) return true; + if (!EAGER_EXPRS.has(e.kind)) return false; + let found = false; + everyExprChild( + e, + (child) => !(found = evaluates(child)), + () => true, + ); + return found; + }; + const statementEvaluates = (s: IrStmt): boolean => { + if (s.kind === "if") return evaluates(s.cond); + if (s.kind === "block") return s.body.some(statementEvaluates); + if (!EAGER_STMTS.has(s.kind)) return false; + let found = false; + everyStmtChild( + s, + (child) => !(found = evaluates(child)), + () => true, + ); + return found; + }; + const labels = new Set(loop.labels ?? []); + // Can `s` jump to this loop's next iteration? Unlabeled continues bind to + // the innermost enclosing loop; labeled ones may skip out of nested loops. + const continues = (s: IrStmt, nested: boolean): boolean => { + if (s.kind === "continue") return s.label === undefined ? !nested : labels.has(s.label); + const inner = + nested || + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "for" || + s.kind === "forOf"; + let found = false; + everyStmtChild( + s, + () => true, + (child) => !(found = continues(child, inner)), + ); + return found; + }; + if (loop.kind === "while" && evaluates(loop.cond)) return true; + if (loop.kind === "doWhile" && evaluates(loop.cond)) return true; + if (loop.kind === "for") { + if (loop.cond !== null && evaluates(loop.cond)) return true; + if (loop.update !== null && statementEvaluates(loop.update)) return true; + } + for (const s of loop.body) { + if (continues(s, false)) return false; + if (statementEvaluates(s)) return true; + } + return false; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..f3521e1bd 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15742,6 +15742,12 @@ "/tests/corpus/4596-tdz-nullable-forward-capture.ts" ], "diags": [] + }, + "/tests/corpus/native-worker-terminate-tested-loops.ts": { + "order": [ + "/tests/corpus/native-worker-terminate-tested-loops.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/native-worker-terminate-tested-loops.ts b/tests/corpus/native-worker-terminate-tested-loops.ts new file mode 100644 index 000000000..969b0dd9e --- /dev/null +++ b/tests/corpus/native-worker-terminate-tested-loops.ts @@ -0,0 +1,138 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Loops whose every iteration calls a function that can throw observe a +// terminate request through that call's exception test; loops that can skip +// such a call keep their own poll. Each worker spins until it is terminated. + +class Box { + value = 0; + bump(n: number): number { + if (n < 0) throw new RangeError("negative " + n); + this.value = (this.value + n) % 1000003; + return this.value; + } +} + +function risky(n: number): number { + if (n < 0) throw new RangeError("negative " + n); + return (n * 31 + 7) % 1000003; +} + +let announced = false; +function announce(i: number): void { + if (!announced && i >= 1000) { + announced = true; + parentPort!.postMessage("ready"); + } +} + +function spinCalls(): number { + let acc = 0; + for (let i = 0; ; i++) { + acc = (acc + risky(i)) % 1000003; + announce(i); + } +} + +function spinCondition(): number { + let i = 0; + while (risky(i) >= 0) { + i++; + announce(i); + } + return i; +} + +// Once i passes 2000 every iteration continues before the call. +function spinContinue(): number { + let acc = 0; + for (let i = 0; ; i++) { + if (i > 2000) continue; + acc = (acc + risky(i)) % 1000003; + announce(i); + } +} + +function spinMethods(): number { + const box = new Box(); + let n = 0; + do { + try { + box.bump(n % 5 === 0 ? -1 : n); + } catch (e) { + if (!(e instanceof RangeError)) throw e; + } + n++; + announce(n); + } while (box.bump(1) >= 0); + return n; +} + +function spinNested(): number { + let acc = 0; + for (let i = 0; ; i++) { + for (let j = 0; j < 4; j++) acc = (acc + risky(j)) % 1000003; + announce(i); + } +} + +function run(kind: string): number { + if (kind === "calls") return spinCalls(); + if (kind === "condition") return spinCondition(); + if (kind === "continue") return spinContinue(); + if (kind === "methods") return spinMethods(); + return spinNested(); +} + +// The same loop shapes, bounded, with exceptions escaping mid-iteration. +function countdown(start: number): number { + let acc = 0; + for (let i = start; ; i--) acc = (acc + risky(i)) % 1000003; +} + +function countdownWhile(start: number): number { + let i = start; + while (risky(i) >= 0) i--; + return i; +} + +function methodsUntil(limit: number): number { + const box = new Box(); + let n = 0; + do { + n++; + } while (box.bump(limit - n) >= 0); + return n; +} + +if (isMainThread) { + for (const probe of [() => countdown(5), () => countdownWhile(7), () => methodsUntil(4)]) { + try { + console.log("unexpected", probe()); + } catch (e) { + console.log("caught", (e as Error).name, (e as Error).message); + } finally { + console.log("finally"); + } + } + for (const kind of ["calls", "condition", "continue", "methods", "nested"]) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + worker.once("message", () => { + void worker.terminate().then((code: number) => console.log(kind, "terminate", code)); + }); + worker.once("exit", (code: number) => { + console.log(kind, "exit", code); + resolve(); + }); + }); + } +} else { + try { + console.log("unexpected result", run(String(workerData))); + } catch { + console.log("unexpected catch"); + } finally { + console.log("unexpected finally"); + } +} From bdba4a7a1c458c68b738b14aac77bfef28a488dd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:50:51 -0700 Subject: [PATCH 3/8] Test an exact per-thread pending word in ELF worker executables Worker programs on Linux tested a process-wide alert word after every call that may throw and called scr_exc_pending on its slow path to answer for the calling thread. Each of the ~55k slow-path calls in tsc-ts counted against LLVM's inline cost, and while any thread had an exception in flight every other thread took the slow path. ELF worker executables (the emitter's executableTls targets) now give every script thread an exact pending word in the executable's static TLS block, and the emitted check is one thread-pointer-relative load and a branch to the unwind, with no out-of-line call: - bit 0 mirrors "active cell kind != NONE". Only the owner thread writes it, at every change of its active cell (throws, rethrows, take/clear, fiber switches, payload moves, the termination sentinel). The uncaught printer now settles it after resetting the cell. - bit 1 requests the context's stop. worker.terminate() sets it through a pointer the worker registers under a mutex while its script runs (a terminate before registration is found through the cancel flag), and scr_context_stop sets it for process.exit() and fatal paths. The stop bit counts as the stop signal in scr_context_quiet/checkpoint. A set stop bit unwinds while the active cell may still be clear, so every consumer of an unwind installs the TERMINATE sentinel before inspecting the cell: emitted try regions test the bit before entering a handler and call scr_context_checkpoint, and fiber completion, the uncaught handlers and exit listeners checkpoint instead of only testing scr_context_stopping. The runtime's out-of-line scr_exc_pending already checkpoints. Darwin and Windows keep the alert word (a Darwin thread-local access is a call). New corpus programs pin terminate() inside catch and finally bodies, with exceptions stashed or propagating, inside generator and async bodies and runtime callbacks, and before the worker starts, plus every way an exception is raised, moved, consumed or discarded on the main thread and in a worker, each followed by a may-throw call that must not unwind. --- .../compiler/src/backend/llvm/emitter.test.ts | 17 +- packages/compiler/src/backend/llvm/emitter.ts | 59 +++- .../test/ts7/baselines/order-parity.json | 12 + packages/compiler/test/worker-threads.test.ts | 70 ++-- packages/runtime/src/scr_async.c | 5 +- packages/runtime/src/scr_context.c | 15 +- packages/runtime/src/scr_events.c | 4 +- packages/runtime/src/scr_exception.c | 60 +++- packages/runtime/src/scr_runtime.h | 21 +- packages/runtime/src/scr_worker.c | 43 +++ tests/corpus/native-worker-exception-state.ts | 308 ++++++++++++++++++ ...native-worker-terminate-exception-state.ts | 211 ++++++++++++ 12 files changed, 771 insertions(+), 54 deletions(-) create mode 100644 tests/corpus/native-worker-exception-state.ts create mode 100644 tests/corpus/native-worker-terminate-exception-state.ts diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index d78c74f71..238247a1a 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -545,10 +545,21 @@ test("synchronous bodies load the active exception cell once for all pending che /^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/, ); expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3); - // Worker executables test the process-wide alert word at each check - // instead, and never read their thread-local cell inline. - const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work"); + // Darwin and Windows worker executables test the process-wide alert word + // at each check instead, and never read their thread-local cell inline. + const darwin = { targetTriple: "arm64-apple-macosx14.0.0" }; + const workers = body(emitLlvmModule({ ...module, workers: true }, darwin), "sc_f_work"); expect(workers).not.toContain("%exc.cell"); expect(workers).not.toContain("@scr_exc_active"); expect(workers.match(/= load atomic i32, ptr @scr_exc_alert monotonic/g)).toHaveLength(3); + expect(workers.match(/call zeroext i1 @scr_exc_pending\(\)/g)).toHaveLength(3); + // ELF worker executables test the thread's exact pending word and unwind + // directly: no check carries an out-of-line slow path. + const elf = { targetTriple: "x86_64-unknown-linux-gnu" }; + const flagLlvm = emitLlvmModule({ ...module, workers: true }, elf); + expect(flagLlvm).toContain("@scr_exc_flag = external thread_local(localexec) global i32"); + const flag = body(flagLlvm, "sc_f_work"); + expect(flag).not.toContain("@scr_exc_alert"); + expect(flag).not.toContain("@scr_exc_pending"); + expect(flag.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(3); }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index abbc650a4..70ccfe414 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3774,13 +3774,27 @@ export class LlEmitter { * return and stays eligible for tail-call and recursion elimination. */ private emitUnwind(passThrough?: string): void { if (this.mod.workers && this.tryStack.length > 0) { - this.declare(`declare zeroext i1 @scr_context_stopping()`); - const stopped = this.B.tmp(); - this.B.line(`${stopped} = call zeroext i1 @scr_context_stopping()`); const stop = this.B.newLabel("context.stop"); const ordinary = this.B.newLabel("context.throw"); - this.B.condBr(stopped, stop, ordinary); - this.B.startBlock(stop); + if (this.executableTls) { + // A set stop bit unwinds while the active cell may still be clear: + // install the termination sentinel before skipping the handlers. + const word = this.loadExcFlag(); + const bit = this.B.tmp(); + const stopped = this.B.tmp(); + this.B.line(`${bit} = and i32 ${word}, 2`); + this.B.line(`${stopped} = icmp ne i32 ${bit}, 0`); + this.B.condBr(stopped, stop, ordinary); + this.B.startBlock(stop); + this.declare(`declare zeroext i1 @scr_context_checkpoint()`); + this.B.line(`call zeroext i1 @scr_context_checkpoint()`); + } else { + this.declare(`declare zeroext i1 @scr_context_stopping()`); + const stopped = this.B.tmp(); + this.B.line(`${stopped} = call zeroext i1 @scr_context_stopping()`); + this.B.condBr(stopped, stop, ordinary); + this.B.startBlock(stop); + } const handlers = this.tryStack; this.tryStack = []; this.emitUnwind(); @@ -3834,6 +3848,18 @@ export class LlEmitter { this.B.br(cleanup.label); } + /** Loads the running thread's exact pending word (ELF worker + * executables, scr_exception.c): bit 0 mirrors the active cell, bit 1 + * requests the context's stop. It lives in the executable's static TLS + * block, so the access is one thread-pointer-relative load. Monotonic: a + * loop must not hoist the load out and miss a cross-thread stop. */ + private loadExcFlag(): string { + this.declare(`@scr_exc_flag = external thread_local(localexec) global i32, align 4`); + const word = this.B.tmp(); + this.B.line(`${word} = load atomic i32, ptr @scr_exc_flag monotonic, align 4`); + return word; + } + /** 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 @@ -3884,15 +3910,22 @@ export class LlEmitter { return; } // Executables test the active exception cell's kind inline (its first - // field). Worker programs test the process-wide alert word instead: it - // is nonzero while any script thread may have an exception pending in - // its active cell or has been asked to stop, so the common case is one - // ordinary global load (a thread-local access is a call on Darwin) and - // the out-of-line scr_exc_pending answers for this thread, observing - // cancellation and reinstalling the termination sentinel. + // field). ELF worker executables test the thread's exact pending word, + // which is also nonzero once the context must stop, and unwind directly: + // no site carries an out-of-line call, so LLVM's inliner sees the check + // as two instructions. Other worker programs test the process-wide + // alert word: it is nonzero while any script thread may have an + // exception pending in its active cell or has been asked to stop, so + // the common case is one ordinary global load (a thread-local access is + // a call on Darwin) and the out-of-line scr_exc_pending answers for this + // thread, observing cancellation and reinstalling the termination + // sentinel. this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); let word: string; - if (this.mod.workers === true) { + const exact = this.mod.workers !== true || this.executableTls; + if (this.executableTls) { + word = this.loadExcFlag(); + } else if (this.mod.workers === true) { // dso_local: executables link the runtime statically, so the word is // addressed directly instead of through the GOT. this.declare(`@scr_exc_alert = external dso_local global i32, align 64`); @@ -3923,7 +3956,7 @@ export class LlEmitter { B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${hit}, i1 false)`); const lu = B.newLabel("exc.u"); const lk = B.newLabel("exc.k"); - if (this.mod.workers === true) { + if (!exact) { this.declare(`declare zeroext i1 @scr_exc_pending()`); const slow = B.newLabel("exc.s"); B.condBr(cold, slow, lk); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index f3521e1bd..b6b2cb5a9 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15748,6 +15748,18 @@ "/tests/corpus/native-worker-terminate-tested-loops.ts" ], "diags": [] + }, + "/tests/corpus/native-worker-exception-state.ts": { + "order": [ + "/tests/corpus/native-worker-exception-state.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-terminate-exception-state.ts": { + "order": [ + "/tests/corpus/native-worker-terminate-exception-state.ts" + ], + "diags": [] } } } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index c6339c793..a6bfee0f2 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -228,35 +228,55 @@ test("worker entries without a source file stay refused", async () => { ); }); -test("worker programs poll inline and skip termination polls in bounded functions", async () => { - const request = await fixture( - worker + - ` +test.each([ + ["arm64-apple-macosx14.0.0", "alert"], + ["x86_64-unknown-linux-gnu", "flag"], + ["aarch64-unknown-linux-gnu", "flag"], +])( + "worker programs for %s poll inline (%s) and skip termination polls in bounded functions", + async (target, lane) => { + vi.stubEnv("SCRIPTC_TARGET", target); + const request = await fixture( + worker + + ` function mix(a: number, b: number): number { return (a * 31 + b) % 1000003; } function spin(n: number): number { return n < 2 ? mix(n, 1) : spin(n - 1) + spin(n - 2); } console.log(mix(1, 2), spin(5)); `, - ); - const result = await compile(request.entry, request); - expect(result.ok, JSON.stringify(result)).toBe(true); - if (result.ok) { - const llvm = await readFile(request.outPath, "utf8"); - const body = (name: string): string => { - const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m")); - expect(start).toBeGreaterThanOrEqual(0); - return llvm.slice(start, llvm.indexOf("\n}\n", start)); - }; - // A bounded helper neither polls on entry nor makes its callers poll. - expect(body("sc_f_mix")).not.toContain("@scr_exc_alert"); - // Recursion tests after its recursive calls (its stack check replaces - // the entry poll): the inline test reads the process-wide alert word (no - // thread-local access), and only the slow path calls into the runtime. - expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); - expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); - expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); - expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); - } -}); + ); + const result = await compile(request.entry, request); + expect(result.ok, JSON.stringify(result)).toBe(true); + if (result.ok) { + const llvm = await readFile(request.outPath, "utf8"); + const body = (name: string): string => { + const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m")); + expect(start).toBeGreaterThanOrEqual(0); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); + }; + // A bounded helper neither polls on entry nor makes its callers poll. + expect(body("sc_f_mix")).not.toMatch(/@scr_exc_(alert|flag)/); + // Recursion tests after its recursive calls (its stack check replaces + // the entry poll) and never reads its thread-local cell inline. + expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); + if (lane === "alert") { + // Darwin: the inline test reads the process-wide alert word (a + // thread-local access is a call there), and only the slow path calls + // into the runtime. + expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); + expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); + expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); + } else { + // ELF: the thread's exact pending word, one TLS load, no slow path. + expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_flag monotonic/); + expect(body("sc_f_spin")).not.toContain("@scr_exc_pending"); + expect(llvm).not.toContain("@scr_exc_alert"); + expect(llvm).toMatch( + /^@scr_exc_flag = external thread_local\(localexec\) global i32, align 4$/m, + ); + } + } + }, +); test.each([ ["x86_64-unknown-linux-gnu", true], diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 88fb42ad6..c693a7488 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1537,8 +1537,9 @@ static void scr_fiber_finish(ScrFiber *self) { } ScrPromise *p = self->promise; #ifdef SCR_WORKERS - if (scr_context_stopping()) { - scr_context_checkpoint(); + /* Checkpoint, not just test: the body may have unwound for a stop + * request that its cell does not show yet. */ + if (scr_context_checkpoint()) { scr_promise_reject_from_cell(p, &self->exc); p->rejection_observed = true; self->done = true; diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index 0485cf1c2..4b4e54784 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -122,9 +122,19 @@ void scr_context_stop_flag(const void *flag) { if (!scr_context_stopped) scr_context_signal = flag ? flag : &scr_context_unsignaled; } bool scr_context_stopping(void) { return scr_context_stopped; } +/* Asked to stop: the owner's signal, or (ELF worker executables) the stop + * bit of the thread's exact pending word, which worker.terminate() sets + * directly so that emitted checks observe it. */ +static bool scr_context_signaled(void) { +#ifdef SCR_EXC_FLAG + if (atomic_load_explicit(&scr_exc_flag, memory_order_acquire) & SCR_EXC_FLAG_STOP) return true; +#endif + return atomic_load_explicit(scr_context_signal, memory_order_relaxed); +} + /* Neither stopping nor asked to stop. */ bool scr_context_quiet(void) { - return !scr_context_stopped && !atomic_load_explicit(scr_context_signal, memory_order_relaxed); + return !scr_context_stopped && !scr_context_signaled(); } void scr_context_stop(int code) { @@ -137,8 +147,7 @@ void scr_context_stop(int code) { } bool scr_context_checkpoint(void) { - if (!scr_context_stopped && atomic_load_explicit(scr_context_signal, memory_order_relaxed)) - scr_context_stop(1); + if (!scr_context_stopped && scr_context_signaled()) scr_context_stop(1); if (!scr_context_stopped) return false; /* Each fiber has its own exception cell. Reinstall the sentinel after a * context switch or a runtime continuation consumes its pending payload. */ diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index fcd426f18..40019bb06 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1114,7 +1114,9 @@ void scr_run_exit_listeners(double code) { #endif scr_exit_ls[i].fn(scr_exit_ls[i].cb, code); #ifdef SCR_WORKERS - if (scr_context_stopping()) break; + /* The listener may have unwound for a stop request (checkpoint installs + * the sentinel its cell may not show yet). */ + if (scr_context_checkpoint()) break; #endif if (scr_exc_pending()) { /* Node: a throw in an 'exit' listener is fatal (exit code 7). */ diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index ff0523e9c..e95af8270 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -62,7 +62,53 @@ ScrExcCell *scr_exc_active = &scr_main_exc; * localized library archive would otherwise leave shared. */ SCR_TL uintptr_t scr_stack_guard = 0; -#if defined(SCR_WORKERS) && !defined(SCR_LIB) +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(SCR_EXC_FLAG) +/* The thread's exact pending word, tested inline by every emitted pending + * check in ELF worker executables (one %fs/tpidr-relative load: the word is + * in the executable's static TLS block). Nonzero means the running code + * must unwind, so the emitted check needs no out-of-line answer: + * + * - SCR_EXC_FLAG_PENDING mirrors `active cell kind != NONE` exactly. Only + * the owner thread writes it, at every change of its active cell (throws, + * rethrows, take/clear, fiber switches, payload moves, the termination + * sentinel), because a stale bit would unwind a normal path. + * - SCR_EXC_FLAG_STOP means the context must stop. worker.terminate() sets + * it from the owner's thread (scr_exc_flag_request_stop, through the + * pointer the worker registers while its script runs), scr_context_stop + * sets it for process.exit() and fatal paths, and nothing clears it. + * + * A set STOP bit unwinds while the active cell may still be NONE: the + * TERMINATE sentinel is installed by whoever consumes the unwind before it + * inspects the cell. scr_exc_pending and scr_context_checkpoint do that, + * emitted try regions test the bit before entering a handler, and the + * runtime's unwind consumers (fiber completion, the uncaught reporters, + * exit listeners) checkpoint first. */ +SCR_TL _Atomic uint32_t scr_exc_flag; + +static inline void scr_exc_flag_pending(bool pending) { + uint32_t word = atomic_load_explicit(&scr_exc_flag, memory_order_relaxed); + if (((word & SCR_EXC_FLAG_PENDING) != 0) == pending) return; + if (pending) atomic_fetch_or_explicit(&scr_exc_flag, SCR_EXC_FLAG_PENDING, memory_order_relaxed); + else atomic_fetch_and_explicit(&scr_exc_flag, ~SCR_EXC_FLAG_PENDING, memory_order_relaxed); +} + +void scr_exc_flag_request_stop(_Atomic uint32_t *flag) { + /* Release: a thread that observes the bit also observes the requester's + * earlier writes (the worker's cancel flag). */ + atomic_fetch_or_explicit(flag, SCR_EXC_FLAG_STOP, memory_order_release); +} + +/* The active cell, or the context's stop state, may have changed. */ +void scr_exc_cell_changed(void) { + if (!scr_context_quiet() && + !(atomic_load_explicit(&scr_exc_flag, memory_order_relaxed) & SCR_EXC_FLAG_STOP)) + atomic_fetch_or_explicit(&scr_exc_flag, SCR_EXC_FLAG_STOP, memory_order_relaxed); + scr_exc_flag_pending(SCR_EXC_CUR()->kind != SCR_EXC_NONE); +} + +void scr_exc_alert_thread_exit(void) {} +#define SCR_EXC_RAISED() scr_exc_flag_pending(true) +#elif defined(SCR_WORKERS) && !defined(SCR_LIB) /* The process-wide exception alert, tested inline by every emitted pending * check in worker executables: one ordinary load, where reading the active * cell would be a thread-local access (a call on Darwin). It counts the @@ -186,7 +232,10 @@ bool(scr_exc_pending)(void) { if (scr_context_checkpoint()) return true; #endif bool pending = scr_exc_kind != SCR_EXC_NONE; -#if defined(SCR_WORKERS) && !defined(SCR_LIB) +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(SCR_EXC_FLAG) + /* The pending bit is exact; settling it here is only a safety net. */ + scr_exc_flag_pending(pending); +#elif defined(SCR_WORKERS) && !defined(SCR_LIB) /* The slow path of the alert test: settle this thread's share. */ if (pending != scr_exc_alerted) scr_exc_cell_changed(); #endif @@ -518,7 +567,9 @@ static SCR_TL bool scr_uncaught_handler_failed = false; bool scr_exc_handle_uncaught(bool from_promise) { #ifdef SCR_WORKERS - if (scr_context_stopping()) return false; + /* Checkpoint, not just test: the unwind that brought control here may + * have been a stop request the cell does not show yet. */ + if (scr_context_checkpoint()) return false; #endif if (!scr_exc_pending()) return true; if (!scr_uncaught_exception_hook || scr_uncaught_handler_failed) return false; @@ -554,7 +605,7 @@ static bool scr_uncaught_print_dyn_primitive(const ScrDyn *d) { void scr_exc_print_uncaught(void) { #ifdef SCR_WORKERS - if (scr_context_stopping()) { scr_exc_clear(); return; } + if (scr_context_checkpoint()) { scr_exc_clear(); return; } if (scr_context_report_error) { scr_exit_code_note(scr_uncaught_handler_failed ? 7 : 1); scr_context_report_error(); @@ -610,4 +661,5 @@ void scr_exc_print_uncaught(void) { } fputc('\n', stderr); scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); } diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..06362c794 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -290,11 +290,26 @@ void scr_context_stop(int code); bool scr_context_stopping(void); bool scr_context_checkpoint(void); bool scr_context_quiet(void); -/* The process-wide exception alert that emitted pending checks of worker - * executables test inline (scr_exception.c): nonzero while some script - * thread may have an exception pending in its active cell or must stop. */ +#if !defined(__APPLE__) && !defined(_WIN32) && !defined(__CYGWIN__) && !defined(__wasi__) +/* ELF worker executables (the LLVM emitter's executableTls targets): every + * script thread owns an exact pending word in the executable's static TLS + * block, which emitted pending checks load with one thread-pointer-relative + * access and no out-of-line slow path (scr_exception.c). */ +#define SCR_EXC_FLAG 1 +#define SCR_EXC_FLAG_PENDING 1u /* the active cell's kind is not NONE */ +#define SCR_EXC_FLAG_STOP 2u /* the context must stop (never cleared) */ +extern SCR_TL _Atomic uint32_t scr_exc_flag; +/* Requests the stop of the thread that owns `flag` (scr_worker.c holds the + * owner's word only while its script runs). */ +void scr_exc_flag_request_stop(_Atomic uint32_t *flag); +#else +/* The process-wide exception alert that emitted pending checks of other + * worker executables test inline (scr_exception.c): nonzero while some + * script thread may have an exception pending in its active cell or must + * stop. */ extern _Atomic uint32_t scr_exc_alert; void scr_exc_alert_add(int delta); /* worker.terminate() references */ +#endif void scr_exc_alert_thread_exit(void); void scr_loop_context_shutdown(void); extern SCR_TL void (*scr_context_report_error)(void); diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 18312cef5..34410d684 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -14,9 +14,17 @@ struct ScrWorkerThread { atomic_size_t references; atomic_bool cancel; +#ifdef SCR_EXC_FLAG + /* The worker thread's exact pending word (scr_exception.c) while its + * script runs, NULL before and after: terminate() sets the stop bit + * through it. The lock keeps the thread's TLS alive across that write. */ + pthread_mutex_t flag_lock; + _Atomic uint32_t *flag; +#else /* The exception alert reference of a terminate request: 0 none, 1 held, * 2 the thread finished its script (no reference is taken any more). */ atomic_int alert; +#endif atomic_bool port_closed; uint64_t id; uint32_t root; @@ -65,6 +73,9 @@ void scr_worker_thread_release(ScrWorkerThread *worker) { scr_context_env_free(worker->environment); for (int i = 0; i < worker->argc; i++) free(worker->argv[i]); free(worker->argv); +#ifdef SCR_EXC_FLAG + pthread_mutex_destroy(&worker->flag_lock); +#endif free(worker); } @@ -99,6 +110,15 @@ uint64_t scr_worker_thread_id(const ScrWorkerThread *worker) { return worker->id void scr_worker_thread_terminate(ScrWorkerThread *worker) { atomic_store_explicit(&worker->cancel, true, memory_order_relaxed); +#ifdef SCR_EXC_FLAG + /* Emitted checks test only the worker's own pending word: raise its stop + * bit. A worker that has not registered its word yet finds the cancel + * flag when it does (scr_worker_thread_run); one that has finished its + * script needs nothing. */ + pthread_mutex_lock(&worker->flag_lock); + if (worker->flag) scr_exc_flag_request_stop(worker->flag); + pthread_mutex_unlock(&worker->flag_lock); +#else /* Emitted polls only call into the runtime while the exception alert is * raised: hold a reference until the worker's script has ended. */ scr_exc_alert_add(1); @@ -106,6 +126,7 @@ void scr_worker_thread_terminate(ScrWorkerThread *worker) { if (!atomic_compare_exchange_strong_explicit(&worker->alert, &idle, 1, memory_order_acq_rel, memory_order_acquire)) scr_exc_alert_add(-1); +#endif scr_mailbox_post(worker->inbox, scr_worker_event(SCR_WORKER_WAKE, 0, 0, NULL)); } @@ -150,6 +171,14 @@ static void scr_worker_report_error(void) { static void scr_worker_thread_run(ScrWorkerThread *worker) { scr_context_enter(worker->id); +#ifdef SCR_EXC_FLAG + pthread_mutex_lock(&worker->flag_lock); + worker->flag = &scr_exc_flag; + pthread_mutex_unlock(&worker->flag_lock); + /* A terminate() that ran before the registration only set the flag. */ + if (atomic_load_explicit(&worker->cancel, memory_order_relaxed)) + scr_exc_flag_request_stop(&scr_exc_flag); +#endif scr_context_stop_flag(&worker->cancel); scr_context_env_enter(worker->environment); worker->environment = NULL; @@ -161,7 +190,13 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { scr_context_cleanup(); scr_worker_current = NULL; scr_exc_alert_thread_exit(); +#ifdef SCR_EXC_FLAG + pthread_mutex_lock(&worker->flag_lock); + worker->flag = NULL; + pthread_mutex_unlock(&worker->flag_lock); +#else if (atomic_exchange_explicit(&worker->alert, 2, memory_order_acq_rel) == 1) scr_exc_alert_add(-1); +#endif /* Cleanup may itself finish owner-bound native operations. Publish exit * only once none of those operations can touch the worker's script heap. */ scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_EXIT, worker->id, code, NULL)); @@ -189,7 +224,12 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, if (!worker) scr_trap("scriptc: out of memory\n"); atomic_init(&worker->references, 2); /* owner and native thread */ atomic_init(&worker->cancel, false); +#ifdef SCR_EXC_FLAG + if (pthread_mutex_init(&worker->flag_lock, NULL) != 0) scr_trap("scriptc: out of memory\n"); + worker->flag = NULL; +#else atomic_init(&worker->alert, 0); +#endif atomic_init(&worker->port_closed, false); worker->id = atomic_fetch_add_explicit(&scr_worker_next_id, 1, memory_order_relaxed); worker->entry = entry; @@ -235,6 +275,9 @@ failed: scr_mailbox_release(worker->inbox); scr_mailbox_release(worker->parent); scr_message_free(worker->data); +#ifdef SCR_EXC_FLAG + pthread_mutex_destroy(&worker->flag_lock); +#endif free(worker); if (!scr_exc_pending()) { static const char message[] = "Could not start worker thread"; diff --git a/tests/corpus/native-worker-exception-state.ts b/tests/corpus/native-worker-exception-state.ts new file mode 100644 index 000000000..5f8303f89 --- /dev/null +++ b/tests/corpus/native-worker-exception-state.ts @@ -0,0 +1,308 @@ +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +// Every way an exception can be raised, moved, consumed or discarded, run on +// the main thread and in a worker of the same program. After each scenario a +// call that may throw but does not proves that nothing is left pending: a +// stale pending state would unwind it. + +class Fault extends Error { + readonly code: number; + constructor(code: number) { + super(`fault ${code}`); + this.name = "Fault"; + this.code = code; + } +} + +function fail(code: number): never { + throw new Fault(code); +} + +function label(code: number): string { + if (code < 0) fail(-code); + return `item-${code}`; +} + +function message(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +function finallyReturns(): string { + try { + fail(1); + } finally { + return "finally return"; + } +} + +function finallyBreaks(log: string[]): number { + let rounds = 0; + for (let i = 0; i < 3; i++) { + rounds++; + try { + fail(2); + } finally { + log.push(`break round ${i}`); + break; + } + } + return rounds; +} + +function finallyContinues(log: string[]): number { + let rounds = 0; + for (let i = 0; i < 3; i++) { + try { + if (i !== 1) fail(3); + rounds += 10; + } finally { + rounds++; + continue; + } + } + log.push(`continue rounds ${rounds}`); + return rounds; +} + +function finallyReplaces(): string { + try { + fail(4); + } finally { + label(-5); + } +} + +function deep(n: number, log: string[]): string { + if (n === 0) fail(6); + try { + return deep(n - 1, log); + } finally { + if (n % 2 === 0) log.push(`unwound ${n}`); + } +} + +function catchRethrows(): string { + try { + return label(-7); + } catch (error) { + try { + fail((error as Fault).code + 1); + } catch (inner) { + throw new Fault((inner as Fault).code * 10); + } + } +} + +function labelled(log: string[]): number { + let caught = 0; + outer: for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + try { + if (j === 1) fail(i * 3 + j); + } catch { + caught++; + continue outer; + } + } + } + log.push(`labelled caught ${caught}`); + return caught; +} + +function* resumable(log: string[]): Generator { + let round = 0; + while (round < 3) { + try { + const sent = yield `round ${round}`; + log.push(`sent ${sent}`); + } catch (error) { + log.push(`generator caught ${message(error)}`); + } + round++; + } + return "generator done"; +} + +function* guarded(log: string[]): Generator { + try { + yield 1; + yield 2; + } finally { + log.push("guarded finally"); + } +} + +function* explosive(): Generator { + yield 1; + fail(8); +} + +function* finallyThrows(): Generator { + try { + yield 1; + } finally { + fail(9); + } +} + +async function rejectsLater(code: number): Promise { + await null; + return label(-code); +} + +async function rejectsNow(code: number): Promise { + return label(-code); +} + +function overflow(n: number): number { + return overflow(n + 1) + 1; +} + +async function scenarios(): Promise { + const log: string[] = []; + const attempt = (name: string, run: () => unknown): void => { + try { + log.push(`${name} ${String(run())}`); + } catch (error) { + log.push(`${name} caught ${message(error)}`); + } + log.push(`${name} then ${label(name.length)}`); + }; + + attempt("bound catch", () => { + try { + return label(-10); + } catch (error) { + return `took ${(error as Fault).code}`; + } + }); + attempt("bare catch", () => { + try { + return label(-11); + } catch { + return "dropped"; + } + }); + attempt("finally return", finallyReturns); + attempt("finally break", () => finallyBreaks(log)); + attempt("finally continue", () => finallyContinues(log)); + attempt("finally replace", finallyReplaces); + attempt("deep", () => deep(5, log)); + attempt("catch rethrow", catchRethrows); + attempt("labelled", () => labelled(log)); + + const gen = resumable(log); + log.push(`next ${gen.next("ignored").value}`); + log.push(`next ${gen.next("a").value}`); + log.push(`throw ${gen.throw(new Fault(12)).value}`); + const last = gen.next("b"); + log.push(`last ${last.value} ${last.done}`); + log.push(`after generator ${label(13)}`); + + const guard = guarded(log); + guard.next(); + const returned = guard.return(99); + log.push(`return ${returned.value} ${returned.done}`); + for (const value of guarded(log)) { + log.push(`for-of ${value}`); + break; + } + attempt("generator escape", () => [...explosive()].length); + attempt("generator finally throw", () => { + const g = finallyThrows(); + g.next(); + return g.return(0).value; + }); + + try { + await rejectsLater(14); + } catch (error) { + log.push(`await caught ${message(error)}`); + } + log.push(`after await ${label(15)}`); + await rejectsNow(16).catch((error: unknown) => log.push(`catch handler ${message(error)}`)); + const statuses: string[] = []; + for (const pending of [rejectsLater(17), Promise.resolve("ok"), rejectsNow(18)]) { + try { + statuses.push(`fulfilled ${await pending}`); + } catch (error) { + statuses.push(`rejected ${(error as Fault).code}`); + } + } + log.push(`settled ${statuses.join(",")}`); + try { + await Promise.all([rejectsLater(19), rejectsLater(20)]); + } catch (error) { + log.push(`all caught ${message(error)}`); + } + try { + await new Promise(() => fail(21)); + } catch (error) { + log.push(`executor caught ${message(error)}`); + } + const chained = await Promise.resolve(1) + .then(() => fail(22)) + .then( + () => "unexpected", + (error: unknown) => `then rejected ${message(error)}`, + ); + log.push(chained); + log.push(`after promises ${label(23)}`); + + attempt("map callback", () => [1, 2, 3].map((n) => label(n === 2 ? -24 : n)).join()); + attempt("sort comparator", () => + [3, 1, 2] + .sort((a, b) => { + if (a === 1 || b === 1) fail(25); + return a - b; + }) + .join(), + ); + attempt("reduce", () => [1, 2, 3].reduce((acc, n) => acc + label(n === 3 ? -26 : n), "")); + attempt("forEach", () => { + let seen = 0; + try { + [1, 2, 3].forEach((n) => { + seen++; + if (n === 2) fail(27); + }); + } finally { + log.push(`forEach seen ${seen}`); + } + return seen; + }); + attempt("json", () => { + try { + return JSON.parse("{bad"); + } catch (error) { + return `parse failed ${(error as Error).name}`; + } + }); + attempt("overflow", () => overflow(0)); + attempt("nested callbacks", () => + [1, 2].map((n) => { + try { + return [n].map(() => label(-28))[0]; + } catch (error) { + return `inner ${(error as Fault).code}`; + } + }), + ); + return log; +} + +if (isMainThread) { + for (const line of await scenarios()) console.log("main", line); + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url)); + worker.on("message", (lines: string[]) => { + for (const line of lines) console.log("worker", line); + }); + worker.once("exit", (code: number) => { + console.log("worker exit", code); + resolve(); + }); + }); +} else { + parentPort!.postMessage(await scenarios()); +} diff --git a/tests/corpus/native-worker-terminate-exception-state.ts b/tests/corpus/native-worker-terminate-exception-state.ts new file mode 100644 index 000000000..8f1468697 --- /dev/null +++ b/tests/corpus/native-worker-terminate-exception-state.ts @@ -0,0 +1,211 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// worker.terminate() arriving while the worker's exception state is busy: +// inside catch and finally bodies, with an exception stashed across a finally +// or propagating through frames, inside generator and async bodies, inside +// callbacks the runtime calls, and before the worker has started. Termination +// is not catchable and skips every catch and finally handler, so the worker +// prints nothing once it has announced itself. + +class Fault extends Error { + readonly code: number; + constructor(code: number) { + super(`fault ${code}`); + this.name = "Fault"; + this.code = code; + } +} + +function fail(code: number): never { + throw new Fault(code); +} + +function risky(n: number): number { + if (n < 0) fail(-n); + return (n * 31 + 7) % 1000003; +} + +let announced = false; +function announce(): void { + if (!announced) { + announced = true; + parentPort!.postMessage("ready"); + } +} + +function spin(): number { + let acc = 0; + for (let i = 0; ; i++) { + acc = (acc + risky(i % 1000)) % 1000003; + if (i === 1000) announce(); + } +} + +function inCatch(): number { + try { + return risky(-1); + } catch (error) { + if ((error as Fault).code !== 1) console.log("unexpected code"); + return spin(); + } +} + +function inFinally(): number { + try { + return risky(-2); + } finally { + spin(); + } +} + +function inNestedFinally(): number { + try { + try { + return spin(); + } finally { + console.log("unexpected inner finally"); + } + } catch { + console.log("unexpected outer catch"); + return 0; + } +} + +function deep(n: number): number { + if (n === 0) return risky(-3); + try { + return deep(n - 1); + } finally { + if (n === 1) risky(n); + } +} + +function propagating(): number { + let caught = 0; + for (let i = 0; ; i++) { + try { + deep(40); + } catch { + caught++; + if (caught === 1000) announce(); + } + } +} + +function* producer(): Generator { + yield 1; + try { + yield spin(); + } finally { + console.log("unexpected generator finally"); + } +} + +function inGenerator(): number { + let total = 0; + try { + for (const value of producer()) total += value; + } catch { + console.log("unexpected generator catch"); + } + return total; +} + +async function asyncBody(): Promise { + await null; + try { + return spin(); + } catch { + console.log("unexpected async catch"); + return 0; + } finally { + console.log("unexpected async finally"); + } +} + +function inCallback(): number { + try { + return [1, 2, 3].map((n) => (n === 2 ? spin() : n)).length; + } catch { + console.log("unexpected callback catch"); + return 0; + } finally { + console.log("unexpected callback finally"); + } +} + +function inComparator(): number { + try { + return [3, 1, 2].sort((a, b) => (a === 1 || b === 1 ? spin() : a - b)).length; + } finally { + console.log("unexpected comparator finally"); + } +} + +function run(kind: string): number { + switch (kind) { + case "catch": + return inCatch(); + case "finally": + return inFinally(); + case "nested": + return inNestedFinally(); + case "propagating": + return propagating(); + case "generator": + return inGenerator(); + case "callback": + return inCallback(); + case "comparator": + return inComparator(); + default: + return spin(); + } +} + +if (isMainThread) { + const kinds = ["catch", "finally", "nested", "propagating", "generator", "async", "callback", "comparator"]; + for (const kind of kinds) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + worker.once("message", () => { + void worker.terminate().then((code: number) => console.log(kind, "terminate", code)); + }); + worker.once("exit", (code: number) => { + console.log(kind, "exit", code); + resolve(); + }); + }); + } + // Terminated before the worker can have started its script (Node reports + // exit code 0 here, scriptc 1: only the stop itself is pinned). + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: "early" }); + void worker.terminate(); + worker.once("exit", () => { + console.log("early exit", i); + resolve(); + }); + }); + } + // The main thread's own exceptions are unaffected by the stopped workers. + try { + risky(-4); + } catch (error) { + console.log("main caught", (error as Fault).code, risky(5)); + } +} else if (workerData === "async") { + asyncBody().then( + () => console.log("unexpected async result"), + () => console.log("unexpected async rejection"), + ); +} else { + try { + console.log("unexpected result", run(String(workerData))); + } catch { + console.log("unexpected catch"); + } finally { + console.log("unexpected finally"); + } +} From d49846d18314eed0aa9d4869d700699766760b4b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:51:11 -0700 Subject: [PATCH 4/8] Let LLVM forward pending tests outside loops in ELF worker executables The exact pending word was read with monotonic atomic loads. LLVM's memory analyses treat an ordered load as a write: it clobbers every other location in MemorySSA, so loads around a pending test could not be forwarded after inlining, and x86 selection never folds it into a compare. Ordered loads are only needed for liveness, where a cross-thread stop must be observed: - termination polls (function entry and loop budgets); - tests inside source loops: the loop-poll rule drops a loop's poll because these tests run every iteration, so LLVM must not hoist them; - tail-position tests whose unwind returns the call's own result: an unordered test there is dead, which lets tail-recursion elimination turn the recursion into a loop that neither grows the stack nor tests again. Every other test is now an unordered load of the same word. Same-thread changes only happen inside runtime calls, which clobber memory, so the unwind decision is unchanged. --- .../compiler/src/backend/llvm/emitter.test.ts | 15 ++++++- packages/compiler/src/backend/llvm/emitter.ts | 39 ++++++++++++++----- packages/compiler/test/worker-threads.test.ts | 4 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 238247a1a..f206eced2 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -561,5 +561,18 @@ test("synchronous bodies load the active exception cell once for all pending che const flag = body(flagLlvm, "sc_f_work"); expect(flag).not.toContain("@scr_exc_alert"); expect(flag).not.toContain("@scr_exc_pending"); - expect(flag.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(3); + // Outside loops the tests are unordered loads LLVM may forward around. + expect(flag.match(/= load atomic i32, ptr @scr_exc_flag unordered/g)).toHaveLength(3); + expect(flag).not.toContain("monotonic"); + // Inside a source loop (whose termination poll the call's test replaces) + // the test stays ordered, so LLVM cannot hoist it out of the loop. + const spin: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [call()], + loc, + }; + const looped = body(emitLlvmModule({ ...moduleFor([spin]), workers: true }, elf), "sc_f_work"); + expect(looped.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(1); + expect(looped).not.toContain("unordered"); }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 70ccfe414..976ad9452 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -519,6 +519,9 @@ export class LlEmitter { readonly wasi: boolean; /** ELF worker executables give thread-locals the executable TLS models. */ private readonly executableTls: boolean; + /** Source loops enclosing the statement being emitted in the current + * function body. */ + private loopDepth = 0; private readonly emitLibraryIdentity: boolean; private readonly runtimeAbiMarker: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — @@ -3851,12 +3854,15 @@ export class LlEmitter { /** Loads the running thread's exact pending word (ELF worker * executables, scr_exception.c): bit 0 mirrors the active cell, bit 1 * requests the context's stop. It lives in the executable's static TLS - * block, so the access is one thread-pointer-relative load. Monotonic: a - * loop must not hoist the load out and miss a cross-thread stop. */ - private loadExcFlag(): string { + * block, so the access is one thread-pointer-relative load. Monotonic by + * default: a loop must not hoist the load out and miss a cross-thread + * stop (see emitPendingCheck for the unordered tests). */ + private loadExcFlag(unordered = false): string { this.declare(`@scr_exc_flag = external thread_local(localexec) global i32, align 4`); const word = this.B.tmp(); - this.B.line(`${word} = load atomic i32, ptr @scr_exc_flag monotonic, align 4`); + this.B.line( + `${word} = load atomic i32, ptr @scr_exc_flag ${unordered ? "unordered" : "monotonic"}, align 4`, + ); return word; } @@ -3887,8 +3893,9 @@ export class LlEmitter { * call's result temp must join its frame BEFORE this runs so the unwind * releases the dummy (NULL for refcounted kinds) harmlessly. A scalar * call result of the function's return type may be passed as the unwind's - * dummy value (see emitUnwind). */ - emitPendingCheck(passThrough?: { name: string; type: IrType }): void { + * dummy value (see emitUnwind). `poll` marks a worker termination poll + * (function entry or loop budget), whose test must stay ordered. */ + emitPendingCheck(passThrough?: { name: string; type: IrType }, poll = false): void { const through = passThrough !== undefined && this.llType(passThrough.type) === this.llType(this.currentReturnType) && @@ -3924,7 +3931,16 @@ export class LlEmitter { let word: string; const exact = this.mod.workers !== true || this.executableTls; if (this.executableTls) { - word = this.loadExcFlag(); + // Termination stays observable where the polls were dropped for it: + // polls, tests inside source loops (exc's loop-poll rule relies on + // them every iteration) and tail-position tests (a dead ordered load + // keeps recursion from becoming a loop without stack growth) use an + // ordered load, which LLVM never deletes, hoists or merges. Every + // other test is an unordered load: it reads the same word, but LLVM's + // memory analyses no longer treat it as a write, so loads around it + // stay forwardable after inlining. Same-thread changes only happen in + // runtime calls, which clobber memory. + word = this.loadExcFlag(!poll && through === undefined && this.loopDepth === 0); } else if (this.mod.workers === true) { // dso_local: executables link the runtime statically, so the word is // addressed directly instead of through the GOT. @@ -4058,7 +4074,7 @@ export class LlEmitter { // Calls and throwing operations retain their own checks. Amortize the // extra cancellation poll across bounded loop batches so scalar work // does not cross the runtime ABI on every iteration. - this.emitPendingCheck(); + this.emitPendingCheck(undefined, true); B.br(next); B.startBlock(next); } @@ -5052,6 +5068,7 @@ export class LlEmitter { this.chainSlots.clear(); this.finallyStack = []; this.tryStack = []; + this.loopDepth = 0; this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); @@ -5220,7 +5237,7 @@ export class LlEmitter { B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`; } if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); - if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(); + if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true); this.emitStmts(fn.body); // Implicit exit of a void function: release the function scope unless // the body already terminated its final block (return, or a throw @@ -5423,10 +5440,14 @@ export class LlEmitter { emitStmt(s: IrStmt): void { const previous = this.B.debugLocation; this.B.debugLocation = this.debug?.location(s.loc, this.debugScope) ?? null; + const loop = + s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf"; + if (loop) this.loopDepth++; try { this.emitStmtBody(s); } finally { this.B.debugLocation = previous; + if (loop) this.loopDepth--; } } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index a6bfee0f2..a410ba66b 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -267,7 +267,9 @@ console.log(mix(1, 2), spin(5)); expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); } else { // ELF: the thread's exact pending word, one TLS load, no slow path. - expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_flag monotonic/); + expect(body("sc_f_spin")).toMatch( + /load atomic i32, ptr @scr_exc_flag (unordered|monotonic)/, + ); expect(body("sc_f_spin")).not.toContain("@scr_exc_pending"); expect(llvm).not.toContain("@scr_exc_alert"); expect(llvm).toMatch( From bc736b211f81d964b37a93483182c5a134c038a7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:16:21 -0700 Subject: [PATCH 5/8] Address the worker exception alert directly Executables link the runtime statically, so scr_exc_alert can be dso_local and addressed without the GOT. macOS tsc-ts self-check: 24.07G -> 23.92G instructions; the Linux x86-64 binary is unchanged. --- packages/compiler/src/backend/llvm/emitter.ts | 4 +++- packages/compiler/test/worker-threads.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 91c0b6baa..abbc650a4 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3893,7 +3893,9 @@ export class LlEmitter { this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); let word: string; if (this.mod.workers === true) { - this.declare(`@scr_exc_alert = external global i32, align 64`); + // dso_local: executables link the runtime statically, so the word is + // addressed directly instead of through the GOT. + this.declare(`@scr_exc_alert = external dso_local global i32, align 64`); word = B.tmp(); B.line(`${word} = load atomic i32, ptr @scr_exc_alert monotonic, align 64`); } else { diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 2f70de9e3..c6339c793 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -254,7 +254,7 @@ console.log(mix(1, 2), spin(5)); expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); - expect(llvm).toMatch(/^@scr_exc_alert = external global i32, align 64$/m); + expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); } }); From a8c9b45a0aff7b46e6d6591e1e41e0aa3085471c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:51:17 -0700 Subject: [PATCH 6/8] Read the macOS worker stack guard from a thread-specific-data slot In worker executables scr_stack_guard is thread-local, and on Darwin every thread-local access calls the variable's TLV thunk. Every stack-checked function paid that call on entry; in tsc-ts the checks were about 12% of the instructions retired on macOS. The runtime now also keeps the running stack's guard in a pthread key's thread-specific-data slot, written wherever scr_stack_guard changes (thread start, fiber switches). libpthread stores a thread's values in an array at its thread pointer, indexed by key; scr_stack_guard_init probes that layout once and exports the slot's byte offset. Emitted checks in Darwin worker executables load the guard from TPIDRRO_EL0 (masked) plus the offset on arm64, or %gs plus the offset on x86-64: no call. If the probe fails the offset stays zero and the checks call scr_stack_guard_get from a cold block. Other targets are unchanged. --- packages/compiler/src/backend/llvm/emitter.ts | 78 ++++++++++++++++--- packages/compiler/test/worker-threads.test.ts | 37 +++++++++ packages/runtime/src/scr_async.c | 4 +- packages/runtime/src/scr_context.c | 46 ++++++++++- packages/runtime/src/scr_runtime.h | 16 ++++ 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 976ad9452..798de8a0a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -522,6 +522,10 @@ export class LlEmitter { /** Source loops enclosing the statement being emitted in the current * function body. */ private loopDepth = 0; + /** Darwin worker executables read the stack guard from a pthread + * thread-specific-data slot off the thread pointer (scr_runtime.h's + * SCR_STACK_GUARD_TSD): the architecture names the access sequence. */ + private readonly stackGuardTsd: "aarch64" | "x86_64" | null; private readonly emitLibraryIdentity: boolean; private readonly runtimeAbiMarker: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — @@ -816,6 +820,15 @@ export class LlEmitter { mod.lib === undefined && !this.wasi && !/apple|darwin|windows|mingw|cygwin|win32/.test(options.targetTriple || process.platform); + const host = options.targetTriple || `${process.arch}-${process.platform}`; + this.stackGuardTsd = + mod.workers === true && mod.lib === undefined && !this.wasi && /apple|darwin/.test(host) + ? /^(arm64|aarch64)/.test(host) + ? "aarch64" + : /^(x86_64|amd64|x64)/.test(host) + ? "x86_64" + : null + : null; this.emitLibraryIdentity = options.emitLibraryIdentity !== false; this.runtimeAbiMarker = options.runtimeAbiMarker === true; this.rcHelpers = options.inlineRc === true ? new Set() : null; @@ -4004,22 +4017,65 @@ export class LlEmitter { * throw Node's RangeError and unwind like any other pending exception. */ private emitStackCheck(): void { const B = this.B; - const tl = this.mod.workers === true ? "thread_local " : ""; - this.declare(`@scr_stack_guard = external ${tl}global ptr`); this.declare(`declare ptr @llvm.stacksave.p0()`); this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); this.declare(`declare void @scr_stack_overflow()`); - const sp = B.tmp(); - const guard = B.tmp(); - const low = B.tmp(); - const cold = B.tmp(); - B.line(`${sp} = call ptr @llvm.stacksave.p0()`); - B.line(`${guard} = load ptr, ptr @scr_stack_guard`); - B.line(`${low} = icmp ult ptr ${sp}, ${guard}`); - B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`); const over = B.newLabel("stack.over"); const ok = B.newLabel("stack.ok"); - B.condBr(cold, over, ok); + const sp = B.tmp(); + B.line(`${sp} = call ptr @llvm.stacksave.p0()`); + const test = (guard: string): void => { + const low = B.tmp(); + const cold = B.tmp(); + B.line(`${low} = icmp ult ptr ${sp}, ${guard}`); + B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`); + B.condBr(cold, over, ok); + }; + if (this.stackGuardTsd !== null) { + // A Darwin thread-local access is a call to its TLV thunk: read the + // guard from its thread-specific-data slot at the runtime-validated + // offset instead, and call out only when the runtime found no slot. + this.declare(`@scr_stack_guard_tsd = external dso_local global i64`); + this.declare(`declare ptr @scr_stack_guard_get()`); + const offset = B.tmp(); + const direct = B.tmp(); + const likely = B.tmp(); + B.line(`${offset} = load i64, ptr @scr_stack_guard_tsd`); + B.line(`${direct} = icmp ne i64 ${offset}, 0`); + B.line(`${likely} = call i1 @llvm.expect.i1(i1 ${direct}, i1 true)`); + const fast = B.newLabel("stack.tsd"); + const slow = B.newLabel("stack.tlv"); + B.condBr(likely, fast, slow); + B.startBlock(fast); + const guard = B.tmp(); + if (this.stackGuardTsd === "aarch64") { + this.declare(`declare i64 @llvm.read_register.i64(metadata)`); + const thread = B.tmp(); + const base = B.tmp(); + const slot = B.tmp(); + const address = B.tmp(); + B.line(`${thread} = call i64 @llvm.read_register.i64(metadata !{!"tpidrro_el0"})`); + B.line(`${base} = and i64 ${thread}, -8`); + B.line(`${slot} = add i64 ${base}, ${offset}`); + B.line(`${address} = inttoptr i64 ${slot} to ptr`); + B.line(`${guard} = load ptr, ptr ${address}`); + } else { + const address = B.tmp(); + B.line(`${address} = inttoptr i64 ${offset} to ptr addrspace(256)`); + B.line(`${guard} = load ptr, ptr addrspace(256) ${address}`); + } + test(guard); + B.startBlock(slow); + const fallback = B.tmp(); + B.line(`${fallback} = call ptr @scr_stack_guard_get()`); + test(fallback); + } else { + const tl = this.mod.workers === true ? "thread_local " : ""; + this.declare(`@scr_stack_guard = external ${tl}global ptr`); + const guard = B.tmp(); + B.line(`${guard} = load ptr, ptr @scr_stack_guard`); + test(guard); + } B.startBlock(over); B.line(`call void @scr_stack_overflow()`); this.emitUnwind(); diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index a410ba66b..f3dab96ed 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -280,6 +280,43 @@ console.log(mix(1, 2), spin(5)); }, ); +test.each([ + ["arm64-apple-macosx14.0.0", "tpidrro"], + ["x86_64-apple-macosx14.0.0", "gs"], + ["x86_64-unknown-linux-gnu", "tls"], +])("worker stack checks for %s read the guard through %s", async (target, access) => { + vi.stubEnv("SCRIPTC_TARGET", target); + const request = await fixture( + worker + + "\nfunction depth(n: number): number { return n < 1 ? 0 : depth(n - 1) + 1; }\nconsole.log(depth(9));\n", + ); + const result = await compile(request.entry, request); + expect(result.ok, JSON.stringify(result)).toBe(true); + if (result.ok) { + const llvm = await readFile(request.outPath, "utf8"); + const start = llvm.search(/^define [^\n]*@sc_f_depth\(/m); + expect(start).toBeGreaterThanOrEqual(0); + const depth = llvm.slice(start, llvm.indexOf("\n}\n", start)); + expect(depth).toContain("call ptr @llvm.stacksave.p0()"); + if (access === "tls") { + // ELF: one thread-pointer-relative load of the thread-local guard. + expect(depth).toContain("load ptr, ptr @scr_stack_guard"); + expect(depth).not.toContain("@scr_stack_guard_tsd"); + } else { + // Darwin: a thread-local access calls the TLV thunk, so the guard comes + // from its thread-specific-data slot, with an out-of-line fallback. + expect(depth).not.toContain("ptr @scr_stack_guard\n"); + expect(depth).toContain("load i64, ptr @scr_stack_guard_tsd"); + expect(depth).toContain("call ptr @scr_stack_guard_get()"); + if (access === "tpidrro") { + expect(depth).toContain('@llvm.read_register.i64(metadata !{!"tpidrro_el0"})'); + } else { + expect(depth).toMatch(/load ptr, ptr addrspace\(256\) %/); + } + } + } +}); + test.each([ ["x86_64-unknown-linux-gnu", true], ["aarch64-unknown-linux-gnu", true], diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index c693a7488..e78a6043e 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1256,7 +1256,7 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { /* The stack guard follows the running stack. A fresh fiber starts with * its own; every resumed context restores the guard it saved here. */ uintptr_t guard = scr_stack_guard; - if (to_fiber) scr_stack_guard = scr_stack_guard_for((uintptr_t)to_fiber->stack, SCR_FIBER_STACK); + if (to_fiber) SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)to_fiber->stack, SCR_FIBER_STACK)); #endif #ifdef _WIN32 /* SwitchToFiber snapshots the outgoing fiber's state inside its own @@ -1287,7 +1287,7 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { swapcontext(from, to); #endif #if !defined(_WIN32) && !defined(__wasi__) - scr_stack_guard = guard; + SCR_STACK_GUARD_SET(guard); #endif } diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index 4b4e54784..0b07c6532 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -207,12 +207,56 @@ ScrDyn *scr_worker_parent_port(void) { return scr_dyn_new_null(); } #include #endif +#ifdef SCR_STACK_GUARD_TSD +/* The guard's thread-specific-data slot (scr_runtime.h). libpthread keeps a + * thread's values in an array at its thread pointer, indexed by key, which + * is also how its own inline accessors read them; the probe below checks + * that layout once before compiled code relies on it. */ +uintptr_t scr_stack_guard_tsd = 0; +static pthread_key_t scr_stack_guard_key; +static bool scr_stack_guard_keyed; +static pthread_once_t scr_stack_guard_once = PTHREAD_ONCE_INIT; + +static void *scr_stack_guard_tsd_read(uintptr_t offset) { + void *value; +#if defined(__aarch64__) + uintptr_t base; + __asm__ volatile("mrs %0, TPIDRRO_EL0" : "=r"(base)); + value = *(void *volatile *)((base & ~(uintptr_t)7) + offset); +#else + __asm__ volatile("movq %%gs:(%1), %0" : "=r"(value) : "r"(offset)); +#endif + return value; +} + +static void scr_stack_guard_key_init(void) { + if (pthread_key_create(&scr_stack_guard_key, NULL) != 0) return; + scr_stack_guard_keyed = true; + static const char probe; + uintptr_t offset = (uintptr_t)scr_stack_guard_key * sizeof(void *); + if (offset != 0 && pthread_setspecific(scr_stack_guard_key, &probe) == 0 && + scr_stack_guard_tsd_read(offset) == &probe) + scr_stack_guard_tsd = offset; + (void)pthread_setspecific(scr_stack_guard_key, NULL); +} + +void scr_stack_guard_set(uintptr_t guard) { + scr_stack_guard = guard; + if (scr_stack_guard_keyed) (void)pthread_setspecific(scr_stack_guard_key, (void *)guard); +} + +uintptr_t scr_stack_guard_get(void) { return scr_stack_guard; } +#endif + void scr_stack_guard_init(void) { #if defined(__APPLE__) && !defined(SCR_LIB) +#ifdef SCR_STACK_GUARD_TSD + (void)pthread_once(&scr_stack_guard_once, scr_stack_guard_key_init); +#endif pthread_t self = pthread_self(); uintptr_t high = (uintptr_t)pthread_get_stackaddr_np(self); size_t size = pthread_get_stacksize_np(self); - if (high > size) scr_stack_guard = scr_stack_guard_for(high - size, size); + if (high > size) SCR_STACK_GUARD_SET(scr_stack_guard_for(high - size, size)); #elif defined(__linux__) && !defined(SCR_LIB) #ifdef SCR_WORKERS if (!scr_context_is_main()) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 06362c794..3c3d72f34 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -337,6 +337,22 @@ bool scr_context_is_main(void); extern SCR_TL uintptr_t scr_stack_guard; void scr_stack_guard_init(void); void scr_stack_overflow(void); +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(__APPLE__) && \ + (defined(__aarch64__) || defined(__x86_64__)) +/* Darwin worker executables: reading a thread-local variable calls its TLV + * thunk, so the emitted entry checks read the running stack's guard from a + * pthread thread-specific-data slot instead, addressed from the thread + * pointer (TPIDRRO_EL0 on arm64, %gs on x86-64) plus this byte offset. + * scr_stack_guard_init validates the slot on first use; zero means the + * direct read is unavailable and the checks call scr_stack_guard_get. */ +#define SCR_STACK_GUARD_TSD 1 +extern uintptr_t scr_stack_guard_tsd; +uintptr_t scr_stack_guard_get(void); +void scr_stack_guard_set(uintptr_t guard); +#define SCR_STACK_GUARD_SET(guard) scr_stack_guard_set(guard) +#else +#define SCR_STACK_GUARD_SET(guard) (scr_stack_guard = (guard)) +#endif #if defined(__has_feature) #if __has_feature(address_sanitizer) #define SCR_STACK_RESERVE_MAX ((size_t)2 << 20) From 865beda7dbeccf7340b993b3f735e85ee6661c04 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 02:28:41 -0700 Subject: [PATCH 7/8] Give the runtime's thread-locals the local-exec model in ELF workers ELF worker executables gave the program's own thread-locals the local-exec model and the runtime's external ones initial-exec. The linker relaxes initial-exec to `mov $offset, reg` plus an indexed %fs access, so every inline allocation (scr_thread_hot) and every stack check (scr_stack_guard) paid an extra instruction and register. The runtime is always linked statically into the executable, so its thread-locals are in the executable's own TLS block too and local-exec is valid for them: the access becomes one %fs-relative operand that LLVM folds into loads and compares, as the pending word already is. --- packages/compiler/src/backend/llvm/emitter.ts | 14 ++++++++------ packages/compiler/test/worker-threads.test.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 798de8a0a..73be114cb 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1444,11 +1444,13 @@ export class LlEmitter { const parts = this.emitModuleParts(); if (!this.executableTls) return parts; // A worker executable's thread-locals all live in the executable's own - // TLS block (the program's internal ones, the runtime's external ones), - // so on ELF they can use the executable models: LLVM's default - // general-dynamic sequence (relaxed by the linker to `mov %fs:0` plus - // an add) becomes one %fs-relative access LLVM can fold into loads. - // Darwin's thread-local variables have a single model. + // TLS block: the program's internal ones, and the runtime's external + // ones, because the runtime is always linked statically into the + // executable. So on ELF they can all use the local-exec model: LLVM's + // default general-dynamic sequence (relaxed by the linker to `mov %fs:0` + // plus an add), and even initial-exec (relaxed to `mov $offset` plus an + // indexed access), become one %fs-relative operand LLVM can fold into + // loads and compares. Darwin's thread-local variables have one model. return parts.map((part) => part .replace( @@ -1457,7 +1459,7 @@ export class LlEmitter { ) .replace( /^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm, - "$1thread_local(initialexec) global ", + "$1thread_local(localexec) global ", ), ); } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index f3dab96ed..01b934872 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -179,7 +179,7 @@ settle().then((value) => console.log(value)); // Worker runtimes keep one small-object allocator per script thread: // the inline paths address its state, live counts and dispose hook in // the runtime's one thread-local ScrThreadHot block. - expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(initialexec\))? global /); + expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(localexec\))? global /); expect(llvm).not.toMatch(/@scr_(sa|cyc_live|cyc_old_freed|weak_dispose_hook) = external/); expect(llvm).toContain("call ptr @llvm.threadlocal.address.p0(ptr @scr_thread_hot)"); expect(llvm).toMatch(/getelementptr inbounds i8, ptr %[\w.]+\.tl\.hot, i64 808/); @@ -328,14 +328,15 @@ test.each([ expect(result.ok, JSON.stringify(result)).toBe(true); if (result.ok) { const llvm = await readFile(request.outPath, "utf8"); - // Every thread-local of a worker executable lives in its own TLS block: - // ELF uses local-exec for the program's and initial-exec for the - // runtime's; Darwin's thread-local variables have one model. + // Every thread-local of a worker executable lives in its own TLS block + // (the runtime links statically): ELF uses local-exec for the program's + // and the runtime's; Darwin's thread-local variables have one model. const plain = /^@[-$._A-Za-z0-9]+ = (internal|external) thread_local global /m; if (models) { expect(llvm).not.toMatch(plain); + expect(llvm).not.toContain("initialexec"); expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = internal thread_local\(localexec\) global /m); - expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(initialexec\) global /m); + expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(localexec\) global /m); } else { expect(llvm).toMatch(plain); expect(llvm).not.toContain("thread_local("); From 7afe786b3a3db2b2d7879f5bbf32a3eb3c5ac965 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:23 -0700 Subject: [PATCH 8/8] Declare scr_stack_guard_get with the header's i64 return scr_runtime.h declares uintptr_t scr_stack_guard_get(void), an i64 in the C ABI, but the Darwin worker stack check's cold fallback declared and called it returning ptr, which the runtime ABI test rejects. Declare i64 and convert the result with inttoptr. --- packages/compiler/src/backend/llvm/emitter.ts | 7 +++++-- packages/compiler/test/worker-threads.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 73be114cb..962b117f8 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4038,7 +4038,7 @@ export class LlEmitter { // guard from its thread-specific-data slot at the runtime-validated // offset instead, and call out only when the runtime found no slot. this.declare(`@scr_stack_guard_tsd = external dso_local global i64`); - this.declare(`declare ptr @scr_stack_guard_get()`); + this.declare(`declare i64 @scr_stack_guard_get()`); const offset = B.tmp(); const direct = B.tmp(); const likely = B.tmp(); @@ -4068,8 +4068,11 @@ export class LlEmitter { } test(guard); B.startBlock(slow); + // uintptr_t scr_stack_guard_get(void): an i64 in the C ABI. + const raw = B.tmp(); const fallback = B.tmp(); - B.line(`${fallback} = call ptr @scr_stack_guard_get()`); + B.line(`${raw} = call i64 @scr_stack_guard_get()`); + B.line(`${fallback} = inttoptr i64 ${raw} to ptr`); test(fallback); } else { const tl = this.mod.workers === true ? "thread_local " : ""; diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 01b934872..68e115909 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -307,7 +307,7 @@ test.each([ // from its thread-specific-data slot, with an out-of-line fallback. expect(depth).not.toContain("ptr @scr_stack_guard\n"); expect(depth).toContain("load i64, ptr @scr_stack_guard_tsd"); - expect(depth).toContain("call ptr @scr_stack_guard_get()"); + expect(depth).toContain("call i64 @scr_stack_guard_get()"); if (access === "tpidrro") { expect(depth).toContain('@llvm.read_register.i64(metadata !{!"tpidrro_el0"})'); } else {