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.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index d74552eb2..958df8cbe 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -555,10 +555,34 @@ 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"); + // 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 9735609ff..a8284c13b 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, @@ -529,6 +530,13 @@ export class LlEmitter { readonly inlineTrunc: 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; + /** 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; private readonly detachedExit: boolean; @@ -842,6 +850,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.detachedExit = options.detachedExit === true; @@ -1458,11 +1475,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( @@ -1471,7 +1490,7 @@ export class LlEmitter { ) .replace( /^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm, - "$1thread_local(initialexec) global ", + "$1thread_local(localexec) global ", ), ); } @@ -3799,13 +3818,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(); @@ -3859,6 +3892,21 @@ 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 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 ${unordered ? "unordered" : "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 @@ -3886,8 +3934,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) && @@ -3909,16 +3958,34 @@ 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) { - this.declare(`@scr_exc_alert = external global i32, align 64`); + const exact = this.mod.workers !== true || this.executableTls; + if (this.executableTls) { + // 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. + 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 { @@ -3946,7 +4013,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); @@ -3978,30 +4045,98 @@ 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 i64 @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); + // uintptr_t scr_stack_guard_get(void): an i64 in the C ABI. + const raw = B.tmp(); + const fallback = B.tmp(); + 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 " : ""; + 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(); 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}`); @@ -4026,7 +4161,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); } @@ -5022,6 +5157,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); @@ -5190,7 +5326,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 @@ -5407,10 +5543,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--; } } @@ -5980,7 +6120,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"); @@ -6005,7 +6145,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"); @@ -6209,7 +6349,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); @@ -6312,7 +6452,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/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index c60d754eb..b08e77312 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 8223e597d..abe72e187 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -429,7 +429,7 @@ export function computeMayThrow( fns: may, ...(why !== null ? { why } : {}), indirect, - ...(terminations ? { workerEntryPolls: terminations.entry } : {}), + ...(terminations ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackChecks) } : {}), ...(stackChecks ? { stackChecks } : {}), }; } @@ -512,6 +512,31 @@ function describeThrowingNode(rec: IrExpr | IrStmt): string { } } +/** 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/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 3c61db291..a1f0734d5 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -14530,12 +14530,6 @@ ], "diags": [] }, - "/tests/corpus/native-worker-arena-reuse.ts": { - "order": [ - "/tests/corpus/native-worker-arena-reuse.ts" - ], - "diags": [] - }, "/tests/corpus/native-worker-buffer-consumers.ts": { "order": [ "/tests/corpus/native-worker-buffer-consumers.ts" @@ -15749,6 +15743,30 @@ ], "diags": [] }, + "/tests/corpus/native-worker-terminate-tested-loops.ts": { + "order": [ + "/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": [] + }, + "/tests/corpus/native-worker-arena-reuse.ts": { + "order": [ + "/tests/corpus/native-worker-arena-reuse.ts" + ], + "diags": [] + }, "/tests/corpus/4598-map-set-clear-reuse.ts": { "order": [ "/tests/corpus/4598-map-set-clear-reuse.ts" @@ -15797,12 +15815,6 @@ ], "diags": [] }, - "/tests/corpus/4598-os-available-parallelism.ts": { - "order": [ - "/tests/corpus/4598-os-available-parallelism.ts" - ], - "diags": [] - }, "/tests/corpus/4598-live-array-ref-element-access.ts": { "order": [ "/tests/corpus/4598-live-array-ref-element-access.ts" @@ -15835,6 +15847,19 @@ ], "diags": [] }, + "/tests/corpus/4598-os-available-parallelism.ts": { + "order": [ + "/tests/corpus/4598-os-available-parallelism.ts" + ], + "diags": [] + }, + "/tests/corpus/constant-module-globals/main.ts": { + "order": [ + "/tests/corpus/constant-module-globals/consts.ts", + "/tests/corpus/constant-module-globals/main.ts" + ], + "diags": [] + }, "/tests/corpus/4630-contract-int32.ts": { "order": [ "/tests/corpus/4630-contract-int32.ts" diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 25df45995..68e115909 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/); @@ -228,33 +228,92 @@ 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.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 (unordered|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([ + ["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 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 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. - 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); + 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 i64 @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\) %/); + } + } } }); @@ -269,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("); diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 88fb42ad6..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 } @@ -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..0b07c6532 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. */ @@ -198,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_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 cddb7c50c..80be65b29 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); @@ -322,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) diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index d9927e8c6..7245c5349 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -41,9 +41,17 @@ __attribute__((constructor)) static void scr_worker_reserve_fd_table(void) { 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; @@ -92,6 +100,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); } @@ -126,6 +137,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); @@ -133,6 +153,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)); } @@ -177,6 +198,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; @@ -188,7 +217,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)); @@ -216,7 +251,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; @@ -262,6 +302,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"); + } +} 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"); + } +}