From 4b325150fa16da370b30feb1604f265bb886659f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:45:24 -0700 Subject: [PATCH 01/22] Support os.availableParallelism() in static programs The runtime answers like libuv 1.51's uv_available_parallelism, which Node's os.availableParallelism() returns: on Linux the CPUs in the process's affinity mask, lowered to a smaller cgroup (v2 cpu.max or v1 CFS) CPU quota; on Darwin hw.activecpu, hw.logicalcpu or hw.ncpu; on Windows GetSystemInfo's processor count; at least 1. WASI answers 1, as the dynamic island does. Parallel programs (tsc-ts --checkers) need it to size their thread pools. Corpus 4598 pins the value against the host's Node. --- .../node-v24-compatibility-meta.json | 2 +- .../src/generated/node-v24-compatibility.json | 10 +- .../generated/node-v24-backlog.json | 18 +-- .../generated/node-v24-internal.json | 14 ++- internal/compatibility/static-support.json | 1 + .../ambient/scriptc-node-fallback.d.ts | 1 + .../compiler/src/backend/llvm/lib-shared.ts | 1 + .../src/frontend/lowering/surfaces.ts | 8 ++ .../compiler/src/ir/builtin-signatures.ts | 2 + packages/compiler/surface-manifest.json | 6 + packages/runtime/src/scr_lib.c | 104 ++++++++++++++++++ packages/runtime/src/scr_runtime.h | 1 + tests/corpus/4598-os-available-parallelism.ts | 13 +++ 13 files changed, 162 insertions(+), 19 deletions(-) create mode 100644 tests/corpus/4598-os-available-parallelism.ts diff --git a/docs/src/generated/node-v24-compatibility-meta.json b/docs/src/generated/node-v24-compatibility-meta.json index cbbe881f9..223a3027b 100644 --- a/docs/src/generated/node-v24-compatibility-meta.json +++ b/docs/src/generated/node-v24-compatibility-meta.json @@ -2,6 +2,6 @@ "schemaVersion": 3, "nodeVersion": "24.15.0", "nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119", - "artifactVersion": "ae934a592466ef1e67c1", + "artifactVersion": "cb524f2eb043e40eb9f4", "rowCount": 3662 } diff --git a/docs/src/generated/node-v24-compatibility.json b/docs/src/generated/node-v24-compatibility.json index 1391cf23b..155348306 100644 --- a/docs/src/generated/node-v24-compatibility.json +++ b/docs/src/generated/node-v24-compatibility.json @@ -746,9 +746,9 @@ "apiEntries": 24, "static": { "supported": 0, - "partial": 10, + "partial": 11, "refused": 0, - "not-implemented": 14, + "not-implemented": 13, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -61038,9 +61038,9 @@ "inherited": true }, "static": { - "status": "not-implemented", - "detail": "No static compiler lowering is registered for this API yet.", - "verification": "registry-gap" + "status": "partial", + "detail": "Implemented for the call shapes accepted by the compiler lowering.", + "verification": "test-backed" }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/generated/node-v24-backlog.json b/internal/compatibility/generated/node-v24-backlog.json index e73d033de..e16c10848 100644 --- a/internal/compatibility/generated/node-v24-backlog.json +++ b/internal/compatibility/generated/node-v24-backlog.json @@ -8,8 +8,8 @@ "tiers": { "static": { "replace-refusal": 42, - "verify-gap": 2418, - "audit-partial": 746, + "verify-gap": 2417, + "audit-partial": 747, "classify": 2, "implement": 80 }, @@ -66294,12 +66294,14 @@ "priority": "high", "tiers": { "static": { - "status": "not-implemented", - "action": "verify-gap", - "verification": "registry-gap", - "confidence": "low", - "source": "compiler-unmatched:os", - "tests": [] + "status": "partial", + "action": "audit-partial", + "verification": "test-backed", + "confidence": "high", + "source": "surface-manifest:node-builtin.os.availableParallelism", + "tests": [ + "tests/corpus/4598-os-available-parallelism.ts" + ] }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/generated/node-v24-internal.json b/internal/compatibility/generated/node-v24-internal.json index faa5855d9..4a4faed26 100644 --- a/internal/compatibility/generated/node-v24-internal.json +++ b/internal/compatibility/generated/node-v24-internal.json @@ -819,9 +819,9 @@ "apiEntries": 24, "static": { "supported": 0, - "partial": 10, + "partial": 11, "refused": 0, - "not-implemented": 14, + "not-implemented": 13, "by-design": 0, "unreviewed": 0, "not-applicable": 0 @@ -83033,7 +83033,8 @@ "evidence": "derived:descendants", "tests": [ "tests/corpus/1480-os-network-interfaces.ts", - "tests/corpus/2917-builtin-callable-widths.ts" + "tests/corpus/2917-builtin-callable-widths.ts", + "tests/corpus/4598-os-available-parallelism.ts" ] }, "dynamic": { @@ -83279,8 +83280,11 @@ "inherited": true }, "static": { - "status": "not-implemented", - "evidence": "compiler-unmatched:os" + "status": "partial", + "evidence": "surface-manifest:node-builtin.os.availableParallelism", + "tests": [ + "tests/corpus/4598-os-available-parallelism.ts" + ] }, "dynamic": { "status": "partial", diff --git a/internal/compatibility/static-support.json b/internal/compatibility/static-support.json index 80cdeb2f9..3f5ecefff 100644 --- a/internal/compatibility/static-support.json +++ b/internal/compatibility/static-support.json @@ -80,6 +80,7 @@ "node-builtin.module": ["tests/corpus/1598-cjs-builtin-require/main.js"], "node-builtin.net": ["tests/corpus/2500-net-autosel-timeout.ts", "tests/corpus/2596-net-arg-ladders.cjs"], "node-builtin.os": ["tests/corpus/1480-os-network-interfaces.ts", "tests/corpus/2917-builtin-callable-widths.ts"], + "node-builtin.os.availableParallelism": ["tests/corpus/4598-os-available-parallelism.ts"], "node-builtin.path": ["tests/corpus/957-builtins-namespace.ts", "tests/corpus/2915-path-function-values.ts", "tests/corpus/2916-path-function-values-cjs.ts", "tests/corpus/2917-builtin-callable-widths.ts", "tests/corpus/2918-builtin-callable-widths-cjs.ts"], "node-builtin.perf_hooks": ["tests/corpus/957-builtins-namespace.ts"], "node-builtin.process": ["tests/corpus/990-process-basics.ts", "tests/corpus/2314-process-introspection.ts"], diff --git a/packages/compiler/ambient/scriptc-node-fallback.d.ts b/packages/compiler/ambient/scriptc-node-fallback.d.ts index 7e4590be5..b3933ef5e 100644 --- a/packages/compiler/ambient/scriptc-node-fallback.d.ts +++ b/packages/compiler/ambient/scriptc-node-fallback.d.ts @@ -1688,6 +1688,7 @@ declare module "os" { export function type(): string; /* Total system memory in bytes (sysctl hw.memsize / sysconf). */ export function totalmem(): number; + export function availableParallelism(): number; /* The passwd-entry snapshot (uv_os_get_passwd): shell is `string | * null` to match @types/node (POSIX always answers the string arm); * homedir is pw_dir — NOT os.homedir()'s $HOME-first cascade. The type diff --git a/packages/compiler/src/backend/llvm/lib-shared.ts b/packages/compiler/src/backend/llvm/lib-shared.ts index 4d367c1eb..07df1406f 100644 --- a/packages/compiler/src/backend/llvm/lib-shared.ts +++ b/packages/compiler/src/backend/llvm/lib-shared.ts @@ -151,6 +151,7 @@ export const LIB_FN_SYMS: Record = { "os.homedir": "scr_os_homedir", "os.type": "scr_os_type", "os.totalmem": "scr_os_totalmem", + "os.availableParallelism": "scr_os_available_parallelism", "os.release": "scr_os_release", "os.userName": "scr_os_user_name", "os.userShell": "scr_os_user_shell", diff --git a/packages/compiler/src/frontend/lowering/surfaces.ts b/packages/compiler/src/frontend/lowering/surfaces.ts index 8080b0fed..91ef6c4b5 100644 --- a/packages/compiler/src/frontend/lowering/surfaces.ts +++ b/packages/compiler/src/frontend/lowering/surfaces.ts @@ -1311,6 +1311,14 @@ export const BUILTIN_MODULE_FNS: Record< type: { fn: "os.type", params: [], result: STRING, valueParams: [] }, // Total physical memory in bytes — same predating-pair story. totalmem: { fn: "os.totalmem", params: [], result: F64, valueParams: [] }, + // uv_available_parallelism: the CPUs this process may use (affinity, + // then cgroup quota on Linux), at least 1. + availableParallelism: { + fn: "os.availableParallelism", + params: [], + result: F64, + valueParams: [], + }, // Entirely special-cased (lowerOsNetworkInterfacesCall): the result is // the call site's mapped Dict shape, verified // structurally there — this entry only routes the dispatch. diff --git a/packages/compiler/src/ir/builtin-signatures.ts b/packages/compiler/src/ir/builtin-signatures.ts index 9e367aacf..245157851 100644 --- a/packages/compiler/src/ir/builtin-signatures.ts +++ b/packages/compiler/src/ir/builtin-signatures.ts @@ -432,6 +432,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({ "os.type": { argTypes: [], result: STRING }, /** os.totalmem(): total physical memory in bytes. Never throws. */ "os.totalmem": { argTypes: [], result: F64 }, + /** os.availableParallelism(): CPUs the process may use (libuv's answer), >= 1. Never throws. */ + "os.availableParallelism": { argTypes: [], result: F64 }, /** umask(2): arg < 0 reads without setting (umask has no read-only form * — set 0, restore); otherwise sets and answers the PREVIOUS mask. * Never throws. */ diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 00a1908f5..531a17197 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1334,6 +1334,12 @@ "status": "static", "note": "constant value read" }, + { + "id": "node-builtin.os.availableParallelism", + "kind": "node-builtin", + "name": "os.availableParallelism", + "status": "static" + }, { "id": "node-builtin.os.homedir", "kind": "node-builtin", diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e259f6f7e..6360c7215 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -123,6 +123,11 @@ static ScrStr *scr_fs_win_utf8(const WCHAR *text, size_t length) { #elif defined(__linux__) #include #endif +#if defined(__linux__) +#include /* sched_getaffinity, CPU_COUNT (os.availableParallelism) */ +#elif defined(__APPLE__) +#include /* sysctlbyname (os.availableParallelism) */ +#endif #define scr_sys_mkdir(p, m) mkdir((p), (m)) @@ -1376,6 +1381,13 @@ ScrStr *scr_os_type(void) { return scr_str_new("Windows_NT", 10); } +double scr_os_available_parallelism(void) { + /* uv_available_parallelism on Windows: GetSystemInfo's processor count. */ + SYSTEM_INFO info; + GetSystemInfo(&info); + return info.dwNumberOfProcessors < 1 ? 1 : (double)info.dwNumberOfProcessors; +} + double scr_os_totalmem(void) { MEMORYSTATUSEX ms; memset(&ms, 0, sizeof ms); @@ -1413,6 +1425,8 @@ ScrStr *scr_os_user_homedir(void) { return scr_os_homedir(); } ScrStr *scr_os_release(void) { return scr_str_new("", 0); } ScrStr *scr_os_type(void) { return scr_str_new("WASI", 4); } double scr_os_totalmem(void) { return 0; } +/* One thread of execution, as the dynamic island's os answers. */ +double scr_os_available_parallelism(void) { return 1; } /* The guest temp namespace is stable across hosts. `scriptc run` preopens * the host's /tmp at this path; other WASI hosts can provide the same * capability without leaking a host-specific TMPDIR into the module. */ @@ -1486,6 +1500,96 @@ ScrStr *scr_os_type(void) { return scr_str_new(u.sysname, strlen(u.sysname)); } +#ifdef __linux__ +/* Reads at most cap - 1 bytes of path into buf (NUL-terminated); false when + * the file cannot be read. */ +static bool scr_os_slurp(const char *path, char *buf, size_t cap) { + int fd = open(path, O_RDONLY | O_CLOEXEC); + if (fd < 0) return false; + size_t len = 0; + while (len + 1 < cap) { + ssize_t n = read(fd, buf + len, cap - 1 - len); + if (n < 0 && errno == EINTR) continue; + if (n <= 0) break; + len += (size_t)n; + } + close(fd); + buf[len] = '\0'; + return true; +} + +/* libuv's uv__get_constrained_cpu (1.51): the CPU quota of the process's + * cgroup in whole CPUs. For cgroup v2 it walks cpu.max from the process's + * cgroup up to the root; like libuv, the last level with a numeric limit + * (the outermost) decides. Returns false when there is no answer. */ +static bool scr_os_cgroup_cpu_quota(long long *quota) { + char cgroup[1024]; + if (!scr_os_slurp("/proc/self/cgroup", cgroup, sizeof cgroup)) return false; + if (strncmp(cgroup, "0::/", 4) == 0) { + static const char mount[] = "/sys/fs/cgroup"; + const char *trimmed = cgroup + 4; + int size = (int)strcspn(trimmed, "\n"); + char path[256], file[256], buf[1024], value[16]; + snprintf(path, sizeof path, "%s/%.*s/cgroup.controllers", mount, size, trimmed); + if (!scr_os_slurp(path, buf, sizeof buf)) return false; + snprintf(path, sizeof path, "%s/%.*s", mount, size, trimmed); + while (strncmp(path, mount, sizeof mount - 1) == 0) { + long long limit, period; + snprintf(file, sizeof file, "%s/cpu.max", path); + if (scr_os_slurp(file, value, sizeof value) && strncmp(value, "max", 3) != 0 && + sscanf(value, "%lld %lld", &limit, &period) == 2 && period != 0) + *quota = limit / period; + char *slash = strrchr(path, '/'); + if (slash == NULL || strcmp(path, mount) == 0) break; + *slash = '\0'; + } + return true; + } + char *cpu = strstr(cgroup, ":cpu,"); + if (cpu == NULL) return false; + cpu += sizeof ":cpu," - 1; + int size = (int)strcspn(cpu, "\n"); + char path[256], buf[64]; + long long per_period, period; + snprintf(path, sizeof path, "/sys/fs/cgroup/%.*s/cpu.cfs_quota_us", size, cpu); + if (!scr_os_slurp(path, buf, sizeof buf) || sscanf(buf, "%lld", &per_period) != 1) return false; + snprintf(path, sizeof path, "/sys/fs/cgroup/%.*s/cpu.cfs_period_us", size, cpu); + if (!scr_os_slurp(path, buf, sizeof buf) || sscanf(buf, "%lld", &period) != 1 || period == 0) + return false; + *quota = per_period / period; + return true; +} +#endif + +double scr_os_available_parallelism(void) { + /* uv_available_parallelism (libuv 1.51, Node's os.availableParallelism): + * the CPUs this process may run on (Linux: its affinity mask, then any + * smaller cgroup CPU quota; Darwin: hw.activecpu, hw.logicalcpu, hw.ncpu), + * else the online processors; at least 1. */ + long rc = -1; +#if defined(__linux__) + cpu_set_t set; + memset(&set, 0, sizeof set); + if (sched_getaffinity(0, sizeof set, &set) == 0) rc = CPU_COUNT(&set); +#elif defined(__APPLE__) + static const char *const mib[] = {"hw.activecpu", "hw.logicalcpu", "hw.ncpu"}; + for (size_t i = 0; i < sizeof mib / sizeof mib[0]; i++) { + int nprocs = 0; + size_t len = sizeof nprocs; + if (sysctlbyname(mib[i], &nprocs, &len, NULL, 0) == 0 && len == sizeof nprocs && nprocs > 0) { + rc = nprocs; + break; + } + } +#endif + if (rc < 0) rc = sysconf(_SC_NPROCESSORS_ONLN); +#if defined(__linux__) + long long quota = 0; + if (scr_os_cgroup_cpu_quota("a) && quota > 0 && quota < rc) rc = (long)quota; +#endif + return rc < 1 ? 1 : (double)rc; +} + double scr_os_totalmem(void) { /* Total physical memory in bytes (sysconf pages × page size — Darwin * and Linux both answer _SC_PHYS_PAGES). */ diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..068699d17 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -4059,6 +4059,7 @@ ScrStr *scr_os_tmpdir(void); ScrStr *scr_os_release(void); /* uname(2) release — +1 fresh */ ScrStr *scr_os_type(void); /* uname(2) sysname — +1 fresh */ double scr_os_totalmem(void); /* total physical memory, bytes */ +double scr_os_available_parallelism(void); /* uv_available_parallelism: CPUs this process may use, >= 1 */ /* os.userInfo()'s field trio (pw_name / pw_shell / pw_dir — the passwd * home, not the $HOME cascade). +1 fresh; abort on lookup failure. */ ScrStr *scr_os_user_name(void); diff --git a/tests/corpus/4598-os-available-parallelism.ts b/tests/corpus/4598-os-available-parallelism.ts new file mode 100644 index 000000000..31a89a24c --- /dev/null +++ b/tests/corpus/4598-os-available-parallelism.ts @@ -0,0 +1,13 @@ +// os.availableParallelism() — differential against the host's own Node: +// libuv's uv_available_parallelism (the CPU affinity mask, then a smaller +// cgroup CPU quota on Linux; hw.activecpu on Darwin), so both processes +// see the same count. +import * as os from "node:os"; +import { availableParallelism } from "node:os"; + +const n = os.availableParallelism(); +console.log(typeof n, Number.isInteger(n), n >= 1); +console.log(n === availableParallelism()); +console.log(n); +// A thread count derived from it, like a parallel tool's default. +console.log(Math.max(1, Math.min(n, 64)) === n || n > 64); From b7309201dc4549970f7f70e32aa6f2862c1302bc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 21:42:14 -0700 Subject: [PATCH 02/22] Skip worker entry polls in stack-checked functions A stack-checked synchronous function may throw, so every caller tests the pending state after calling it, and a descent that never returns ends at the stack guard, whose RangeError unwinds through those tests. A terminate request keeps the alert word raised, so the next test stops the thread; the entry poll only added a rejoining slow path that kept arguments live across a call in every recursive function. Async and generator bodies and the module entry keep their polls. --- .../compiler/src/backend/may-throw.test.ts | 32 +++++++++++++++++++ packages/compiler/src/backend/may-throw.ts | 27 +++++++++++++++- packages/compiler/test/worker-threads.test.ts | 6 ++-- 3 files changed, 61 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/backend/may-throw.test.ts b/packages/compiler/src/backend/may-throw.test.ts index 4a07f8227..fee54361c 100644 --- a/packages/compiler/src/backend/may-throw.test.ts +++ b/packages/compiler/src/backend/may-throw.test.ts @@ -437,6 +437,38 @@ test("stack checks cover recursive components and recursion through reentrant op expect(computeMayThrow(mod).fns.size).toBe(0); }); +test("stack-checked synchronous functions skip the worker entry poll", () => { + const asyncSelf: IrFunction = { ...fn("asyncSelf", [call("asyncSelf")], []), async: true }; + const mod: IrModule = { + ...moduleWith( + fn("caller", [call("caller"), call("self"), call("left"), call("walker")], []), + fn("self", [call("self")], []), + fn("left", [call("right")], []), + fn("right", [call("left")], []), + fn("walker", [callClosure("leaf")], []), + fn("leaf", [exprStmt(value)], []), + asyncSelf, + ), + workers: true, + }; + const answer = computeMayThrow(mod, { stackChecks: true }); + expect([...answer.stackChecks!].sort()).toEqual(["asyncSelf", "caller", "left", "right", "self"]); + // Callers test after every call of a stack-checked function, and its + // overflow unwinds through those tests, so only the module entry, async + // bodies and unbounded functions without a stack check poll on entry. + expect([...answer.workerEntryPolls!].sort()).toEqual(["asyncSelf", "caller", "walker"]); + expect(answer.fns.has("self") && answer.fns.has("left") && answer.fns.has("right")).toBe(true); + // Without stack checks (WASI, Windows, libraries) every cycle keeps its poll. + expect([...computeMayThrow(mod).workerEntryPolls!].sort()).toEqual([ + "asyncSelf", + "caller", + "left", + "right", + "self", + "walker", + ]); +}); + test("class members are reachable from reentrant operations", () => { const mod: IrModule = { ...moduleWith( diff --git a/packages/compiler/src/backend/may-throw.ts b/packages/compiler/src/backend/may-throw.ts index 21d0fb900..55effebad 100644 --- a/packages/compiler/src/backend/may-throw.ts +++ b/packages/compiler/src/backend/may-throw.ts @@ -368,11 +368,36 @@ export function computeMayThrow( return { fns: may, indirect, - ...(terminations ? { workerEntryPolls: terminations.entry } : {}), + ...(terminations ? { workerEntryPolls: entryPolls(mod, terminations.entry, stackChecks) } : {}), ...(stackChecks ? { stackChecks } : {}), }; } +/** The termination polls a worker program places on function entry. A + * stack-checked synchronous function needs none: it may throw (the stack + * guard seeds the fixpoint), so every caller tests the pending state after + * the call, and a descent that never returns ends at the stack guard, whose + * RangeError unwinds through those tests. An outstanding terminate request + * keeps the alert word nonzero, so the first test that runs afterwards (a + * post-call test, a loop poll or the unwinding) stops the thread; dropping + * the entry poll only delays the stop by bounded work, which Node's + * asynchronous terminate allows. The entry poll's slow path rejoins the + * body, so every polled function kept its arguments live across a call. */ +function entryPolls( + mod: IrModule, + entry: Set, + stackChecks: Set | null, +): Set { + if (stackChecks === null) return entry; + // Calling an async or generator function never tests the caller's pending + // state, and the module entry has no caller: those keep their polls. + const keep = new Set( + mod.functions.filter((fn) => fn.async || fn.generator !== undefined).map((fn) => fn.name), + ); + keep.add(mod.entry); + return new Set([...entry].filter((name) => keep.has(name) || !stackChecks.has(name))); +} + /** Statement and expression kinds that cannot reenter scriptc code except * through the direct call edges recorded below (`call`, `new`, * `virtualCall`), and cannot block in the runtime. Everything else, such as diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 25df45995..2f70de9e3 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -248,9 +248,9 @@ console.log(mix(1, 2), spin(5)); }; // A bounded helper neither polls on entry nor makes its callers poll. expect(body("sc_f_mix")).not.toContain("@scr_exc_alert"); - // Recursion polls on entry: the inline test reads the process-wide - // alert word (no thread-local access), and only the slow path calls into - // the runtime. + // Recursion tests after its recursive calls (its stack check replaces + // the entry poll): the inline test reads the process-wide alert word (no + // thread-local access), and only the slow path calls into the runtime. expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); From ad99adc1a08f4a0a0d73d864608f4fb606517c47 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 21:43:28 -0700 Subject: [PATCH 03/22] Skip worker loop polls when every iteration tests the pending state Worker loops poll the exception alert every 64 iterations so that a loop calling nothing that tests still observes a terminate request. A loop whose condition, update or do-while condition evaluates a call followed by a pending test, or whose body evaluates one before any statement that can continue the loop, observes the request through that test. Corpus native-worker-terminate-tested-loops terminates workers spinning in each shape, including a continue path that keeps its poll. --- .oxlintrc.jsonc | 1 + packages/compiler/src/backend/llvm/emitter.ts | 33 ++++- .../compiler/src/backend/loop-polls.test.ts | 94 ++++++++++++ packages/compiler/src/backend/loop-polls.ts | 107 ++++++++++++++ .../test/ts7/baselines/order-parity.json | 6 + .../native-worker-terminate-tested-loops.ts | 138 ++++++++++++++++++ 6 files changed, 374 insertions(+), 5 deletions(-) create mode 100644 packages/compiler/src/backend/loop-polls.test.ts create mode 100644 packages/compiler/src/backend/loop-polls.ts create mode 100644 tests/corpus/native-worker-terminate-tested-loops.ts diff --git a/.oxlintrc.jsonc b/.oxlintrc.jsonc index 58ab0455d..7de4019ae 100644 --- a/.oxlintrc.jsonc +++ b/.oxlintrc.jsonc @@ -268,6 +268,7 @@ "files": [ "packages/compiler/src/frontend/lowering/**", "packages/compiler/src/backend/llvm/**", + "packages/compiler/src/backend/loop-polls.test.ts", "packages/compiler/src/ir/**", "packages/compiler/src/library/*.test.ts", "packages/compiler/test/**", diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..91c0b6baa 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -28,6 +28,7 @@ import { undefinedArmTag, } from "../../ir/analysis.js"; import { emitLibraryIdentityLines } from "../library-identity-markers.js"; +import { loopTestsEveryIteration } from "../loop-polls.js"; import type { IrBytesElem, IrExpr, @@ -3974,8 +3975,30 @@ export class LlEmitter { B.startBlock(ok); } - private workerLoopBudget(): string | null { + /** Whether the emitter follows this call with a pending test (the + * conditions in expr-calls.ts). */ + private callTestsPending(e: IrExpr): boolean { + switch (e.kind) { + case "call": + return this.mayThrow.has(e.callee); + case "new": + return this.mayThrow.has(`%${e.className}.constructor`); + case "virtualCall": + return this.mayThrowMethods.has(e.method); + case "callValue": + return this.indirectMayThrow; + default: + return false; + } + } + + private workerLoopBudget( + loop: Extract, + ): string | null { if (!this.mod.workers) return null; + // A loop that runs a pending test on every iteration observes a + // terminate request through it; a separate poll would only add work. + if (loopTestsEveryIteration(loop, (e) => this.callTestsPending(e))) return null; const slot = this.B.slot(); this.B.entryAllocas.push(`${slot} = alloca i32 ; worker cancellation budget`); this.B.line(`store i32 0, ptr ${slot}`); @@ -5930,7 +5953,7 @@ export class LlEmitter { break; } case "while": { - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); const lc = B.newLabel("loop.c"); const lb = B.newLabel("loop.b"); const le = B.newLabel("loop.e"); @@ -5955,7 +5978,7 @@ export class LlEmitter { break; } case "doWhile": { - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); // Body first (runs at least once); continue jumps to the CONDITION. const lb = B.newLabel("loop.b"); const lc = B.newLabel("loop.c"); @@ -6124,7 +6147,7 @@ export class LlEmitter { }; const lu = s.update || capturedLets.length > 0 ? B.newLabel("loop.u") : lc; freshenBindings(); - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); B.br(lc); B.startBlock(lc); this.emitWorkerLoopCheck(workerBudget); @@ -6227,7 +6250,7 @@ export class LlEmitter { const lb = B.newLabel("fof.b"); const lu = B.newLabel("fof.u"); const le = B.newLabel("fof.e"); - const workerBudget = this.workerLoopBudget(); + const workerBudget = this.workerLoopBudget(s); B.br(lc); B.startBlock(lc); this.emitWorkerLoopCheck(workerBudget); diff --git a/packages/compiler/src/backend/loop-polls.test.ts b/packages/compiler/src/backend/loop-polls.test.ts new file mode 100644 index 000000000..b3ecd5e71 --- /dev/null +++ b/packages/compiler/src/backend/loop-polls.test.ts @@ -0,0 +1,94 @@ +import { expect, test } from "vitest"; +import { BOOL, F64, VOID, type IrExpr, type IrStmt } from "../ir/ir.js"; +import { loopTestsEveryIteration } from "./loop-polls.js"; + +const loc = { file: "loops.ts", start: 0, end: 1 }; +const call = (callee: string): IrExpr => ({ kind: "call", callee, args: [], type: F64, loc }); +const stmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc }); +const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc }; +const tested = (e: IrExpr): boolean => e.kind === "call" && e.callee === "throws"; +const whileLoop = (body: IrStmt[], cond: IrExpr = yes, labels?: string[]): IrStmt => ({ + kind: "while", + cond, + body, + loc, + ...(labels ? { labels } : {}), +}); +const answer = (loop: IrStmt): boolean => + loopTestsEveryIteration(loop as Parameters[0], tested); +const ifThen = (body: IrStmt[]): IrStmt => ({ + kind: "if", + cond: yes, + then: body, + else_: null, + loc, +}); +const sum = (left: IrExpr, right: IrExpr): IrExpr => ({ + kind: "bin", + op: "+", + left, + right, + type: F64, + loc, +}); + +test("an unconditional tested call in the body or condition replaces the poll", () => { + expect(answer(whileLoop([stmt(call("throws"))]))).toBe(true); + expect(answer(whileLoop([stmt(sum(call("pure"), call("throws")))]))).toBe(true); + expect(answer(whileLoop([], { kind: "toBool", operand: call("throws"), type: BOOL, loc }))).toBe( + true, + ); + // Untested calls and calls behind a condition or short circuit do not. + expect(answer(whileLoop([stmt(call("pure"))]))).toBe(false); + expect(answer(whileLoop([ifThen([stmt(call("throws"))])]))).toBe(false); + const shortCircuit: IrExpr = { + kind: "logical", + op: "&&", + left: yes, + right: call("throws"), + type: BOOL, + loc, + }; + expect(answer(whileLoop([stmt(shortCircuit)]))).toBe(false); +}); + +test("a continue before the tested call keeps the poll", () => { + const skip: IrStmt = { kind: "continue", loc }; + expect(answer(whileLoop([ifThen([skip]), stmt(call("throws"))]))).toBe(false); + // After the call, or bound to a nested loop, a continue is harmless. + expect(answer(whileLoop([stmt(call("throws")), ifThen([skip])]))).toBe(true); + expect(answer(whileLoop([whileLoop([skip]), stmt(call("throws"))]))).toBe(true); + // A labeled continue out of a nested loop still skips this iteration. + const labeled: IrStmt = { kind: "continue", label: "outer", loc }; + expect(answer(whileLoop([whileLoop([labeled]), stmt(call("throws"))], yes, ["outer"]))).toBe( + false, + ); +}); + +test("for updates and do-while conditions run after every continue", () => { + const skip: IrStmt = { kind: "continue", loc }; + const forLoop: IrStmt = { + kind: "for", + init: null, + cond: null, + update: stmt(call("throws")), + body: [ifThen([skip])], + loc, + }; + expect(answer(forLoop)).toBe(true); + const doWhile: IrStmt = { + kind: "doWhile", + body: [ifThen([skip])], + cond: { kind: "toBool", operand: call("throws"), type: BOOL, loc }, + loc, + }; + expect(answer(doWhile)).toBe(true); + const forOf: IrStmt = { + kind: "forOf", + localId: "x", + iterable: { kind: "varRef", localId: "xs", type: { kind: "array", elem: F64 }, loc }, + body: [stmt({ kind: "call", callee: "throws", args: [], type: VOID, loc })], + loc, + } as IrStmt; + expect(answer(forOf)).toBe(true); +}); diff --git a/packages/compiler/src/backend/loop-polls.ts b/packages/compiler/src/backend/loop-polls.ts new file mode 100644 index 000000000..9fc70a2bb --- /dev/null +++ b/packages/compiler/src/backend/loop-polls.ts @@ -0,0 +1,107 @@ +/* Which worker-program loops can skip their termination poll. */ +import type { IrExpr, IrStmt } from "../ir/ir.js"; +import { everyExprChild, everyStmtChild } from "../ir/traverse.js"; + +type Loop = Extract; + +/** Expression kinds whose operands all evaluate whenever the expression does + * (no short-circuit, deferred or conditional operand). */ +const EAGER_EXPRS: ReadonlySet = new Set([ + "call", + "new", + "virtualCall", + "callValue", + "bin", + "unary", + "toBool", + "strConcat", + "strEq", + "strCmp", + "assignExpr", + "fieldGet", + "fieldIncDec", + "arrayGet", + "recordGet", + "upcast", + "downcast", + "unionWrap", + "dynFrom", +]); + +/** Statement kinds whose expressions all evaluate whenever the statement + * does. */ +const EAGER_STMTS: ReadonlySet = new Set([ + "varDecl", + "assign", + "exprStmt", + "arraySet", + "bytesSet", + "fieldSet", + "recordSet", + "return", + "throw", +]); + +/** True when every iteration of `loop` evaluates a call that the emitter + * follows with a pending test (`tested`). Worker loops poll for termination + * every 64 iterations (emitWorkerLoopCheck) so that a loop whose body calls + * nothing that tests still observes a terminate request; a pending test reads + * the same alert word, so a loop that always runs one needs no extra poll. + * The tested call must be unconditional: in the condition (while, for), in + * the update or the do-while condition (both run after `continue` too), or + * in the body before any statement that can `continue` this loop. */ +export function loopTestsEveryIteration(loop: Loop, tested: (call: IrExpr) => boolean): boolean { + const evaluates = (e: IrExpr): boolean => { + if (tested(e)) return true; + if (!EAGER_EXPRS.has(e.kind)) return false; + let found = false; + everyExprChild( + e, + (child) => !(found = evaluates(child)), + () => true, + ); + return found; + }; + const statementEvaluates = (s: IrStmt): boolean => { + if (s.kind === "if") return evaluates(s.cond); + if (s.kind === "block") return s.body.some(statementEvaluates); + if (!EAGER_STMTS.has(s.kind)) return false; + let found = false; + everyStmtChild( + s, + (child) => !(found = evaluates(child)), + () => true, + ); + return found; + }; + const labels = new Set(loop.labels ?? []); + // Can `s` jump to this loop's next iteration? Unlabeled continues bind to + // the innermost enclosing loop; labeled ones may skip out of nested loops. + const continues = (s: IrStmt, nested: boolean): boolean => { + if (s.kind === "continue") return s.label === undefined ? !nested : labels.has(s.label); + const inner = + nested || + s.kind === "while" || + s.kind === "doWhile" || + s.kind === "for" || + s.kind === "forOf"; + let found = false; + everyStmtChild( + s, + () => true, + (child) => !(found = continues(child, inner)), + ); + return found; + }; + if (loop.kind === "while" && evaluates(loop.cond)) return true; + if (loop.kind === "doWhile" && evaluates(loop.cond)) return true; + if (loop.kind === "for") { + if (loop.cond !== null && evaluates(loop.cond)) return true; + if (loop.update !== null && statementEvaluates(loop.update)) return true; + } + for (const s of loop.body) { + if (continues(s, false)) return false; + if (statementEvaluates(s)) return true; + } + return false; +} diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 7bf630618..f3521e1bd 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15742,6 +15742,12 @@ "/tests/corpus/4596-tdz-nullable-forward-capture.ts" ], "diags": [] + }, + "/tests/corpus/native-worker-terminate-tested-loops.ts": { + "order": [ + "/tests/corpus/native-worker-terminate-tested-loops.ts" + ], + "diags": [] } } } diff --git a/tests/corpus/native-worker-terminate-tested-loops.ts b/tests/corpus/native-worker-terminate-tested-loops.ts new file mode 100644 index 000000000..969b0dd9e --- /dev/null +++ b/tests/corpus/native-worker-terminate-tested-loops.ts @@ -0,0 +1,138 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// Loops whose every iteration calls a function that can throw observe a +// terminate request through that call's exception test; loops that can skip +// such a call keep their own poll. Each worker spins until it is terminated. + +class Box { + value = 0; + bump(n: number): number { + if (n < 0) throw new RangeError("negative " + n); + this.value = (this.value + n) % 1000003; + return this.value; + } +} + +function risky(n: number): number { + if (n < 0) throw new RangeError("negative " + n); + return (n * 31 + 7) % 1000003; +} + +let announced = false; +function announce(i: number): void { + if (!announced && i >= 1000) { + announced = true; + parentPort!.postMessage("ready"); + } +} + +function spinCalls(): number { + let acc = 0; + for (let i = 0; ; i++) { + acc = (acc + risky(i)) % 1000003; + announce(i); + } +} + +function spinCondition(): number { + let i = 0; + while (risky(i) >= 0) { + i++; + announce(i); + } + return i; +} + +// Once i passes 2000 every iteration continues before the call. +function spinContinue(): number { + let acc = 0; + for (let i = 0; ; i++) { + if (i > 2000) continue; + acc = (acc + risky(i)) % 1000003; + announce(i); + } +} + +function spinMethods(): number { + const box = new Box(); + let n = 0; + do { + try { + box.bump(n % 5 === 0 ? -1 : n); + } catch (e) { + if (!(e instanceof RangeError)) throw e; + } + n++; + announce(n); + } while (box.bump(1) >= 0); + return n; +} + +function spinNested(): number { + let acc = 0; + for (let i = 0; ; i++) { + for (let j = 0; j < 4; j++) acc = (acc + risky(j)) % 1000003; + announce(i); + } +} + +function run(kind: string): number { + if (kind === "calls") return spinCalls(); + if (kind === "condition") return spinCondition(); + if (kind === "continue") return spinContinue(); + if (kind === "methods") return spinMethods(); + return spinNested(); +} + +// The same loop shapes, bounded, with exceptions escaping mid-iteration. +function countdown(start: number): number { + let acc = 0; + for (let i = start; ; i--) acc = (acc + risky(i)) % 1000003; +} + +function countdownWhile(start: number): number { + let i = start; + while (risky(i) >= 0) i--; + return i; +} + +function methodsUntil(limit: number): number { + const box = new Box(); + let n = 0; + do { + n++; + } while (box.bump(limit - n) >= 0); + return n; +} + +if (isMainThread) { + for (const probe of [() => countdown(5), () => countdownWhile(7), () => methodsUntil(4)]) { + try { + console.log("unexpected", probe()); + } catch (e) { + console.log("caught", (e as Error).name, (e as Error).message); + } finally { + console.log("finally"); + } + } + for (const kind of ["calls", "condition", "continue", "methods", "nested"]) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + worker.once("message", () => { + void worker.terminate().then((code: number) => console.log(kind, "terminate", code)); + }); + worker.once("exit", (code: number) => { + console.log(kind, "exit", code); + resolve(); + }); + }); + } +} else { + try { + console.log("unexpected result", run(String(workerData))); + } catch { + console.log("unexpected catch"); + } finally { + console.log("unexpected finally"); + } +} From bdba4a7a1c458c68b738b14aac77bfef28a488dd Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:50:51 -0700 Subject: [PATCH 04/22] Test an exact per-thread pending word in ELF worker executables Worker programs on Linux tested a process-wide alert word after every call that may throw and called scr_exc_pending on its slow path to answer for the calling thread. Each of the ~55k slow-path calls in tsc-ts counted against LLVM's inline cost, and while any thread had an exception in flight every other thread took the slow path. ELF worker executables (the emitter's executableTls targets) now give every script thread an exact pending word in the executable's static TLS block, and the emitted check is one thread-pointer-relative load and a branch to the unwind, with no out-of-line call: - bit 0 mirrors "active cell kind != NONE". Only the owner thread writes it, at every change of its active cell (throws, rethrows, take/clear, fiber switches, payload moves, the termination sentinel). The uncaught printer now settles it after resetting the cell. - bit 1 requests the context's stop. worker.terminate() sets it through a pointer the worker registers under a mutex while its script runs (a terminate before registration is found through the cancel flag), and scr_context_stop sets it for process.exit() and fatal paths. The stop bit counts as the stop signal in scr_context_quiet/checkpoint. A set stop bit unwinds while the active cell may still be clear, so every consumer of an unwind installs the TERMINATE sentinel before inspecting the cell: emitted try regions test the bit before entering a handler and call scr_context_checkpoint, and fiber completion, the uncaught handlers and exit listeners checkpoint instead of only testing scr_context_stopping. The runtime's out-of-line scr_exc_pending already checkpoints. Darwin and Windows keep the alert word (a Darwin thread-local access is a call). New corpus programs pin terminate() inside catch and finally bodies, with exceptions stashed or propagating, inside generator and async bodies and runtime callbacks, and before the worker starts, plus every way an exception is raised, moved, consumed or discarded on the main thread and in a worker, each followed by a may-throw call that must not unwind. --- .../compiler/src/backend/llvm/emitter.test.ts | 17 +- packages/compiler/src/backend/llvm/emitter.ts | 59 +++- .../test/ts7/baselines/order-parity.json | 12 + packages/compiler/test/worker-threads.test.ts | 70 ++-- packages/runtime/src/scr_async.c | 5 +- packages/runtime/src/scr_context.c | 15 +- packages/runtime/src/scr_events.c | 4 +- packages/runtime/src/scr_exception.c | 60 +++- packages/runtime/src/scr_runtime.h | 21 +- packages/runtime/src/scr_worker.c | 43 +++ tests/corpus/native-worker-exception-state.ts | 308 ++++++++++++++++++ ...native-worker-terminate-exception-state.ts | 211 ++++++++++++ 12 files changed, 771 insertions(+), 54 deletions(-) create mode 100644 tests/corpus/native-worker-exception-state.ts create mode 100644 tests/corpus/native-worker-terminate-exception-state.ts diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index d78c74f71..238247a1a 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -545,10 +545,21 @@ test("synchronous bodies load the active exception cell once for all pending che /^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/, ); expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3); - // Worker executables test the process-wide alert word at each check - // instead, and never read their thread-local cell inline. - const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work"); + // Darwin and Windows worker executables test the process-wide alert word + // at each check instead, and never read their thread-local cell inline. + const darwin = { targetTriple: "arm64-apple-macosx14.0.0" }; + const workers = body(emitLlvmModule({ ...module, workers: true }, darwin), "sc_f_work"); expect(workers).not.toContain("%exc.cell"); expect(workers).not.toContain("@scr_exc_active"); expect(workers.match(/= load atomic i32, ptr @scr_exc_alert monotonic/g)).toHaveLength(3); + expect(workers.match(/call zeroext i1 @scr_exc_pending\(\)/g)).toHaveLength(3); + // ELF worker executables test the thread's exact pending word and unwind + // directly: no check carries an out-of-line slow path. + const elf = { targetTriple: "x86_64-unknown-linux-gnu" }; + const flagLlvm = emitLlvmModule({ ...module, workers: true }, elf); + expect(flagLlvm).toContain("@scr_exc_flag = external thread_local(localexec) global i32"); + const flag = body(flagLlvm, "sc_f_work"); + expect(flag).not.toContain("@scr_exc_alert"); + expect(flag).not.toContain("@scr_exc_pending"); + expect(flag.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(3); }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index abbc650a4..70ccfe414 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3774,13 +3774,27 @@ export class LlEmitter { * return and stays eligible for tail-call and recursion elimination. */ private emitUnwind(passThrough?: string): void { if (this.mod.workers && this.tryStack.length > 0) { - this.declare(`declare zeroext i1 @scr_context_stopping()`); - const stopped = this.B.tmp(); - this.B.line(`${stopped} = call zeroext i1 @scr_context_stopping()`); const stop = this.B.newLabel("context.stop"); const ordinary = this.B.newLabel("context.throw"); - this.B.condBr(stopped, stop, ordinary); - this.B.startBlock(stop); + if (this.executableTls) { + // A set stop bit unwinds while the active cell may still be clear: + // install the termination sentinel before skipping the handlers. + const word = this.loadExcFlag(); + const bit = this.B.tmp(); + const stopped = this.B.tmp(); + this.B.line(`${bit} = and i32 ${word}, 2`); + this.B.line(`${stopped} = icmp ne i32 ${bit}, 0`); + this.B.condBr(stopped, stop, ordinary); + this.B.startBlock(stop); + this.declare(`declare zeroext i1 @scr_context_checkpoint()`); + this.B.line(`call zeroext i1 @scr_context_checkpoint()`); + } else { + this.declare(`declare zeroext i1 @scr_context_stopping()`); + const stopped = this.B.tmp(); + this.B.line(`${stopped} = call zeroext i1 @scr_context_stopping()`); + this.B.condBr(stopped, stop, ordinary); + this.B.startBlock(stop); + } const handlers = this.tryStack; this.tryStack = []; this.emitUnwind(); @@ -3834,6 +3848,18 @@ export class LlEmitter { this.B.br(cleanup.label); } + /** Loads the running thread's exact pending word (ELF worker + * executables, scr_exception.c): bit 0 mirrors the active cell, bit 1 + * requests the context's stop. It lives in the executable's static TLS + * block, so the access is one thread-pointer-relative load. Monotonic: a + * loop must not hoist the load out and miss a cross-thread stop. */ + private loadExcFlag(): string { + this.declare(`@scr_exc_flag = external thread_local(localexec) global i32, align 4`); + const word = this.B.tmp(); + this.B.line(`${word} = load atomic i32, ptr @scr_exc_flag monotonic, align 4`); + return word; + } + /** Instructions (two-space indented) that set the i1 `dest` to "an * exception is pending in the active context" — scr_exc_pending()'s * meaning — for the helper and adapter bodies that test it outside @@ -3884,15 +3910,22 @@ export class LlEmitter { return; } // Executables test the active exception cell's kind inline (its first - // field). Worker programs test the process-wide alert word instead: it - // is nonzero while any script thread may have an exception pending in - // its active cell or has been asked to stop, so the common case is one - // ordinary global load (a thread-local access is a call on Darwin) and - // the out-of-line scr_exc_pending answers for this thread, observing - // cancellation and reinstalling the termination sentinel. + // field). ELF worker executables test the thread's exact pending word, + // which is also nonzero once the context must stop, and unwind directly: + // no site carries an out-of-line call, so LLVM's inliner sees the check + // as two instructions. Other worker programs test the process-wide + // alert word: it is nonzero while any script thread may have an + // exception pending in its active cell or has been asked to stop, so + // the common case is one ordinary global load (a thread-local access is + // a call on Darwin) and the out-of-line scr_exc_pending answers for this + // thread, observing cancellation and reinstalling the termination + // sentinel. this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); let word: string; - if (this.mod.workers === true) { + const exact = this.mod.workers !== true || this.executableTls; + if (this.executableTls) { + word = this.loadExcFlag(); + } else if (this.mod.workers === true) { // dso_local: executables link the runtime statically, so the word is // addressed directly instead of through the GOT. this.declare(`@scr_exc_alert = external dso_local global i32, align 64`); @@ -3923,7 +3956,7 @@ export class LlEmitter { B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${hit}, i1 false)`); const lu = B.newLabel("exc.u"); const lk = B.newLabel("exc.k"); - if (this.mod.workers === true) { + if (!exact) { this.declare(`declare zeroext i1 @scr_exc_pending()`); const slow = B.newLabel("exc.s"); B.condBr(cold, slow, lk); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index f3521e1bd..b6b2cb5a9 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15748,6 +15748,18 @@ "/tests/corpus/native-worker-terminate-tested-loops.ts" ], "diags": [] + }, + "/tests/corpus/native-worker-exception-state.ts": { + "order": [ + "/tests/corpus/native-worker-exception-state.ts" + ], + "diags": [] + }, + "/tests/corpus/native-worker-terminate-exception-state.ts": { + "order": [ + "/tests/corpus/native-worker-terminate-exception-state.ts" + ], + "diags": [] } } } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index c6339c793..a6bfee0f2 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -228,35 +228,55 @@ test("worker entries without a source file stay refused", async () => { ); }); -test("worker programs poll inline and skip termination polls in bounded functions", async () => { - const request = await fixture( - worker + - ` +test.each([ + ["arm64-apple-macosx14.0.0", "alert"], + ["x86_64-unknown-linux-gnu", "flag"], + ["aarch64-unknown-linux-gnu", "flag"], +])( + "worker programs for %s poll inline (%s) and skip termination polls in bounded functions", + async (target, lane) => { + vi.stubEnv("SCRIPTC_TARGET", target); + const request = await fixture( + worker + + ` function mix(a: number, b: number): number { return (a * 31 + b) % 1000003; } function spin(n: number): number { return n < 2 ? mix(n, 1) : spin(n - 1) + spin(n - 2); } console.log(mix(1, 2), spin(5)); `, - ); - const result = await compile(request.entry, request); - expect(result.ok, JSON.stringify(result)).toBe(true); - if (result.ok) { - const llvm = await readFile(request.outPath, "utf8"); - const body = (name: string): string => { - const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m")); - expect(start).toBeGreaterThanOrEqual(0); - return llvm.slice(start, llvm.indexOf("\n}\n", start)); - }; - // A bounded helper neither polls on entry nor makes its callers poll. - expect(body("sc_f_mix")).not.toContain("@scr_exc_alert"); - // Recursion tests after its recursive calls (its stack check replaces - // the entry poll): the inline test reads the process-wide alert word (no - // thread-local access), and only the slow path calls into the runtime. - expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); - expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); - expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); - expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); - } -}); + ); + const result = await compile(request.entry, request); + expect(result.ok, JSON.stringify(result)).toBe(true); + if (result.ok) { + const llvm = await readFile(request.outPath, "utf8"); + const body = (name: string): string => { + const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m")); + expect(start).toBeGreaterThanOrEqual(0); + return llvm.slice(start, llvm.indexOf("\n}\n", start)); + }; + // A bounded helper neither polls on entry nor makes its callers poll. + expect(body("sc_f_mix")).not.toMatch(/@scr_exc_(alert|flag)/); + // Recursion tests after its recursive calls (its stack check replaces + // the entry poll) and never reads its thread-local cell inline. + expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); + if (lane === "alert") { + // Darwin: the inline test reads the process-wide alert word (a + // thread-local access is a call there), and only the slow path calls + // into the runtime. + expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); + expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); + expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); + } else { + // ELF: the thread's exact pending word, one TLS load, no slow path. + expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_flag monotonic/); + expect(body("sc_f_spin")).not.toContain("@scr_exc_pending"); + expect(llvm).not.toContain("@scr_exc_alert"); + expect(llvm).toMatch( + /^@scr_exc_flag = external thread_local\(localexec\) global i32, align 4$/m, + ); + } + } + }, +); test.each([ ["x86_64-unknown-linux-gnu", true], diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index 88fb42ad6..c693a7488 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1537,8 +1537,9 @@ static void scr_fiber_finish(ScrFiber *self) { } ScrPromise *p = self->promise; #ifdef SCR_WORKERS - if (scr_context_stopping()) { - scr_context_checkpoint(); + /* Checkpoint, not just test: the body may have unwound for a stop + * request that its cell does not show yet. */ + if (scr_context_checkpoint()) { scr_promise_reject_from_cell(p, &self->exc); p->rejection_observed = true; self->done = true; diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index 0485cf1c2..4b4e54784 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -122,9 +122,19 @@ void scr_context_stop_flag(const void *flag) { if (!scr_context_stopped) scr_context_signal = flag ? flag : &scr_context_unsignaled; } bool scr_context_stopping(void) { return scr_context_stopped; } +/* Asked to stop: the owner's signal, or (ELF worker executables) the stop + * bit of the thread's exact pending word, which worker.terminate() sets + * directly so that emitted checks observe it. */ +static bool scr_context_signaled(void) { +#ifdef SCR_EXC_FLAG + if (atomic_load_explicit(&scr_exc_flag, memory_order_acquire) & SCR_EXC_FLAG_STOP) return true; +#endif + return atomic_load_explicit(scr_context_signal, memory_order_relaxed); +} + /* Neither stopping nor asked to stop. */ bool scr_context_quiet(void) { - return !scr_context_stopped && !atomic_load_explicit(scr_context_signal, memory_order_relaxed); + return !scr_context_stopped && !scr_context_signaled(); } void scr_context_stop(int code) { @@ -137,8 +147,7 @@ void scr_context_stop(int code) { } bool scr_context_checkpoint(void) { - if (!scr_context_stopped && atomic_load_explicit(scr_context_signal, memory_order_relaxed)) - scr_context_stop(1); + if (!scr_context_stopped && scr_context_signaled()) scr_context_stop(1); if (!scr_context_stopped) return false; /* Each fiber has its own exception cell. Reinstall the sentinel after a * context switch or a runtime continuation consumes its pending payload. */ diff --git a/packages/runtime/src/scr_events.c b/packages/runtime/src/scr_events.c index fcd426f18..40019bb06 100644 --- a/packages/runtime/src/scr_events.c +++ b/packages/runtime/src/scr_events.c @@ -1114,7 +1114,9 @@ void scr_run_exit_listeners(double code) { #endif scr_exit_ls[i].fn(scr_exit_ls[i].cb, code); #ifdef SCR_WORKERS - if (scr_context_stopping()) break; + /* The listener may have unwound for a stop request (checkpoint installs + * the sentinel its cell may not show yet). */ + if (scr_context_checkpoint()) break; #endif if (scr_exc_pending()) { /* Node: a throw in an 'exit' listener is fatal (exit code 7). */ diff --git a/packages/runtime/src/scr_exception.c b/packages/runtime/src/scr_exception.c index ff0523e9c..e95af8270 100644 --- a/packages/runtime/src/scr_exception.c +++ b/packages/runtime/src/scr_exception.c @@ -62,7 +62,53 @@ ScrExcCell *scr_exc_active = &scr_main_exc; * localized library archive would otherwise leave shared. */ SCR_TL uintptr_t scr_stack_guard = 0; -#if defined(SCR_WORKERS) && !defined(SCR_LIB) +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(SCR_EXC_FLAG) +/* The thread's exact pending word, tested inline by every emitted pending + * check in ELF worker executables (one %fs/tpidr-relative load: the word is + * in the executable's static TLS block). Nonzero means the running code + * must unwind, so the emitted check needs no out-of-line answer: + * + * - SCR_EXC_FLAG_PENDING mirrors `active cell kind != NONE` exactly. Only + * the owner thread writes it, at every change of its active cell (throws, + * rethrows, take/clear, fiber switches, payload moves, the termination + * sentinel), because a stale bit would unwind a normal path. + * - SCR_EXC_FLAG_STOP means the context must stop. worker.terminate() sets + * it from the owner's thread (scr_exc_flag_request_stop, through the + * pointer the worker registers while its script runs), scr_context_stop + * sets it for process.exit() and fatal paths, and nothing clears it. + * + * A set STOP bit unwinds while the active cell may still be NONE: the + * TERMINATE sentinel is installed by whoever consumes the unwind before it + * inspects the cell. scr_exc_pending and scr_context_checkpoint do that, + * emitted try regions test the bit before entering a handler, and the + * runtime's unwind consumers (fiber completion, the uncaught reporters, + * exit listeners) checkpoint first. */ +SCR_TL _Atomic uint32_t scr_exc_flag; + +static inline void scr_exc_flag_pending(bool pending) { + uint32_t word = atomic_load_explicit(&scr_exc_flag, memory_order_relaxed); + if (((word & SCR_EXC_FLAG_PENDING) != 0) == pending) return; + if (pending) atomic_fetch_or_explicit(&scr_exc_flag, SCR_EXC_FLAG_PENDING, memory_order_relaxed); + else atomic_fetch_and_explicit(&scr_exc_flag, ~SCR_EXC_FLAG_PENDING, memory_order_relaxed); +} + +void scr_exc_flag_request_stop(_Atomic uint32_t *flag) { + /* Release: a thread that observes the bit also observes the requester's + * earlier writes (the worker's cancel flag). */ + atomic_fetch_or_explicit(flag, SCR_EXC_FLAG_STOP, memory_order_release); +} + +/* The active cell, or the context's stop state, may have changed. */ +void scr_exc_cell_changed(void) { + if (!scr_context_quiet() && + !(atomic_load_explicit(&scr_exc_flag, memory_order_relaxed) & SCR_EXC_FLAG_STOP)) + atomic_fetch_or_explicit(&scr_exc_flag, SCR_EXC_FLAG_STOP, memory_order_relaxed); + scr_exc_flag_pending(SCR_EXC_CUR()->kind != SCR_EXC_NONE); +} + +void scr_exc_alert_thread_exit(void) {} +#define SCR_EXC_RAISED() scr_exc_flag_pending(true) +#elif defined(SCR_WORKERS) && !defined(SCR_LIB) /* The process-wide exception alert, tested inline by every emitted pending * check in worker executables: one ordinary load, where reading the active * cell would be a thread-local access (a call on Darwin). It counts the @@ -186,7 +232,10 @@ bool(scr_exc_pending)(void) { if (scr_context_checkpoint()) return true; #endif bool pending = scr_exc_kind != SCR_EXC_NONE; -#if defined(SCR_WORKERS) && !defined(SCR_LIB) +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(SCR_EXC_FLAG) + /* The pending bit is exact; settling it here is only a safety net. */ + scr_exc_flag_pending(pending); +#elif defined(SCR_WORKERS) && !defined(SCR_LIB) /* The slow path of the alert test: settle this thread's share. */ if (pending != scr_exc_alerted) scr_exc_cell_changed(); #endif @@ -518,7 +567,9 @@ static SCR_TL bool scr_uncaught_handler_failed = false; bool scr_exc_handle_uncaught(bool from_promise) { #ifdef SCR_WORKERS - if (scr_context_stopping()) return false; + /* Checkpoint, not just test: the unwind that brought control here may + * have been a stop request the cell does not show yet. */ + if (scr_context_checkpoint()) return false; #endif if (!scr_exc_pending()) return true; if (!scr_uncaught_exception_hook || scr_uncaught_handler_failed) return false; @@ -554,7 +605,7 @@ static bool scr_uncaught_print_dyn_primitive(const ScrDyn *d) { void scr_exc_print_uncaught(void) { #ifdef SCR_WORKERS - if (scr_context_stopping()) { scr_exc_clear(); return; } + if (scr_context_checkpoint()) { scr_exc_clear(); return; } if (scr_context_report_error) { scr_exit_code_note(scr_uncaught_handler_failed ? 7 : 1); scr_context_report_error(); @@ -610,4 +661,5 @@ void scr_exc_print_uncaught(void) { } fputc('\n', stderr); scr_exc_reset(); /* the payload counts as live until released */ + scr_exc_cell_changed(); } diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..06362c794 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -290,11 +290,26 @@ void scr_context_stop(int code); bool scr_context_stopping(void); bool scr_context_checkpoint(void); bool scr_context_quiet(void); -/* The process-wide exception alert that emitted pending checks of worker - * executables test inline (scr_exception.c): nonzero while some script - * thread may have an exception pending in its active cell or must stop. */ +#if !defined(__APPLE__) && !defined(_WIN32) && !defined(__CYGWIN__) && !defined(__wasi__) +/* ELF worker executables (the LLVM emitter's executableTls targets): every + * script thread owns an exact pending word in the executable's static TLS + * block, which emitted pending checks load with one thread-pointer-relative + * access and no out-of-line slow path (scr_exception.c). */ +#define SCR_EXC_FLAG 1 +#define SCR_EXC_FLAG_PENDING 1u /* the active cell's kind is not NONE */ +#define SCR_EXC_FLAG_STOP 2u /* the context must stop (never cleared) */ +extern SCR_TL _Atomic uint32_t scr_exc_flag; +/* Requests the stop of the thread that owns `flag` (scr_worker.c holds the + * owner's word only while its script runs). */ +void scr_exc_flag_request_stop(_Atomic uint32_t *flag); +#else +/* The process-wide exception alert that emitted pending checks of other + * worker executables test inline (scr_exception.c): nonzero while some + * script thread may have an exception pending in its active cell or must + * stop. */ extern _Atomic uint32_t scr_exc_alert; void scr_exc_alert_add(int delta); /* worker.terminate() references */ +#endif void scr_exc_alert_thread_exit(void); void scr_loop_context_shutdown(void); extern SCR_TL void (*scr_context_report_error)(void); diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 18312cef5..34410d684 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -14,9 +14,17 @@ struct ScrWorkerThread { atomic_size_t references; atomic_bool cancel; +#ifdef SCR_EXC_FLAG + /* The worker thread's exact pending word (scr_exception.c) while its + * script runs, NULL before and after: terminate() sets the stop bit + * through it. The lock keeps the thread's TLS alive across that write. */ + pthread_mutex_t flag_lock; + _Atomic uint32_t *flag; +#else /* The exception alert reference of a terminate request: 0 none, 1 held, * 2 the thread finished its script (no reference is taken any more). */ atomic_int alert; +#endif atomic_bool port_closed; uint64_t id; uint32_t root; @@ -65,6 +73,9 @@ void scr_worker_thread_release(ScrWorkerThread *worker) { scr_context_env_free(worker->environment); for (int i = 0; i < worker->argc; i++) free(worker->argv[i]); free(worker->argv); +#ifdef SCR_EXC_FLAG + pthread_mutex_destroy(&worker->flag_lock); +#endif free(worker); } @@ -99,6 +110,15 @@ uint64_t scr_worker_thread_id(const ScrWorkerThread *worker) { return worker->id void scr_worker_thread_terminate(ScrWorkerThread *worker) { atomic_store_explicit(&worker->cancel, true, memory_order_relaxed); +#ifdef SCR_EXC_FLAG + /* Emitted checks test only the worker's own pending word: raise its stop + * bit. A worker that has not registered its word yet finds the cancel + * flag when it does (scr_worker_thread_run); one that has finished its + * script needs nothing. */ + pthread_mutex_lock(&worker->flag_lock); + if (worker->flag) scr_exc_flag_request_stop(worker->flag); + pthread_mutex_unlock(&worker->flag_lock); +#else /* Emitted polls only call into the runtime while the exception alert is * raised: hold a reference until the worker's script has ended. */ scr_exc_alert_add(1); @@ -106,6 +126,7 @@ void scr_worker_thread_terminate(ScrWorkerThread *worker) { if (!atomic_compare_exchange_strong_explicit(&worker->alert, &idle, 1, memory_order_acq_rel, memory_order_acquire)) scr_exc_alert_add(-1); +#endif scr_mailbox_post(worker->inbox, scr_worker_event(SCR_WORKER_WAKE, 0, 0, NULL)); } @@ -150,6 +171,14 @@ static void scr_worker_report_error(void) { static void scr_worker_thread_run(ScrWorkerThread *worker) { scr_context_enter(worker->id); +#ifdef SCR_EXC_FLAG + pthread_mutex_lock(&worker->flag_lock); + worker->flag = &scr_exc_flag; + pthread_mutex_unlock(&worker->flag_lock); + /* A terminate() that ran before the registration only set the flag. */ + if (atomic_load_explicit(&worker->cancel, memory_order_relaxed)) + scr_exc_flag_request_stop(&scr_exc_flag); +#endif scr_context_stop_flag(&worker->cancel); scr_context_env_enter(worker->environment); worker->environment = NULL; @@ -161,7 +190,13 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) { scr_context_cleanup(); scr_worker_current = NULL; scr_exc_alert_thread_exit(); +#ifdef SCR_EXC_FLAG + pthread_mutex_lock(&worker->flag_lock); + worker->flag = NULL; + pthread_mutex_unlock(&worker->flag_lock); +#else if (atomic_exchange_explicit(&worker->alert, 2, memory_order_acq_rel) == 1) scr_exc_alert_add(-1); +#endif /* Cleanup may itself finish owner-bound native operations. Publish exit * only once none of those operations can touch the worker's script heap. */ scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_EXIT, worker->id, code, NULL)); @@ -189,7 +224,12 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root, if (!worker) scr_trap("scriptc: out of memory\n"); atomic_init(&worker->references, 2); /* owner and native thread */ atomic_init(&worker->cancel, false); +#ifdef SCR_EXC_FLAG + if (pthread_mutex_init(&worker->flag_lock, NULL) != 0) scr_trap("scriptc: out of memory\n"); + worker->flag = NULL; +#else atomic_init(&worker->alert, 0); +#endif atomic_init(&worker->port_closed, false); worker->id = atomic_fetch_add_explicit(&scr_worker_next_id, 1, memory_order_relaxed); worker->entry = entry; @@ -235,6 +275,9 @@ failed: scr_mailbox_release(worker->inbox); scr_mailbox_release(worker->parent); scr_message_free(worker->data); +#ifdef SCR_EXC_FLAG + pthread_mutex_destroy(&worker->flag_lock); +#endif free(worker); if (!scr_exc_pending()) { static const char message[] = "Could not start worker thread"; diff --git a/tests/corpus/native-worker-exception-state.ts b/tests/corpus/native-worker-exception-state.ts new file mode 100644 index 000000000..5f8303f89 --- /dev/null +++ b/tests/corpus/native-worker-exception-state.ts @@ -0,0 +1,308 @@ +import { Worker, isMainThread, parentPort } from "node:worker_threads"; + +// Every way an exception can be raised, moved, consumed or discarded, run on +// the main thread and in a worker of the same program. After each scenario a +// call that may throw but does not proves that nothing is left pending: a +// stale pending state would unwind it. + +class Fault extends Error { + readonly code: number; + constructor(code: number) { + super(`fault ${code}`); + this.name = "Fault"; + this.code = code; + } +} + +function fail(code: number): never { + throw new Fault(code); +} + +function label(code: number): string { + if (code < 0) fail(-code); + return `item-${code}`; +} + +function message(error: unknown): string { + return error instanceof Error ? `${error.name}: ${error.message}` : String(error); +} + +function finallyReturns(): string { + try { + fail(1); + } finally { + return "finally return"; + } +} + +function finallyBreaks(log: string[]): number { + let rounds = 0; + for (let i = 0; i < 3; i++) { + rounds++; + try { + fail(2); + } finally { + log.push(`break round ${i}`); + break; + } + } + return rounds; +} + +function finallyContinues(log: string[]): number { + let rounds = 0; + for (let i = 0; i < 3; i++) { + try { + if (i !== 1) fail(3); + rounds += 10; + } finally { + rounds++; + continue; + } + } + log.push(`continue rounds ${rounds}`); + return rounds; +} + +function finallyReplaces(): string { + try { + fail(4); + } finally { + label(-5); + } +} + +function deep(n: number, log: string[]): string { + if (n === 0) fail(6); + try { + return deep(n - 1, log); + } finally { + if (n % 2 === 0) log.push(`unwound ${n}`); + } +} + +function catchRethrows(): string { + try { + return label(-7); + } catch (error) { + try { + fail((error as Fault).code + 1); + } catch (inner) { + throw new Fault((inner as Fault).code * 10); + } + } +} + +function labelled(log: string[]): number { + let caught = 0; + outer: for (let i = 0; i < 3; i++) { + for (let j = 0; j < 3; j++) { + try { + if (j === 1) fail(i * 3 + j); + } catch { + caught++; + continue outer; + } + } + } + log.push(`labelled caught ${caught}`); + return caught; +} + +function* resumable(log: string[]): Generator { + let round = 0; + while (round < 3) { + try { + const sent = yield `round ${round}`; + log.push(`sent ${sent}`); + } catch (error) { + log.push(`generator caught ${message(error)}`); + } + round++; + } + return "generator done"; +} + +function* guarded(log: string[]): Generator { + try { + yield 1; + yield 2; + } finally { + log.push("guarded finally"); + } +} + +function* explosive(): Generator { + yield 1; + fail(8); +} + +function* finallyThrows(): Generator { + try { + yield 1; + } finally { + fail(9); + } +} + +async function rejectsLater(code: number): Promise { + await null; + return label(-code); +} + +async function rejectsNow(code: number): Promise { + return label(-code); +} + +function overflow(n: number): number { + return overflow(n + 1) + 1; +} + +async function scenarios(): Promise { + const log: string[] = []; + const attempt = (name: string, run: () => unknown): void => { + try { + log.push(`${name} ${String(run())}`); + } catch (error) { + log.push(`${name} caught ${message(error)}`); + } + log.push(`${name} then ${label(name.length)}`); + }; + + attempt("bound catch", () => { + try { + return label(-10); + } catch (error) { + return `took ${(error as Fault).code}`; + } + }); + attempt("bare catch", () => { + try { + return label(-11); + } catch { + return "dropped"; + } + }); + attempt("finally return", finallyReturns); + attempt("finally break", () => finallyBreaks(log)); + attempt("finally continue", () => finallyContinues(log)); + attempt("finally replace", finallyReplaces); + attempt("deep", () => deep(5, log)); + attempt("catch rethrow", catchRethrows); + attempt("labelled", () => labelled(log)); + + const gen = resumable(log); + log.push(`next ${gen.next("ignored").value}`); + log.push(`next ${gen.next("a").value}`); + log.push(`throw ${gen.throw(new Fault(12)).value}`); + const last = gen.next("b"); + log.push(`last ${last.value} ${last.done}`); + log.push(`after generator ${label(13)}`); + + const guard = guarded(log); + guard.next(); + const returned = guard.return(99); + log.push(`return ${returned.value} ${returned.done}`); + for (const value of guarded(log)) { + log.push(`for-of ${value}`); + break; + } + attempt("generator escape", () => [...explosive()].length); + attempt("generator finally throw", () => { + const g = finallyThrows(); + g.next(); + return g.return(0).value; + }); + + try { + await rejectsLater(14); + } catch (error) { + log.push(`await caught ${message(error)}`); + } + log.push(`after await ${label(15)}`); + await rejectsNow(16).catch((error: unknown) => log.push(`catch handler ${message(error)}`)); + const statuses: string[] = []; + for (const pending of [rejectsLater(17), Promise.resolve("ok"), rejectsNow(18)]) { + try { + statuses.push(`fulfilled ${await pending}`); + } catch (error) { + statuses.push(`rejected ${(error as Fault).code}`); + } + } + log.push(`settled ${statuses.join(",")}`); + try { + await Promise.all([rejectsLater(19), rejectsLater(20)]); + } catch (error) { + log.push(`all caught ${message(error)}`); + } + try { + await new Promise(() => fail(21)); + } catch (error) { + log.push(`executor caught ${message(error)}`); + } + const chained = await Promise.resolve(1) + .then(() => fail(22)) + .then( + () => "unexpected", + (error: unknown) => `then rejected ${message(error)}`, + ); + log.push(chained); + log.push(`after promises ${label(23)}`); + + attempt("map callback", () => [1, 2, 3].map((n) => label(n === 2 ? -24 : n)).join()); + attempt("sort comparator", () => + [3, 1, 2] + .sort((a, b) => { + if (a === 1 || b === 1) fail(25); + return a - b; + }) + .join(), + ); + attempt("reduce", () => [1, 2, 3].reduce((acc, n) => acc + label(n === 3 ? -26 : n), "")); + attempt("forEach", () => { + let seen = 0; + try { + [1, 2, 3].forEach((n) => { + seen++; + if (n === 2) fail(27); + }); + } finally { + log.push(`forEach seen ${seen}`); + } + return seen; + }); + attempt("json", () => { + try { + return JSON.parse("{bad"); + } catch (error) { + return `parse failed ${(error as Error).name}`; + } + }); + attempt("overflow", () => overflow(0)); + attempt("nested callbacks", () => + [1, 2].map((n) => { + try { + return [n].map(() => label(-28))[0]; + } catch (error) { + return `inner ${(error as Fault).code}`; + } + }), + ); + return log; +} + +if (isMainThread) { + for (const line of await scenarios()) console.log("main", line); + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url)); + worker.on("message", (lines: string[]) => { + for (const line of lines) console.log("worker", line); + }); + worker.once("exit", (code: number) => { + console.log("worker exit", code); + resolve(); + }); + }); +} else { + parentPort!.postMessage(await scenarios()); +} diff --git a/tests/corpus/native-worker-terminate-exception-state.ts b/tests/corpus/native-worker-terminate-exception-state.ts new file mode 100644 index 000000000..8f1468697 --- /dev/null +++ b/tests/corpus/native-worker-terminate-exception-state.ts @@ -0,0 +1,211 @@ +import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads"; + +// worker.terminate() arriving while the worker's exception state is busy: +// inside catch and finally bodies, with an exception stashed across a finally +// or propagating through frames, inside generator and async bodies, inside +// callbacks the runtime calls, and before the worker has started. Termination +// is not catchable and skips every catch and finally handler, so the worker +// prints nothing once it has announced itself. + +class Fault extends Error { + readonly code: number; + constructor(code: number) { + super(`fault ${code}`); + this.name = "Fault"; + this.code = code; + } +} + +function fail(code: number): never { + throw new Fault(code); +} + +function risky(n: number): number { + if (n < 0) fail(-n); + return (n * 31 + 7) % 1000003; +} + +let announced = false; +function announce(): void { + if (!announced) { + announced = true; + parentPort!.postMessage("ready"); + } +} + +function spin(): number { + let acc = 0; + for (let i = 0; ; i++) { + acc = (acc + risky(i % 1000)) % 1000003; + if (i === 1000) announce(); + } +} + +function inCatch(): number { + try { + return risky(-1); + } catch (error) { + if ((error as Fault).code !== 1) console.log("unexpected code"); + return spin(); + } +} + +function inFinally(): number { + try { + return risky(-2); + } finally { + spin(); + } +} + +function inNestedFinally(): number { + try { + try { + return spin(); + } finally { + console.log("unexpected inner finally"); + } + } catch { + console.log("unexpected outer catch"); + return 0; + } +} + +function deep(n: number): number { + if (n === 0) return risky(-3); + try { + return deep(n - 1); + } finally { + if (n === 1) risky(n); + } +} + +function propagating(): number { + let caught = 0; + for (let i = 0; ; i++) { + try { + deep(40); + } catch { + caught++; + if (caught === 1000) announce(); + } + } +} + +function* producer(): Generator { + yield 1; + try { + yield spin(); + } finally { + console.log("unexpected generator finally"); + } +} + +function inGenerator(): number { + let total = 0; + try { + for (const value of producer()) total += value; + } catch { + console.log("unexpected generator catch"); + } + return total; +} + +async function asyncBody(): Promise { + await null; + try { + return spin(); + } catch { + console.log("unexpected async catch"); + return 0; + } finally { + console.log("unexpected async finally"); + } +} + +function inCallback(): number { + try { + return [1, 2, 3].map((n) => (n === 2 ? spin() : n)).length; + } catch { + console.log("unexpected callback catch"); + return 0; + } finally { + console.log("unexpected callback finally"); + } +} + +function inComparator(): number { + try { + return [3, 1, 2].sort((a, b) => (a === 1 || b === 1 ? spin() : a - b)).length; + } finally { + console.log("unexpected comparator finally"); + } +} + +function run(kind: string): number { + switch (kind) { + case "catch": + return inCatch(); + case "finally": + return inFinally(); + case "nested": + return inNestedFinally(); + case "propagating": + return propagating(); + case "generator": + return inGenerator(); + case "callback": + return inCallback(); + case "comparator": + return inComparator(); + default: + return spin(); + } +} + +if (isMainThread) { + const kinds = ["catch", "finally", "nested", "propagating", "generator", "async", "callback", "comparator"]; + for (const kind of kinds) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: kind }); + worker.once("message", () => { + void worker.terminate().then((code: number) => console.log(kind, "terminate", code)); + }); + worker.once("exit", (code: number) => { + console.log(kind, "exit", code); + resolve(); + }); + }); + } + // Terminated before the worker can have started its script (Node reports + // exit code 0 here, scriptc 1: only the stop itself is pinned). + for (let i = 0; i < 3; i++) { + await new Promise((resolve) => { + const worker = new Worker(new URL(import.meta.url), { workerData: "early" }); + void worker.terminate(); + worker.once("exit", () => { + console.log("early exit", i); + resolve(); + }); + }); + } + // The main thread's own exceptions are unaffected by the stopped workers. + try { + risky(-4); + } catch (error) { + console.log("main caught", (error as Fault).code, risky(5)); + } +} else if (workerData === "async") { + asyncBody().then( + () => console.log("unexpected async result"), + () => console.log("unexpected async rejection"), + ); +} else { + try { + console.log("unexpected result", run(String(workerData))); + } catch { + console.log("unexpected catch"); + } finally { + console.log("unexpected finally"); + } +} From d49846d18314eed0aa9d4869d700699766760b4b Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:51:11 -0700 Subject: [PATCH 05/22] Let LLVM forward pending tests outside loops in ELF worker executables The exact pending word was read with monotonic atomic loads. LLVM's memory analyses treat an ordered load as a write: it clobbers every other location in MemorySSA, so loads around a pending test could not be forwarded after inlining, and x86 selection never folds it into a compare. Ordered loads are only needed for liveness, where a cross-thread stop must be observed: - termination polls (function entry and loop budgets); - tests inside source loops: the loop-poll rule drops a loop's poll because these tests run every iteration, so LLVM must not hoist them; - tail-position tests whose unwind returns the call's own result: an unordered test there is dead, which lets tail-recursion elimination turn the recursion into a loop that neither grows the stack nor tests again. Every other test is now an unordered load of the same word. Same-thread changes only happen inside runtime calls, which clobber memory, so the unwind decision is unchanged. --- .../compiler/src/backend/llvm/emitter.test.ts | 15 ++++++- packages/compiler/src/backend/llvm/emitter.ts | 39 ++++++++++++++----- packages/compiler/test/worker-threads.test.ts | 4 +- 3 files changed, 47 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index 238247a1a..f206eced2 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -561,5 +561,18 @@ test("synchronous bodies load the active exception cell once for all pending che const flag = body(flagLlvm, "sc_f_work"); expect(flag).not.toContain("@scr_exc_alert"); expect(flag).not.toContain("@scr_exc_pending"); - expect(flag.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(3); + // Outside loops the tests are unordered loads LLVM may forward around. + expect(flag.match(/= load atomic i32, ptr @scr_exc_flag unordered/g)).toHaveLength(3); + expect(flag).not.toContain("monotonic"); + // Inside a source loop (whose termination poll the call's test replaces) + // the test stays ordered, so LLVM cannot hoist it out of the loop. + const spin: IrStmt = { + kind: "while", + cond: { kind: "boolLit", value: true, type: BOOL, loc }, + body: [call()], + loc, + }; + const looped = body(emitLlvmModule({ ...moduleFor([spin]), workers: true }, elf), "sc_f_work"); + expect(looped.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(1); + expect(looped).not.toContain("unordered"); }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 70ccfe414..976ad9452 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -519,6 +519,9 @@ export class LlEmitter { readonly wasi: boolean; /** ELF worker executables give thread-locals the executable TLS models. */ private readonly executableTls: boolean; + /** Source loops enclosing the statement being emitted in the current + * function body. */ + private loopDepth = 0; private readonly emitLibraryIdentity: boolean; private readonly runtimeAbiMarker: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — @@ -3851,12 +3854,15 @@ export class LlEmitter { /** Loads the running thread's exact pending word (ELF worker * executables, scr_exception.c): bit 0 mirrors the active cell, bit 1 * requests the context's stop. It lives in the executable's static TLS - * block, so the access is one thread-pointer-relative load. Monotonic: a - * loop must not hoist the load out and miss a cross-thread stop. */ - private loadExcFlag(): string { + * block, so the access is one thread-pointer-relative load. Monotonic by + * default: a loop must not hoist the load out and miss a cross-thread + * stop (see emitPendingCheck for the unordered tests). */ + private loadExcFlag(unordered = false): string { this.declare(`@scr_exc_flag = external thread_local(localexec) global i32, align 4`); const word = this.B.tmp(); - this.B.line(`${word} = load atomic i32, ptr @scr_exc_flag monotonic, align 4`); + this.B.line( + `${word} = load atomic i32, ptr @scr_exc_flag ${unordered ? "unordered" : "monotonic"}, align 4`, + ); return word; } @@ -3887,8 +3893,9 @@ export class LlEmitter { * call's result temp must join its frame BEFORE this runs so the unwind * releases the dummy (NULL for refcounted kinds) harmlessly. A scalar * call result of the function's return type may be passed as the unwind's - * dummy value (see emitUnwind). */ - emitPendingCheck(passThrough?: { name: string; type: IrType }): void { + * dummy value (see emitUnwind). `poll` marks a worker termination poll + * (function entry or loop budget), whose test must stay ordered. */ + emitPendingCheck(passThrough?: { name: string; type: IrType }, poll = false): void { const through = passThrough !== undefined && this.llType(passThrough.type) === this.llType(this.currentReturnType) && @@ -3924,7 +3931,16 @@ export class LlEmitter { let word: string; const exact = this.mod.workers !== true || this.executableTls; if (this.executableTls) { - word = this.loadExcFlag(); + // Termination stays observable where the polls were dropped for it: + // polls, tests inside source loops (exc's loop-poll rule relies on + // them every iteration) and tail-position tests (a dead ordered load + // keeps recursion from becoming a loop without stack growth) use an + // ordered load, which LLVM never deletes, hoists or merges. Every + // other test is an unordered load: it reads the same word, but LLVM's + // memory analyses no longer treat it as a write, so loads around it + // stay forwardable after inlining. Same-thread changes only happen in + // runtime calls, which clobber memory. + word = this.loadExcFlag(!poll && through === undefined && this.loopDepth === 0); } else if (this.mod.workers === true) { // dso_local: executables link the runtime statically, so the word is // addressed directly instead of through the GOT. @@ -4058,7 +4074,7 @@ export class LlEmitter { // Calls and throwing operations retain their own checks. Amortize the // extra cancellation poll across bounded loop batches so scalar work // does not cross the runtime ABI on every iteration. - this.emitPendingCheck(); + this.emitPendingCheck(undefined, true); B.br(next); B.startBlock(next); } @@ -5052,6 +5068,7 @@ export class LlEmitter { this.chainSlots.clear(); this.finallyStack = []; this.tryStack = []; + this.loopDepth = 0; this.currentReturnType = fn.returnType; this.currentGenerator = fn.generator ?? null; this.currentBorrowedReturn = this.borrowedReturns.has(fn.name); @@ -5220,7 +5237,7 @@ export class LlEmitter { B.returnEpilogue = `call void @scr_stack_leave(ptr %source_frame)`; } if (this.stackChecks?.has(fn.name)) this.emitStackCheck(); - if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(); + if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true); this.emitStmts(fn.body); // Implicit exit of a void function: release the function scope unless // the body already terminated its final block (return, or a throw @@ -5423,10 +5440,14 @@ export class LlEmitter { emitStmt(s: IrStmt): void { const previous = this.B.debugLocation; this.B.debugLocation = this.debug?.location(s.loc, this.debugScope) ?? null; + const loop = + s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf"; + if (loop) this.loopDepth++; try { this.emitStmtBody(s); } finally { this.B.debugLocation = previous; + if (loop) this.loopDepth--; } } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index a6bfee0f2..a410ba66b 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -267,7 +267,9 @@ console.log(mix(1, 2), spin(5)); expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); } else { // ELF: the thread's exact pending word, one TLS load, no slow path. - expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_flag monotonic/); + expect(body("sc_f_spin")).toMatch( + /load atomic i32, ptr @scr_exc_flag (unordered|monotonic)/, + ); expect(body("sc_f_spin")).not.toContain("@scr_exc_pending"); expect(llvm).not.toContain("@scr_exc_alert"); expect(llvm).toMatch( From bc736b211f81d964b37a93483182c5a134c038a7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 22:16:21 -0700 Subject: [PATCH 06/22] Address the worker exception alert directly Executables link the runtime statically, so scr_exc_alert can be dso_local and addressed without the GOT. macOS tsc-ts self-check: 24.07G -> 23.92G instructions; the Linux x86-64 binary is unchanged. --- packages/compiler/src/backend/llvm/emitter.ts | 4 +++- packages/compiler/test/worker-threads.test.ts | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 91c0b6baa..abbc650a4 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -3893,7 +3893,9 @@ export class LlEmitter { this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); let word: string; if (this.mod.workers === true) { - this.declare(`@scr_exc_alert = external global i32, align 64`); + // dso_local: executables link the runtime statically, so the word is + // addressed directly instead of through the GOT. + this.declare(`@scr_exc_alert = external dso_local global i32, align 64`); word = B.tmp(); B.line(`${word} = load atomic i32, ptr @scr_exc_alert monotonic, align 64`); } else { diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 2f70de9e3..c6339c793 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -254,7 +254,7 @@ console.log(mix(1, 2), spin(5)); expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/); expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()"); expect(body("sc_f_spin")).not.toContain("@scr_exc_active"); - expect(llvm).toMatch(/^@scr_exc_alert = external global i32, align 64$/m); + expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m); } }); From a8c9b45a0aff7b46e6d6591e1e41e0aa3085471c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:51:17 -0700 Subject: [PATCH 07/22] Read the macOS worker stack guard from a thread-specific-data slot In worker executables scr_stack_guard is thread-local, and on Darwin every thread-local access calls the variable's TLV thunk. Every stack-checked function paid that call on entry; in tsc-ts the checks were about 12% of the instructions retired on macOS. The runtime now also keeps the running stack's guard in a pthread key's thread-specific-data slot, written wherever scr_stack_guard changes (thread start, fiber switches). libpthread stores a thread's values in an array at its thread pointer, indexed by key; scr_stack_guard_init probes that layout once and exports the slot's byte offset. Emitted checks in Darwin worker executables load the guard from TPIDRRO_EL0 (masked) plus the offset on arm64, or %gs plus the offset on x86-64: no call. If the probe fails the offset stays zero and the checks call scr_stack_guard_get from a cold block. Other targets are unchanged. --- packages/compiler/src/backend/llvm/emitter.ts | 78 ++++++++++++++++--- packages/compiler/test/worker-threads.test.ts | 37 +++++++++ packages/runtime/src/scr_async.c | 4 +- packages/runtime/src/scr_context.c | 46 ++++++++++- packages/runtime/src/scr_runtime.h | 16 ++++ 5 files changed, 167 insertions(+), 14 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 976ad9452..798de8a0a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -522,6 +522,10 @@ export class LlEmitter { /** Source loops enclosing the statement being emitted in the current * function body. */ private loopDepth = 0; + /** Darwin worker executables read the stack guard from a pthread + * thread-specific-data slot off the thread pointer (scr_runtime.h's + * SCR_STACK_GUARD_TSD): the architecture names the access sequence. */ + private readonly stackGuardTsd: "aarch64" | "x86_64" | null; private readonly emitLibraryIdentity: boolean; private readonly runtimeAbiMarker: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — @@ -816,6 +820,15 @@ export class LlEmitter { mod.lib === undefined && !this.wasi && !/apple|darwin|windows|mingw|cygwin|win32/.test(options.targetTriple || process.platform); + const host = options.targetTriple || `${process.arch}-${process.platform}`; + this.stackGuardTsd = + mod.workers === true && mod.lib === undefined && !this.wasi && /apple|darwin/.test(host) + ? /^(arm64|aarch64)/.test(host) + ? "aarch64" + : /^(x86_64|amd64|x64)/.test(host) + ? "x86_64" + : null + : null; this.emitLibraryIdentity = options.emitLibraryIdentity !== false; this.runtimeAbiMarker = options.runtimeAbiMarker === true; this.rcHelpers = options.inlineRc === true ? new Set() : null; @@ -4004,22 +4017,65 @@ export class LlEmitter { * throw Node's RangeError and unwind like any other pending exception. */ private emitStackCheck(): void { const B = this.B; - const tl = this.mod.workers === true ? "thread_local " : ""; - this.declare(`@scr_stack_guard = external ${tl}global ptr`); this.declare(`declare ptr @llvm.stacksave.p0()`); this.declare(`declare i1 @llvm.expect.i1(i1, i1)`); this.declare(`declare void @scr_stack_overflow()`); - const sp = B.tmp(); - const guard = B.tmp(); - const low = B.tmp(); - const cold = B.tmp(); - B.line(`${sp} = call ptr @llvm.stacksave.p0()`); - B.line(`${guard} = load ptr, ptr @scr_stack_guard`); - B.line(`${low} = icmp ult ptr ${sp}, ${guard}`); - B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`); const over = B.newLabel("stack.over"); const ok = B.newLabel("stack.ok"); - B.condBr(cold, over, ok); + const sp = B.tmp(); + B.line(`${sp} = call ptr @llvm.stacksave.p0()`); + const test = (guard: string): void => { + const low = B.tmp(); + const cold = B.tmp(); + B.line(`${low} = icmp ult ptr ${sp}, ${guard}`); + B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`); + B.condBr(cold, over, ok); + }; + if (this.stackGuardTsd !== null) { + // A Darwin thread-local access is a call to its TLV thunk: read the + // guard from its thread-specific-data slot at the runtime-validated + // offset instead, and call out only when the runtime found no slot. + this.declare(`@scr_stack_guard_tsd = external dso_local global i64`); + this.declare(`declare ptr @scr_stack_guard_get()`); + const offset = B.tmp(); + const direct = B.tmp(); + const likely = B.tmp(); + B.line(`${offset} = load i64, ptr @scr_stack_guard_tsd`); + B.line(`${direct} = icmp ne i64 ${offset}, 0`); + B.line(`${likely} = call i1 @llvm.expect.i1(i1 ${direct}, i1 true)`); + const fast = B.newLabel("stack.tsd"); + const slow = B.newLabel("stack.tlv"); + B.condBr(likely, fast, slow); + B.startBlock(fast); + const guard = B.tmp(); + if (this.stackGuardTsd === "aarch64") { + this.declare(`declare i64 @llvm.read_register.i64(metadata)`); + const thread = B.tmp(); + const base = B.tmp(); + const slot = B.tmp(); + const address = B.tmp(); + B.line(`${thread} = call i64 @llvm.read_register.i64(metadata !{!"tpidrro_el0"})`); + B.line(`${base} = and i64 ${thread}, -8`); + B.line(`${slot} = add i64 ${base}, ${offset}`); + B.line(`${address} = inttoptr i64 ${slot} to ptr`); + B.line(`${guard} = load ptr, ptr ${address}`); + } else { + const address = B.tmp(); + B.line(`${address} = inttoptr i64 ${offset} to ptr addrspace(256)`); + B.line(`${guard} = load ptr, ptr addrspace(256) ${address}`); + } + test(guard); + B.startBlock(slow); + const fallback = B.tmp(); + B.line(`${fallback} = call ptr @scr_stack_guard_get()`); + test(fallback); + } else { + const tl = this.mod.workers === true ? "thread_local " : ""; + this.declare(`@scr_stack_guard = external ${tl}global ptr`); + const guard = B.tmp(); + B.line(`${guard} = load ptr, ptr @scr_stack_guard`); + test(guard); + } B.startBlock(over); B.line(`call void @scr_stack_overflow()`); this.emitUnwind(); diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index a410ba66b..f3dab96ed 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -280,6 +280,43 @@ console.log(mix(1, 2), spin(5)); }, ); +test.each([ + ["arm64-apple-macosx14.0.0", "tpidrro"], + ["x86_64-apple-macosx14.0.0", "gs"], + ["x86_64-unknown-linux-gnu", "tls"], +])("worker stack checks for %s read the guard through %s", async (target, access) => { + vi.stubEnv("SCRIPTC_TARGET", target); + const request = await fixture( + worker + + "\nfunction depth(n: number): number { return n < 1 ? 0 : depth(n - 1) + 1; }\nconsole.log(depth(9));\n", + ); + const result = await compile(request.entry, request); + expect(result.ok, JSON.stringify(result)).toBe(true); + if (result.ok) { + const llvm = await readFile(request.outPath, "utf8"); + const start = llvm.search(/^define [^\n]*@sc_f_depth\(/m); + expect(start).toBeGreaterThanOrEqual(0); + const depth = llvm.slice(start, llvm.indexOf("\n}\n", start)); + expect(depth).toContain("call ptr @llvm.stacksave.p0()"); + if (access === "tls") { + // ELF: one thread-pointer-relative load of the thread-local guard. + expect(depth).toContain("load ptr, ptr @scr_stack_guard"); + expect(depth).not.toContain("@scr_stack_guard_tsd"); + } else { + // Darwin: a thread-local access calls the TLV thunk, so the guard comes + // from its thread-specific-data slot, with an out-of-line fallback. + expect(depth).not.toContain("ptr @scr_stack_guard\n"); + expect(depth).toContain("load i64, ptr @scr_stack_guard_tsd"); + expect(depth).toContain("call ptr @scr_stack_guard_get()"); + if (access === "tpidrro") { + expect(depth).toContain('@llvm.read_register.i64(metadata !{!"tpidrro_el0"})'); + } else { + expect(depth).toMatch(/load ptr, ptr addrspace\(256\) %/); + } + } + } +}); + test.each([ ["x86_64-unknown-linux-gnu", true], ["aarch64-unknown-linux-gnu", true], diff --git a/packages/runtime/src/scr_async.c b/packages/runtime/src/scr_async.c index c693a7488..e78a6043e 100644 --- a/packages/runtime/src/scr_async.c +++ b/packages/runtime/src/scr_async.c @@ -1256,7 +1256,7 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { /* The stack guard follows the running stack. A fresh fiber starts with * its own; every resumed context restores the guard it saved here. */ uintptr_t guard = scr_stack_guard; - if (to_fiber) scr_stack_guard = scr_stack_guard_for((uintptr_t)to_fiber->stack, SCR_FIBER_STACK); + if (to_fiber) SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)to_fiber->stack, SCR_FIBER_STACK)); #endif #ifdef _WIN32 /* SwitchToFiber snapshots the outgoing fiber's state inside its own @@ -1287,7 +1287,7 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) { swapcontext(from, to); #endif #if !defined(_WIN32) && !defined(__wasi__) - scr_stack_guard = guard; + SCR_STACK_GUARD_SET(guard); #endif } diff --git a/packages/runtime/src/scr_context.c b/packages/runtime/src/scr_context.c index 4b4e54784..0b07c6532 100644 --- a/packages/runtime/src/scr_context.c +++ b/packages/runtime/src/scr_context.c @@ -207,12 +207,56 @@ ScrDyn *scr_worker_parent_port(void) { return scr_dyn_new_null(); } #include #endif +#ifdef SCR_STACK_GUARD_TSD +/* The guard's thread-specific-data slot (scr_runtime.h). libpthread keeps a + * thread's values in an array at its thread pointer, indexed by key, which + * is also how its own inline accessors read them; the probe below checks + * that layout once before compiled code relies on it. */ +uintptr_t scr_stack_guard_tsd = 0; +static pthread_key_t scr_stack_guard_key; +static bool scr_stack_guard_keyed; +static pthread_once_t scr_stack_guard_once = PTHREAD_ONCE_INIT; + +static void *scr_stack_guard_tsd_read(uintptr_t offset) { + void *value; +#if defined(__aarch64__) + uintptr_t base; + __asm__ volatile("mrs %0, TPIDRRO_EL0" : "=r"(base)); + value = *(void *volatile *)((base & ~(uintptr_t)7) + offset); +#else + __asm__ volatile("movq %%gs:(%1), %0" : "=r"(value) : "r"(offset)); +#endif + return value; +} + +static void scr_stack_guard_key_init(void) { + if (pthread_key_create(&scr_stack_guard_key, NULL) != 0) return; + scr_stack_guard_keyed = true; + static const char probe; + uintptr_t offset = (uintptr_t)scr_stack_guard_key * sizeof(void *); + if (offset != 0 && pthread_setspecific(scr_stack_guard_key, &probe) == 0 && + scr_stack_guard_tsd_read(offset) == &probe) + scr_stack_guard_tsd = offset; + (void)pthread_setspecific(scr_stack_guard_key, NULL); +} + +void scr_stack_guard_set(uintptr_t guard) { + scr_stack_guard = guard; + if (scr_stack_guard_keyed) (void)pthread_setspecific(scr_stack_guard_key, (void *)guard); +} + +uintptr_t scr_stack_guard_get(void) { return scr_stack_guard; } +#endif + void scr_stack_guard_init(void) { #if defined(__APPLE__) && !defined(SCR_LIB) +#ifdef SCR_STACK_GUARD_TSD + (void)pthread_once(&scr_stack_guard_once, scr_stack_guard_key_init); +#endif pthread_t self = pthread_self(); uintptr_t high = (uintptr_t)pthread_get_stackaddr_np(self); size_t size = pthread_get_stacksize_np(self); - if (high > size) scr_stack_guard = scr_stack_guard_for(high - size, size); + if (high > size) SCR_STACK_GUARD_SET(scr_stack_guard_for(high - size, size)); #elif defined(__linux__) && !defined(SCR_LIB) #ifdef SCR_WORKERS if (!scr_context_is_main()) { diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 06362c794..3c3d72f34 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -337,6 +337,22 @@ bool scr_context_is_main(void); extern SCR_TL uintptr_t scr_stack_guard; void scr_stack_guard_init(void); void scr_stack_overflow(void); +#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(__APPLE__) && \ + (defined(__aarch64__) || defined(__x86_64__)) +/* Darwin worker executables: reading a thread-local variable calls its TLV + * thunk, so the emitted entry checks read the running stack's guard from a + * pthread thread-specific-data slot instead, addressed from the thread + * pointer (TPIDRRO_EL0 on arm64, %gs on x86-64) plus this byte offset. + * scr_stack_guard_init validates the slot on first use; zero means the + * direct read is unavailable and the checks call scr_stack_guard_get. */ +#define SCR_STACK_GUARD_TSD 1 +extern uintptr_t scr_stack_guard_tsd; +uintptr_t scr_stack_guard_get(void); +void scr_stack_guard_set(uintptr_t guard); +#define SCR_STACK_GUARD_SET(guard) scr_stack_guard_set(guard) +#else +#define SCR_STACK_GUARD_SET(guard) (scr_stack_guard = (guard)) +#endif #if defined(__has_feature) #if __has_feature(address_sanitizer) #define SCR_STACK_RESERVE_MAX ((size_t)2 << 20) From 865beda7dbeccf7340b993b3f735e85ee6661c04 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 02:28:41 -0700 Subject: [PATCH 08/22] Give the runtime's thread-locals the local-exec model in ELF workers ELF worker executables gave the program's own thread-locals the local-exec model and the runtime's external ones initial-exec. The linker relaxes initial-exec to `mov $offset, reg` plus an indexed %fs access, so every inline allocation (scr_thread_hot) and every stack check (scr_stack_guard) paid an extra instruction and register. The runtime is always linked statically into the executable, so its thread-locals are in the executable's own TLS block too and local-exec is valid for them: the access becomes one %fs-relative operand that LLVM folds into loads and compares, as the pending word already is. --- packages/compiler/src/backend/llvm/emitter.ts | 14 ++++++++------ packages/compiler/test/worker-threads.test.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 798de8a0a..73be114cb 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1444,11 +1444,13 @@ export class LlEmitter { const parts = this.emitModuleParts(); if (!this.executableTls) return parts; // A worker executable's thread-locals all live in the executable's own - // TLS block (the program's internal ones, the runtime's external ones), - // so on ELF they can use the executable models: LLVM's default - // general-dynamic sequence (relaxed by the linker to `mov %fs:0` plus - // an add) becomes one %fs-relative access LLVM can fold into loads. - // Darwin's thread-local variables have a single model. + // TLS block: the program's internal ones, and the runtime's external + // ones, because the runtime is always linked statically into the + // executable. So on ELF they can all use the local-exec model: LLVM's + // default general-dynamic sequence (relaxed by the linker to `mov %fs:0` + // plus an add), and even initial-exec (relaxed to `mov $offset` plus an + // indexed access), become one %fs-relative operand LLVM can fold into + // loads and compares. Darwin's thread-local variables have one model. return parts.map((part) => part .replace( @@ -1457,7 +1459,7 @@ export class LlEmitter { ) .replace( /^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm, - "$1thread_local(initialexec) global ", + "$1thread_local(localexec) global ", ), ); } diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index f3dab96ed..01b934872 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -179,7 +179,7 @@ settle().then((value) => console.log(value)); // Worker runtimes keep one small-object allocator per script thread: // the inline paths address its state, live counts and dispose hook in // the runtime's one thread-local ScrThreadHot block. - expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(initialexec\))? global /); + expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(localexec\))? global /); expect(llvm).not.toMatch(/@scr_(sa|cyc_live|cyc_old_freed|weak_dispose_hook) = external/); expect(llvm).toContain("call ptr @llvm.threadlocal.address.p0(ptr @scr_thread_hot)"); expect(llvm).toMatch(/getelementptr inbounds i8, ptr %[\w.]+\.tl\.hot, i64 808/); @@ -328,14 +328,15 @@ test.each([ expect(result.ok, JSON.stringify(result)).toBe(true); if (result.ok) { const llvm = await readFile(request.outPath, "utf8"); - // Every thread-local of a worker executable lives in its own TLS block: - // ELF uses local-exec for the program's and initial-exec for the - // runtime's; Darwin's thread-local variables have one model. + // Every thread-local of a worker executable lives in its own TLS block + // (the runtime links statically): ELF uses local-exec for the program's + // and the runtime's; Darwin's thread-local variables have one model. const plain = /^@[-$._A-Za-z0-9]+ = (internal|external) thread_local global /m; if (models) { expect(llvm).not.toMatch(plain); + expect(llvm).not.toContain("initialexec"); expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = internal thread_local\(localexec\) global /m); - expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(initialexec\) global /m); + expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(localexec\) global /m); } else { expect(llvm).toMatch(plain); expect(llvm).not.toContain("thread_local("); From 6a9475e931cad80061db7703595740c798b29a31 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 07:05:29 -0700 Subject: [PATCH 09/22] Free the address space after process.exit off the critical path (--detached-exit) `scriptc build|run --detached-exit` opts an executable into handing the address-space teardown after process.exit to a helper process on Linux. The helper shares the address space (CLONE_VM), blocks signals, closes every descriptor but the read end of a pipe and waits for EOF on it. The kernel releases an exiting process's memory (exit_mm) before its files (exit_files), so EOF means the helper holds the last reference; it then exits and the kernel frees the pages there instead of before the parent sees the exit (30-40 ms per GB of 4 KiB pages; tsc-ts 20-110 ms). The same latency mold hides with --fork. It is a build-time choice, not a run-time knob, because the helper keeps the executable mapped (overwriting the binary right after it exits can fail with ETXTBSY) and the memory charged to the cgroup for up to ~100 ms, and the teardown CPU time leaves the parent's rusage. The program object defines `scr_detached_exit = 1`, overriding the runtime's weak default of 0, so no runtime flavor or linker flag is added. The option is part of the early executable cache and route keys; executables only, no effect on other platforms. --- docs/content/docs/cli.mdx | 3 ++ packages/cli/src/bootstrap.ts | 2 + packages/cli/test/output-options.test.ts | 2 + .../compiler/src/backend/llvm/emitter.test.ts | 10 ++++ packages/compiler/src/backend/llvm/emitter.ts | 10 ++++ packages/compiler/src/cli/command.ts | 9 +++- packages/compiler/src/cli/output-options.ts | 4 ++ packages/compiler/src/cli/usage.ts | 6 +++ packages/compiler/src/compile-types.ts | 5 ++ .../src/executable/executable-cache.test.ts | 1 + .../src/executable/executable-cache.ts | 4 ++ packages/compiler/src/index.ts | 15 ++++++ packages/compiler/src/native/prepare.ts | 1 + packages/compiler/test/source-output.test.ts | 13 +++++ packages/runtime/src/scr_lib.c | 53 +++++++++++++++++++ 15 files changed, 137 insertions(+), 1 deletion(-) diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx index 169f838a4..f08ec558a 100644 --- a/docs/content/docs/cli.mdx +++ b/docs/content/docs/cli.mdx @@ -108,6 +108,9 @@ Verifies and reads the installed precompiled runtime objects and vendor archives
--windows-subsystem <console|gui>
Select the subsystem of a Windows executable. console is the default; gui prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.
+
--detached-exit
+
On Linux, when the program calls process.exit(), hand the release of its memory to a helper process so the parent sees the exit sooner. The kernel otherwise frees the whole address space before the exit is reported, which takes tens of milliseconds per gigabyte. Opt in only where the trade-offs are acceptable: for up to about 100 ms after the exit, the helper keeps the executable mapped (overwriting the binary in place fails with "text file busy"), its memory stays charged to the cgroup, and the teardown CPU time no longer appears in the parent's resource usage. Valid only for executable builds; it has no effect on other platforms.
+
--npm-static <pkg[,pkg…]|auto>
EXPERIMENTAL. Compile the named npm packages' shipped JS statically as program modules instead of embedding them for the engine (repeatable; auto opts in every eligible direct import). A package the preflight refuses falls back to the island with a coverage-report note. See npm Dependencies for maturity notes.
diff --git a/packages/cli/src/bootstrap.ts b/packages/cli/src/bootstrap.ts index 9bd04eed7..7aba1e674 100644 --- a/packages/cli/src/bootstrap.ts +++ b/packages/cli/src/bootstrap.ts @@ -126,6 +126,7 @@ async function tryFastPath(): Promise { backend: "llvm", ...(optimization === "dev" || optimization === "speed" ? { optimization } : {}), ...(values.strip ? { strip: true as const } : {}), + ...(values["detached-exit"] ? { detachedExit: true as const } : {}), npmStatic, ffiProfile: ffiPath === null ? null : { path: ffiPath, bytes: ffiBytes! }, target: `${process.env["SCRIPTC_TARGET"] ?? "native"}:${buildPlatform}:${process.arch}:${ @@ -183,6 +184,7 @@ async function coverageFastPath( (failOn !== undefined && failOn !== "blockers" && failOn !== "divergences") || values.emit !== undefined || values.strip || + values["detached-exit"] || values.lib || values.profile !== undefined || values.ffi !== undefined || diff --git a/packages/cli/test/output-options.test.ts b/packages/cli/test/output-options.test.ts index 81d8343ef..ead3280c0 100644 --- a/packages/cli/test/output-options.test.ts +++ b/packages/cli/test/output-options.test.ts @@ -46,6 +46,8 @@ describe("output option compatibility", () => { [{ emit: "llvm", windowsSubsystem: "gui" }, /windows-subsystem/], [{ emit: "obj", strip: true }, /strip/], [{ emit: "llvm", strip: true }, /strip/], + [{ emit: "obj", detachedExit: true }, /detached-exit/], + [{ emit: "llvm", detachedExit: true }, /detached-exit/], ] as const)("rejects %j", (override, message) => { const result = resolveOutputOptions("build", { ...BASE, ...override }); expect(result).toEqual({ ok: false, message: expect.stringMatching(message) }); diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts index d78c74f71..d74552eb2 100644 --- a/packages/compiler/src/backend/llvm/emitter.test.ts +++ b/packages/compiler/src/backend/llvm/emitter.test.ts @@ -109,6 +109,16 @@ test("throwing-call count does not multiply identical scope cleanup", () => { } }); +test("detached exit defines the runtime opt-in symbol only when requested", () => { + const module = moduleFor([call()]); + const plain = emitLlvmModule(module); + expect(emitLlvmModule(module, { detachedExit: false })).toBe(plain); + expect(plain).not.toContain("@scr_detached_exit"); + const detached = emitLlvmModule(module, { detachedExit: true }); + expect(detached.match(/^@scr_detached_exit = constant i8 1$/gm)).toHaveLength(1); + expect(detached.replace("@scr_detached_exit = constant i8 1\n", "")).toBe(plain); +}); + test("inline RC is a speed-only emission mode and leaves release IR unchanged", () => { for (const boxed of [false, true]) { const module = moduleFor(Array.from({ length: 4 }, call), { boxed }); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..84005a48c 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -397,6 +397,10 @@ export interface LlvmTargetOptions { /** Emit retain/release fast paths inline (the `speed` posture). Off, every * retain and release is a runtime call. */ inlineRc?: boolean; + /** Define `scr_detached_exit` so the Linux runtime hands the address-space + * teardown after `process.exit` to a helper process (`--detached-exit`). + * Executables only; the runtime's weak default keeps it off. */ + detachedExit?: boolean; } export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string { @@ -520,6 +524,7 @@ export class LlEmitter { private readonly executableTls: boolean; private readonly emitLibraryIdentity: boolean; private readonly runtimeAbiMarker: boolean; + private readonly detachedExit: boolean; /** Interned string literals: UTF-8 text → { symbol, byte length } — * first-use order, the runtime ABI’s determinism discipline. */ private readonly literals = new Map(); @@ -814,6 +819,7 @@ export class LlEmitter { !/apple|darwin|windows|mingw|cygwin|win32/.test(options.targetTriple || process.platform); this.emitLibraryIdentity = options.emitLibraryIdentity !== false; this.runtimeAbiMarker = options.runtimeAbiMarker === true; + this.detachedExit = options.detachedExit === true; this.rcHelpers = options.inlineRc === true ? new Set() : null; // ScrCycHdr.color stays 12 bytes behind a wasm32 object and 16 bytes // behind a 64-bit object. The wasm32 header pads before color so its @@ -1900,6 +1906,10 @@ export class LlEmitter { ...(this.runtimeAbiMarker && this.mod.lib === undefined ? [`declare void @${RUNTIME_ABI_MARKER}()`] : []), + // Overrides the runtime's weak default (scr_lib.c). + ...(this.detachedExit && this.mod.lib === undefined + ? [`@scr_detached_exit = constant i8 1`] + : []), ); for (const d of this.decls) out.push(d); out.push(``); diff --git a/packages/compiler/src/cli/command.ts b/packages/compiler/src/cli/command.ts index 1eea9b32e..8dd8a9f28 100644 --- a/packages/compiler/src/cli/command.ts +++ b/packages/compiler/src/cli/command.ts @@ -86,6 +86,7 @@ async function main(args: string[], host: CliHost): Promise { values.profile !== undefined || values.strip || values["windows-subsystem"] !== undefined || + values["detached-exit"] || (values["npm-static"] ?? []).length > 0 || values["provenance-sources"] || externalTypeArgs.length > 0 || @@ -151,11 +152,12 @@ async function main(args: string[], host: CliHost): Promise { values.strip || values.ffi !== undefined || values["windows-subsystem"] !== undefined || + values["detached-exit"] || (values["npm-static"] ?? []).length > 0 || externalTypeArgs.length > 0 ) { fail( - "scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--strip/--windows-subsystem/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage", + "scriptc build --lib takes no --dynamic/--backend/--emit/--print/--optimization/--strip/--windows-subsystem/--detached-exit/--npm-static/--ffi/--external-types: the profile pins the emission and optimization, npm imports are judged automatically, outbound FFI belongs to executable builds, and external type mappings belong to coverage", ); } const profilePath = resolve(profileArg); @@ -198,6 +200,9 @@ async function main(args: string[], host: CliHost): Promise { if (command === "coverage" && values.strip) { fail(`--strip is a build/run option\n\n${USAGE}`); } + if (command === "coverage" && values["detached-exit"]) { + fail(`--detached-exit is a build/run option\n\n${USAGE}`); + } if ( values.print !== undefined && values.print !== "native-link-info" && @@ -292,6 +297,7 @@ async function main(args: string[], host: CliHost): Promise { sanitize: values.sanitize, ...(values.optimization === undefined ? {} : { optimization: values.optimization }), strip: values.strip, + detachedExit: values["detached-exit"], ...(windowsSubsystem === undefined ? {} : { windowsSubsystem }), ...(values.ffi === undefined ? {} : { ffi: values.ffi }), }); @@ -380,6 +386,7 @@ async function main(args: string[], host: CliHost): Promise { ...(optimization !== undefined ? { optimization } : {}), ...(values.strip ? { strip: true } : {}), ...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}), + ...(values["detached-exit"] ? { detachedExit: true } : {}), ...(npmStatic !== undefined ? { npmStatic } : {}), ...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}), ...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}), diff --git a/packages/compiler/src/cli/output-options.ts b/packages/compiler/src/cli/output-options.ts index 85513abde..938777526 100644 --- a/packages/compiler/src/cli/output-options.ts +++ b/packages/compiler/src/cli/output-options.ts @@ -10,6 +10,7 @@ export interface OutputOptionValues { optimization?: string; strip?: boolean; windowsSubsystem?: string; + detachedExit?: boolean; ffi?: string; } @@ -69,6 +70,9 @@ export function resolveOutputOptions( if (values.strip && emit !== "exe") { return { ok: false, message: `--strip is only supported with --emit=exe` }; } + if (values.detachedExit && emit !== "exe") { + return { ok: false, message: `--detached-exit is only supported with --emit=exe` }; + } if (emit === "ir" && backend !== undefined) { return { ok: false, diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts index b707f2fdb..0a24477f2 100644 --- a/packages/compiler/src/cli/usage.ts +++ b/packages/compiler/src/cli/usage.ts @@ -42,6 +42,11 @@ Options: --windows-subsystem Windows executable subsystem (default: console). gui prevents Windows from opening a console window + --detached-exit + Linux: after process.exit, free the program's memory in + a helper process so the parent sees the exit sooner. The + helper keeps the executable mapped and its memory charged + for up to ~100 ms after exit (opt in; --emit=exe only) --keep-llvm keep generated LLVM IR beside the executable (default) --no-keep-llvm delete generated LLVM IR after compiling --emit-ir also write IR beside an executable or library archive; @@ -81,6 +86,7 @@ export const CLI_OPTIONS = { optimization: { type: "string" }, strip: { type: "boolean", default: false }, "windows-subsystem": { type: "string" }, + "detached-exit": { type: "boolean", default: false }, "keep-llvm": { type: "boolean", default: true }, "emit-ir": { type: "boolean", default: false }, sanitize: { type: "boolean", default: false }, diff --git a/packages/compiler/src/compile-types.ts b/packages/compiler/src/compile-types.ts index d48b551f6..db0d57966 100644 --- a/packages/compiler/src/compile-types.ts +++ b/packages/compiler/src/compile-types.ts @@ -34,6 +34,11 @@ export interface CompileBaseOptions { /** Windows PE executable subsystem. Console is the default; GUI suppresses * automatic console-window creation. Only valid for Windows executables. */ windowsSubsystem?: WindowsSubsystem; + /** On Linux, hand the address-space teardown after `process.exit` to a + * helper process so the parent sees the exit sooner. The helper keeps the + * executable mapped and the memory charged for up to ~100 ms after exit. + * Executables only; no effect on other platforms. */ + detachedExit?: boolean; /** --npm-static: package names whose shipped, unminified JS compiles * STATICALLY as program modules (inference types the bodies; statements * the lowering cannot prove become runtime fences). "auto" opts in every diff --git a/packages/compiler/src/executable/executable-cache.test.ts b/packages/compiler/src/executable/executable-cache.test.ts index 6b4ca73ae..a287a7c5a 100644 --- a/packages/compiler/src/executable/executable-cache.test.ts +++ b/packages/compiler/src/executable/executable-cache.test.ts @@ -217,6 +217,7 @@ test("early executable keys isolate compile modes, paths, implementation, and FF { ...f.options, dynamic: true }, { ...f.options, optimization: "dev" }, { ...f.options, optimization: "speed" }, + { ...f.options, detachedExit: true }, { ...f.options, npmStatic: "auto" }, { ...f.options, npmStatic: ["commander"] }, { ...f.options, outPath: join(f.options.outDir, "other") }, diff --git a/packages/compiler/src/executable/executable-cache.ts b/packages/compiler/src/executable/executable-cache.ts index 876904a25..9542e6fa5 100644 --- a/packages/compiler/src/executable/executable-cache.ts +++ b/packages/compiler/src/executable/executable-cache.ts @@ -110,6 +110,8 @@ export interface EarlyExecutableCacheOptions { strip?: true; /** Omitted for the default Windows console subsystem. */ windowsSubsystem?: "gui"; + /** Omitted unless --detached-exit is set. */ + detachedExit?: true; npmStatic: readonly string[] | "auto" | null; /** Raw manifest identity: path and bytes. Native archives remain under the * stricter native cache's independent dependency validation. */ @@ -220,6 +222,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string { ...optimizationKeyParts(options.optimization), ...(options.strip ? ["strip"] : []), ...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []), + ...(options.detachedExit ? ["detached-exit"] : []), options.npmStatic === null ? "" : options.npmStatic === "auto" @@ -258,6 +261,7 @@ function routeKey(options: Omit { + const { entry, outDir } = await fixture(); + const outPath = join(outDir, "main.ll"); + const result = await compile(entry, { outDir, outPath, outputKind: "llvm", detachedExit: true }); + expect(result).toMatchObject({ + ok: false, + diagnostics: [ + { code: "SC3002", message: expect.stringContaining("only supported for executable output") }, + ], + }); + await expect(readFile(outPath)).rejects.toMatchObject({ code: "ENOENT" }); +}); + test("LLVM is the exact primary artifact and never creates an executable", async () => { const { entry, outDir } = await fixture(); const llvmPath = join(outDir, "exact.llvm-output"); diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index e259f6f7e..80558c1ec 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1806,6 +1806,56 @@ double scr_process_exit_code_or_zero(void) { return (double)scr_exit_code_hint_get(); } +#if defined(__linux__) +#include +#include +#include +#ifndef SYS_close_range +#define SYS_close_range 436 /* the generic syscall table: x86_64 and aarch64 */ +#endif + +/* Detached teardown (`scriptc build --detached-exit`): the kernel frees an + * exiting process's memory on its critical path, 30-40 ms per GB of 4 KiB + * pages, before its parent sees it exit. A helper process that shares the + * address space (CLONE_VM) and holds no descriptor but the read end of a + * pipe keeps the memory alive past the exit, then exits itself and frees it + * off the parent's critical path (the latency mold's --fork hides). The + * kernel releases an exiting process's memory (exit_mm) before its files + * (exit_files), so EOF on the pipe means the helper holds the last + * reference. The helper keeps the executable mapped and the memory charged + * to the cgroup until then, so a program opts in at build time: the program + * object then defines scr_detached_exit = 1, overriding this weak default. */ +__attribute__((weak)) const unsigned char scr_detached_exit = 0; +static char scr_teardown_stack[16384] __attribute__((aligned(64))); + +static int scr_teardown_main(void *arg) { + int fd = (int)(intptr_t)arg; + /* Raw syscalls only: the helper runs on the exiting thread's TLS. */ + if (fd > 0) (void)syscall(SYS_close_range, 0u, (unsigned)fd - 1u, 0u); + (void)syscall(SYS_close_range, (unsigned)fd + 1u, ~0u, 0u); + char c; + while (syscall(SYS_read, fd, &c, 1) != 0) { + } + syscall(SYS_exit, 0); + return 0; +} + +static void scr_detach_teardown(void) { + if (!scr_detached_exit) return; + int fds[2]; + if (pipe2(fds, O_CLOEXEC) != 0) return; + sigset_t all, old; + sigfillset(&all); + pthread_sigmask(SIG_SETMASK, &all, &old); + pid_t pid = clone(scr_teardown_main, scr_teardown_stack + sizeof scr_teardown_stack, CLONE_VM | SIGCHLD, + (void *)(intptr_t)fds[0]); + pthread_sigmask(SIG_SETMASK, &old, NULL); + (void)close(fds[0]); + if (pid < 0) (void)close(fds[1]); + /* fds[1] stays open until the process's files are released. */ +} +#endif + void scr_process_exit(double code) { /* Node runs 'exit' listeners on explicit process.exit() too — they run * HERE, synchronously, before the teardown-free exit below. The hook is @@ -1823,6 +1873,9 @@ void scr_process_exit(double code) { * Node), and the RC audit is meaningless mid-program (live values are * expected). scr_init's flush-at-exit is also skipped — flush here. */ fflush(stdout); +#if defined(__linux__) + scr_detach_teardown(); +#endif _Exit((int)code); } From 7afe786b3a3db2b2d7879f5bbf32a3eb3c5ac965 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:23 -0700 Subject: [PATCH 10/22] Declare scr_stack_guard_get with the header's i64 return scr_runtime.h declares uintptr_t scr_stack_guard_get(void), an i64 in the C ABI, but the Darwin worker stack check's cold fallback declared and called it returning ptr, which the runtime ABI test rejects. Declare i64 and convert the result with inttoptr. --- packages/compiler/src/backend/llvm/emitter.ts | 7 +++++-- packages/compiler/test/worker-threads.test.ts | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 73be114cb..962b117f8 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4038,7 +4038,7 @@ export class LlEmitter { // guard from its thread-specific-data slot at the runtime-validated // offset instead, and call out only when the runtime found no slot. this.declare(`@scr_stack_guard_tsd = external dso_local global i64`); - this.declare(`declare ptr @scr_stack_guard_get()`); + this.declare(`declare i64 @scr_stack_guard_get()`); const offset = B.tmp(); const direct = B.tmp(); const likely = B.tmp(); @@ -4068,8 +4068,11 @@ export class LlEmitter { } test(guard); B.startBlock(slow); + // uintptr_t scr_stack_guard_get(void): an i64 in the C ABI. + const raw = B.tmp(); const fallback = B.tmp(); - B.line(`${fallback} = call ptr @scr_stack_guard_get()`); + B.line(`${raw} = call i64 @scr_stack_guard_get()`); + B.line(`${fallback} = inttoptr i64 ${raw} to ptr`); test(fallback); } else { const tl = this.mod.workers === true ? "thread_local " : ""; diff --git a/packages/compiler/test/worker-threads.test.ts b/packages/compiler/test/worker-threads.test.ts index 01b934872..68e115909 100644 --- a/packages/compiler/test/worker-threads.test.ts +++ b/packages/compiler/test/worker-threads.test.ts @@ -307,7 +307,7 @@ test.each([ // from its thread-specific-data slot, with an out-of-line fallback. expect(depth).not.toContain("ptr @scr_stack_guard\n"); expect(depth).toContain("load i64, ptr @scr_stack_guard_tsd"); - expect(depth).toContain("call ptr @scr_stack_guard_get()"); + expect(depth).toContain("call i64 @scr_stack_guard_get()"); if (access === "tpidrro") { expect(depth).toContain('@llvm.read_register.i64(metadata !{!"tpidrro_el0"})'); } else { From 3e83a7b38c613a66e527333a579f31a3f168bfe4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:26 -0700 Subject: [PATCH 11/22] Declare scr_detached_exit in scr_runtime.h The emitter defines @scr_detached_exit for --detached-exit executables, and the runtime ABI test requires every scr_* symbol the backend can emit to have a prototype or extern data declaration in the header. --- packages/runtime/src/scr_runtime.h | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..426b0d537 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -3128,6 +3128,10 @@ bool scr_process_kill_named(double pid, const ScrStr *signal); /* fflush stdout, then _Exit((int)code): no atexit handlers run — the RC * audit is deliberately skipped (exiting mid-program leaves live values). */ void scr_process_exit(double code); +/* Nonzero hands the address-space teardown after scr_process_exit to a + * helper process (Linux, `--detached-exit`). scr_lib.c defines a weak 0; + * the program object of such an executable defines it as 1. */ +extern const unsigned char scr_detached_exit; void scr_process_set_exit_code(double code); double scr_process_exit_code_or_zero(void); /* process._exiting: true once the exit sequence began (process.exit or From 50bc1d2240014d24acbd759c14e38dfa19b117e3 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:26:08 -0700 Subject: [PATCH 12/22] Read cgroup files with one read, as libuv does os.availableParallelism() follows libuv's uv_available_parallelism, whose uv__slurp reads each /proc and cgroup file with a single read and fails on a read error; the helper looped until the buffer filled and ignored read errors, so a long /proc/self/cgroup could resolve a controller Node does not see. A cgroup CPU quota below one CPU truncates to 0 and stays ignored rather than clamped to 1: libuv 1.51 (Node 24) through 1.52 applies a quota only when it is positive, and Node then answers the affinity count. --- packages/runtime/src/scr_lib.c | 22 ++++++++++++---------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/packages/runtime/src/scr_lib.c b/packages/runtime/src/scr_lib.c index 6360c7215..14ba35d21 100644 --- a/packages/runtime/src/scr_lib.c +++ b/packages/runtime/src/scr_lib.c @@ -1501,20 +1501,19 @@ ScrStr *scr_os_type(void) { } #ifdef __linux__ -/* Reads at most cap - 1 bytes of path into buf (NUL-terminated); false when - * the file cannot be read. */ +/* libuv's uv__slurp: one read of at most cap - 1 bytes of path into buf + * (NUL-terminated); false when the file cannot be opened or read. A single + * read, like libuv, so a long /proc/self/cgroup is cut where Node cuts it. */ static bool scr_os_slurp(const char *path, char *buf, size_t cap) { int fd = open(path, O_RDONLY | O_CLOEXEC); if (fd < 0) return false; - size_t len = 0; - while (len + 1 < cap) { - ssize_t n = read(fd, buf + len, cap - 1 - len); - if (n < 0 && errno == EINTR) continue; - if (n <= 0) break; - len += (size_t)n; - } + ssize_t n; + do + n = read(fd, buf, cap - 1); + while (n < 0 && errno == EINTR); close(fd); - buf[len] = '\0'; + if (n < 0) return false; + buf[n] = '\0'; return true; } @@ -1584,6 +1583,9 @@ double scr_os_available_parallelism(void) { #endif if (rc < 0) rc = sysconf(_SC_NPROCESSORS_ONLN); #if defined(__linux__) + /* A quota below one CPU truncates to 0 and is ignored, not clamped to 1: + * libuv (1.51 through 1.52) applies only `quota > 0 && quota < rc`, so + * Node then answers the affinity count. */ long long quota = 0; if (scr_os_cgroup_cpu_quota("a) && quota > 0 && quota < rc) rc = (long)quota; #endif From 7a983d1b0f2198ee65c78588cd5612e7e47c59bc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:26:10 -0700 Subject: [PATCH 13/22] Record the preflight/order baseline for 4598-os-available-parallelism --- packages/compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index a00bebf93..f0c431d54 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15784,6 +15784,12 @@ "/tests/corpus/4602-constructor-escape-order.ts" ], "diags": [] + }, + "/tests/corpus/4598-os-available-parallelism.ts": { + "order": [ + "/tests/corpus/4598-os-available-parallelism.ts" + ], + "diags": [] } } } From 20029454ef9171915198a87441571d461447847d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:20:25 -0700 Subject: [PATCH 14/22] Read nullable-union array elements inline and borrow them for preserving live ranges Optional array reads also match arrays whose element is itself the read T | undefined nullable-pointer union; owned copies retain and release through its NULL-tolerant entry points. A local array read borrows when its array operand is rooted at a stable parameter and its own live range preserves heap edges, and a call-argument read borrows when the call preserves them. An optional chain on such a read borrows the inline element instead of taking a second reference. --- packages/compiler/src/backend/llvm/emitter.ts | 7 +- .../compiler/src/backend/llvm/expr-control.ts | 13 +- .../src/backend/llvm/local-array-reads.ts | 183 +++++++++++++++--- .../corpus/4671-scoped-array-read-borrows.ts | 152 +++++++++++++++ 4 files changed, 323 insertions(+), 32 deletions(-) create mode 100644 tests/corpus/4671-scoped-array-read-borrows.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index b5b61572a..401c5faf4 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -894,7 +894,10 @@ export class LlEmitter { this.nullableFields = new NullableRefFields(mod.classes ?? [], this.boxedUnionsById); // Optional array reads also cover nullable unions: emitLocalArrayRead // then yields the (borrowed or owned) element pointer, not a stack box. - this.optionalArrayReads = new OptionalArrayReads(this.fnByName, this.unionsById); + // Arrays of such a union are read the same way. + this.optionalArrayReads = new OptionalArrayReads(this.fnByName, this.unionsById, (id) => + this.nullableUnions.has(id), + ); this.checkedNarrows = new CheckedNarrows(this.fnByName); this.referenceEffects = new ReferenceEffects( this.fnByName, @@ -5129,12 +5132,14 @@ export class LlEmitter { this.callLifetimes, this.optionalArrayReads, this.loopArrayBorrows, + this.debug === null ? this.referenceEffects : undefined, ); this.callArrayReads = findCallArrayReads( fn, this.optionalArrayReads, this.referenceEffects.functions, this.callLifetimes, + this.debug === null ? this.referenceEffects : undefined, ); this.scalarStringSlices = this.debug === null ? findScalarStringSlices(fn, this.callLifetimes) : new Map(); diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 634671057..b425862f7 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -290,9 +290,18 @@ export function emitControlExpr( isRefCounted(narrowed) && host.canBorrowReceiver(e.receiver) && host.referenceEffects.preserves(e.body); + // An optional array read of a nullable-pointer union is the element + // pointer itself, read inline and owned by the statement frame until + // the body finishes; the bind slot borrows it like an emitExpr value. + const nullableRead = + !borrowReceiver && + !multiple && + host.nullableUnions.has(def.id) && + host.optionalArrayReads.get(e.receiver) !== null; // A single present arm is only tested and extracted (+1) before the // body runs, so a fresh receiver box can stay on the stack. - const stacked = !borrowReceiver && !multiple && host.canStackReceiver(e.receiver); + const stacked = + !borrowReceiver && !nullableRead && !multiple && host.canStackReceiver(e.receiver); let r: LlValue; const nullableField = borrowReceiver ? host.nullableFieldGet(e.receiver) : null; if (nullableField !== null && e.receiver.kind === "fieldGet") { @@ -307,7 +316,7 @@ export function emitControlExpr( name: host.nullableUnions.get(def.id) ? p : host.stackNullableBox(p, nullableField), type: e.receiver.type, }; - } else if (borrowReceiver || stacked) r = host.emitReadReceiver(e.receiver); + } else if (borrowReceiver || stacked || nullableRead) r = host.emitReadReceiver(e.receiver); else r = host.emitExpr(e.receiver); // The bind slot borrows a ref payload whenever something else keeps it // alive until the body finishes: the borrowed projection above, or the diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index a438a2c95..f4059b104 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -22,8 +22,17 @@ export interface LocalArrayRead { presentTag: number; missingTag: number; borrow?: boolean; + /** The array's elements are themselves the read's `T | undefined` + * nullable-pointer union, so a present slot may hold NULL (undefined): + * owned copies retain and release through the union's NULL-tolerant + * entry points. */ + nullableElement?: boolean; } +/** Whether a union is held as its reference arm's plain pointer + * (nullable-unions.ts). Without it, only reference elements are matched. */ +export type NullablePointerUnion = (unionId: string) => boolean; + /** Kept as the array-analysis entry point; the same edge-preservation * proof also governs projections passed to borrowing consumers. */ export function findArrayPreservingFunctions( @@ -39,6 +48,7 @@ interface ArrayReadShape { element: IrType; presentTag: number; missingTag: number; + nullableElement?: boolean; } /** f64 and bool elements copy their slot bits into the box payload. They @@ -53,6 +63,7 @@ function isScalarElement(element: IrType): boolean { function inlineArrayRead( value: IrExpr, unions: ReadonlyMap, + nullable?: NullablePointerUnion, ): LocalArrayRead | null { if (value.kind !== "ternary" || value.type.kind !== "union") return null; const { cond, then, else_: missing } = value; @@ -62,15 +73,20 @@ function inlineArrayRead( cond.left.kind !== "arrayState" || cond.right.kind !== "numLit" || cond.right.value !== 1 || - then.kind !== "unionWrap" || - then.unionId !== value.type.unionId || - then.value.kind !== "arrayGet" || missing.kind !== "unionWrap" || missing.unionId !== value.type.unionId || missing.value.kind !== "unitLit" || missing.value.unit !== "undefined" ) return null; + // A `T | undefined` element is read as it is (arrayValueRead adds no wrap). + const elementRead = + then.kind === "unionWrap" && then.unionId === value.type.unionId + ? then.value + : then.kind === "arrayGet" && typeEquals(then.type, value.type) + ? then + : null; + if (elementRead?.kind !== "arrayGet") return null; const sameOperand = (left: IrExpr, right: IrExpr): boolean => (left.kind === "varRef" && right.kind === "varRef" && left.localId === right.localId) || (left.kind === "numLit" && right.kind === "numLit" && Object.is(left.value, right.value)); @@ -80,23 +96,31 @@ function inlineArrayRead( array.kind !== "varRef" || array.type.kind !== "array" || index.type.kind !== "f64" || - !sameOperand(array, then.value.arr) || - !sameOperand(index, then.value.index) + !sameOperand(array, elementRead.arr) || + !sameOperand(index, elementRead.index) ) return null; - const element = array.type.elem; const arms = unions.get(value.type.unionId)?.arms; - if ( - (!isRefCounted(element) && !isScalarElement(element)) || - element.kind === "union" || - !arms || - arms.length !== 2 - ) - return null; - const presentTag = arms.findIndex((arm) => typeEquals(arm, element)); + if (!arms || arms.length !== 2) return null; const missingTag = arms.findIndex((arm) => arm.kind === "undefinedT"); - if (presentTag < 0 || missingTag < 0 || then.tag !== presentTag || missing.tag !== missingTag) - return null; + if (missingTag < 0 || missing.tag !== missingTag) return null; + if (then === elementRead) { + // The slots hold the union itself: only its plain-pointer form (NULL is + // undefined) shares the element encoding of the reference-arm reads. + const presentTag = 1 - missingTag; + const element = arms[presentTag]!; + if ( + !typeEquals(array.type.elem, value.type) || + !isRefCounted(element) || + nullable?.(value.type.unionId) !== true + ) + return null; + return { type: value.type, element, presentTag, missingTag, array, index, nullableElement: true }; + } + const element = array.type.elem; + if ((!isRefCounted(element) && !isScalarElement(element)) || element.kind === "union") return null; + const presentTag = arms.findIndex((arm) => typeEquals(arm, element)); + if (presentTag < 0 || then.kind !== "unionWrap" || then.tag !== presentTag) return null; return { type: value.type, element, presentTag, missingTag, array, index }; } @@ -104,6 +128,7 @@ function inlineArrayRead( function arrayReadShape( fn: IrFunction, unions: ReadonlyMap, + nullable?: NullablePointerUnion, ): ArrayReadShape | null { if ( fn.async || @@ -118,7 +143,7 @@ function arrayReadShape( return null; const ret = fn.body[0]!; if (ret.kind !== "return" || !ret.value) return null; - const read = inlineArrayRead(ret.value, unions); + const read = inlineArrayRead(ret.value, unions, nullable); if ( !read || !typeEquals(read.type, fn.returnType) || @@ -133,6 +158,7 @@ function arrayReadShape( element: read.element, presentTag: read.presentTag, missingTag: read.missingTag, + ...(read.nullableElement ? { nullableElement: true } : {}), }; } @@ -145,15 +171,16 @@ export class OptionalArrayReads { constructor( functions: ReadonlyMap, private readonly unions: ReadonlyMap, + private readonly nullable?: NullablePointerUnion, ) { for (const fn of functions.values()) { - const shape = arrayReadShape(fn, unions); + const shape = arrayReadShape(fn, unions, nullable); if (shape) this.shapes.set(fn.name, shape); } } get(call: IrExpr): LocalArrayRead | null { - if (call.kind === "ternary") return inlineArrayRead(call, this.unions); + if (call.kind === "ternary") return inlineArrayRead(call, this.unions, this.nullable); if (call.kind !== "call" || call.args.length !== 2) return null; const shape = this.shapes.get(call.callee); if (!shape || !typeEquals(shape.type, call.type)) return null; @@ -206,6 +233,87 @@ function stableArrayOperand( } } +interface StatementPosition { + list: readonly IrStmt[]; + index: number; +} + +/** The statement list and index of every statement of a body. Statements + * nested in expressions (sequence statements) and `for` initializers are + * absent: their declarations keep the frame-wide proof alone. */ +function statementPositions(body: readonly IrStmt[]): Map { + const positions = new Map(); + const visit = (list: readonly IrStmt[]): void => { + list.forEach((stmt, index) => { + positions.set(stmt, { list, index }); + switch (stmt.kind) { + case "if": + visit(stmt.then); + if (stmt.else_) visit(stmt.else_); + break; + case "while": + case "doWhile": + case "for": + case "forOf": + case "block": + visit(stmt.body); + break; + case "switch": + for (const c of stmt.cases) visit(c.body); + break; + case "tryCatch": + visit(stmt.tryBody); + if (stmt.catchBody) visit(stmt.catchBody); + if (stmt.finallyBody) visit(stmt.finallyBody); + break; + } + }); + }; + visit(body); + return positions; +} + +function countReferences(body: readonly IrStmt[], localId: string): number { + let count = 0; + everyStmtList(body as IrStmt[], { + stmt: () => true, + expr: (e) => { + if (e.kind === "varRef" && e.localId === localId) count++; + return true; + }, + }); + return count; +} + +/** A declaration whose whole live range preserves heap edges: every use + * of the local follows it in its own statement list, and neither its + * initializer nor any statement up to the last use can remove a reference + * (effects as in ReferenceEffects). The array operand, rooted at a stable + * parameter, then keeps the element alive until the local is dead, though + * other code of the function may mutate. */ +function preservingLiveRange( + decl: IrStmt & { kind: "varDecl" }, + positions: ReadonlyMap, + body: readonly IrStmt[], + effects: ReferenceEffects, +): boolean { + const position = positions.get(decl); + if (!position || !decl.init || !effects.preserves(decl.init)) return false; + const { list, index } = position; + let last = index, + seen = 0; + for (let i = index + 1; i < list.length; i++) { + const count = countReferences([list[i]!], decl.localId); + if (count === 0) continue; + last = i; + seen += count; + } + return ( + seen === countReferences(body, decl.localId) && + effects.preservesScope(list.slice(index + 1, last + 1) as IrStmt[]) + ); +} + export function findLocalArrayReads( fn: IrFunction, functions: ReadonlyMap, @@ -214,6 +322,7 @@ export function findLocalArrayReads( lifetimes: CallLifetimes = analyzeCallLifetimes(functions), reads = new OptionalArrayReads(functions, unions), loopBorrows: ReadonlySet = new Set(), + effects?: ReferenceEffects, ): Map { const result = new Map(); if (fn.async || fn.generator) return result; @@ -224,6 +333,7 @@ export function findLocalArrayReads( const params = new Set(fn.params.map((p) => p.localId)); const stableParams = stableArrayParameters(fn, lifetimes); const borrow = arrayPreservingFunctions.has(fn.name); + let positions: Map | undefined; everyStmtList(fn.body, { expr: () => true, stmt: (node) => { @@ -233,11 +343,20 @@ export function findLocalArrayReads( return true; const read = reads.get(node.init); if (read && lifetimes.locals.get(fn.name)?.has(local.id)) { + const stable = stableArrayOperand(read.array, stableParams, locals); if ( - (borrow && stableArrayOperand(read.array, stableParams, locals)) || + (borrow && stable) || (read.array.kind === "varRef" && loopBorrows.has(node) && - !locals.get(read.array.localId)?.boxed) + !locals.get(read.array.localId)?.boxed) || + (stable && + effects !== undefined && + preservingLiveRange( + node, + (positions ??= statementPositions(fn.body)), + fn.body, + effects, + )) ) read.borrow = true; result.set(local.id, read); @@ -250,14 +369,16 @@ export function findLocalArrayReads( /** An immediate argument needs no heap union when its parameter only * projects the box. The payload owns a snapshot through all later arguments - * and the call, unless the enclosing function preserves its parameter's - * array edges. The latter proof includes every later argument and callee, - * so a mutation anywhere keeps the independent payload owner. */ + * and the call, unless the enclosing function, or the call itself with all + * of its arguments, preserves its parameter's array edges. That proof + * includes every later argument and callee, so a mutation anywhere keeps + * the independent payload owner. */ export function findCallArrayReads( fn: IrFunction, reads: OptionalArrayReads, arrayPreservingFunctions: ReadonlySet, lifetimes: CallLifetimes, + effects?: ReferenceEffects, ): Map { const result = new Map(); if (fn.async || fn.generator) return result; @@ -274,7 +395,11 @@ export function findCallArrayReads( if (!parameters.has(index)) return; const read = reads.get(arg); if (!read) return; - if (borrow && stableArrayOperand(read.array, stableParams, locals)) read.borrow = true; + if ( + (borrow || effects?.preserves(node) === true) && + stableArrayOperand(read.array, stableParams, locals) + ) + read.borrow = true; result.set(arg, read); }); return true; @@ -457,10 +582,10 @@ export function emitLocalArrayRead( const slow = B.newLabel("local.array.slow"), no = B.newLabel("local.array.missing"), join = B.newLabel("local.array.join"); + // A slot of a nullable-union array may hold NULL even when present. + const owned = read.nullableElement ? read.type : read.element; const store = (value: string): void => { - B.line( - `store ptr ${read.borrow ? value : host.retainValue(value, read.element)}, ptr ${localSlot}`, - ); + B.line(`store ptr ${read.borrow ? value : host.retainValue(value, owned)}, ptr ${localSlot}`); }; if (inline) emitDenseReferenceArrayRead(host, array, index, integerIndex, store, no, slow, join, "ptr"); @@ -482,7 +607,7 @@ export function emitLocalArrayRead( ); B.br(join); B.startBlock(join); - return read.borrow ? null : { slot: localSlot, type: read.element }; + return read.borrow ? null : { slot: localSlot, type: owned }; } const box = B.slot(), payload = B.slot(), diff --git a/tests/corpus/4671-scoped-array-read-borrows.ts b/tests/corpus/4671-scoped-array-read-borrows.ts new file mode 100644 index 000000000..2cb4fbbda --- /dev/null +++ b/tests/corpus/4671-scoped-array-read-borrows.ts @@ -0,0 +1,152 @@ +export {}; + +// Optional reads of `T | undefined` element arrays, and local reads whose +// live range may or may not remove the element's last reference. +class Item { + name: string; + hits = 0; + constructor(name: string) { + this.name = name; + } +} + +type Page = (Item | undefined)[] | undefined; + +class Store { + readonly pages: Page[] = []; + items: (Item | undefined)[] = []; + created = 0; + + // A get-or-create store in pages of 4 ids: the page read is dead before + // the creating path mutates the page table. + item(id: number): Item { + const page = this.pages[id >> 2]; + let item = page !== undefined ? page[id & 3] : undefined; + if (item === undefined) { + item = new Item(`item${id}`); + this.created++; + store(this.pages, id, item); + } + return item; + } + + // The same through an optional chain on the page read. + peek(id: number): string { + const item = this.pages[id >> 2]?.[id & 3]; + return item === undefined ? "none" : item.name; + } + + // The local is still used after the slot is overwritten: it keeps its own + // reference, so the old element survives until the read. + replaceThenRead(i: number): string { + const old = this.items[i]; + this.items[i] = new Item(`new${i}`); + return old === undefined ? "none" : `${old.name}:${old.hits}`; + } + + // Truncation and field replacement between the read and its last use. + truncateThenRead(i: number): string { + const old = this.items[i]; + this.items.length = 0; + return old === undefined ? "none" : old.name; + } + + swapThenRead(i: number): string { + const old = this.items[i]; + this.items = [new Item("swapped")]; + return old === undefined ? "none" : old.name; + } + + // Mutation after the last use: the read may borrow. + readThenMutate(i: number): string { + const it = this.items[i]; + const label = it === undefined ? "none" : it.name; + this.items.push(new Item(`pushed${i}`)); + return label; + } + + // A preserving call consumes the element; a mutating one keeps an owner. + describe(i: number): string { + return label(this.items[i]); + } + + clearAndDescribe(i: number): string { + return clearing(this, this.items[i]); + } +} + +function store(pages: Page[], id: number, item: Item): void { + const index = id >> 2; + while (pages.length <= index) pages.push(undefined); + let page = pages[index]; + if (page === undefined) { + page = []; + for (let i = 0; i < 4; i++) page.push(undefined); + pages[index] = page; + } + page[id & 3] = item; +} + +function label(item: Item | undefined): string { + return item === undefined ? "none" : `${item.name}#${item.hits}`; +} + +function clearing(s: Store, item: Item | undefined): string { + s.items = []; + return item === undefined ? "none" : `${item.name} after clear`; +} + +const s = new Store(); +for (let round = 0; round < 3; round++) + for (let id = 0; id < 11; id += 3) s.item(id).hits++; +console.log("created", s.created, s.item(0).hits, s.item(9).hits); +for (const id of [0, 1, 3, 9, 10, 40]) console.log("peek", id, s.peek(id)); + +function fill(): void { + s.items = [new Item("a"), undefined, new Item("c")]; + s.items.push(undefined); + s.items[6] = new Item("g"); + s.items[0]!.hits = 5; +} +fill(); +for (const i of [0, 1, 2, 3, 4, 6, 7, 1.5, -1]) console.log("describe", i, s.describe(i)); +for (const i of [0, 1, 2]) console.log("readThenMutate", s.readThenMutate(i), s.items.length); +fill(); +for (const i of [0, 1, 6]) console.log("replace", s.replaceThenRead(i), s.items[i]?.name); +fill(); +console.log("truncate", s.truncateThenRead(2), s.items.length); +fill(); +console.log("swap", s.swapThenRead(6), s.items.length, s.items[0]?.name); +fill(); +console.log("clear", s.clearAndDescribe(2), s.items.length); + +// An optional chain whose body empties the array that held its receiver. +class Node2 { + name: string; + owner: Holder; + constructor(name: string, owner: Holder) { + this.name = name; + this.owner = owner; + } + detach(): string { + this.owner.nodes = []; + return `${this.name} detached, ${this.owner.nodes.length} left`; + } +} +class Holder { + nodes: (Node2 | undefined)[] = []; +} +const h = new Holder(); +h.nodes.push(new Node2("n0", h), undefined, new Node2("n2", h)); +console.log("chain", h.nodes[1]?.detach(), h.nodes.length); +console.log("chain", h.nodes[2]?.detach(), h.nodes.length); +console.log("chain", h.nodes[0]?.detach()); + +// Many reads, so leaked or freed elements would show up under the sanitizers. +let total = 0; +for (let round = 0; round < 500; round++) { + fill(); + for (let i = 0; i < 8; i++) total += s.describe(i).length + s.readThenMutate(i).length; + total += s.replaceThenRead(round % 7).length; +} +console.log("total", total, s.items.length); From 9b80f3d4b4bda1d352c4cb2db6638fe168d6cf13 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 14:45:30 -0500 Subject: [PATCH 15/22] Update constant-table emission test for exact index checks --- packages/compiler/test/constant-table-emission.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/compiler/test/constant-table-emission.test.ts b/packages/compiler/test/constant-table-emission.test.ts index 60be74507..5fddb2663 100644 --- a/packages/compiler/test/constant-table-emission.test.ts +++ b/packages/compiler/test/constant-table-emission.test.ts @@ -33,14 +33,15 @@ console.log(lookup(1), mutated[0] * 1); expect(llvm).toContain("@sc_const_numbers_0 = private constant [3 x double]"); expect(llvm).toContain("%initialized = icmp ne ptr %a, null"); - expect(llvm).toContain("br i1 %safe, label %convert, label %fallback"); - expect(llvm).toContain("br i1 %integer, label %read, label %fallback"); + expect(llvm).toContain("%ix0 = fptosi double %i to i64"); + expect(llvm).toContain("%safe = and i1 %initialized, %ix5"); + expect(llvm).toContain("br i1 %safe, label %read, label %fallback"); expect(llvm).toMatch(/call double @sc_const_numbers_0_get\(/); expect(llvm).toContain("call double @scr_arr_get_number(ptr %a, double %i)"); expect(llvm).toMatch(/call double @scr_arr_push_many\(ptr [^,]+, i64 3, ptr /); const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true }); - expect(wasm).toContain("%index = fptoui double %i to i32"); - expect(wasm).toContain("ptr @sc_const_numbers_0, i32 0, i32 %index"); + expect(wasm).toContain("%ix0 = fptosi double %i to i64"); + expect(wasm).toContain("ptr @sc_const_numbers_0, i64 0, i64 %ix1"); } finally { await rm(dir, { recursive: true, force: true }); } From f3cfa80a8ebfb065de51558df4ade06861a6a422 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 14:48:03 -0500 Subject: [PATCH 16/22] Update byte-loop emission test for exact index checks --- packages/compiler/test/bytes-element-emission.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 953687993..3c001a148 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -314,7 +314,7 @@ test("canonical byte loops keep their induction variable and indices integral", expect(ll).toContain("alloca i64 ; integer induction index"); expect(ll).toContain("icmp ult i64"); - expect(ll).not.toContain("bytes.index.range"); + expect(ll).not.toContain("fptosi double"); }); test("a body mutation keeps the byte loop on the general f64 path", () => { @@ -324,8 +324,8 @@ test("a body mutation keeps the byte loop on the general f64 path", () => { const ll = emitLlvmModule(mod); expect(ll).not.toContain("integer induction index"); - expect(ll).toContain("bytes.index.range"); - expect(ll).toContain("fptoui double"); + expect(ll).toContain("bytes.index.valid"); + expect(ll).toContain("fptosi double"); }); test("large record clones stay outlined while small clones remain inlineable", () => { From 7cdbfc26a969b157be40bdb3d0aecf3341e3cf18 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 12:49:26 -0700 Subject: [PATCH 17/22] Format local-array-reads and record the preflight baseline for corpus 4671 --- .../compiler/src/backend/llvm/local-array-reads.ts | 13 +++++++++++-- .../compiler/test/ts7/baselines/order-parity.json | 6 ++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index f4059b104..abe88c9d1 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -115,10 +115,19 @@ function inlineArrayRead( nullable?.(value.type.unionId) !== true ) return null; - return { type: value.type, element, presentTag, missingTag, array, index, nullableElement: true }; + return { + type: value.type, + element, + presentTag, + missingTag, + array, + index, + nullableElement: true, + }; } const element = array.type.elem; - if ((!isRefCounted(element) && !isScalarElement(element)) || element.kind === "union") return null; + if ((!isRefCounted(element) && !isScalarElement(element)) || element.kind === "union") + return null; const presentTag = arms.findIndex((arm) => typeEquals(arm, element)); if (presentTag < 0 || then.kind !== "unionWrap" || then.tag !== presentTag) return null; return { type: value.type, element, presentTag, missingTag, array, index }; diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 537f6dea7..987062767 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15859,6 +15859,12 @@ "/tests/corpus/constant-module-globals/main.ts" ], "diags": [] + }, + "/tests/corpus/4671-scoped-array-read-borrows.ts": { + "order": [ + "/tests/corpus/4671-scoped-array-read-borrows.ts" + ], + "diags": [] } } } From 6eef5b7e5a7492ce4d0cd43e502962282a529a33 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 14:54:07 -0500 Subject: [PATCH 18/22] Merge main into perf/gc-trace-null-skip-main --- packages/compiler/test/bytes-element-emission.test.ts | 6 +++--- packages/compiler/test/constant-table-emission.test.ts | 9 +++++---- 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 953687993..3c001a148 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -314,7 +314,7 @@ test("canonical byte loops keep their induction variable and indices integral", expect(ll).toContain("alloca i64 ; integer induction index"); expect(ll).toContain("icmp ult i64"); - expect(ll).not.toContain("bytes.index.range"); + expect(ll).not.toContain("fptosi double"); }); test("a body mutation keeps the byte loop on the general f64 path", () => { @@ -324,8 +324,8 @@ test("a body mutation keeps the byte loop on the general f64 path", () => { const ll = emitLlvmModule(mod); expect(ll).not.toContain("integer induction index"); - expect(ll).toContain("bytes.index.range"); - expect(ll).toContain("fptoui double"); + expect(ll).toContain("bytes.index.valid"); + expect(ll).toContain("fptosi double"); }); test("large record clones stay outlined while small clones remain inlineable", () => { diff --git a/packages/compiler/test/constant-table-emission.test.ts b/packages/compiler/test/constant-table-emission.test.ts index 60be74507..5fddb2663 100644 --- a/packages/compiler/test/constant-table-emission.test.ts +++ b/packages/compiler/test/constant-table-emission.test.ts @@ -33,14 +33,15 @@ console.log(lookup(1), mutated[0] * 1); expect(llvm).toContain("@sc_const_numbers_0 = private constant [3 x double]"); expect(llvm).toContain("%initialized = icmp ne ptr %a, null"); - expect(llvm).toContain("br i1 %safe, label %convert, label %fallback"); - expect(llvm).toContain("br i1 %integer, label %read, label %fallback"); + expect(llvm).toContain("%ix0 = fptosi double %i to i64"); + expect(llvm).toContain("%safe = and i1 %initialized, %ix5"); + expect(llvm).toContain("br i1 %safe, label %read, label %fallback"); expect(llvm).toMatch(/call double @sc_const_numbers_0_get\(/); expect(llvm).toContain("call double @scr_arr_get_number(ptr %a, double %i)"); expect(llvm).toMatch(/call double @scr_arr_push_many\(ptr [^,]+, i64 3, ptr /); const wasm = emitLlvmModule(mod, { pointerBits: 32, wasi: true }); - expect(wasm).toContain("%index = fptoui double %i to i32"); - expect(wasm).toContain("ptr @sc_const_numbers_0, i32 0, i32 %index"); + expect(wasm).toContain("%ix0 = fptosi double %i to i64"); + expect(wasm).toContain("ptr @sc_const_numbers_0, i64 0, i64 %ix1"); } finally { await rm(dir, { recursive: true, force: true }); } From 187ca8b998b17b88e7206ee21788d832f11b9869 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 15:05:18 -0500 Subject: [PATCH 19/22] Check the index guard in the integer byte-loop test --- packages/compiler/test/bytes-element-emission.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/compiler/test/bytes-element-emission.test.ts b/packages/compiler/test/bytes-element-emission.test.ts index 3c001a148..08da691ee 100644 --- a/packages/compiler/test/bytes-element-emission.test.ts +++ b/packages/compiler/test/bytes-element-emission.test.ts @@ -314,7 +314,7 @@ test("canonical byte loops keep their induction variable and indices integral", expect(ll).toContain("alloca i64 ; integer induction index"); expect(ll).toContain("icmp ult i64"); - expect(ll).not.toContain("fptosi double"); + expect(ll).not.toContain("bytes.index.valid"); }); test("a body mutation keeps the byte loop on the general f64 path", () => { From 3bf0d0b9a59d9215a965c1a00e9a5d67dbedd7bb Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 16:45:12 -0500 Subject: [PATCH 20/22] Give the sanitized emitter job swap and a larger stack The sanitized native emitter peaks near 13 GB while it emits the compiler and then compiles the 348 MB module with clang under AddressSanitizer, which together exceed the 16 GB runner. Back the rebuild-emit job with a 24 GiB swap file and lift its stack limit, since the instrumented emitter's recursion can exceed 8 MiB. --- .github/workflows/ci.yml | 18 +++++++++++++++++- tests/harness/bootstrap-seed.test.ts | 4 +++- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 89c8da310..cdfe1f11c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -314,8 +314,24 @@ jobs: path: ${{ runner.temp }} - name: Restore bootstrap seed run: tar -C "$RUNNER_TEMP" -xf "$RUNNER_TEMP/bootstrap-seed.tar" + # The sanitized emitter peaks near 13 GB while it emits the compiler, + # then clang compiles the 348 MB module under AddressSanitizer. Together + # they exceed the runner's 16 GB of memory, so back them with swap. + - name: Enlarge swap for the sanitized emitter + if: matrix.phase == 'rebuild-emit' + run: | + sudo swapoff -a + sudo rm -f /mnt/swapfile + sudo fallocate -l 24G /mnt/swapfile + sudo chmod 600 /mnt/swapfile + sudo mkswap /mnt/swapfile + sudo swapon /mnt/swapfile + free -h - name: Native bootstrap and package installation checks - run: node scripts/ci-native-bootstrap.mjs + # The instrumented emitter's recursion can exceed the default 8 MiB stack. + run: | + if [ "$SCRIPTC_BOOTSTRAP_PHASE" = rebuild-emit ]; then ulimit -s unlimited; fi + node scripts/ci-native-bootstrap.mjs env: SCRIPTC_BOOTSTRAP_SEED_DIRECTORY: ${{ runner.temp }}/native-bootstrap SCRIPTC_BOOTSTRAP_PACKAGE_DIR: ${{ runner.temp }}/native-cli-package diff --git a/tests/harness/bootstrap-seed.test.ts b/tests/harness/bootstrap-seed.test.ts index 08d03203e..2e8b3bad9 100644 --- a/tests/harness/bootstrap-seed.test.ts +++ b/tests/harness/bootstrap-seed.test.ts @@ -75,7 +75,9 @@ test("CI restores each seed only into its matching instrumented bootstrap lane", expect([...checkFlavors].toSorted()).toEqual(seed.strategy.matrix.flavor); expect(seed.env.SCRIPTC_SAN).toBe(checks.env.SCRIPTC_SAN); const prepare = seed.steps.find((step) => step.run === "node scripts/bootstrap-seed.mjs")!; - const execute = checks.steps.find((step) => step.run === "node scripts/ci-native-bootstrap.mjs")!; + const execute = checks.steps.find((step) => + step.run?.split("\n").includes("node scripts/ci-native-bootstrap.mjs"), + )!; expect(prepare.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY).toBe("${{ runner.temp }}/native-bootstrap"); expect(execute.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY).toBe( prepare.env?.SCRIPTC_BOOTSTRAP_SEED_DIRECTORY, From fa11424e5f3351bc2e8b244243735651b1baca8e Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 17:25:14 -0500 Subject: [PATCH 21/22] Prepare v0.2.9 --- CHANGELOG.md | 27 +++++++++++++++++-- docs/content/docs/coverage.mdx | 2 +- packages/cli-darwin-arm64/package.json | 2 +- packages/cli-darwin-x64/package.json | 2 +- packages/cli-linux-arm64-gnu/package.json | 2 +- packages/cli-linux-arm64-musl/package.json | 2 +- packages/cli-linux-x64-gnu/package.json | 2 +- packages/cli-linux-x64-musl/package.json | 2 +- packages/cli-win32-x64-msvc/package.json | 2 +- packages/cli/package.json | 2 +- packages/compiler/package.json | 2 +- packages/compiler/surface-manifest.json | 2 +- packages/llvm-darwin-arm64/package.json | 2 +- packages/llvm-darwin-x64/package.json | 2 +- packages/llvm-linux-arm64-gnu/package.json | 2 +- packages/llvm-linux-arm64-musl/package.json | 2 +- packages/llvm-linux-x64-gnu/package.json | 2 +- packages/llvm-linux-x64-musl/package.json | 2 +- packages/llvm-win32-x64-msvc/package.json | 2 +- packages/runtime-android-arm64/package.json | 2 +- packages/runtime-darwin-arm64/package.json | 2 +- packages/runtime-darwin-x64/package.json | 2 +- packages/runtime-ios-arm64/package.json | 2 +- .../runtime-ios-simulator-arm64/package.json | 2 +- packages/runtime-linux-arm64-gnu/package.json | 2 +- .../runtime-linux-arm64-musl/package.json | 2 +- packages/runtime-linux-x64-gnu/package.json | 2 +- packages/runtime-linux-x64-musl/package.json | 2 +- packages/runtime-wasm32-wasi/package.json | 2 +- packages/runtime-win32-x64-msvc/package.json | 2 +- packages/runtime/package.json | 2 +- packages/threads/package.json | 2 +- 32 files changed, 56 insertions(+), 33 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26bdea424..e94aa68ca 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,31 @@ All notable changes to scriptc will be documented in this file. +## 0.2.9 + +### Features + +- **`os.availableParallelism()` works in static programs.** +- **`--detached-exit` frees the address space after `process.exit` off the critical path,** so large programs exit sooner. + +### Performance + +- **Cycle-capable objects are smaller.** A 16-byte collector header, typed rest spreads, leaf capture boxes, rarely used array storage moved out of line, and tracing only types that can actually lie on a cycle. +- **The heap and cycle collector scale better.** Busy small-object slices use transparent huge pages, the collector's growth fraction widens further on large live heaps, and `Map`/`Set` `clear` and `delete` cost O(live) rather than O(peak). +- **Indexed reads are cheaper.** Typed-array reads stay plain numbers where `undefined` acts like `NaN`, unproven indexes are checked with a signed round trip, lengths and in-range indexes convert to doubles without the unsigned sequence, nullable-union array elements are read inline, and counted loops that start from an unproven value run on integer counters. +- **Worker programs pay less overhead.** Fewer termination polls, an exact per-thread pending word, a call-free macOS stack guard, and exiting worker threads return their free pages to the system. +- **More receivers and fields stay native.** Optional-chain receivers are borrowed, `int32` fields survive `Error` subclasses that forward constructor arguments, and method calls stay native when only an unrelated callback field shares the name. +- **Compiler-scale programs compile without hitting V8's string limit.** Serialized IR is streamed. + +### Fixes + +- **More values behave as in Node.js.** Missing array and typed-array reads stay `undefined` through number arguments, narrowed slots and lambda results, and `null` stays distinct from the temporal dead zone in forward-captured bindings. +- **Union class fields work in more shapes.** Optional base-class receivers are narrowed before shared field access, and fields declared with different types across a union can be read and written. +- **Workers share descriptor tables safely,** and live array references are accessed one element at a time. +- **Native program objects use runtime ABI v10.** The collector header changed size, so external object consumers must link the matching v0.2.9 runtime pack; the versioned marker rejects incompatible runtime objects at link time. + + + ## 0.2.8 ### Features @@ -28,8 +53,6 @@ All notable changes to scriptc will be documented in this file. - **Debuggers stop only at user code.** Shared helpers no longer appear on user source lines, so a breakpoint on a line stops only at that line's code. - **Native program objects use runtime ABI v9.** External object consumers must link the matching v0.2.8 runtime pack; the versioned marker rejects incompatible runtime objects at link time. - - ## 0.2.7 ### Features diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx index 4f300e11e..571aad875 100644 --- a/docs/content/docs/coverage.mdx +++ b/docs/content/docs/coverage.mdx @@ -187,7 +187,7 @@ An unchanged program reuses the previous verdict: coverage records the files and { "schema": "scriptc-diagnostics", "schemaVersion": 1, - "compilerVersion": "0.2.8", + "compilerVersion": "0.2.9", "command": "coverage", "entry": "/work/app/src/main.ts", "success": false, diff --git a/packages/cli-darwin-arm64/package.json b/packages/cli-darwin-arm64/package.json index eb0069acc..e00e9f9a2 100644 --- a/packages/cli-darwin-arm64/package.json +++ b/packages/cli-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for darwin-arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-darwin-x64/package.json b/packages/cli-darwin-x64/package.json index 685567983..da7694127 100644 --- a/packages/cli-darwin-x64/package.json +++ b/packages/cli-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-darwin-x64", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for darwin-x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-gnu/package.json b/packages/cli-linux-arm64-gnu/package.json index b92422d85..d6f7214ef 100644 --- a/packages/cli-linux-arm64-gnu/package.json +++ b/packages/cli-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for linux-arm64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-arm64-musl/package.json b/packages/cli-linux-arm64-musl/package.json index 18f0667b5..46e3d7982 100644 --- a/packages/cli-linux-arm64-musl/package.json +++ b/packages/cli-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-arm64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for linux-arm64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-gnu/package.json b/packages/cli-linux-x64-gnu/package.json index bce0745e9..f515da505 100644 --- a/packages/cli-linux-x64-gnu/package.json +++ b/packages/cli-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for linux-x64-gnu", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-linux-x64-musl/package.json b/packages/cli-linux-x64-musl/package.json index c32d8820c..28a50f5a7 100644 --- a/packages/cli-linux-x64-musl/package.json +++ b/packages/cli-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-linux-x64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for linux-x64-musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli-win32-x64-msvc/package.json b/packages/cli-win32-x64-msvc/package.json index e9f4b8a2c..7a97df09e 100644 --- a/packages/cli-win32-x64-msvc/package.json +++ b/packages/cli-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/cli-win32-x64-msvc", - "version": "0.2.8", + "version": "0.2.9", "description": "Native scriptc compiler for win32-x64-msvc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/cli/package.json b/packages/cli/package.json index 97010754a..e013e6080 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,6 +1,6 @@ { "name": "scriptc", - "version": "0.2.8", + "version": "0.2.9", "description": "Compile ordinary TypeScript and JavaScript to small, fast native executables — no Node, no V8, no JavaScript engine in the binary", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/package.json b/packages/compiler/package.json index 60ac2a43d..fb697202b 100644 --- a/packages/compiler/package.json +++ b/packages/compiler/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/compiler", - "version": "0.2.8", + "version": "0.2.9", "description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/compiler/surface-manifest.json b/packages/compiler/surface-manifest.json index 531a17197..083f7bad8 100644 --- a/packages/compiler/surface-manifest.json +++ b/packages/compiler/surface-manifest.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "compilerVersion": "0.2.8", + "compilerVersion": "0.2.9", "coverage": [ "Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.", "Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, global functions (parseInt, parseFloat, isNaN, isFinite), and the process surface outside its ambient slice.", diff --git a/packages/llvm-darwin-arm64/package.json b/packages/llvm-darwin-arm64/package.json index 0411366b2..1d11eb252 100644 --- a/packages/llvm-darwin-arm64/package.json +++ b/packages/llvm-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Pinned LLVM code-generation helper for scriptc on macOS arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-darwin-x64/package.json b/packages/llvm-darwin-x64/package.json index f82125597..91bb387b8 100644 --- a/packages/llvm-darwin-x64/package.json +++ b/packages/llvm-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-darwin-x64", - "version": "0.2.8", + "version": "0.2.9", "description": "Pinned LLVM code-generation helper for scriptc on macOS x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-gnu/package.json b/packages/llvm-linux-arm64-gnu/package.json index 5999c7dad..09e1b3dc4 100644 --- a/packages/llvm-linux-arm64-gnu/package.json +++ b/packages/llvm-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Pinned LLVM code-generation helper for scriptc on Linux arm64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-arm64-musl/package.json b/packages/llvm-linux-arm64-musl/package.json index b35e9560d..2a196f380 100644 --- a/packages/llvm-linux-arm64-musl/package.json +++ b/packages/llvm-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-arm64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Statically linked LLVM code-generation helper for scriptc on Linux arm64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-gnu/package.json b/packages/llvm-linux-x64-gnu/package.json index 38215d335..0898235d8 100644 --- a/packages/llvm-linux-x64-gnu/package.json +++ b/packages/llvm-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Pinned LLVM code-generation helper for scriptc on Linux x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-linux-x64-musl/package.json b/packages/llvm-linux-x64-musl/package.json index 3c741d11c..9a99c16fd 100644 --- a/packages/llvm-linux-x64-musl/package.json +++ b/packages/llvm-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-linux-x64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Statically linked LLVM code-generation helper for scriptc on Linux x64 musl", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/llvm-win32-x64-msvc/package.json b/packages/llvm-win32-x64-msvc/package.json index e36e92138..4bcae5a98 100644 --- a/packages/llvm-win32-x64-msvc/package.json +++ b/packages/llvm-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/llvm-win32-x64-msvc", - "version": "0.2.8", + "version": "0.2.9", "description": "Pinned LLVM code-generation helper for scriptc on Windows x64", "license": "Apache-2.0 WITH LLVM-exception", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-android-arm64/package.json b/packages/runtime-android-arm64/package.json index f34dcd9ac..815f2c76b 100644 --- a/packages/runtime-android-arm64/package.json +++ b/packages/runtime-android-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-android-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc library runtime pack for Android arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-arm64/package.json b/packages/runtime-darwin-arm64/package.json index 91283e733..f701a215c 100644 --- a/packages/runtime-darwin-arm64/package.json +++ b/packages/runtime-darwin-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for macOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-darwin-x64/package.json b/packages/runtime-darwin-x64/package.json index f98a848cb..14f68f212 100644 --- a/packages/runtime-darwin-x64/package.json +++ b/packages/runtime-darwin-x64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-darwin-x64", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for macOS x64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-arm64/package.json b/packages/runtime-ios-arm64/package.json index bf1491661..61755f9ce 100644 --- a/packages/runtime-ios-arm64/package.json +++ b/packages/runtime-ios-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc library runtime pack for iOS arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-ios-simulator-arm64/package.json b/packages/runtime-ios-simulator-arm64/package.json index 23d08ba5c..531021975 100644 --- a/packages/runtime-ios-simulator-arm64/package.json +++ b/packages/runtime-ios-simulator-arm64/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-ios-simulator-arm64", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc library runtime pack for iOS simulator arm64", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-gnu/package.json b/packages/runtime-linux-arm64-gnu/package.json index 9df175e51..c207e53ef 100644 --- a/packages/runtime-linux-arm64-gnu/package.json +++ b/packages/runtime-linux-arm64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for Linux arm64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-arm64-musl/package.json b/packages/runtime-linux-arm64-musl/package.json index 2e2ab24c7..6f401d0f7 100644 --- a/packages/runtime-linux-arm64-musl/package.json +++ b/packages/runtime-linux-arm64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-arm64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for Linux arm64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-gnu/package.json b/packages/runtime-linux-x64-gnu/package.json index 2bc304d0b..b7039f61b 100644 --- a/packages/runtime-linux-x64-gnu/package.json +++ b/packages/runtime-linux-x64-gnu/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-gnu", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for Linux x64 glibc", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-linux-x64-musl/package.json b/packages/runtime-linux-x64-musl/package.json index 8bd8367e2..ed2c8f68d 100644 --- a/packages/runtime-linux-x64-musl/package.json +++ b/packages/runtime-linux-x64-musl/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-linux-x64-musl", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for Linux x64 musl", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-wasm32-wasi/package.json b/packages/runtime-wasm32-wasi/package.json index fb6107af9..80cd4647d 100644 --- a/packages/runtime-wasm32-wasi/package.json +++ b/packages/runtime-wasm32-wasi/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-wasm32-wasi", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for WASI Preview 1", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime-win32-x64-msvc/package.json b/packages/runtime-win32-x64-msvc/package.json index 0d85ed544..81815d6a0 100644 --- a/packages/runtime-win32-x64-msvc/package.json +++ b/packages/runtime-win32-x64-msvc/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime-win32-x64-msvc", - "version": "0.2.8", + "version": "0.2.9", "description": "Precompiled scriptc runtime pack for Windows x64 MSVC", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/runtime/package.json b/packages/runtime/package.json index e6ea4b45d..7d43c5741 100644 --- a/packages/runtime/package.json +++ b/packages/runtime/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/runtime", - "version": "0.2.8", + "version": "0.2.9", "description": "scriptc native runtime sources and vendored dependencies", "license": "Apache-2.0", "homepage": "https://scriptc.dev", diff --git a/packages/threads/package.json b/packages/threads/package.json index 33c874916..ee37918ff 100644 --- a/packages/threads/package.json +++ b/packages/threads/package.json @@ -1,6 +1,6 @@ { "name": "@scriptc/threads", - "version": "0.2.8", + "version": "0.2.9", "description": "Zero-copy publication of immutable object graphs to worker threads (scriptc), with a deep-freezing Node.js implementation", "license": "Apache-2.0", "homepage": "https://scriptc.dev", From 6adb753e895b17d1638240c5f4984073bc03f513 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Sun, 11 Oct 2026 17:32:45 -0500 Subject: [PATCH 22/22] Correct the runtime ABI version in the 0.2.9 notes --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e94aa68ca..3092cff60 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ All notable changes to scriptc will be documented in this file. - **More values behave as in Node.js.** Missing array and typed-array reads stay `undefined` through number arguments, narrowed slots and lambda results, and `null` stays distinct from the temporal dead zone in forward-captured bindings. - **Union class fields work in more shapes.** Optional base-class receivers are narrowed before shared field access, and fields declared with different types across a union can be read and written. - **Workers share descriptor tables safely,** and live array references are accessed one element at a time. -- **Native program objects use runtime ABI v10.** The collector header changed size, so external object consumers must link the matching v0.2.9 runtime pack; the versioned marker rejects incompatible runtime objects at link time. +- **Native program objects use runtime ABI v12.** Runtime object layouts changed, so external object consumers must link the matching v0.2.9 runtime pack; the versioned marker rejects incompatible runtime objects at link time.