From 4b325150fa16da370b30feb1604f265bb886659f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:45:24 -0700 Subject: [PATCH 1/5] 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 6a9475e931cad80061db7703595740c798b29a31 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 07:05:29 -0700 Subject: [PATCH 2/5] 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 3e83a7b38c613a66e527333a579f31a3f168bfe4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:26 -0700 Subject: [PATCH 3/5] 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 4/5] 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 5/5] 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": [] } } }