mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-30 07:14:39 +08:00
Merge branch 'main' into perf/locality
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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/**",
|
||||
|
||||
+25
-2
@@ -6,6 +6,31 @@ All notable changes to scriptc will be documented in this file.
|
||||
|
||||
<!-- release:start -->
|
||||
|
||||
## 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 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.
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 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.
|
||||
|
||||
<!-- release:end -->
|
||||
|
||||
## 0.2.7
|
||||
|
||||
### Features
|
||||
|
||||
@@ -108,6 +108,9 @@ Verifies and reads the installed precompiled runtime objects and vendor archives
|
||||
<dt><code>--windows-subsystem <console|gui></code></dt>
|
||||
<dd>Select the subsystem of a Windows executable. <code>console</code> is the default; <code>gui</code> prevents Windows from opening a console window for the app. Valid only for executable builds targeting Windows.</dd>
|
||||
|
||||
<dt><code>--detached-exit</code></dt>
|
||||
<dd>On Linux, when the program calls <code>process.exit()</code>, 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.</dd>
|
||||
|
||||
<dt><code>--npm-static <pkg[,pkg…]|auto></code></dt>
|
||||
<dd>EXPERIMENTAL. Compile the named npm packages' shipped JS statically as program modules instead of embedding them for the engine (repeatable; <code>auto</code> opts in every eligible direct import). A package the preflight refuses falls back to the island with a coverage-report note. See <a href="/dependencies">npm Dependencies</a> for maturity notes.</dd>
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,6 @@
|
||||
"schemaVersion": 3,
|
||||
"nodeVersion": "24.15.0",
|
||||
"nodeCommit": "848430679556aed0bd073f2bc263331ad84fa119",
|
||||
"artifactVersion": "ae934a592466ef1e67c1",
|
||||
"artifactVersion": "cb524f2eb043e40eb9f4",
|
||||
"rowCount": 3662
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -126,6 +126,7 @@ async function tryFastPath(): Promise<number | null> {
|
||||
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 ||
|
||||
|
||||
@@ -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) });
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
@@ -545,10 +555,34 @@ test("synchronous bodies load the active exception cell once for all pending che
|
||||
/^(?: %[^\n]* = alloca [^\n]*\n)* %exc\.cell = load ptr, ptr @scr_exc_active/,
|
||||
);
|
||||
expect(work.match(/= load i32, ptr %exc\.cell\b/g)).toHaveLength(3);
|
||||
// Worker executables test the process-wide alert word at each check
|
||||
// instead, and never read their thread-local cell inline.
|
||||
const workers = body(emitLlvmModule({ ...module, workers: true }), "sc_f_work");
|
||||
// Darwin and Windows worker executables test the process-wide alert word
|
||||
// at each check instead, and never read their thread-local cell inline.
|
||||
const darwin = { targetTriple: "arm64-apple-macosx14.0.0" };
|
||||
const workers = body(emitLlvmModule({ ...module, workers: true }, darwin), "sc_f_work");
|
||||
expect(workers).not.toContain("%exc.cell");
|
||||
expect(workers).not.toContain("@scr_exc_active");
|
||||
expect(workers.match(/= load atomic i32, ptr @scr_exc_alert monotonic/g)).toHaveLength(3);
|
||||
expect(workers.match(/call zeroext i1 @scr_exc_pending\(\)/g)).toHaveLength(3);
|
||||
// ELF worker executables test the thread's exact pending word and unwind
|
||||
// directly: no check carries an out-of-line slow path.
|
||||
const elf = { targetTriple: "x86_64-unknown-linux-gnu" };
|
||||
const flagLlvm = emitLlvmModule({ ...module, workers: true }, elf);
|
||||
expect(flagLlvm).toContain("@scr_exc_flag = external thread_local(localexec) global i32");
|
||||
const flag = body(flagLlvm, "sc_f_work");
|
||||
expect(flag).not.toContain("@scr_exc_alert");
|
||||
expect(flag).not.toContain("@scr_exc_pending");
|
||||
// Outside loops the tests are unordered loads LLVM may forward around.
|
||||
expect(flag.match(/= load atomic i32, ptr @scr_exc_flag unordered/g)).toHaveLength(3);
|
||||
expect(flag).not.toContain("monotonic");
|
||||
// Inside a source loop (whose termination poll the call's test replaces)
|
||||
// the test stays ordered, so LLVM cannot hoist it out of the loop.
|
||||
const spin: IrStmt = {
|
||||
kind: "while",
|
||||
cond: { kind: "boolLit", value: true, type: BOOL, loc },
|
||||
body: [call()],
|
||||
loc,
|
||||
};
|
||||
const looped = body(emitLlvmModule({ ...moduleFor([spin]), workers: true }, elf), "sc_f_work");
|
||||
expect(looped.match(/= load atomic i32, ptr @scr_exc_flag monotonic/g)).toHaveLength(1);
|
||||
expect(looped).not.toContain("unordered");
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
@@ -400,6 +401,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 {
|
||||
@@ -528,8 +533,16 @@ export class LlEmitter {
|
||||
readonly inlineTrunc: boolean;
|
||||
/** ELF worker executables give thread-locals the executable TLS models. */
|
||||
private readonly executableTls: boolean;
|
||||
/** Source loops enclosing the statement being emitted in the current
|
||||
* function body. */
|
||||
private loopDepth = 0;
|
||||
/** Darwin worker executables read the stack guard from a pthread
|
||||
* thread-specific-data slot off the thread pointer (scr_runtime.h's
|
||||
* SCR_STACK_GUARD_TSD): the architecture names the access sequence. */
|
||||
private readonly stackGuardTsd: "aarch64" | "x86_64" | null;
|
||||
private readonly emitLibraryIdentity: boolean;
|
||||
private readonly runtimeAbiMarker: boolean;
|
||||
private readonly detachedExit: boolean;
|
||||
/** Interned string literals: UTF-8 text → { symbol, byte length } —
|
||||
* first-use order, the runtime ABI’s determinism discipline. */
|
||||
private readonly literals = new Map<string, { sym: string; len: number }>();
|
||||
@@ -836,8 +849,18 @@ export class LlEmitter {
|
||||
mod.lib === undefined &&
|
||||
!this.wasi &&
|
||||
!/apple|darwin|windows|mingw|cygwin|win32/.test(options.targetTriple || process.platform);
|
||||
const host = options.targetTriple || `${process.arch}-${process.platform}`;
|
||||
this.stackGuardTsd =
|
||||
mod.workers === true && mod.lib === undefined && !this.wasi && /apple|darwin/.test(host)
|
||||
? /^(arm64|aarch64)/.test(host)
|
||||
? "aarch64"
|
||||
: /^(x86_64|amd64|x64)/.test(host)
|
||||
? "x86_64"
|
||||
: null
|
||||
: null;
|
||||
this.emitLibraryIdentity = options.emitLibraryIdentity !== false;
|
||||
this.runtimeAbiMarker = options.runtimeAbiMarker === true;
|
||||
this.detachedExit = options.detachedExit === true;
|
||||
this.rcHelpers = options.inlineRc === true ? new Set() : null;
|
||||
// ScrCycHdr.buffered sits 6 bytes behind every object. The wasm32
|
||||
// header pads before its descriptor so its payload remains
|
||||
@@ -874,7 +897,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,
|
||||
@@ -1452,11 +1478,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(
|
||||
@@ -1465,7 +1493,7 @@ export class LlEmitter {
|
||||
)
|
||||
.replace(
|
||||
/^(@[-$._A-Za-z0-9]+ = external )thread_local global /gm,
|
||||
"$1thread_local(initialexec) global ",
|
||||
"$1thread_local(localexec) global ",
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -1928,6 +1956,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(``);
|
||||
@@ -3792,13 +3824,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();
|
||||
@@ -3852,6 +3898,21 @@ export class LlEmitter {
|
||||
this.B.br(cleanup.label);
|
||||
}
|
||||
|
||||
/** Loads the running thread's exact pending word (ELF worker
|
||||
* executables, scr_exception.c): bit 0 mirrors the active cell, bit 1
|
||||
* requests the context's stop. It lives in the executable's static TLS
|
||||
* block, so the access is one thread-pointer-relative load. Monotonic by
|
||||
* default: a loop must not hoist the load out and miss a cross-thread
|
||||
* stop (see emitPendingCheck for the unordered tests). */
|
||||
private loadExcFlag(unordered = false): string {
|
||||
this.declare(`@scr_exc_flag = external thread_local(localexec) global i32, align 4`);
|
||||
const word = this.B.tmp();
|
||||
this.B.line(
|
||||
`${word} = load atomic i32, ptr @scr_exc_flag ${unordered ? "unordered" : "monotonic"}, align 4`,
|
||||
);
|
||||
return word;
|
||||
}
|
||||
|
||||
/** Instructions (two-space indented) that set the i1 `dest` to "an
|
||||
* exception is pending in the active context" — scr_exc_pending()'s
|
||||
* meaning — for the helper and adapter bodies that test it outside
|
||||
@@ -3879,8 +3940,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) &&
|
||||
@@ -3902,16 +3964,34 @@ export class LlEmitter {
|
||||
return;
|
||||
}
|
||||
// Executables test the active exception cell's kind inline (its first
|
||||
// field). Worker programs test the process-wide alert word instead: it
|
||||
// is nonzero while any script thread may have an exception pending in
|
||||
// its active cell or has been asked to stop, so the common case is one
|
||||
// ordinary global load (a thread-local access is a call on Darwin) and
|
||||
// the out-of-line scr_exc_pending answers for this thread, observing
|
||||
// cancellation and reinstalling the termination sentinel.
|
||||
// field). ELF worker executables test the thread's exact pending word,
|
||||
// which is also nonzero once the context must stop, and unwind directly:
|
||||
// no site carries an out-of-line call, so LLVM's inliner sees the check
|
||||
// as two instructions. Other worker programs test the process-wide
|
||||
// alert word: it is nonzero while any script thread may have an
|
||||
// exception pending in its active cell or has been asked to stop, so
|
||||
// the common case is one ordinary global load (a thread-local access is
|
||||
// a call on Darwin) and the out-of-line scr_exc_pending answers for this
|
||||
// thread, observing cancellation and reinstalling the termination
|
||||
// sentinel.
|
||||
this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
|
||||
let word: string;
|
||||
if (this.mod.workers === true) {
|
||||
this.declare(`@scr_exc_alert = external global i32, align 64`);
|
||||
const exact = this.mod.workers !== true || this.executableTls;
|
||||
if (this.executableTls) {
|
||||
// Termination stays observable where the polls were dropped for it:
|
||||
// polls, tests inside source loops (exc's loop-poll rule relies on
|
||||
// them every iteration) and tail-position tests (a dead ordered load
|
||||
// keeps recursion from becoming a loop without stack growth) use an
|
||||
// ordered load, which LLVM never deletes, hoists or merges. Every
|
||||
// other test is an unordered load: it reads the same word, but LLVM's
|
||||
// memory analyses no longer treat it as a write, so loads around it
|
||||
// stay forwardable after inlining. Same-thread changes only happen in
|
||||
// runtime calls, which clobber memory.
|
||||
word = this.loadExcFlag(!poll && through === undefined && this.loopDepth === 0);
|
||||
} else if (this.mod.workers === true) {
|
||||
// dso_local: executables link the runtime statically, so the word is
|
||||
// addressed directly instead of through the GOT.
|
||||
this.declare(`@scr_exc_alert = external dso_local global i32, align 64`);
|
||||
word = B.tmp();
|
||||
B.line(`${word} = load atomic i32, ptr @scr_exc_alert monotonic, align 64`);
|
||||
} else {
|
||||
@@ -3939,7 +4019,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);
|
||||
@@ -3971,30 +4051,98 @@ export class LlEmitter {
|
||||
* throw Node's RangeError and unwind like any other pending exception. */
|
||||
private emitStackCheck(): void {
|
||||
const B = this.B;
|
||||
const tl = this.mod.workers === true ? "thread_local " : "";
|
||||
this.declare(`@scr_stack_guard = external ${tl}global ptr`);
|
||||
this.declare(`declare ptr @llvm.stacksave.p0()`);
|
||||
this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
|
||||
this.declare(`declare void @scr_stack_overflow()`);
|
||||
const sp = B.tmp();
|
||||
const guard = B.tmp();
|
||||
const low = B.tmp();
|
||||
const cold = B.tmp();
|
||||
B.line(`${sp} = call ptr @llvm.stacksave.p0()`);
|
||||
B.line(`${guard} = load ptr, ptr @scr_stack_guard`);
|
||||
B.line(`${low} = icmp ult ptr ${sp}, ${guard}`);
|
||||
B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`);
|
||||
const over = B.newLabel("stack.over");
|
||||
const ok = B.newLabel("stack.ok");
|
||||
B.condBr(cold, over, ok);
|
||||
const sp = B.tmp();
|
||||
B.line(`${sp} = call ptr @llvm.stacksave.p0()`);
|
||||
const test = (guard: string): void => {
|
||||
const low = B.tmp();
|
||||
const cold = B.tmp();
|
||||
B.line(`${low} = icmp ult ptr ${sp}, ${guard}`);
|
||||
B.line(`${cold} = call i1 @llvm.expect.i1(i1 ${low}, i1 false)`);
|
||||
B.condBr(cold, over, ok);
|
||||
};
|
||||
if (this.stackGuardTsd !== null) {
|
||||
// A Darwin thread-local access is a call to its TLV thunk: read the
|
||||
// guard from its thread-specific-data slot at the runtime-validated
|
||||
// offset instead, and call out only when the runtime found no slot.
|
||||
this.declare(`@scr_stack_guard_tsd = external dso_local global i64`);
|
||||
this.declare(`declare i64 @scr_stack_guard_get()`);
|
||||
const offset = B.tmp();
|
||||
const direct = B.tmp();
|
||||
const likely = B.tmp();
|
||||
B.line(`${offset} = load i64, ptr @scr_stack_guard_tsd`);
|
||||
B.line(`${direct} = icmp ne i64 ${offset}, 0`);
|
||||
B.line(`${likely} = call i1 @llvm.expect.i1(i1 ${direct}, i1 true)`);
|
||||
const fast = B.newLabel("stack.tsd");
|
||||
const slow = B.newLabel("stack.tlv");
|
||||
B.condBr(likely, fast, slow);
|
||||
B.startBlock(fast);
|
||||
const guard = B.tmp();
|
||||
if (this.stackGuardTsd === "aarch64") {
|
||||
this.declare(`declare i64 @llvm.read_register.i64(metadata)`);
|
||||
const thread = B.tmp();
|
||||
const base = B.tmp();
|
||||
const slot = B.tmp();
|
||||
const address = B.tmp();
|
||||
B.line(`${thread} = call i64 @llvm.read_register.i64(metadata !{!"tpidrro_el0"})`);
|
||||
B.line(`${base} = and i64 ${thread}, -8`);
|
||||
B.line(`${slot} = add i64 ${base}, ${offset}`);
|
||||
B.line(`${address} = inttoptr i64 ${slot} to ptr`);
|
||||
B.line(`${guard} = load ptr, ptr ${address}`);
|
||||
} else {
|
||||
const address = B.tmp();
|
||||
B.line(`${address} = inttoptr i64 ${offset} to ptr addrspace(256)`);
|
||||
B.line(`${guard} = load ptr, ptr addrspace(256) ${address}`);
|
||||
}
|
||||
test(guard);
|
||||
B.startBlock(slow);
|
||||
// uintptr_t scr_stack_guard_get(void): an i64 in the C ABI.
|
||||
const raw = B.tmp();
|
||||
const fallback = B.tmp();
|
||||
B.line(`${raw} = call i64 @scr_stack_guard_get()`);
|
||||
B.line(`${fallback} = inttoptr i64 ${raw} to ptr`);
|
||||
test(fallback);
|
||||
} else {
|
||||
const tl = this.mod.workers === true ? "thread_local " : "";
|
||||
this.declare(`@scr_stack_guard = external ${tl}global ptr`);
|
||||
const guard = B.tmp();
|
||||
B.line(`${guard} = load ptr, ptr @scr_stack_guard`);
|
||||
test(guard);
|
||||
}
|
||||
B.startBlock(over);
|
||||
B.line(`call void @scr_stack_overflow()`);
|
||||
this.emitUnwind();
|
||||
B.startBlock(ok);
|
||||
}
|
||||
|
||||
private workerLoopBudget(): string | null {
|
||||
/** Whether the emitter follows this call with a pending test (the
|
||||
* conditions in expr-calls.ts). */
|
||||
private callTestsPending(e: IrExpr): boolean {
|
||||
switch (e.kind) {
|
||||
case "call":
|
||||
return this.mayThrow.has(e.callee);
|
||||
case "new":
|
||||
return this.mayThrow.has(`%${e.className}.constructor`);
|
||||
case "virtualCall":
|
||||
return this.mayThrowMethods.has(e.method);
|
||||
case "callValue":
|
||||
return this.indirectMayThrow;
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private workerLoopBudget(
|
||||
loop: Extract<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>,
|
||||
): string | null {
|
||||
if (!this.mod.workers) return null;
|
||||
// A loop that runs a pending test on every iteration observes a
|
||||
// terminate request through it; a separate poll would only add work.
|
||||
if (loopTestsEveryIteration(loop, (e) => this.callTestsPending(e))) return null;
|
||||
const slot = this.B.slot();
|
||||
this.B.entryAllocas.push(`${slot} = alloca i32 ; worker cancellation budget`);
|
||||
this.B.line(`store i32 0, ptr ${slot}`);
|
||||
@@ -4019,7 +4167,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);
|
||||
}
|
||||
@@ -4991,12 +5139,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();
|
||||
@@ -5015,6 +5165,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);
|
||||
@@ -5183,7 +5334,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
|
||||
@@ -5400,10 +5551,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--;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5965,7 +6120,7 @@ export class LlEmitter {
|
||||
break;
|
||||
}
|
||||
case "while": {
|
||||
const workerBudget = this.workerLoopBudget();
|
||||
const workerBudget = this.workerLoopBudget(s);
|
||||
const lc = B.newLabel("loop.c");
|
||||
const lb = B.newLabel("loop.b");
|
||||
const le = B.newLabel("loop.e");
|
||||
@@ -5990,7 +6145,7 @@ export class LlEmitter {
|
||||
break;
|
||||
}
|
||||
case "doWhile": {
|
||||
const workerBudget = this.workerLoopBudget();
|
||||
const workerBudget = this.workerLoopBudget(s);
|
||||
// Body first (runs at least once); continue jumps to the CONDITION.
|
||||
const lb = B.newLabel("loop.b");
|
||||
const lc = B.newLabel("loop.c");
|
||||
@@ -6194,7 +6349,7 @@ export class LlEmitter {
|
||||
};
|
||||
const lu = s.update || capturedLets.length > 0 ? B.newLabel("loop.u") : lc;
|
||||
freshenBindings();
|
||||
const workerBudget = this.workerLoopBudget();
|
||||
const workerBudget = this.workerLoopBudget(s);
|
||||
B.br(lc);
|
||||
B.startBlock(lc);
|
||||
this.emitWorkerLoopCheck(workerBudget);
|
||||
@@ -6297,7 +6452,7 @@ export class LlEmitter {
|
||||
const lb = B.newLabel("fof.b");
|
||||
const lu = B.newLabel("fof.u");
|
||||
const le = B.newLabel("fof.e");
|
||||
const workerBudget = this.workerLoopBudget();
|
||||
const workerBudget = this.workerLoopBudget(s);
|
||||
B.br(lc);
|
||||
B.startBlock(lc);
|
||||
this.emitWorkerLoopCheck(workerBudget);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -151,6 +151,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
|
||||
"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",
|
||||
|
||||
@@ -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<string, IrUnionDef>,
|
||||
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,40 @@ 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
|
||||
)
|
||||
if (!arms || arms.length !== 2) return null;
|
||||
const missingTag = arms.findIndex((arm) => arm.kind === "undefinedT");
|
||||
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));
|
||||
const missingTag = arms.findIndex((arm) => arm.kind === "undefinedT");
|
||||
if (presentTag < 0 || missingTag < 0 || then.tag !== presentTag || missing.tag !== missingTag)
|
||||
return null;
|
||||
if (presentTag < 0 || then.kind !== "unionWrap" || then.tag !== presentTag) return null;
|
||||
return { type: value.type, element, presentTag, missingTag, array, index };
|
||||
}
|
||||
|
||||
@@ -104,6 +137,7 @@ function inlineArrayRead(
|
||||
function arrayReadShape(
|
||||
fn: IrFunction,
|
||||
unions: ReadonlyMap<string, IrUnionDef>,
|
||||
nullable?: NullablePointerUnion,
|
||||
): ArrayReadShape | null {
|
||||
if (
|
||||
fn.async ||
|
||||
@@ -118,7 +152,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 +167,7 @@ function arrayReadShape(
|
||||
element: read.element,
|
||||
presentTag: read.presentTag,
|
||||
missingTag: read.missingTag,
|
||||
...(read.nullableElement ? { nullableElement: true } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -145,15 +180,16 @@ export class OptionalArrayReads {
|
||||
constructor(
|
||||
functions: ReadonlyMap<string, IrFunction>,
|
||||
private readonly unions: ReadonlyMap<string, IrUnionDef>,
|
||||
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 +242,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<IrStmt, StatementPosition> {
|
||||
const positions = new Map<IrStmt, StatementPosition>();
|
||||
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<IrStmt, StatementPosition>,
|
||||
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<string, IrFunction>,
|
||||
@@ -214,6 +331,7 @@ export function findLocalArrayReads(
|
||||
lifetimes: CallLifetimes = analyzeCallLifetimes(functions),
|
||||
reads = new OptionalArrayReads(functions, unions),
|
||||
loopBorrows: ReadonlySet<IrStmt> = new Set(),
|
||||
effects?: ReferenceEffects,
|
||||
): Map<string, LocalArrayRead> {
|
||||
const result = new Map<string, LocalArrayRead>();
|
||||
if (fn.async || fn.generator) return result;
|
||||
@@ -224,6 +342,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<IrStmt, StatementPosition> | undefined;
|
||||
everyStmtList(fn.body, {
|
||||
expr: () => true,
|
||||
stmt: (node) => {
|
||||
@@ -233,11 +352,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 +378,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<string>,
|
||||
lifetimes: CallLifetimes,
|
||||
effects?: ReferenceEffects,
|
||||
): Map<IrExpr, LocalArrayRead> {
|
||||
const result = new Map<IrExpr, LocalArrayRead>();
|
||||
if (fn.async || fn.generator) return result;
|
||||
@@ -274,7 +404,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 +591,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 +616,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(),
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { BOOL, F64, VOID, type IrExpr, type IrStmt } from "../ir/ir.js";
|
||||
import { loopTestsEveryIteration } from "./loop-polls.js";
|
||||
|
||||
const loc = { file: "loops.ts", start: 0, end: 1 };
|
||||
const call = (callee: string): IrExpr => ({ kind: "call", callee, args: [], type: F64, loc });
|
||||
const stmt = (expr: IrExpr): IrStmt => ({ kind: "exprStmt", expr, loc });
|
||||
const yes: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
|
||||
const tested = (e: IrExpr): boolean => e.kind === "call" && e.callee === "throws";
|
||||
const whileLoop = (body: IrStmt[], cond: IrExpr = yes, labels?: string[]): IrStmt => ({
|
||||
kind: "while",
|
||||
cond,
|
||||
body,
|
||||
loc,
|
||||
...(labels ? { labels } : {}),
|
||||
});
|
||||
const answer = (loop: IrStmt): boolean =>
|
||||
loopTestsEveryIteration(loop as Parameters<typeof loopTestsEveryIteration>[0], tested);
|
||||
const ifThen = (body: IrStmt[]): IrStmt => ({
|
||||
kind: "if",
|
||||
cond: yes,
|
||||
then: body,
|
||||
else_: null,
|
||||
loc,
|
||||
});
|
||||
const sum = (left: IrExpr, right: IrExpr): IrExpr => ({
|
||||
kind: "bin",
|
||||
op: "+",
|
||||
left,
|
||||
right,
|
||||
type: F64,
|
||||
loc,
|
||||
});
|
||||
|
||||
test("an unconditional tested call in the body or condition replaces the poll", () => {
|
||||
expect(answer(whileLoop([stmt(call("throws"))]))).toBe(true);
|
||||
expect(answer(whileLoop([stmt(sum(call("pure"), call("throws")))]))).toBe(true);
|
||||
expect(answer(whileLoop([], { kind: "toBool", operand: call("throws"), type: BOOL, loc }))).toBe(
|
||||
true,
|
||||
);
|
||||
// Untested calls and calls behind a condition or short circuit do not.
|
||||
expect(answer(whileLoop([stmt(call("pure"))]))).toBe(false);
|
||||
expect(answer(whileLoop([ifThen([stmt(call("throws"))])]))).toBe(false);
|
||||
const shortCircuit: IrExpr = {
|
||||
kind: "logical",
|
||||
op: "&&",
|
||||
left: yes,
|
||||
right: call("throws"),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
expect(answer(whileLoop([stmt(shortCircuit)]))).toBe(false);
|
||||
});
|
||||
|
||||
test("a continue before the tested call keeps the poll", () => {
|
||||
const skip: IrStmt = { kind: "continue", loc };
|
||||
expect(answer(whileLoop([ifThen([skip]), stmt(call("throws"))]))).toBe(false);
|
||||
// After the call, or bound to a nested loop, a continue is harmless.
|
||||
expect(answer(whileLoop([stmt(call("throws")), ifThen([skip])]))).toBe(true);
|
||||
expect(answer(whileLoop([whileLoop([skip]), stmt(call("throws"))]))).toBe(true);
|
||||
// A labeled continue out of a nested loop still skips this iteration.
|
||||
const labeled: IrStmt = { kind: "continue", label: "outer", loc };
|
||||
expect(answer(whileLoop([whileLoop([labeled]), stmt(call("throws"))], yes, ["outer"]))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("for updates and do-while conditions run after every continue", () => {
|
||||
const skip: IrStmt = { kind: "continue", loc };
|
||||
const forLoop: IrStmt = {
|
||||
kind: "for",
|
||||
init: null,
|
||||
cond: null,
|
||||
update: stmt(call("throws")),
|
||||
body: [ifThen([skip])],
|
||||
loc,
|
||||
};
|
||||
expect(answer(forLoop)).toBe(true);
|
||||
const doWhile: IrStmt = {
|
||||
kind: "doWhile",
|
||||
body: [ifThen([skip])],
|
||||
cond: { kind: "toBool", operand: call("throws"), type: BOOL, loc },
|
||||
loc,
|
||||
};
|
||||
expect(answer(doWhile)).toBe(true);
|
||||
const forOf: IrStmt = {
|
||||
kind: "forOf",
|
||||
localId: "x",
|
||||
iterable: { kind: "varRef", localId: "xs", type: { kind: "array", elem: F64 }, loc },
|
||||
body: [stmt({ kind: "call", callee: "throws", args: [], type: VOID, loc })],
|
||||
loc,
|
||||
} as IrStmt;
|
||||
expect(answer(forOf)).toBe(true);
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
/* Which worker-program loops can skip their termination poll. */
|
||||
import type { IrExpr, IrStmt } from "../ir/ir.js";
|
||||
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
|
||||
|
||||
type Loop = Extract<IrStmt, { kind: "while" | "doWhile" | "for" | "forOf" }>;
|
||||
|
||||
/** Expression kinds whose operands all evaluate whenever the expression does
|
||||
* (no short-circuit, deferred or conditional operand). */
|
||||
const EAGER_EXPRS: ReadonlySet<string> = new Set([
|
||||
"call",
|
||||
"new",
|
||||
"virtualCall",
|
||||
"callValue",
|
||||
"bin",
|
||||
"unary",
|
||||
"toBool",
|
||||
"strConcat",
|
||||
"strEq",
|
||||
"strCmp",
|
||||
"assignExpr",
|
||||
"fieldGet",
|
||||
"fieldIncDec",
|
||||
"arrayGet",
|
||||
"recordGet",
|
||||
"upcast",
|
||||
"downcast",
|
||||
"unionWrap",
|
||||
"dynFrom",
|
||||
]);
|
||||
|
||||
/** Statement kinds whose expressions all evaluate whenever the statement
|
||||
* does. */
|
||||
const EAGER_STMTS: ReadonlySet<string> = new Set([
|
||||
"varDecl",
|
||||
"assign",
|
||||
"exprStmt",
|
||||
"arraySet",
|
||||
"bytesSet",
|
||||
"fieldSet",
|
||||
"recordSet",
|
||||
"return",
|
||||
"throw",
|
||||
]);
|
||||
|
||||
/** True when every iteration of `loop` evaluates a call that the emitter
|
||||
* follows with a pending test (`tested`). Worker loops poll for termination
|
||||
* every 64 iterations (emitWorkerLoopCheck) so that a loop whose body calls
|
||||
* nothing that tests still observes a terminate request; a pending test reads
|
||||
* the same alert word, so a loop that always runs one needs no extra poll.
|
||||
* The tested call must be unconditional: in the condition (while, for), in
|
||||
* the update or the do-while condition (both run after `continue` too), or
|
||||
* in the body before any statement that can `continue` this loop. */
|
||||
export function loopTestsEveryIteration(loop: Loop, tested: (call: IrExpr) => boolean): boolean {
|
||||
const evaluates = (e: IrExpr): boolean => {
|
||||
if (tested(e)) return true;
|
||||
if (!EAGER_EXPRS.has(e.kind)) return false;
|
||||
let found = false;
|
||||
everyExprChild(
|
||||
e,
|
||||
(child) => !(found = evaluates(child)),
|
||||
() => true,
|
||||
);
|
||||
return found;
|
||||
};
|
||||
const statementEvaluates = (s: IrStmt): boolean => {
|
||||
if (s.kind === "if") return evaluates(s.cond);
|
||||
if (s.kind === "block") return s.body.some(statementEvaluates);
|
||||
if (!EAGER_STMTS.has(s.kind)) return false;
|
||||
let found = false;
|
||||
everyStmtChild(
|
||||
s,
|
||||
(child) => !(found = evaluates(child)),
|
||||
() => true,
|
||||
);
|
||||
return found;
|
||||
};
|
||||
const labels = new Set(loop.labels ?? []);
|
||||
// Can `s` jump to this loop's next iteration? Unlabeled continues bind to
|
||||
// the innermost enclosing loop; labeled ones may skip out of nested loops.
|
||||
const continues = (s: IrStmt, nested: boolean): boolean => {
|
||||
if (s.kind === "continue") return s.label === undefined ? !nested : labels.has(s.label);
|
||||
const inner =
|
||||
nested ||
|
||||
s.kind === "while" ||
|
||||
s.kind === "doWhile" ||
|
||||
s.kind === "for" ||
|
||||
s.kind === "forOf";
|
||||
let found = false;
|
||||
everyStmtChild(
|
||||
s,
|
||||
() => true,
|
||||
(child) => !(found = continues(child, inner)),
|
||||
);
|
||||
return found;
|
||||
};
|
||||
if (loop.kind === "while" && evaluates(loop.cond)) return true;
|
||||
if (loop.kind === "doWhile" && evaluates(loop.cond)) return true;
|
||||
if (loop.kind === "for") {
|
||||
if (loop.cond !== null && evaluates(loop.cond)) return true;
|
||||
if (loop.update !== null && statementEvaluates(loop.update)) return true;
|
||||
}
|
||||
for (const s of loop.body) {
|
||||
if (continues(s, false)) return false;
|
||||
if (statementEvaluates(s)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -437,6 +437,38 @@ test("stack checks cover recursive components and recursion through reentrant op
|
||||
expect(computeMayThrow(mod).fns.size).toBe(0);
|
||||
});
|
||||
|
||||
test("stack-checked synchronous functions skip the worker entry poll", () => {
|
||||
const asyncSelf: IrFunction = { ...fn("asyncSelf", [call("asyncSelf")], []), async: true };
|
||||
const mod: IrModule = {
|
||||
...moduleWith(
|
||||
fn("caller", [call("caller"), call("self"), call("left"), call("walker")], []),
|
||||
fn("self", [call("self")], []),
|
||||
fn("left", [call("right")], []),
|
||||
fn("right", [call("left")], []),
|
||||
fn("walker", [callClosure("leaf")], []),
|
||||
fn("leaf", [exprStmt(value)], []),
|
||||
asyncSelf,
|
||||
),
|
||||
workers: true,
|
||||
};
|
||||
const answer = computeMayThrow(mod, { stackChecks: true });
|
||||
expect([...answer.stackChecks!].sort()).toEqual(["asyncSelf", "caller", "left", "right", "self"]);
|
||||
// Callers test after every call of a stack-checked function, and its
|
||||
// overflow unwinds through those tests, so only the module entry, async
|
||||
// bodies and unbounded functions without a stack check poll on entry.
|
||||
expect([...answer.workerEntryPolls!].sort()).toEqual(["asyncSelf", "caller", "walker"]);
|
||||
expect(answer.fns.has("self") && answer.fns.has("left") && answer.fns.has("right")).toBe(true);
|
||||
// Without stack checks (WASI, Windows, libraries) every cycle keeps its poll.
|
||||
expect([...computeMayThrow(mod).workerEntryPolls!].sort()).toEqual([
|
||||
"asyncSelf",
|
||||
"caller",
|
||||
"left",
|
||||
"right",
|
||||
"self",
|
||||
"walker",
|
||||
]);
|
||||
});
|
||||
|
||||
test("class members are reachable from reentrant operations", () => {
|
||||
const mod: IrModule = {
|
||||
...moduleWith(
|
||||
|
||||
@@ -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<string>,
|
||||
stackChecks: Set<string> | null,
|
||||
): Set<string> {
|
||||
if (stackChecks === null) return entry;
|
||||
// Calling an async or generator function never tests the caller's pending
|
||||
// state, and the module entry has no caller: those keep their polls.
|
||||
const keep = new Set(
|
||||
mod.functions.filter((fn) => fn.async || fn.generator !== undefined).map((fn) => fn.name),
|
||||
);
|
||||
keep.add(mod.entry);
|
||||
return new Set([...entry].filter((name) => keep.has(name) || !stackChecks.has(name)));
|
||||
}
|
||||
|
||||
/** Statement and expression kinds that cannot reenter scriptc code except
|
||||
* through the direct call edges recorded below (`call`, `new`,
|
||||
* `virtualCall`), and cannot block in the runtime. Everything else, such as
|
||||
|
||||
@@ -86,6 +86,7 @@ async function main(args: string[], host: CliHost): Promise<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
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<number> {
|
||||
...(optimization !== undefined ? { optimization } : {}),
|
||||
...(values.strip ? { strip: true } : {}),
|
||||
...(windowsSubsystem !== undefined ? { windowsSubsystem } : {}),
|
||||
...(values["detached-exit"] ? { detachedExit: true } : {}),
|
||||
...(npmStatic !== undefined ? { npmStatic } : {}),
|
||||
...(ffiProfilePath !== undefined ? { ffiProfilePath } : {}),
|
||||
...(printNativeLinkInfo ? { nativeLinkInfo: true } : {}),
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -42,6 +42,11 @@ Options:
|
||||
--windows-subsystem <console|gui>
|
||||
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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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") },
|
||||
|
||||
@@ -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
|
||||
? "<npm-static-off>"
|
||||
: options.npmStatic === "auto"
|
||||
@@ -258,6 +261,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
|
||||
for (const part of optimizationKeyParts(options.optimization)) hash.update(`${part}\0`);
|
||||
if (options.strip) hash.update("strip\0");
|
||||
if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
|
||||
if (options.detachedExit) hash.update("detached-exit\0");
|
||||
hash
|
||||
.update(
|
||||
options.npmStatic === null
|
||||
|
||||
@@ -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<NetworkInterfaceInfo[]> shape, verified
|
||||
// structurally there — this entry only routes the dispatch.
|
||||
|
||||
@@ -762,6 +762,7 @@ async function prepareExecutableInput(
|
||||
runtimeAbiMarker: useRuntimePack,
|
||||
objectAudit: opts.sanitize === true,
|
||||
...(opts.optimization === "speed" ? { inlineRc: true } : {}),
|
||||
...(opts.detachedExit === true ? { detachedExit: true } : {}),
|
||||
});
|
||||
} catch (err) {
|
||||
if (!(err instanceof LlvmUnsupportedError)) throw err;
|
||||
@@ -842,6 +843,19 @@ async function compileTracked(
|
||||
sourceTexts: new Map(),
|
||||
};
|
||||
}
|
||||
if (opts.detachedExit === true && outputKind !== "exe") {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
nativeCodegenDiag(
|
||||
"SC3002",
|
||||
"--detached-exit is only supported for executable output",
|
||||
entryPath,
|
||||
),
|
||||
],
|
||||
sourceTexts: new Map(),
|
||||
};
|
||||
}
|
||||
if (opts.nativeLinkInfo === true && outputKind !== "obj") {
|
||||
return {
|
||||
ok: false,
|
||||
@@ -1017,6 +1031,7 @@ async function compileTracked(
|
||||
...optimizationField(opts.optimization),
|
||||
...(opts.strip ? { strip: true as const } : {}),
|
||||
...(opts.windowsSubsystem === "gui" ? { windowsSubsystem: "gui" as const } : {}),
|
||||
...(opts.detachedExit === true ? { detachedExit: true as const } : {}),
|
||||
npmStatic: opts.npmStatic ?? null,
|
||||
ffiProfile:
|
||||
opts.ffiProfilePath === undefined || ffiProfileBytes === null
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -284,6 +284,7 @@ export function prepareNativeExecutable(
|
||||
runtimeAbiMarker: outputKind === "obj" || outputKind === "exe",
|
||||
objectAudit: options.sanitize === true,
|
||||
...(options.optimization === "speed" ? { inlineRc: true } : {}),
|
||||
...(options.detachedExit === true && outputKind === "exe" ? { detachedExit: true } : {}),
|
||||
...(options.optimization === "dev" && !options.strip
|
||||
? { debugSources: prepared.sourceTexts }
|
||||
: {}),
|
||||
|
||||
@@ -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.",
|
||||
@@ -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",
|
||||
|
||||
@@ -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("bytes.index.valid");
|
||||
});
|
||||
|
||||
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", () => {
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
|
||||
@@ -46,6 +46,19 @@ test("Windows subsystem selection refuses non-executable compiler output", async
|
||||
await expect(readFile(outPath)).rejects.toMatchObject({ code: "ENOENT" });
|
||||
});
|
||||
|
||||
test("detached exit refuses non-executable compiler output", async () => {
|
||||
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");
|
||||
|
||||
@@ -15846,6 +15846,43 @@
|
||||
"<repo>/tests/corpus/final-field-borrows.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/native-worker-terminate-tested-loops.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/native-worker-terminate-tested-loops.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/native-worker-exception-state.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/native-worker-exception-state.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/native-worker-terminate-exception-state.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/native-worker-terminate-exception-state.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/4598-os-available-parallelism.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4598-os-available-parallelism.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/constant-module-globals/main.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/constant-module-globals/consts.ts",
|
||||
"<repo>/tests/corpus/constant-module-globals/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/4671-scoped-array-read-borrows.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4671-scoped-array-read-borrows.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,7 +179,7 @@ settle().then((value) => console.log(value));
|
||||
// Worker runtimes keep one small-object allocator per script thread:
|
||||
// the inline paths address its state, live counts and dispose hook in
|
||||
// the runtime's one thread-local ScrThreadHot block.
|
||||
expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(initialexec\))? global /);
|
||||
expect(llvm).toMatch(/@scr_thread_hot = external thread_local(\(localexec\))? global /);
|
||||
expect(llvm).not.toMatch(/@scr_(sa|cyc_live|cyc_old_freed|weak_dispose_hook) = external/);
|
||||
expect(llvm).toContain("call ptr @llvm.threadlocal.address.p0(ptr @scr_thread_hot)");
|
||||
expect(llvm).toMatch(/getelementptr inbounds i8, ptr %[\w.]+\.tl\.hot, i64 808/);
|
||||
@@ -228,33 +228,92 @@ test("worker entries without a source file stay refused", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("worker programs poll inline and skip termination polls in bounded functions", async () => {
|
||||
const request = await fixture(
|
||||
worker +
|
||||
`
|
||||
test.each([
|
||||
["arm64-apple-macosx14.0.0", "alert"],
|
||||
["x86_64-unknown-linux-gnu", "flag"],
|
||||
["aarch64-unknown-linux-gnu", "flag"],
|
||||
])(
|
||||
"worker programs for %s poll inline (%s) and skip termination polls in bounded functions",
|
||||
async (target, lane) => {
|
||||
vi.stubEnv("SCRIPTC_TARGET", target);
|
||||
const request = await fixture(
|
||||
worker +
|
||||
`
|
||||
function mix(a: number, b: number): number { return (a * 31 + b) % 1000003; }
|
||||
function spin(n: number): number { return n < 2 ? mix(n, 1) : spin(n - 1) + spin(n - 2); }
|
||||
console.log(mix(1, 2), spin(5));
|
||||
`,
|
||||
);
|
||||
const result = await compile(request.entry, request);
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
if (result.ok) {
|
||||
const llvm = await readFile(request.outPath, "utf8");
|
||||
const body = (name: string): string => {
|
||||
const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m"));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
return llvm.slice(start, llvm.indexOf("\n}\n", start));
|
||||
};
|
||||
// A bounded helper neither polls on entry nor makes its callers poll.
|
||||
expect(body("sc_f_mix")).not.toMatch(/@scr_exc_(alert|flag)/);
|
||||
// Recursion tests after its recursive calls (its stack check replaces
|
||||
// the entry poll) and never reads its thread-local cell inline.
|
||||
expect(body("sc_f_spin")).not.toContain("@scr_exc_active");
|
||||
if (lane === "alert") {
|
||||
// Darwin: the inline test reads the process-wide alert word (a
|
||||
// thread-local access is a call there), and only the slow path calls
|
||||
// into the runtime.
|
||||
expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/);
|
||||
expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()");
|
||||
expect(llvm).toMatch(/^@scr_exc_alert = external dso_local global i32, align 64$/m);
|
||||
} else {
|
||||
// ELF: the thread's exact pending word, one TLS load, no slow path.
|
||||
expect(body("sc_f_spin")).toMatch(
|
||||
/load atomic i32, ptr @scr_exc_flag (unordered|monotonic)/,
|
||||
);
|
||||
expect(body("sc_f_spin")).not.toContain("@scr_exc_pending");
|
||||
expect(llvm).not.toContain("@scr_exc_alert");
|
||||
expect(llvm).toMatch(
|
||||
/^@scr_exc_flag = external thread_local\(localexec\) global i32, align 4$/m,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["arm64-apple-macosx14.0.0", "tpidrro"],
|
||||
["x86_64-apple-macosx14.0.0", "gs"],
|
||||
["x86_64-unknown-linux-gnu", "tls"],
|
||||
])("worker stack checks for %s read the guard through %s", async (target, access) => {
|
||||
vi.stubEnv("SCRIPTC_TARGET", target);
|
||||
const request = await fixture(
|
||||
worker +
|
||||
"\nfunction depth(n: number): number { return n < 1 ? 0 : depth(n - 1) + 1; }\nconsole.log(depth(9));\n",
|
||||
);
|
||||
const result = await compile(request.entry, request);
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
if (result.ok) {
|
||||
const llvm = await readFile(request.outPath, "utf8");
|
||||
const body = (name: string): string => {
|
||||
const start = llvm.search(new RegExp(`^define [^\\n]*@${name}\\(`, "m"));
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
return llvm.slice(start, llvm.indexOf("\n}\n", start));
|
||||
};
|
||||
// A bounded helper neither polls on entry nor makes its callers poll.
|
||||
expect(body("sc_f_mix")).not.toContain("@scr_exc_alert");
|
||||
// Recursion polls on entry: the inline test reads the process-wide
|
||||
// alert word (no thread-local access), and only the slow path calls into
|
||||
// the runtime.
|
||||
expect(body("sc_f_spin")).toMatch(/load atomic i32, ptr @scr_exc_alert monotonic/);
|
||||
expect(body("sc_f_spin")).toContain("call zeroext i1 @scr_exc_pending()");
|
||||
expect(body("sc_f_spin")).not.toContain("@scr_exc_active");
|
||||
expect(llvm).toMatch(/^@scr_exc_alert = external global i32, align 64$/m);
|
||||
const start = llvm.search(/^define [^\n]*@sc_f_depth\(/m);
|
||||
expect(start).toBeGreaterThanOrEqual(0);
|
||||
const depth = llvm.slice(start, llvm.indexOf("\n}\n", start));
|
||||
expect(depth).toContain("call ptr @llvm.stacksave.p0()");
|
||||
if (access === "tls") {
|
||||
// ELF: one thread-pointer-relative load of the thread-local guard.
|
||||
expect(depth).toContain("load ptr, ptr @scr_stack_guard");
|
||||
expect(depth).not.toContain("@scr_stack_guard_tsd");
|
||||
} else {
|
||||
// Darwin: a thread-local access calls the TLV thunk, so the guard comes
|
||||
// from its thread-specific-data slot, with an out-of-line fallback.
|
||||
expect(depth).not.toContain("ptr @scr_stack_guard\n");
|
||||
expect(depth).toContain("load i64, ptr @scr_stack_guard_tsd");
|
||||
expect(depth).toContain("call i64 @scr_stack_guard_get()");
|
||||
if (access === "tpidrro") {
|
||||
expect(depth).toContain('@llvm.read_register.i64(metadata !{!"tpidrro_el0"})');
|
||||
} else {
|
||||
expect(depth).toMatch(/load ptr, ptr addrspace\(256\) %/);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -269,14 +328,15 @@ test.each([
|
||||
expect(result.ok, JSON.stringify(result)).toBe(true);
|
||||
if (result.ok) {
|
||||
const llvm = await readFile(request.outPath, "utf8");
|
||||
// Every thread-local of a worker executable lives in its own TLS block:
|
||||
// ELF uses local-exec for the program's and initial-exec for the
|
||||
// runtime's; Darwin's thread-local variables have one model.
|
||||
// Every thread-local of a worker executable lives in its own TLS block
|
||||
// (the runtime links statically): ELF uses local-exec for the program's
|
||||
// and the runtime's; Darwin's thread-local variables have one model.
|
||||
const plain = /^@[-$._A-Za-z0-9]+ = (internal|external) thread_local global /m;
|
||||
if (models) {
|
||||
expect(llvm).not.toMatch(plain);
|
||||
expect(llvm).not.toContain("initialexec");
|
||||
expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = internal thread_local\(localexec\) global /m);
|
||||
expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(initialexec\) global /m);
|
||||
expect(llvm).toMatch(/^@[-$._A-Za-z0-9]+ = external thread_local\(localexec\) global /m);
|
||||
} else {
|
||||
expect(llvm).toMatch(plain);
|
||||
expect(llvm).not.toContain("thread_local(");
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -1257,7 +1257,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
|
||||
@@ -1288,7 +1288,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
|
||||
}
|
||||
|
||||
@@ -1538,8 +1538,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;
|
||||
|
||||
@@ -122,9 +122,19 @@ void scr_context_stop_flag(const void *flag) {
|
||||
if (!scr_context_stopped) scr_context_signal = flag ? flag : &scr_context_unsignaled;
|
||||
}
|
||||
bool scr_context_stopping(void) { return scr_context_stopped; }
|
||||
/* Asked to stop: the owner's signal, or (ELF worker executables) the stop
|
||||
* bit of the thread's exact pending word, which worker.terminate() sets
|
||||
* directly so that emitted checks observe it. */
|
||||
static bool scr_context_signaled(void) {
|
||||
#ifdef SCR_EXC_FLAG
|
||||
if (atomic_load_explicit(&scr_exc_flag, memory_order_acquire) & SCR_EXC_FLAG_STOP) return true;
|
||||
#endif
|
||||
return atomic_load_explicit(scr_context_signal, memory_order_relaxed);
|
||||
}
|
||||
|
||||
/* Neither stopping nor asked to stop. */
|
||||
bool scr_context_quiet(void) {
|
||||
return !scr_context_stopped && !atomic_load_explicit(scr_context_signal, memory_order_relaxed);
|
||||
return !scr_context_stopped && !scr_context_signaled();
|
||||
}
|
||||
|
||||
void scr_context_stop(int code) {
|
||||
@@ -137,8 +147,7 @@ void scr_context_stop(int code) {
|
||||
}
|
||||
|
||||
bool scr_context_checkpoint(void) {
|
||||
if (!scr_context_stopped && atomic_load_explicit(scr_context_signal, memory_order_relaxed))
|
||||
scr_context_stop(1);
|
||||
if (!scr_context_stopped && scr_context_signaled()) scr_context_stop(1);
|
||||
if (!scr_context_stopped) return false;
|
||||
/* Each fiber has its own exception cell. Reinstall the sentinel after a
|
||||
* context switch or a runtime continuation consumes its pending payload. */
|
||||
@@ -198,12 +207,56 @@ ScrDyn *scr_worker_parent_port(void) { return scr_dyn_new_null(); }
|
||||
#include <pthread.h>
|
||||
#endif
|
||||
|
||||
#ifdef SCR_STACK_GUARD_TSD
|
||||
/* The guard's thread-specific-data slot (scr_runtime.h). libpthread keeps a
|
||||
* thread's values in an array at its thread pointer, indexed by key, which
|
||||
* is also how its own inline accessors read them; the probe below checks
|
||||
* that layout once before compiled code relies on it. */
|
||||
uintptr_t scr_stack_guard_tsd = 0;
|
||||
static pthread_key_t scr_stack_guard_key;
|
||||
static bool scr_stack_guard_keyed;
|
||||
static pthread_once_t scr_stack_guard_once = PTHREAD_ONCE_INIT;
|
||||
|
||||
static void *scr_stack_guard_tsd_read(uintptr_t offset) {
|
||||
void *value;
|
||||
#if defined(__aarch64__)
|
||||
uintptr_t base;
|
||||
__asm__ volatile("mrs %0, TPIDRRO_EL0" : "=r"(base));
|
||||
value = *(void *volatile *)((base & ~(uintptr_t)7) + offset);
|
||||
#else
|
||||
__asm__ volatile("movq %%gs:(%1), %0" : "=r"(value) : "r"(offset));
|
||||
#endif
|
||||
return value;
|
||||
}
|
||||
|
||||
static void scr_stack_guard_key_init(void) {
|
||||
if (pthread_key_create(&scr_stack_guard_key, NULL) != 0) return;
|
||||
scr_stack_guard_keyed = true;
|
||||
static const char probe;
|
||||
uintptr_t offset = (uintptr_t)scr_stack_guard_key * sizeof(void *);
|
||||
if (offset != 0 && pthread_setspecific(scr_stack_guard_key, &probe) == 0 &&
|
||||
scr_stack_guard_tsd_read(offset) == &probe)
|
||||
scr_stack_guard_tsd = offset;
|
||||
(void)pthread_setspecific(scr_stack_guard_key, NULL);
|
||||
}
|
||||
|
||||
void scr_stack_guard_set(uintptr_t guard) {
|
||||
scr_stack_guard = guard;
|
||||
if (scr_stack_guard_keyed) (void)pthread_setspecific(scr_stack_guard_key, (void *)guard);
|
||||
}
|
||||
|
||||
uintptr_t scr_stack_guard_get(void) { return scr_stack_guard; }
|
||||
#endif
|
||||
|
||||
void scr_stack_guard_init(void) {
|
||||
#if defined(__APPLE__) && !defined(SCR_LIB)
|
||||
#ifdef SCR_STACK_GUARD_TSD
|
||||
(void)pthread_once(&scr_stack_guard_once, scr_stack_guard_key_init);
|
||||
#endif
|
||||
pthread_t self = pthread_self();
|
||||
uintptr_t high = (uintptr_t)pthread_get_stackaddr_np(self);
|
||||
size_t size = pthread_get_stacksize_np(self);
|
||||
if (high > size) scr_stack_guard = scr_stack_guard_for(high - size, size);
|
||||
if (high > size) SCR_STACK_GUARD_SET(scr_stack_guard_for(high - size, size));
|
||||
#elif defined(__linux__) && !defined(SCR_LIB)
|
||||
#ifdef SCR_WORKERS
|
||||
if (!scr_context_is_main()) {
|
||||
|
||||
@@ -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). */
|
||||
|
||||
@@ -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
|
||||
@@ -519,7 +568,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;
|
||||
@@ -555,7 +606,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();
|
||||
@@ -611,4 +662,5 @@ void scr_exc_print_uncaught(void) {
|
||||
}
|
||||
fputc('\n', stderr);
|
||||
scr_exc_reset(); /* the payload counts as live until released */
|
||||
scr_exc_cell_changed();
|
||||
}
|
||||
|
||||
@@ -123,6 +123,11 @@ static ScrStr *scr_fs_win_utf8(const WCHAR *text, size_t length) {
|
||||
#elif defined(__linux__)
|
||||
#include <netpacket/packet.h>
|
||||
#endif
|
||||
#if defined(__linux__)
|
||||
#include <sched.h> /* sched_getaffinity, CPU_COUNT (os.availableParallelism) */
|
||||
#elif defined(__APPLE__)
|
||||
#include <sys/sysctl.h> /* 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,98 @@ ScrStr *scr_os_type(void) {
|
||||
return scr_str_new(u.sysname, strlen(u.sysname));
|
||||
}
|
||||
|
||||
#ifdef __linux__
|
||||
/* 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;
|
||||
ssize_t n;
|
||||
do
|
||||
n = read(fd, buf, cap - 1);
|
||||
while (n < 0 && errno == EINTR);
|
||||
close(fd);
|
||||
if (n < 0) return false;
|
||||
buf[n] = '\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__)
|
||||
/* 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
|
||||
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). */
|
||||
@@ -1806,6 +1912,56 @@ double scr_process_exit_code_or_zero(void) {
|
||||
return (double)scr_exit_code_hint_get();
|
||||
}
|
||||
|
||||
#if defined(__linux__)
|
||||
#include <sched.h>
|
||||
#include <signal.h>
|
||||
#include <sys/syscall.h>
|
||||
#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 +1979,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);
|
||||
}
|
||||
|
||||
|
||||
@@ -290,11 +290,26 @@ void scr_context_stop(int code);
|
||||
bool scr_context_stopping(void);
|
||||
bool scr_context_checkpoint(void);
|
||||
bool scr_context_quiet(void);
|
||||
/* The process-wide exception alert that emitted pending checks of worker
|
||||
* executables test inline (scr_exception.c): nonzero while some script
|
||||
* thread may have an exception pending in its active cell or must stop. */
|
||||
#if !defined(__APPLE__) && !defined(_WIN32) && !defined(__CYGWIN__) && !defined(__wasi__)
|
||||
/* ELF worker executables (the LLVM emitter's executableTls targets): every
|
||||
* script thread owns an exact pending word in the executable's static TLS
|
||||
* block, which emitted pending checks load with one thread-pointer-relative
|
||||
* access and no out-of-line slow path (scr_exception.c). */
|
||||
#define SCR_EXC_FLAG 1
|
||||
#define SCR_EXC_FLAG_PENDING 1u /* the active cell's kind is not NONE */
|
||||
#define SCR_EXC_FLAG_STOP 2u /* the context must stop (never cleared) */
|
||||
extern SCR_TL _Atomic uint32_t scr_exc_flag;
|
||||
/* Requests the stop of the thread that owns `flag` (scr_worker.c holds the
|
||||
* owner's word only while its script runs). */
|
||||
void scr_exc_flag_request_stop(_Atomic uint32_t *flag);
|
||||
#else
|
||||
/* The process-wide exception alert that emitted pending checks of other
|
||||
* worker executables test inline (scr_exception.c): nonzero while some
|
||||
* script thread may have an exception pending in its active cell or must
|
||||
* stop. */
|
||||
extern _Atomic uint32_t scr_exc_alert;
|
||||
void scr_exc_alert_add(int delta); /* worker.terminate() references */
|
||||
#endif
|
||||
void scr_exc_alert_thread_exit(void);
|
||||
void scr_loop_context_shutdown(void);
|
||||
extern SCR_TL void (*scr_context_report_error)(void);
|
||||
@@ -322,6 +337,22 @@ bool scr_context_is_main(void);
|
||||
extern SCR_TL uintptr_t scr_stack_guard;
|
||||
void scr_stack_guard_init(void);
|
||||
void scr_stack_overflow(void);
|
||||
#if defined(SCR_WORKERS) && !defined(SCR_LIB) && defined(__APPLE__) && \
|
||||
(defined(__aarch64__) || defined(__x86_64__))
|
||||
/* Darwin worker executables: reading a thread-local variable calls its TLV
|
||||
* thunk, so the emitted entry checks read the running stack's guard from a
|
||||
* pthread thread-specific-data slot instead, addressed from the thread
|
||||
* pointer (TPIDRRO_EL0 on arm64, %gs on x86-64) plus this byte offset.
|
||||
* scr_stack_guard_init validates the slot on first use; zero means the
|
||||
* direct read is unavailable and the checks call scr_stack_guard_get. */
|
||||
#define SCR_STACK_GUARD_TSD 1
|
||||
extern uintptr_t scr_stack_guard_tsd;
|
||||
uintptr_t scr_stack_guard_get(void);
|
||||
void scr_stack_guard_set(uintptr_t guard);
|
||||
#define SCR_STACK_GUARD_SET(guard) scr_stack_guard_set(guard)
|
||||
#else
|
||||
#define SCR_STACK_GUARD_SET(guard) (scr_stack_guard = (guard))
|
||||
#endif
|
||||
#if defined(__has_feature)
|
||||
#if __has_feature(address_sanitizer)
|
||||
#define SCR_STACK_RESERVE_MAX ((size_t)2 << 20)
|
||||
@@ -3216,6 +3247,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
|
||||
@@ -4147,6 +4182,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);
|
||||
|
||||
@@ -41,9 +41,17 @@ __attribute__((constructor)) static void scr_worker_reserve_fd_table(void) {
|
||||
struct ScrWorkerThread {
|
||||
atomic_size_t references;
|
||||
atomic_bool cancel;
|
||||
#ifdef SCR_EXC_FLAG
|
||||
/* The worker thread's exact pending word (scr_exception.c) while its
|
||||
* script runs, NULL before and after: terminate() sets the stop bit
|
||||
* through it. The lock keeps the thread's TLS alive across that write. */
|
||||
pthread_mutex_t flag_lock;
|
||||
_Atomic uint32_t *flag;
|
||||
#else
|
||||
/* The exception alert reference of a terminate request: 0 none, 1 held,
|
||||
* 2 the thread finished its script (no reference is taken any more). */
|
||||
atomic_int alert;
|
||||
#endif
|
||||
atomic_bool port_closed;
|
||||
uint64_t id;
|
||||
uint32_t root;
|
||||
@@ -92,6 +100,9 @@ void scr_worker_thread_release(ScrWorkerThread *worker) {
|
||||
scr_context_env_free(worker->environment);
|
||||
for (int i = 0; i < worker->argc; i++) free(worker->argv[i]);
|
||||
free(worker->argv);
|
||||
#ifdef SCR_EXC_FLAG
|
||||
pthread_mutex_destroy(&worker->flag_lock);
|
||||
#endif
|
||||
free(worker);
|
||||
}
|
||||
|
||||
@@ -126,6 +137,15 @@ uint64_t scr_worker_thread_id(const ScrWorkerThread *worker) { return worker->id
|
||||
|
||||
void scr_worker_thread_terminate(ScrWorkerThread *worker) {
|
||||
atomic_store_explicit(&worker->cancel, true, memory_order_relaxed);
|
||||
#ifdef SCR_EXC_FLAG
|
||||
/* Emitted checks test only the worker's own pending word: raise its stop
|
||||
* bit. A worker that has not registered its word yet finds the cancel
|
||||
* flag when it does (scr_worker_thread_run); one that has finished its
|
||||
* script needs nothing. */
|
||||
pthread_mutex_lock(&worker->flag_lock);
|
||||
if (worker->flag) scr_exc_flag_request_stop(worker->flag);
|
||||
pthread_mutex_unlock(&worker->flag_lock);
|
||||
#else
|
||||
/* Emitted polls only call into the runtime while the exception alert is
|
||||
* raised: hold a reference until the worker's script has ended. */
|
||||
scr_exc_alert_add(1);
|
||||
@@ -133,6 +153,7 @@ void scr_worker_thread_terminate(ScrWorkerThread *worker) {
|
||||
if (!atomic_compare_exchange_strong_explicit(&worker->alert, &idle, 1, memory_order_acq_rel,
|
||||
memory_order_acquire))
|
||||
scr_exc_alert_add(-1);
|
||||
#endif
|
||||
scr_mailbox_post(worker->inbox, scr_worker_event(SCR_WORKER_WAKE, 0, 0, NULL));
|
||||
}
|
||||
|
||||
@@ -177,6 +198,14 @@ static void scr_worker_report_error(void) {
|
||||
|
||||
static void scr_worker_thread_run(ScrWorkerThread *worker) {
|
||||
scr_context_enter(worker->id);
|
||||
#ifdef SCR_EXC_FLAG
|
||||
pthread_mutex_lock(&worker->flag_lock);
|
||||
worker->flag = &scr_exc_flag;
|
||||
pthread_mutex_unlock(&worker->flag_lock);
|
||||
/* A terminate() that ran before the registration only set the flag. */
|
||||
if (atomic_load_explicit(&worker->cancel, memory_order_relaxed))
|
||||
scr_exc_flag_request_stop(&scr_exc_flag);
|
||||
#endif
|
||||
scr_context_stop_flag(&worker->cancel);
|
||||
scr_context_env_enter(worker->environment);
|
||||
worker->environment = NULL;
|
||||
@@ -188,7 +217,13 @@ static void scr_worker_thread_run(ScrWorkerThread *worker) {
|
||||
scr_context_cleanup();
|
||||
scr_worker_current = NULL;
|
||||
scr_exc_alert_thread_exit();
|
||||
#ifdef SCR_EXC_FLAG
|
||||
pthread_mutex_lock(&worker->flag_lock);
|
||||
worker->flag = NULL;
|
||||
pthread_mutex_unlock(&worker->flag_lock);
|
||||
#else
|
||||
if (atomic_exchange_explicit(&worker->alert, 2, memory_order_acq_rel) == 1) scr_exc_alert_add(-1);
|
||||
#endif
|
||||
/* Cleanup may itself finish owner-bound native operations. Publish exit
|
||||
* only once none of those operations can touch the worker's script heap. */
|
||||
scr_mailbox_post(worker->parent, scr_worker_event(SCR_WORKER_EXIT, worker->id, code, NULL));
|
||||
@@ -216,7 +251,12 @@ ScrWorkerThread *scr_worker_thread_start(ScrWorkerEntry entry, uint32_t root,
|
||||
if (!worker) scr_trap("scriptc: out of memory\n");
|
||||
atomic_init(&worker->references, 2); /* owner and native thread */
|
||||
atomic_init(&worker->cancel, false);
|
||||
#ifdef SCR_EXC_FLAG
|
||||
if (pthread_mutex_init(&worker->flag_lock, NULL) != 0) scr_trap("scriptc: out of memory\n");
|
||||
worker->flag = NULL;
|
||||
#else
|
||||
atomic_init(&worker->alert, 0);
|
||||
#endif
|
||||
atomic_init(&worker->port_closed, false);
|
||||
worker->id = atomic_fetch_add_explicit(&scr_worker_next_id, 1, memory_order_relaxed);
|
||||
worker->entry = entry;
|
||||
@@ -262,6 +302,9 @@ failed:
|
||||
scr_mailbox_release(worker->inbox);
|
||||
scr_mailbox_release(worker->parent);
|
||||
scr_message_free(worker->data);
|
||||
#ifdef SCR_EXC_FLAG
|
||||
pthread_mutex_destroy(&worker->flag_lock);
|
||||
#endif
|
||||
free(worker);
|
||||
if (!scr_exc_pending()) {
|
||||
static const char message[] = "Could not start worker thread";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1,308 @@
|
||||
import { Worker, isMainThread, parentPort } from "node:worker_threads";
|
||||
|
||||
// Every way an exception can be raised, moved, consumed or discarded, run on
|
||||
// the main thread and in a worker of the same program. After each scenario a
|
||||
// call that may throw but does not proves that nothing is left pending: a
|
||||
// stale pending state would unwind it.
|
||||
|
||||
class Fault extends Error {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
super(`fault ${code}`);
|
||||
this.name = "Fault";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: number): never {
|
||||
throw new Fault(code);
|
||||
}
|
||||
|
||||
function label(code: number): string {
|
||||
if (code < 0) fail(-code);
|
||||
return `item-${code}`;
|
||||
}
|
||||
|
||||
function message(error: unknown): string {
|
||||
return error instanceof Error ? `${error.name}: ${error.message}` : String(error);
|
||||
}
|
||||
|
||||
function finallyReturns(): string {
|
||||
try {
|
||||
fail(1);
|
||||
} finally {
|
||||
return "finally return";
|
||||
}
|
||||
}
|
||||
|
||||
function finallyBreaks(log: string[]): number {
|
||||
let rounds = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
rounds++;
|
||||
try {
|
||||
fail(2);
|
||||
} finally {
|
||||
log.push(`break round ${i}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
return rounds;
|
||||
}
|
||||
|
||||
function finallyContinues(log: string[]): number {
|
||||
let rounds = 0;
|
||||
for (let i = 0; i < 3; i++) {
|
||||
try {
|
||||
if (i !== 1) fail(3);
|
||||
rounds += 10;
|
||||
} finally {
|
||||
rounds++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
log.push(`continue rounds ${rounds}`);
|
||||
return rounds;
|
||||
}
|
||||
|
||||
function finallyReplaces(): string {
|
||||
try {
|
||||
fail(4);
|
||||
} finally {
|
||||
label(-5);
|
||||
}
|
||||
}
|
||||
|
||||
function deep(n: number, log: string[]): string {
|
||||
if (n === 0) fail(6);
|
||||
try {
|
||||
return deep(n - 1, log);
|
||||
} finally {
|
||||
if (n % 2 === 0) log.push(`unwound ${n}`);
|
||||
}
|
||||
}
|
||||
|
||||
function catchRethrows(): string {
|
||||
try {
|
||||
return label(-7);
|
||||
} catch (error) {
|
||||
try {
|
||||
fail((error as Fault).code + 1);
|
||||
} catch (inner) {
|
||||
throw new Fault((inner as Fault).code * 10);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function labelled(log: string[]): number {
|
||||
let caught = 0;
|
||||
outer: for (let i = 0; i < 3; i++) {
|
||||
for (let j = 0; j < 3; j++) {
|
||||
try {
|
||||
if (j === 1) fail(i * 3 + j);
|
||||
} catch {
|
||||
caught++;
|
||||
continue outer;
|
||||
}
|
||||
}
|
||||
}
|
||||
log.push(`labelled caught ${caught}`);
|
||||
return caught;
|
||||
}
|
||||
|
||||
function* resumable(log: string[]): Generator<string, string, string> {
|
||||
let round = 0;
|
||||
while (round < 3) {
|
||||
try {
|
||||
const sent = yield `round ${round}`;
|
||||
log.push(`sent ${sent}`);
|
||||
} catch (error) {
|
||||
log.push(`generator caught ${message(error)}`);
|
||||
}
|
||||
round++;
|
||||
}
|
||||
return "generator done";
|
||||
}
|
||||
|
||||
function* guarded(log: string[]): Generator<number> {
|
||||
try {
|
||||
yield 1;
|
||||
yield 2;
|
||||
} finally {
|
||||
log.push("guarded finally");
|
||||
}
|
||||
}
|
||||
|
||||
function* explosive(): Generator<number> {
|
||||
yield 1;
|
||||
fail(8);
|
||||
}
|
||||
|
||||
function* finallyThrows(): Generator<number> {
|
||||
try {
|
||||
yield 1;
|
||||
} finally {
|
||||
fail(9);
|
||||
}
|
||||
}
|
||||
|
||||
async function rejectsLater(code: number): Promise<string> {
|
||||
await null;
|
||||
return label(-code);
|
||||
}
|
||||
|
||||
async function rejectsNow(code: number): Promise<string> {
|
||||
return label(-code);
|
||||
}
|
||||
|
||||
function overflow(n: number): number {
|
||||
return overflow(n + 1) + 1;
|
||||
}
|
||||
|
||||
async function scenarios(): Promise<string[]> {
|
||||
const log: string[] = [];
|
||||
const attempt = (name: string, run: () => unknown): void => {
|
||||
try {
|
||||
log.push(`${name} ${String(run())}`);
|
||||
} catch (error) {
|
||||
log.push(`${name} caught ${message(error)}`);
|
||||
}
|
||||
log.push(`${name} then ${label(name.length)}`);
|
||||
};
|
||||
|
||||
attempt("bound catch", () => {
|
||||
try {
|
||||
return label(-10);
|
||||
} catch (error) {
|
||||
return `took ${(error as Fault).code}`;
|
||||
}
|
||||
});
|
||||
attempt("bare catch", () => {
|
||||
try {
|
||||
return label(-11);
|
||||
} catch {
|
||||
return "dropped";
|
||||
}
|
||||
});
|
||||
attempt("finally return", finallyReturns);
|
||||
attempt("finally break", () => finallyBreaks(log));
|
||||
attempt("finally continue", () => finallyContinues(log));
|
||||
attempt("finally replace", finallyReplaces);
|
||||
attempt("deep", () => deep(5, log));
|
||||
attempt("catch rethrow", catchRethrows);
|
||||
attempt("labelled", () => labelled(log));
|
||||
|
||||
const gen = resumable(log);
|
||||
log.push(`next ${gen.next("ignored").value}`);
|
||||
log.push(`next ${gen.next("a").value}`);
|
||||
log.push(`throw ${gen.throw(new Fault(12)).value}`);
|
||||
const last = gen.next("b");
|
||||
log.push(`last ${last.value} ${last.done}`);
|
||||
log.push(`after generator ${label(13)}`);
|
||||
|
||||
const guard = guarded(log);
|
||||
guard.next();
|
||||
const returned = guard.return(99);
|
||||
log.push(`return ${returned.value} ${returned.done}`);
|
||||
for (const value of guarded(log)) {
|
||||
log.push(`for-of ${value}`);
|
||||
break;
|
||||
}
|
||||
attempt("generator escape", () => [...explosive()].length);
|
||||
attempt("generator finally throw", () => {
|
||||
const g = finallyThrows();
|
||||
g.next();
|
||||
return g.return(0).value;
|
||||
});
|
||||
|
||||
try {
|
||||
await rejectsLater(14);
|
||||
} catch (error) {
|
||||
log.push(`await caught ${message(error)}`);
|
||||
}
|
||||
log.push(`after await ${label(15)}`);
|
||||
await rejectsNow(16).catch((error: unknown) => log.push(`catch handler ${message(error)}`));
|
||||
const statuses: string[] = [];
|
||||
for (const pending of [rejectsLater(17), Promise.resolve("ok"), rejectsNow(18)]) {
|
||||
try {
|
||||
statuses.push(`fulfilled ${await pending}`);
|
||||
} catch (error) {
|
||||
statuses.push(`rejected ${(error as Fault).code}`);
|
||||
}
|
||||
}
|
||||
log.push(`settled ${statuses.join(",")}`);
|
||||
try {
|
||||
await Promise.all([rejectsLater(19), rejectsLater(20)]);
|
||||
} catch (error) {
|
||||
log.push(`all caught ${message(error)}`);
|
||||
}
|
||||
try {
|
||||
await new Promise<string>(() => fail(21));
|
||||
} catch (error) {
|
||||
log.push(`executor caught ${message(error)}`);
|
||||
}
|
||||
const chained = await Promise.resolve(1)
|
||||
.then(() => fail(22))
|
||||
.then(
|
||||
() => "unexpected",
|
||||
(error: unknown) => `then rejected ${message(error)}`,
|
||||
);
|
||||
log.push(chained);
|
||||
log.push(`after promises ${label(23)}`);
|
||||
|
||||
attempt("map callback", () => [1, 2, 3].map((n) => label(n === 2 ? -24 : n)).join());
|
||||
attempt("sort comparator", () =>
|
||||
[3, 1, 2]
|
||||
.sort((a, b) => {
|
||||
if (a === 1 || b === 1) fail(25);
|
||||
return a - b;
|
||||
})
|
||||
.join(),
|
||||
);
|
||||
attempt("reduce", () => [1, 2, 3].reduce((acc, n) => acc + label(n === 3 ? -26 : n), ""));
|
||||
attempt("forEach", () => {
|
||||
let seen = 0;
|
||||
try {
|
||||
[1, 2, 3].forEach((n) => {
|
||||
seen++;
|
||||
if (n === 2) fail(27);
|
||||
});
|
||||
} finally {
|
||||
log.push(`forEach seen ${seen}`);
|
||||
}
|
||||
return seen;
|
||||
});
|
||||
attempt("json", () => {
|
||||
try {
|
||||
return JSON.parse("{bad");
|
||||
} catch (error) {
|
||||
return `parse failed ${(error as Error).name}`;
|
||||
}
|
||||
});
|
||||
attempt("overflow", () => overflow(0));
|
||||
attempt("nested callbacks", () =>
|
||||
[1, 2].map((n) => {
|
||||
try {
|
||||
return [n].map(() => label(-28))[0];
|
||||
} catch (error) {
|
||||
return `inner ${(error as Fault).code}`;
|
||||
}
|
||||
}),
|
||||
);
|
||||
return log;
|
||||
}
|
||||
|
||||
if (isMainThread) {
|
||||
for (const line of await scenarios()) console.log("main", line);
|
||||
await new Promise<void>((resolve) => {
|
||||
const worker = new Worker(new URL(import.meta.url));
|
||||
worker.on("message", (lines: string[]) => {
|
||||
for (const line of lines) console.log("worker", line);
|
||||
});
|
||||
worker.once("exit", (code: number) => {
|
||||
console.log("worker exit", code);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
} else {
|
||||
parentPort!.postMessage(await scenarios());
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
// worker.terminate() arriving while the worker's exception state is busy:
|
||||
// inside catch and finally bodies, with an exception stashed across a finally
|
||||
// or propagating through frames, inside generator and async bodies, inside
|
||||
// callbacks the runtime calls, and before the worker has started. Termination
|
||||
// is not catchable and skips every catch and finally handler, so the worker
|
||||
// prints nothing once it has announced itself.
|
||||
|
||||
class Fault extends Error {
|
||||
readonly code: number;
|
||||
constructor(code: number) {
|
||||
super(`fault ${code}`);
|
||||
this.name = "Fault";
|
||||
this.code = code;
|
||||
}
|
||||
}
|
||||
|
||||
function fail(code: number): never {
|
||||
throw new Fault(code);
|
||||
}
|
||||
|
||||
function risky(n: number): number {
|
||||
if (n < 0) fail(-n);
|
||||
return (n * 31 + 7) % 1000003;
|
||||
}
|
||||
|
||||
let announced = false;
|
||||
function announce(): void {
|
||||
if (!announced) {
|
||||
announced = true;
|
||||
parentPort!.postMessage("ready");
|
||||
}
|
||||
}
|
||||
|
||||
function spin(): number {
|
||||
let acc = 0;
|
||||
for (let i = 0; ; i++) {
|
||||
acc = (acc + risky(i % 1000)) % 1000003;
|
||||
if (i === 1000) announce();
|
||||
}
|
||||
}
|
||||
|
||||
function inCatch(): number {
|
||||
try {
|
||||
return risky(-1);
|
||||
} catch (error) {
|
||||
if ((error as Fault).code !== 1) console.log("unexpected code");
|
||||
return spin();
|
||||
}
|
||||
}
|
||||
|
||||
function inFinally(): number {
|
||||
try {
|
||||
return risky(-2);
|
||||
} finally {
|
||||
spin();
|
||||
}
|
||||
}
|
||||
|
||||
function inNestedFinally(): number {
|
||||
try {
|
||||
try {
|
||||
return spin();
|
||||
} finally {
|
||||
console.log("unexpected inner finally");
|
||||
}
|
||||
} catch {
|
||||
console.log("unexpected outer catch");
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
function deep(n: number): number {
|
||||
if (n === 0) return risky(-3);
|
||||
try {
|
||||
return deep(n - 1);
|
||||
} finally {
|
||||
if (n === 1) risky(n);
|
||||
}
|
||||
}
|
||||
|
||||
function propagating(): number {
|
||||
let caught = 0;
|
||||
for (let i = 0; ; i++) {
|
||||
try {
|
||||
deep(40);
|
||||
} catch {
|
||||
caught++;
|
||||
if (caught === 1000) announce();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function* producer(): Generator<number> {
|
||||
yield 1;
|
||||
try {
|
||||
yield spin();
|
||||
} finally {
|
||||
console.log("unexpected generator finally");
|
||||
}
|
||||
}
|
||||
|
||||
function inGenerator(): number {
|
||||
let total = 0;
|
||||
try {
|
||||
for (const value of producer()) total += value;
|
||||
} catch {
|
||||
console.log("unexpected generator catch");
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
async function asyncBody(): Promise<number> {
|
||||
await null;
|
||||
try {
|
||||
return spin();
|
||||
} catch {
|
||||
console.log("unexpected async catch");
|
||||
return 0;
|
||||
} finally {
|
||||
console.log("unexpected async finally");
|
||||
}
|
||||
}
|
||||
|
||||
function inCallback(): number {
|
||||
try {
|
||||
return [1, 2, 3].map((n) => (n === 2 ? spin() : n)).length;
|
||||
} catch {
|
||||
console.log("unexpected callback catch");
|
||||
return 0;
|
||||
} finally {
|
||||
console.log("unexpected callback finally");
|
||||
}
|
||||
}
|
||||
|
||||
function inComparator(): number {
|
||||
try {
|
||||
return [3, 1, 2].sort((a, b) => (a === 1 || b === 1 ? spin() : a - b)).length;
|
||||
} finally {
|
||||
console.log("unexpected comparator finally");
|
||||
}
|
||||
}
|
||||
|
||||
function run(kind: string): number {
|
||||
switch (kind) {
|
||||
case "catch":
|
||||
return inCatch();
|
||||
case "finally":
|
||||
return inFinally();
|
||||
case "nested":
|
||||
return inNestedFinally();
|
||||
case "propagating":
|
||||
return propagating();
|
||||
case "generator":
|
||||
return inGenerator();
|
||||
case "callback":
|
||||
return inCallback();
|
||||
case "comparator":
|
||||
return inComparator();
|
||||
default:
|
||||
return spin();
|
||||
}
|
||||
}
|
||||
|
||||
if (isMainThread) {
|
||||
const kinds = ["catch", "finally", "nested", "propagating", "generator", "async", "callback", "comparator"];
|
||||
for (const kind of kinds) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const worker = new Worker(new URL(import.meta.url), { workerData: kind });
|
||||
worker.once("message", () => {
|
||||
void worker.terminate().then((code: number) => console.log(kind, "terminate", code));
|
||||
});
|
||||
worker.once("exit", (code: number) => {
|
||||
console.log(kind, "exit", code);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
// Terminated before the worker can have started its script (Node reports
|
||||
// exit code 0 here, scriptc 1: only the stop itself is pinned).
|
||||
for (let i = 0; i < 3; i++) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const worker = new Worker(new URL(import.meta.url), { workerData: "early" });
|
||||
void worker.terminate();
|
||||
worker.once("exit", () => {
|
||||
console.log("early exit", i);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
// The main thread's own exceptions are unaffected by the stopped workers.
|
||||
try {
|
||||
risky(-4);
|
||||
} catch (error) {
|
||||
console.log("main caught", (error as Fault).code, risky(5));
|
||||
}
|
||||
} else if (workerData === "async") {
|
||||
asyncBody().then(
|
||||
() => console.log("unexpected async result"),
|
||||
() => console.log("unexpected async rejection"),
|
||||
);
|
||||
} else {
|
||||
try {
|
||||
console.log("unexpected result", run(String(workerData)));
|
||||
} catch {
|
||||
console.log("unexpected catch");
|
||||
} finally {
|
||||
console.log("unexpected finally");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
// Loops whose every iteration calls a function that can throw observe a
|
||||
// terminate request through that call's exception test; loops that can skip
|
||||
// such a call keep their own poll. Each worker spins until it is terminated.
|
||||
|
||||
class Box {
|
||||
value = 0;
|
||||
bump(n: number): number {
|
||||
if (n < 0) throw new RangeError("negative " + n);
|
||||
this.value = (this.value + n) % 1000003;
|
||||
return this.value;
|
||||
}
|
||||
}
|
||||
|
||||
function risky(n: number): number {
|
||||
if (n < 0) throw new RangeError("negative " + n);
|
||||
return (n * 31 + 7) % 1000003;
|
||||
}
|
||||
|
||||
let announced = false;
|
||||
function announce(i: number): void {
|
||||
if (!announced && i >= 1000) {
|
||||
announced = true;
|
||||
parentPort!.postMessage("ready");
|
||||
}
|
||||
}
|
||||
|
||||
function spinCalls(): number {
|
||||
let acc = 0;
|
||||
for (let i = 0; ; i++) {
|
||||
acc = (acc + risky(i)) % 1000003;
|
||||
announce(i);
|
||||
}
|
||||
}
|
||||
|
||||
function spinCondition(): number {
|
||||
let i = 0;
|
||||
while (risky(i) >= 0) {
|
||||
i++;
|
||||
announce(i);
|
||||
}
|
||||
return i;
|
||||
}
|
||||
|
||||
// Once i passes 2000 every iteration continues before the call.
|
||||
function spinContinue(): number {
|
||||
let acc = 0;
|
||||
for (let i = 0; ; i++) {
|
||||
if (i > 2000) continue;
|
||||
acc = (acc + risky(i)) % 1000003;
|
||||
announce(i);
|
||||
}
|
||||
}
|
||||
|
||||
function spinMethods(): number {
|
||||
const box = new Box();
|
||||
let n = 0;
|
||||
do {
|
||||
try {
|
||||
box.bump(n % 5 === 0 ? -1 : n);
|
||||
} catch (e) {
|
||||
if (!(e instanceof RangeError)) throw e;
|
||||
}
|
||||
n++;
|
||||
announce(n);
|
||||
} while (box.bump(1) >= 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
function spinNested(): number {
|
||||
let acc = 0;
|
||||
for (let i = 0; ; i++) {
|
||||
for (let j = 0; j < 4; j++) acc = (acc + risky(j)) % 1000003;
|
||||
announce(i);
|
||||
}
|
||||
}
|
||||
|
||||
function run(kind: string): number {
|
||||
if (kind === "calls") return spinCalls();
|
||||
if (kind === "condition") return spinCondition();
|
||||
if (kind === "continue") return spinContinue();
|
||||
if (kind === "methods") return spinMethods();
|
||||
return spinNested();
|
||||
}
|
||||
|
||||
// The same loop shapes, bounded, with exceptions escaping mid-iteration.
|
||||
function countdown(start: number): number {
|
||||
let acc = 0;
|
||||
for (let i = start; ; i--) acc = (acc + risky(i)) % 1000003;
|
||||
}
|
||||
|
||||
function countdownWhile(start: number): number {
|
||||
let i = start;
|
||||
while (risky(i) >= 0) i--;
|
||||
return i;
|
||||
}
|
||||
|
||||
function methodsUntil(limit: number): number {
|
||||
const box = new Box();
|
||||
let n = 0;
|
||||
do {
|
||||
n++;
|
||||
} while (box.bump(limit - n) >= 0);
|
||||
return n;
|
||||
}
|
||||
|
||||
if (isMainThread) {
|
||||
for (const probe of [() => countdown(5), () => countdownWhile(7), () => methodsUntil(4)]) {
|
||||
try {
|
||||
console.log("unexpected", probe());
|
||||
} catch (e) {
|
||||
console.log("caught", (e as Error).name, (e as Error).message);
|
||||
} finally {
|
||||
console.log("finally");
|
||||
}
|
||||
}
|
||||
for (const kind of ["calls", "condition", "continue", "methods", "nested"]) {
|
||||
await new Promise<void>((resolve) => {
|
||||
const worker = new Worker(new URL(import.meta.url), { workerData: kind });
|
||||
worker.once("message", () => {
|
||||
void worker.terminate().then((code: number) => console.log(kind, "terminate", code));
|
||||
});
|
||||
worker.once("exit", (code: number) => {
|
||||
console.log(kind, "exit", code);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
} else {
|
||||
try {
|
||||
console.log("unexpected result", run(String(workerData)));
|
||||
} catch {
|
||||
console.log("unexpected catch");
|
||||
} finally {
|
||||
console.log("unexpected finally");
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user