Merge remote-tracking branch 'origin/main' into feat/contract-types

# Conflicts:
#	packages/compiler/src/backend/may-throw.ts
#	packages/compiler/test/ts7/baselines/order-parity.json
This commit is contained in:
Malte Ubl
2026-10-11 12:25:31 -07:00
18 changed files with 1442 additions and 95 deletions
+1
View File
@@ -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/**",
@@ -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");
});
+180 -40
View File
@@ -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<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>,
): 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);
@@ -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<typeof loopTestsEveryIteration>[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);
});
+107
View File
@@ -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<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>;
/** Expression kinds whose operands all evaluate whenever the expression does
* (no short-circuit, deferred or conditional operand). */
const EAGER_EXPRS: ReadonlySet<string> = 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<string> = 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;
}
@@ -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(
+26 -1
View File
@@ -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<string>,
stackChecks: Set<string> | null,
): Set<string> {
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
@@ -14530,12 +14530,6 @@
],
"diags": []
},
"<repo>/tests/corpus/native-worker-arena-reuse.ts": {
"order": [
"<repo>/tests/corpus/native-worker-arena-reuse.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-worker-buffer-consumers.ts": {
"order": [
"<repo>/tests/corpus/native-worker-buffer-consumers.ts"
@@ -15749,6 +15743,30 @@
],
"diags": []
},
"<repo>/tests/corpus/native-worker-terminate-tested-loops.ts": {
"order": [
"<repo>/tests/corpus/native-worker-terminate-tested-loops.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-worker-exception-state.ts": {
"order": [
"<repo>/tests/corpus/native-worker-exception-state.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-worker-terminate-exception-state.ts": {
"order": [
"<repo>/tests/corpus/native-worker-terminate-exception-state.ts"
],
"diags": []
},
"<repo>/tests/corpus/native-worker-arena-reuse.ts": {
"order": [
"<repo>/tests/corpus/native-worker-arena-reuse.ts"
],
"diags": []
},
"<repo>/tests/corpus/4598-map-set-clear-reuse.ts": {
"order": [
"<repo>/tests/corpus/4598-map-set-clear-reuse.ts"
@@ -15797,12 +15815,6 @@
],
"diags": []
},
"<repo>/tests/corpus/4598-os-available-parallelism.ts": {
"order": [
"<repo>/tests/corpus/4598-os-available-parallelism.ts"
],
"diags": []
},
"<repo>/tests/corpus/4598-live-array-ref-element-access.ts": {
"order": [
"<repo>/tests/corpus/4598-live-array-ref-element-access.ts"
@@ -15835,6 +15847,19 @@
],
"diags": []
},
"<repo>/tests/corpus/4598-os-available-parallelism.ts": {
"order": [
"<repo>/tests/corpus/4598-os-available-parallelism.ts"
],
"diags": []
},
"<repo>/tests/corpus/constant-module-globals/main.ts": {
"order": [
"<repo>/tests/corpus/constant-module-globals/consts.ts",
"<repo>/tests/corpus/constant-module-globals/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/4630-contract-int32.ts": {
"order": [
"<repo>/tests/corpus/4630-contract-int32.ts"
+83 -23
View File
@@ -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(");
+5 -4
View File
@@ -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;
+57 -4
View File
@@ -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 <pthread.h>
#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()) {
+3 -1
View File
@@ -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). */
+56 -4
View File
@@ -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();
}
+34 -3
View File
@@ -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)
+43
View File
@@ -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";
@@ -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<string, string, string> {
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<number> {
try {
yield 1;
yield 2;
} finally {
log.push("guarded finally");
}
}
function* explosive(): Generator<number> {
yield 1;
fail(8);
}
function* finallyThrows(): Generator<number> {
try {
yield 1;
} finally {
fail(9);
}
}
async function rejectsLater(code: number): Promise<string> {
await null;
return label(-code);
}
async function rejectsNow(code: number): Promise<string> {
return label(-code);
}
function overflow(n: number): number {
return overflow(n + 1) + 1;
}
async function scenarios(): Promise<string[]> {
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<string>(() => 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<void>((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());
}
@@ -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<number> {
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<number> {
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<void>((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<void>((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");
}
}
@@ -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<void>((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");
}
}