Merge main

This commit is contained in:
Chris Tate
2026-10-29 09:14:05 -05:00
97 changed files with 2379 additions and 117 deletions
+40 -2
View File
@@ -6,6 +6,46 @@ All notable changes to scriptc will be documented in this file.
<!-- release:start -->
## 0.4.6
### Highlights
- **Node islands:** programs can load Node.js in-process on first use and run npm packages that scriptc doesn't compile in Node islands.
- Native values, closures and promises pass into islands by reference, and island handles, classes and collections can be used from compiled code.
- Modules are shared between a program and its islands, and island modules are evaluated only when the program uses them.
- Once Node loads, the runtime's event loop runs on Node's libuv, and programs exit cleanly with Node loaded.
- **New options:**
- `--exceptions=rare` unwinds through recursive functions instead of checking a status after each call.
- `--static` builds static Linux executables (opt-in).
- `--large-pages` moves executable code onto huge pages; `--cpu-dispatch` adds x86-64-v3 copies of hot functions.
- Type patches replace a package's declaration files or supply missing declarations.
- The shape overlay types JavaScript from facts the compiler proves (opt-in).
- **Faster programs:**
- Exceptions raise through cached unwind plans on x86-64 Linux, and every native function has an unwind table entry.
- Ordinary objects are stored as a layout plus value slots; unions of classes are the instance pointer itself; class instances that stay in their frame use frame storage.
- Object groups that die together are allocated in arenas, and the cycle collector does cheaper trial deletion and adapts its nursery trigger.
- The speed runtime is optimized with a committed runtime profile, and Math functions are computed as V8 does, with libm linked only when needed.
- Loops: short constant-count loops run together, for and while loops are versioned on the ranges their locals enter with, counted loops unroll by the period of a guard, and small leaf functions called from loops are inlined.
- Typed arrays: header fields are read once at a speculative loop's entry, products under bitwise operators stay in 32 bits, and shared typed-array elements are read and written with plain accesses at a counted loop's counter.
- Strings: UTF-16 indexes of long non-ASCII strings map in constant time, ASCII case converts eight bytes at a time, template literals allocate at their exact length, and strings iterate over their bytes.
- Untyped code: class instance fields are read inline at dynamic property sites with two inline-cache entries, native arrays iterate without type-name scans, and numbers pass to directly called untyped functions as JavaScript values.
- I/O: fs thread-pool completions and pool-thread writes make fewer syscalls; sockets, clients and agent entries register in constant time; fetch() reuses keep-alive connections; web streams, WritableStream and TransformStream run natively.
- Large executables map their text with 2 MiB pages, and freed heap pages are returned to the system after the frontend is disposed.
- **Faster builds:**
- Small release and speed programs compile in balanced partitions, the entry module's top-level code gets its own partition, and unchanged partitions stay cached across edits.
- Optimized executables that stay one partition generate code concurrently, and emission's workers stay busy.
- The native compiler is laid out by context-sensitive profiles collected in two phases.
- Type checking uses a shared tsc-ts server by default on Linux and macOS, and lib diagnostics are reused across options and module sets that cannot change them.
- Rebuilds skip the helper version probe and the digests of unchanged files, and the CLI links without huge text pages, so cold builds use less memory.
- **Node.js parity:**
- `Error.captureStackTrace`, Node's navigator, builtin globals read through the global object, and unhandled rejections reported in programs without async work.
- node:zlib is built on the zlib Node bundles; `Response.clone` and `Request.clone` work on typed values; fetch bodies have the members of a web ReadableStream; redirects whose chunked body has not ended are followed.
- Classes that extend Array compile; strings spread into their code points; frozen checked records stay frozen; sloppy-mode `this` is the global object.
- `util.format` runtime format strings, `inspect.custom` and colors, and untyped `promisify`.
- **TypeScript:** scriptc reads TypeScript 7.1's binary AST format and API server beside 7.0's, and tsc-ts is updated to 0.0.10.
<!-- release:end -->
## 0.4.5
### Highlights
@@ -40,8 +80,6 @@ All notable changes to scriptc will be documented in this file.
- `util.inspect` of dynamic classes, functions and `%o` arguments, and `Symbol.toStringTag` fields and getters, match Node.
- **npm packages:** more npm and CommonJS dependencies compile statically, including namespace-style packages with guarded declarations and `var` exports, and untyped Maps, Sets and arrays are typed from their uses.
<!-- release:end -->
## 0.4.4
### Highlights
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-arm64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for darwin-arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-darwin-x64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for darwin-x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for linux-arm64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-arm64-musl",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for linux-arm64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for linux-x64-gnu",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-linux-x64-musl",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for linux-x64-musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/cli-win32-x64-msvc",
"version": "0.4.5",
"version": "0.4.6",
"description": "Native scriptc compiler for win32-x64-msvc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "scriptc",
"version": "0.4.5",
"version": "0.4.6",
"description": "Compile ordinary TypeScript and JavaScript to small, fast native executables and WebAssembly modules that run without Node.js",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
@@ -13,6 +13,7 @@
#include <windows.h>
#else
#include <poll.h>
#include <time.h>
#include <sys/socket.h>
#include <sys/un.h>
#include <unistd.h>
@@ -102,7 +103,9 @@ static int child_main(int argc, char **argv) {
#endif
}
}
if (strcmp(mode, "echo") != 0) return 73;
// "slow-echo" answers each read 20 ms later: after the client's poll.
int slow = strcmp(mode, "slow-echo") == 0;
if (!slow && strcmp(mode, "echo") != 0) return 73;
// Unbuffered reads let short writes make progress without waiting for a
// complete stdio buffer. Fragment each echoed reply independently.
uint8_t bytes[17];
@@ -113,6 +116,14 @@ static int child_main(int argc, char **argv) {
ssize_t count = read(STDIN_FILENO, bytes, sizeof(bytes));
#endif
if (count <= 0) return count < 0 ? 74 : 0;
if (slow) {
#ifdef _WIN32
Sleep(20);
#else
struct timespec delay = {0, 20000000L};
nanosleep(&delay, NULL);
#endif
}
if (fwrite(bytes, 1, (size_t)count, stdout) != (size_t)count || fflush(stdout) != 0) return 75;
}
}
+36 -2
View File
@@ -552,6 +552,41 @@ static int valid_range(const uint8_t *bytes, size_t size, uint32_t offset, uint3
return 1;
}
#ifndef _WIN32
/* How long a read polls the socket before it blocks. The server answers
* most requests within a few microseconds; a blocked reader that the reply
* wakes takes tens of microseconds more per round trip. */
#define TS7_REPLY_POLL_NS 200000L
static long monotonic_ns(void) {
struct timespec now;
clock_gettime(CLOCK_MONOTONIC, &now);
return now.tv_sec * 1000000000L + now.tv_nsec;
}
/* recv on the server's socket: polls it for TS7_REPLY_POLL_NS, then waits. */
static ssize_t ts7_recv(int socket, uint8_t *bytes, size_t length) {
long started = -1;
for (;;) {
ssize_t count = recv(socket, bytes, length, MSG_DONTWAIT);
if (count >= 0) return count;
if (errno == EINTR) continue;
if (errno != EAGAIN && errno != EWOULDBLOCK) return -1;
long now = monotonic_ns();
if (started < 0) started = now;
else if (now - started >= TS7_REPLY_POLL_NS) break;
#if defined(__x86_64__) || defined(__i386__)
__builtin_ia32_pause();
#elif defined(__aarch64__)
__asm__ volatile("yield");
#endif
}
ssize_t count;
do { count = recv(socket, bytes, length, 0); } while (count < 0 && errno == EINTR);
return count;
}
#endif
int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size,
uint32_t offset, uint32_t length) {
Ts7Process *p = find_process(handle);
@@ -565,8 +600,7 @@ int32_t scriptc_ts7_read(uint32_t handle, uint8_t *bytes, size_t size,
if (code == ERROR_BROKEN_PIPE) return 0;
system_error(code); return -1;
#else
ssize_t count;
do { count = recv(p->socket, bytes + offset, length, 0); } while (count < 0 && errno == EINTR);
ssize_t count = ts7_recv(p->socket, bytes + offset, length);
if (count < 0) { system_error(errno); return -1; }
return (int32_t)count;
#endif
@@ -149,6 +149,20 @@ static void exchange_cases(void) {
scriptc_ts7_close(newer);
}
/* A reply that arrives after the read's poll window: the read waits for it. */
static void slow_reply_cases(void) {
uint32_t id = start("slow-echo", 0);
assert(id != 0);
const uint8_t bytes[3] = {1, 2, 3};
uint8_t result[3] = {0};
for (unsigned round = 0; round < 3; round++) {
write_exact(id, bytes, sizeof(bytes), 0, sizeof(bytes));
read_exact(id, result, sizeof(result));
assert(memcmp(bytes, result, sizeof(bytes)) == 0);
}
scriptc_ts7_close(id);
}
static void failure_cases(void) {
uint8_t bytes[1] = {42};
const char *saved = executable;
@@ -289,6 +303,7 @@ static int process_main(int argc, char **argv) {
unsigned before = resource_count();
argument_cases();
exchange_cases();
slow_reply_cases();
failure_cases();
#ifndef _WIN32
attach_cases();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/compiler",
"version": "0.4.5",
"version": "0.4.6",
"description": "The scriptc compiler — TypeScript/JavaScript frontend, typed IR, LLVM backend",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+6 -6
View File
@@ -48,6 +48,8 @@ export async function createNativeLinkPlan(options: {
windowsSubsystem?: WindowsSubsystem;
profileGenerate?: boolean;
profileLayout?: boolean;
/** `false` keeps 4 KiB segment alignment for large program code. */
hugeText?: boolean;
programObjectDependencies?: readonly NativeArtifactDependency[];
env?: NodeJS.ProcessEnv;
resolver?: (specifier: string) => string;
@@ -55,6 +57,9 @@ export async function createNativeLinkPlan(options: {
if (options.ffi?.frameworks?.length && options.target.platform !== "darwin")
throw new Error("FFI frameworks require a Darwin target");
const runtimePack = await loadRuntimePack(options);
const hugeText =
options.hugeText !== false &&
hugeTextProgram(options.target, [options.programObject, ...(options.programPartitions ?? [])]);
return {
target: options.target,
darwinDebugSymbols: needsDarwinDebugSymbols(
@@ -88,11 +93,6 @@ export async function createNativeLinkPlan(options: {
programObjectDependencies: [...(options.programObjectDependencies ?? [])],
runtimePack,
...(options.staticLink === true ? { staticLink: true } : {}),
...(hugeTextProgram(options.target, [
options.programObject,
...(options.programPartitions ?? []),
])
? { hugeText: true }
: {}),
...(hugeText ? { hugeText: true } : {}),
};
}
+13 -10
View File
@@ -96,6 +96,7 @@ import {
} from "../../ir/inline-arrays.js";
import { inlineArraysApply, programLifetimeClassOf } from "./inline-arrays.js";
import { pairRowSums } from "../../ir/row-pairs.js";
import { jamSaturatingLoops } from "../../ir/saturating-loop-jams.js";
import {
allocatedLiteral,
fillAllocationLength,
@@ -763,16 +764,18 @@ function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
specializeCallbackCalls(
specializeNumericCalls(
pairRowSums(
inlineArraysPass(
options,
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
jamSaturatingLoops(
inlineArraysPass(
options,
inlineExpressionFunctions(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(
dropUnwrittenPropertyBags(runPairedWhileLoopsAsFor(mod)),
),
),
),
),
@@ -61,9 +61,11 @@ export function emitUnionWiden(
/** The identity union conversion (IR unionRetype). Two tagged unions
* share the operand's box: its +1 becomes the result, so no box is built
* and no payload is retained. The operand is a whole-value use in every
* lifetime analysis, so it is never a stack box. A nullable-pointer union
* on either side, or a source union whose boxes a checked-dynamic cast has
* cached (and refreshes in place), rebuilds each listed arm instead. */
* lifetime analysis, so it is never a stack box. Two pointer unions of one
* encoding share the pointer the same way. A pointer union on only one
* side, pointer unions whose encodings differ for an arm the value may
* hold, or a source union whose boxes a checked-dynamic cast has cached (and
* refreshes in place), rebuild each listed arm instead. */
export function emitUnionRetype(
host: LlvmEmitterContext,
e: IrExpr & { kind: "unionRetype" },
@@ -75,14 +77,15 @@ export function emitUnionRetype(
if (from && to) {
// Equal encodings make the pointer itself the target value. A shape
// union derives its record arms from the record and a class union its
// class arms from the class id, so only the unit encodings must agree.
// class arms from the class id, so only the unit encodings must agree,
// and only when the value may hold one of the source's unit arms: a
// narrowing that drops `undefined` or `null` keeps the very pointer.
const holdsUnit = e.tags.some((tag) => tag === from.unitTag || tag === from.nullTag);
const units = !holdsUnit || (from.unitTag === to.unitTag && from.nullTag === to.nullTag);
const same =
from.repr === "single" && to.repr === "single"
? from.refTag === to.refTag && from.unitTag === to.unitTag && from.nullTag === to.nullTag
: from.repr !== "single" &&
from.repr === to.repr &&
from.unitTag === to.unitTag &&
from.nullTag === to.nullTag;
? from.refTag === to.refTag && units
: from.repr !== "single" && from.repr === to.repr && units;
if (same) return host.own({ name: value.name, type: e.type });
return host.own({ name: rebuildRetype(host, value.name, e), type: e.type });
}
@@ -11,6 +11,7 @@ import {
import type { LlValue } from "./expr-context.js";
import { guardedValue } from "./borrowed-receivers.js";
import { InternalCompilerError } from "../../errors.js";
import { llvmQuoted } from "../literals.js";
import { emitBorrowedArrayRead, emitProjectedArrayRead } from "./local-array-reads.js";
import {
emitBorrowedMapGet,
@@ -459,12 +460,19 @@ export function analyzeBorrowedReturns(emitter: LlEmitter): void {
}
}
/** The alias metadata of the module's field and memory categories. Each type
* node is named by its category (`field:<name>`, `array:header`, ...), not
* by its number: numbers follow the order in which the module's functions
* first use the categories, so an edit that adds one renumbers the rest. The
* LLVM helper renumbers each program partition's metadata, so a partition
* whose functions are unchanged keeps the same text and its cached
* artifacts. */
export function fieldAliasMetadata(emitter: LlEmitter): string[] {
if (emitter.fieldAliasTags.size === 0) return [];
const lines = ['!0 = !{!"scriptc native fields"}'];
for (const tag of emitter.fieldAliasTags.values()) {
for (const [category, tag] of emitter.fieldAliasTags) {
lines.push(
`!${tag - 1} = !{!"field.${tag}", !0, i64 0}`,
`!${tag - 1} = !{!${llvmQuoted(category)}, !0, i64 0}`,
`!${tag} = !{!${tag - 1}, !${tag - 1}, i64 0}`,
);
}
@@ -268,3 +268,51 @@ test("record unions retype as the record pointer itself", async () => {
// No record of the visitor is ever boxed.
expect(ir).not.toMatch(/call ptr @scr_union_new_ref/);
});
// Children that may be absent, as IR nodes have: reading one after its
// `=== undefined` or `=== null` test narrows a shape union with a unit arm
// to the shape union without it.
const OPTIONAL_CHILDREN = `
type Num = { kind: "num"; value: number };
type Ref = { kind: "ref"; id: string };
type Call = { kind: "call"; callee: Expr; receiver?: Expr; args: Expr[] };
type Init = { kind: "init"; value: Expr | null };
type Expr = Num | Ref | Call | Init;
function count(e: Expr): number {
switch (e.kind) {
case "num":
case "ref":
return 1;
case "call":
return (
1 +
count(e.callee) +
(e.receiver === undefined ? 0 : count(e.receiver)) +
e.args.reduce((n, a) => n + count(a), 0)
);
case "init":
return 1 + (e.value === null ? 0 : count(e.value));
}
}
const tree: Expr = {
kind: "call",
callee: { kind: "ref", id: "f" },
receiver: { kind: "init", value: { kind: "num", value: 1 } },
args: [{ kind: "init", value: null }],
};
console.log(count(tree));
`;
test("dropping a shape union's unit arm keeps the record pointer", async () => {
const { module, ir } = await llvmFor(OPTIONAL_CHILDREN, false);
const identities = module.functions.filter(
(fn) =>
fn.name.startsWith("%union.retag.") &&
JSON.stringify(fn.body).includes('"kind":"unionRetype"'),
);
expect(identities.length).toBeGreaterThan(0);
for (const helper of identities) {
const symbol = `sc_bf__x25_union_retag_${helper.name.slice("%union.retag.".length)}`;
expect(body(ir, symbol)).not.toMatch(/retype\.built/);
}
});
@@ -14,6 +14,7 @@ import {
RuntimePackError,
type RuntimePackManifest,
} from "./runtime-pack.js";
import { HUGE_TEXT_MIN_CODE_BYTES } from "./huge-text.js";
import { createNativeLinkPlan } from "./link-plan.js";
import {
executableLinkerEnvironmentFingerprint,
@@ -266,6 +267,34 @@ describe("runtime pack manifests", () => {
expect(staticPlan.staticLink).toBe(true);
});
test("Linux links of large program code use huge text unless it is turned off", async () => {
const { packagePath, root } = await fixture(LINUX_X64_GNU_TARGET);
// An ELF64 relocatable object's headers with one executable section of
// HUGE_TEXT_MIN_CODE_BYTES; only the headers are read.
const object = Buffer.alloc(64 + 2 * 64);
object.writeUInt32BE(0x7f454c46, 0);
object[4] = 2;
object[5] = 1;
object.writeBigUInt64LE(64n, 40);
object.writeUInt16LE(64, 58);
object.writeUInt16LE(2, 60);
object.writeUInt32LE(1, 128 + 4); // SHT_PROGBITS
object.writeBigUInt64LE(0x6n, 128 + 8); // SHF_ALLOC | SHF_EXECINSTR
object.writeBigUInt64LE(BigInt(HUGE_TEXT_MIN_CODE_BYTES), 128 + 32);
await writeFile(join(root, "program.o"), object);
const options = {
target: LINUX_X64_GNU_TARGET,
programObject: join(root, "program.o"),
outPath: join(root, "program"),
features: BASE,
ffi: null,
optimization: "release" as const,
resolver: () => packagePath,
};
expect((await createNativeLinkPlan(options)).hugeText).toBe(true);
expect((await createNativeLinkPlan({ ...options, hugeText: false })).hugeText).toBeUndefined();
});
test("selection chooses the most-specific variant and feature archive", async () => {
const { packagePath } = await fixture();
const resolver = () => packagePath;
+6
View File
@@ -57,6 +57,12 @@ export interface CompileBaseOptions {
* at startup when the system allows them. The moved code stays resident.
* Executables only; no effect on other platforms. */
largePages?: boolean;
/** Internal: `false` links a Linux executable with 4 KiB segment alignment
* even when its program code is large enough for text on huge pages
* (backend/huge-text.ts). The scriptc CLI is linked this way
* (scripts/build-native-cli.mts): most of its runs are short, and a short
* run maps every 2 MiB text window it touches whole. */
hugeText?: false;
/** How often the program throws (`--exceptions`). `rare` puts recursive
* functions and those near a `throw` on the unwinding exception ABI too:
* calls to them need no pending-flag test, and an exception that crosses
@@ -172,6 +172,8 @@ export interface EarlyExecutableCacheOptions {
libnodeIdentity?: () => string | undefined;
/** Omitted unless --large-pages is set. */
largePages?: true;
/** Omitted unless the build links without huge text (CompileOptions.hugeText). */
noHugeText?: true;
/** Omitted unless --exceptions=rare is set. */
exceptions?: "rare";
/** Omitted for the x86-64 baseline (`--target-cpu`). */
@@ -343,6 +345,7 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.windowsSubsystem === "gui" ? ["windows-subsystem-gui"] : []),
...(options.detachedExit ? ["detached-exit"] : []),
...(options.largePages ? ["large-pages"] : []),
...(options.noHugeText ? ["no-huge-text"] : []),
...(options.exceptions === "rare" ? ["exceptions-rare"] : []),
...targetCpuKeyParts(options.targetCpu),
...cpuDispatchKeyParts(options.cpuDispatch),
@@ -393,6 +396,7 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.windowsSubsystem === "gui") hash.update("windows-subsystem-gui\0");
if (options.detachedExit) hash.update("detached-exit\0");
if (options.largePages) hash.update("large-pages\0");
if (options.noHugeText) hash.update("no-huge-text\0");
if (options.exceptions === "rare") hash.update("exceptions-rare\0");
for (const part of targetCpuKeyParts(options.targetCpu)) hash.update(`${part}\0`);
for (const part of cpuDispatchKeyParts(options.cpuDispatch)) hash.update(`${part}\0`);
@@ -0,0 +1,44 @@
import { type IrType, typeKey } from "../../ir/ir.js";
import type { Lowerer } from "./lowerer.js";
/** Whether `actual` is `declared` with every `Map<unknown, unknown>` or
* `Set<unknown>` (TypeScript's `any` type arguments of an untyped empty
* collection, mapped to unknown) replaced by a map or set of a concrete
* layout, and with nothing else different. A union `declared` matches when
* `actual` is one of its record arms and every other arm is the residue
* form of that arm. */
export function anyResidueReplacedBy(lowerer: Lowerer, declared: IrType, actual: IrType): boolean {
const residue = (t: IrType): boolean =>
(t.kind === "map" && t.key.kind === "dyn" && t.value.kind === "dyn") ||
(t.kind === "set" && t.elem.kind === "dyn");
const replaces = (d: IrType, a: IrType, seen: Set<string>): boolean => {
if (typeKey(d) === typeKey(a)) return true;
if (residue(d)) return a.kind === d.kind && !residue(a);
if (d.kind === "record" && a.kind === "record") {
const pair = `${d.shapeId}>${a.shapeId}`;
if (seen.has(pair)) return true;
seen.add(pair);
const from = lowerer.shapes.get(d.shapeId);
const to = lowerer.shapes.get(a.shapeId);
if (!from || !to || from.tuple || to.tuple || from.indexValue || to.indexValue) return false;
if (from.fields.length !== to.fields.length) return false;
return from.fields.every((field) => {
const other = to.fields.find((f) => f.name === field.name);
return other !== undefined && replaces(field.type, other.type, seen);
});
}
return false;
};
if (declared.kind === "union" && actual.kind === "record") {
const arms = lowerer.unions.get(declared.unionId)?.arms ?? [];
return (
arms.some((arm) => typeKey(arm) === typeKey(actual)) &&
arms.every(
(arm) =>
typeKey(arm) === typeKey(actual) ||
(arm.kind === "record" && replaces(arm, actual, new Set())),
)
);
}
return replaces(declared, actual, new Set());
}
@@ -76,6 +76,17 @@ export function lowerMapMethodCall(
if (!probedUntyped && !lowerer.isStdlibMember(access) && name !== "has") return null;
const loc = locOf(call);
const value = lowerer.lowerExpr(access.expression);
// A receiver the checker types `Map<any, any>` (inference residue of an
// untyped `new Map()`) whose value has a concrete layout: its keys and
// values convert into that layout.
if (
value.type.kind === "map" &&
receiverIr.key.kind === "dyn" &&
receiverIr.value.kind === "dyn" &&
value.type.key.kind !== "dyn" &&
value.type.value.kind !== "dyn"
)
receiverIr = value.type;
const receiver =
lowerer.runtimeOptionalPropertyReceiver(access.expression, value, receiverIr, name) ?? value;
if (receiver.type.kind !== "map") {
@@ -7,6 +7,8 @@
*
* - a union element to one of its arms, or to a union of some of its
* arms, by its tag;
* - a union element to a record with the same fields as one of its arms,
* through that arm;
* - a checked-dynamic element by dynCheck;
* - a class instance to a subclass by its class;
* - a tuple or record to one with the same fields, field by field.
@@ -241,10 +243,35 @@ function unionConversion(
if (targetArms === null) return null;
const body: IrStmt[] = [];
let kept = 0;
let recordArmConversions = 0;
for (let tag = 0; tag < source.arms.length; tag++) {
const arm = source.arms[tag]!;
const targetTag = targetArms.findIndex((t) => typeEquals(t, arm));
if (targetTag < 0) continue;
if (targetTag < 0) {
// A record target that is no arm itself but the same fields as one
// (`Model<"a">` from the `Model<Api>` arm, whose nested union field
// the predicate narrows): that arm converts field by field.
if (to.kind !== "record" || arm.kind !== "record") continue;
const narrowedArm: IrExpr = {
kind: "unionNarrow",
unionId,
tag,
value: input,
type: arm,
loc,
};
const converted = conversionBody(lowerer, narrowedArm, arm, to, typeText, loc);
if (converted === null) continue;
recordArmConversions++;
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
then: converted,
else_: null,
loc,
});
continue;
}
kept++;
const payload: IrExpr = isUnitType(arm)
? { kind: "unitLit", unit: arm.kind === "undefinedT" ? "undefined" : "null", type: arm, loc }
@@ -261,10 +288,14 @@ function unionConversion(
loc,
});
}
// Every arm the target names must be one of the union's own arms.
if (kept === 0 && to.kind !== "union")
// Every arm the target names must be one of the union's own arms, or a
// record target converts from at least one arm. A record target that is
// itself an arm keeps that arm's elements as they are, and the arms with
// its fields convert. Any other structural target that is no arm converts
// from each arm it can be read from.
if (kept === 0 && recordArmConversions === 0 && to.kind !== "union")
return structuralUnionConversion(lowerer, input, unionId, to, typeText, loc);
if (kept === 0 || kept !== targetArms.length) return null;
if (kept === 0 ? recordArmConversions === 0 : kept !== targetArms.length) return null;
body.push(
typeError(
`a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`,
@@ -354,6 +354,31 @@ function literalDefault(node: ts.Expression): boolean {
);
}
/** A fresh object literal (parentheses allowed) one of whose property
* values, at any literal depth, is `new Map()` or `new Set()` of the
* standard library with neither arguments nor type arguments. */
function untypedCollectionFallback(lowerer: Lowerer, node: ts.Expression): boolean {
let value = node;
while (ts.isParenthesizedExpression(value)) value = value.expression;
if (!ts.isObjectLiteralExpression(value)) return false;
return value.properties.nodes.some((property) => {
if (!ts.isPropertyAssignment(property)) return false;
let inner = property.initializer;
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
if (ts.isObjectLiteralExpression(inner)) return untypedCollectionFallback(lowerer, inner);
if (
!ts.isNewExpression(inner) ||
!ts.isIdentifier(inner.expression) ||
(inner.arguments?.nodes.length ?? 0) !== 0 ||
(inner.typeArguments?.nodes.length ?? 0) !== 0
)
return false;
const symbol = lowerer.resolveValueSymbol(inner.expression);
const name = symbol ? ts.symbolName(symbol) : "";
return symbol !== null && lowerer.isStdlibSymbol(symbol) && (name === "Map" || name === "Set");
});
}
export function lowerNullishPair(
lowerer: Lowerer,
expr: ts.BinaryExpression,
@@ -387,6 +412,24 @@ export function lowerNullishPair(
// arm that has no first-class union representation.
const fresh = rest.length === 1 ? lowerer.emptyCollectionFor(expr.right, rest[0]!) : null;
if (fresh) return { kind: "nullish", left, right: fresh, type: fresh.type, loc };
// `optionalRecord ?? { a: 1, items: new Map() }`: a fresh fallback literal
// whose untyped empty collections (`new Map()`, `new Set()` without type
// arguments, which the checker types `Map<any, any>`) leave the checker's
// join as a second record arm. When the literal is assignable to the
// left's record, it builds as that record, so the collections take its
// element types and the result keeps one layout.
if (
rest.length === 1 &&
rest[0]!.kind === "record" &&
untypedCollectionFallback(lowerer, expr.right) &&
lowerer.checker.isTypeAssignableTo(
lowerer.typeOf(expr.right),
lowerer.checker.getNonNullableType(lowerer.typeOf(expr.left)),
)
) {
const right = lowerer.lowerExprExpecting(expr.right, rest[0]!);
return { kind: "nullish", left, right, type: rest[0]!, loc };
}
let type = lowerer.irTypeOf(expr);
// A runtime-optional read can reach the default even when declarations
// call the left side present. Preserve a differently typed primitive
@@ -154,6 +154,22 @@ function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration):
return open;
}
/** `type` when it is the ambient Promise, or its one Promise arm when it is
* a union with exactly one; undefined for anything else (`void`, `unknown`,
* a PromiseLike). */
function promiseArm(lowerer: Lowerer, type: ts.Type): ts.Type | undefined {
const isPromise = (t: ts.Type): boolean => {
const symbol = t.getSymbol();
return (
symbol !== undefined && ts.symbolName(symbol) === "Promise" && lowerer.isStdlibSymbol(symbol)
);
};
if (isPromise(type)) return type;
if (!type.isUnionType()) return undefined;
const arms = ts.constituentTypes(type).filter(isPromise);
return arms.length === 1 ? arms[0] : undefined;
}
/** Map a declaration's return contract while retaining JavaScript
* inference fallbacks. Diagnostics preserve the caller's blame node. */
export function declaredReturnType(
@@ -164,7 +180,39 @@ export function declaredReturnType(
if (isJsSourceFile(ts.getSourceFileOfNode(decl)) && ts.isGetAccessorDeclaration(decl)) return DYN;
const sig = lowerer.checker.getSignatureFromDeclaration(decl);
if (!sig) lowerer.unsupported("SC1090", decl, "this function form");
const retTsType = lowerer.checker.getReturnTypeOfSignature(sig);
let retTsType = lowerer.checker.getReturnTypeOfSignature(sig);
// An ASYNC object-literal method with no return annotation and a
// contextual signature takes its return from that signature, as an async
// arrow does: the body's `return` builds at the contextually typed
// literal, so the checker's own inferred literal (narrower than the
// contextual record) could never hold it.
if (
ts.isMethodDeclaration(decl) &&
ts.isObjectLiteralExpression(decl.parent) &&
decl.type === undefined &&
decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword) === true
) {
const contextual = lowerer.checker.getContextualType(decl.parent);
const slot =
contextual === undefined || (!ts.isIdentifier(decl.name) && !ts.isStringLiteral(decl.name))
? undefined
: lowerer.checker.getPropertyOfType(
lowerer.checker.getNonNullableType(contextual),
decl.name.text,
);
const signatures =
slot === undefined
? []
: lowerer.checker.getCallSignatures(lowerer.checker.getTypeOfSymbol(slot));
// A slot whose return is not a promise (`() => void`, or a union such
// as `Promise<T> | void`) contributes only its promise: an async
// method returns one whatever the slot says.
const promise =
signatures.length === 1
? promiseArm(lowerer, lowerer.checker.getReturnTypeOfSignature(signatures[0]!))
: undefined;
if (promise !== undefined) retTsType = promise;
}
// An inferred null/undefined return in JS can read a mutable checked-value
// field. Keep the actual value instead of restricting it to the initializer.
if (
@@ -133,7 +133,11 @@ import { isAssertClassNew } from "./assert-instances.js";
import { isCjsRequireGlobal } from "./lower-node-module.js";
import { lowerArrayConstructor } from "./containers/array-construction.js";
import { initializedCollectionType } from "./js-collection-types.js";
import { lowerMapSeedNew, lowerSetSeedNew } from "./containers/collection-methods.js";
import {
lowerMapSeedNew,
lowerSetSeedNew,
mapFromSeedValue,
} from "./containers/collection-methods.js";
import { lowerObjectConstructor } from "./containers/indexed-objects.js";
import { knownBufEncoding, lowerBufferConstruct } from "./containers/bytes.js";
import { lowerSearchParamsNew } from "./builtins/url.js";
@@ -11085,6 +11089,35 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
mapped = ctxMap;
}
}
// `new Map(source)` the checker types `Map<any, any>` because the
// source's checker type joins an untyped `new Map()` with a typed map
// (a union of map types): the copy takes the layout the source value
// actually has.
if (
seedArg &&
!entriesLit &&
mapped?.kind === "map" &&
mapped.key.kind === "dyn" &&
mapped.value.kind === "dyn" &&
(expr.typeArguments?.nodes.length ?? 0) === 0 &&
!isJsSourceFile(ts.getSourceFileOfNode(expr)) &&
lowerer.mapTypeOf(lowerer.typeOf(seedArg))?.kind === "union"
) {
const source = lowerer.lowerExpr(seedArg);
if (
source.type.kind === "map" &&
source.type.key.kind !== "dyn" &&
source.type.value.kind !== "dyn"
) {
const copied = mapFromSeedValue(lowerer, source, source.type);
if (copied) return copied;
}
lowerer.noLowering(
"new Map(entries)",
expr,
"a source whose map layouts differ (give the empty `new Map()` its type arguments)",
);
}
if (seedArg && !entriesLit && mapped?.kind === "map") {
const seeded = lowerMapSeedNew(lowerer, seedArg, mapped);
if (seeded) return seeded;
@@ -32,6 +32,7 @@ import { lowerCollectionSpread } from "./containers/collection-methods.js";
import { lowerEventObjectPropertyWrite } from "./lower-event-target.js";
import { lowerDynObjectLiteral } from "./expressions/object-literals.js";
import { importMetaBindingSource } from "./import-meta.js";
import { anyResidueReplacedBy } from "./any-collection-residue.js";
import { iteratorCanStep, iteratorValue } from "./iterator-consumption.js";
import { notIterableSpelling } from "./iterable-spelling.js";
import { isPrivateSplitConsumer } from "./split-consumers.js";
@@ -6469,6 +6470,19 @@ export function lowerVarDecl(
typeInvolvesWebStream(lowerer, lowerer.typeOf(decl.name))))
)
type = DYN;
// An unannotated TypeScript binding whose checker type carries `any`
// inference residue (`Map<any, any>` from an untyped `new Map()` joined
// into the initializer's type) takes the initializer's own layout when
// that layout replaces only the residue: `any` admits every use, and the
// value has that layout.
if (
!decl.type &&
type !== null &&
!isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!typeEquals(type, init.type) &&
anyResidueReplacedBy(lowerer, type, init.type)
)
type = init.type;
// A const whose initializer has another record or array layout than the
// binding's type keeps the initializer's layout: converting would copy the
// value, so the binding would not be the value its initializer names.
+5
View File
@@ -633,6 +633,7 @@ async function compileExecutableNative(
* (BuildSources): the spelling clang sees and native cache keys name. */
llvmVisiblePath?: string,
profileLayout = false,
hugeText = true,
): Promise<void> {
const programIsObject = /\.(?:o|obj)$/.test(llvmPath);
const runtimePackTarget = programIsObject && !sanitize ? nativeCodegenTarget() : null;
@@ -650,6 +651,7 @@ async function compileExecutableNative(
...(windowsSubsystem === undefined ? {} : { windowsSubsystem }),
...(profileGenerate ? { profileGenerate: true } : {}),
...(profileLayout ? { profileLayout: true } : {}),
...(hugeText ? {} : { hugeText: false }),
programObjectDependencies,
});
const cacheableLinker =
@@ -1823,6 +1825,7 @@ async function compileTracked(
// identity (node-islands/libnode.ts) and a hit requires it unchanged.
libnodeIdentity: () => libnodeCacheKey(opts.libnode, process.env, dirname(entryPath)),
...(opts.largePages === true ? { largePages: true as const } : {}),
...(opts.hugeText === false ? { noHugeText: true as const } : {}),
...(opts.exceptions === "rare" ? { exceptions: "rare" as const } : {}),
...targetCpuField(opts.targetCpu),
...(opts.cpuDispatch === true ? { cpuDispatch: true as const } : {}),
@@ -2003,6 +2006,7 @@ async function compileTracked(
profile?.generate !== undefined,
hitSources.llvmPath,
nativeProfileLayout(profile),
opts.hugeText !== false,
);
if (nativeProgramObject !== null && opts.nativeProgramObject === true) {
await rename(nativeProgramObject.linkPath, nativeProgramObject.artifactPath);
@@ -2151,6 +2155,7 @@ async function compileTracked(
profile?.generate !== undefined,
llvmPath,
nativeProfileLayout(profile),
opts.hugeText !== false,
);
timing("native-link");
if (nativeProgramObject !== null && opts.nativeProgramObject === true) {
@@ -0,0 +1,220 @@
import { expect, test } from "vitest";
import { BOOL, F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
import { everyStmtList } from "./traverse.js";
import { jamSaturatingLoops } from "./saturating-loop-jams.js";
const loc = { file: "saturating-loop-jams.ts", start: 0, end: 0 };
const BYTES = { kind: "bytes", elem: "f64" } as const;
const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 });
const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 });
type Op = "+" | "*" | "-" | "<" | ">" | "|" | "===" | "<<";
const bin = (op: Op, left: IrExpr, right: IrExpr): IrExpr => ({
loc,
kind: "bin",
op,
left,
right,
type: op === "<" || op === ">" || op === "===" ? BOOL : F64,
});
const decl = (id: string, init: IrExpr): IrStmt => ({ loc, kind: "varDecl", localId: id, init });
const set = (id: string, value: IrExpr): IrStmt => ({ loc, kind: "assign", localId: id, value });
const stepBy = (id: string, by: number): IrStmt => set(id, bin("+", ref(id), num(by)));
const loop = (
id: string,
end: IrExpr,
by: number,
body: IrStmt[],
start: IrExpr = num(0),
): IrStmt & { kind: "for" } => ({
loc,
kind: "for",
init: decl(id, start),
cond: bin("<", ref(id), end),
update: stepBy(id, by),
body,
});
const exitWhen = (acc: string, full: number): IrStmt => ({
loc,
kind: "if",
cond: bin("===", ref(acc), num(full)),
then: [{ loc, kind: "break" }],
else_: null,
});
const get = (index: IrExpr): IrExpr => ({
loc,
kind: "bytesIntrinsic",
method: "get",
receiver: { loc, kind: "varRef", localId: "cr", type: BYTES },
args: [index],
type: F64,
invalidNaN: true,
});
/** One lane of mandelbrot's escape test: `z = z * z + c`, `b |= bit` and
* an exit once `b` is full. */
function lane(z: string, c: string, bit: number, full = 3, extra: IrStmt[] = []): IrStmt[] {
return [
decl(`${z}.t`, bin("*", ref(z), ref(z))),
set(z, bin("+", ref(`${z}.t`), ref(c))),
{
loc,
kind: "if",
cond: bin(">", ref(`${z}.t`), num(4)),
then: [set("b", bin("|", ref("b"), num(bit))), exitWhen("b", full), ...extra],
else_: null,
},
];
}
function nest(
options: {
full?: number;
post?: IrStmt[];
preExtra?: IrStmt[];
innerExtra?: IrStmt[];
innerStart?: IrExpr;
innerEnd?: IrExpr;
copies?: number;
} = {},
): IrStmt[] {
const post = options.post ?? [set("res", bin("|", bin("<<", ref("res"), num(2)), ref("b")))];
return [
decl("res", num(0)),
loop("i", num(2 * (options.copies ?? 4)), 2, [
decl("c1", get(ref("i"))),
decl("c2", get(bin("+", ref("i"), num(1)))),
decl("z1", ref("c1")),
decl("z2", ref("c2")),
decl("b", num(0)),
...(options.preExtra ?? []),
loop(
"j",
options.innerEnd ?? num(50),
1,
[
...lane("z1", "c1", 2, options.full),
...lane("z2", "c2", 1, options.full),
...(options.innerExtra ?? []),
],
options.innerStart,
),
...post,
]),
];
}
function moduleOf(body: IrStmt[]): IrModule {
const ids = new Set<string>();
everyStmtList(body, {
expr: (e) => {
if (e.kind === "varRef" && e.localId !== "cr") ids.add(e.localId);
return true;
},
stmt: (s) => {
if (s.kind === "varDecl" || s.kind === "assign") ids.add(s.localId);
return true;
},
});
const fn: IrFunction = {
name: "render",
params: [{ localId: "cr", name: "cr", type: BYTES }],
locals: [
{ id: "cr", name: "cr", type: BYTES, mutable: false },
...[...ids].map((id) => ({ id, name: id, type: F64, mutable: true })),
],
body,
returnType: VOID,
loc,
};
return { irVersion: 17, sourceFile: loc.file, entry: "render", functions: [fn] } as IrModule;
}
function loops(body: IrStmt[]): (IrStmt & { kind: "for" })[] {
const found: (IrStmt & { kind: "for" })[] = [];
everyStmtList(body, {
expr: () => true,
stmt: (s) => {
if (s.kind === "for") found.push(s);
return true;
},
});
return found;
}
test("jams a constant-count loop whose inner loop exits once its accumulator is full", () => {
const mod = moduleOf(nest());
const out = jamSaturatingLoops(mod);
expect(out).not.toBe(mod);
const body = out.functions[0]!.body;
// The outer loop is gone: one inner loop runs the four iterations.
const remaining = loops(body);
expect(remaining.length).toBe(1);
const inner = remaining[0]!;
// Its only exit tests every copy's accumulator, at the end of the body.
let breaks = 0;
everyStmtList(inner.body, {
expr: () => true,
stmt: (s) => {
if (s.kind === "break") breaks++;
return true;
},
});
expect(breaks).toBe(1);
const last = inner.body[inner.body.length - 1]!;
expect(last.kind).toBe("if");
// Each copy keeps its own accumulator and lanes; `post` runs in order.
const added = out.functions[0]!.locals.filter((local) => local.id.startsWith("%jam."));
expect(added.filter((local) => local.name === "%jam.b").length).toBe(4);
const block = body[1]!;
expect(block.kind).toBe("block");
if (block.kind !== "block") return;
const posts = block.body.slice(-4);
expect(posts.every((s) => s.kind === "assign" && s.localId === "res")).toBe(true);
});
test("keeps loops whose exits or values do not allow running iterations together", () => {
const reject = (body: IrStmt[]): void => {
const mod = moduleOf(body);
expect(jamSaturatingLoops(mod)).toBe(mod);
};
// An exit before the accumulator is full: later ORs would change it.
reject(nest({ full: 2 }));
// `post` reads a lane value the copies keep computing past their exit.
reject(nest({ post: [set("res", bin("+", ref("res"), ref("z1")))] }));
// `pre` reads what `post` writes.
reject(nest({ preExtra: [decl("seed", ref("res"))] }));
// The inner loop writes the accumulator other than by an OR: a later
// iteration can clear bits after an exit.
reject(nest({ innerExtra: [set("b", num(0))] }));
reject(nest({ innerExtra: [set("b", bin("+", ref("b"), num(4)))] }));
// The inner loop's body advances its counter, which the copies share.
reject(nest({ innerExtra: [stepBy("j", 1)] }));
// The inner loop's start or bound reads the counter or a local of the
// iteration.
reject(nest({ innerStart: ref("i") }));
reject(nest({ innerEnd: bin("+", num(10), ref("i")) }));
reject(nest({ preExtra: [decl("start", num(3))], innerStart: ref("start") }));
// One iteration, or more than eight.
reject(nest({ copies: 1 }));
reject(nest({ copies: 9 }));
// A store in the inner loop is observable past an exit.
reject([
decl("res", num(0)),
loop("i", num(8), 2, [
decl("b", num(0)),
decl("z1", num(0)),
loop("j", num(50), 1, [
...lane("z1", "z1", 2, 3, [
{
loc,
kind: "bytesSet",
arr: { loc, kind: "varRef", localId: "cr", type: BYTES },
index: num(0),
value: num(1),
},
]),
]),
set("res", bin("|", ref("res"), ref("b"))),
]),
]);
});
@@ -0,0 +1,443 @@
import {
BOOL,
F64,
type IrExpr,
type IrFunction,
type IrLocal,
type IrModule,
type IrStmt,
} from "./ir.js";
import { everyStmtList, transformStmtList } from "./traverse.js";
/** The most iterations of an outer loop that run jammed. */
const MAX_COPIES = 8;
/** The most statement and expression nodes of one outer iteration: every
* copy duplicates it. */
const MAX_BODY_NODES = 160;
/** Binary operators of numbers and booleans that run no other code and
* cannot throw. */
const PURE_BINARY = new Set<string>([
"+",
"-",
"*",
"/",
"%",
"&",
"|",
"^",
"<<",
">>",
">>>",
"<",
"<=",
">",
">=",
"===",
"!==",
]);
const PURE_UNARY = new Set<string>(["-", "+", "~", "!"]);
interface Jam {
counter: string;
/** The counter's value on each iteration. */
values: number[];
pre: IrStmt[];
inner: IrStmt & { kind: "for" };
post: IrStmt[];
/** The accumulator the inner loop's exits test, and its full value. */
acc: string;
full: number;
}
function isRef(e: IrExpr | null | undefined, id: string): boolean {
return e?.kind === "varRef" && e.localId === id;
}
function intLit(e: IrExpr | null | undefined): number | null {
return e?.kind === "numLit" && Number.isSafeInteger(e.value) && !Object.is(e.value, -0)
? e.value
: null;
}
/** `id = id + step` with a positive integer literal step. */
function constantStep(update: IrStmt | null, id: string): number | null {
if (
update?.kind !== "assign" ||
update.localId !== id ||
update.value.kind !== "bin" ||
update.value.op !== "+" ||
!isRef(update.value.left, id)
)
return null;
const step = intLit(update.value.right);
return step !== null && step > 0 ? step : null;
}
/** A number or boolean expression that runs no other code and cannot throw:
* literals, reads of locals and module bindings, arithmetic, comparisons
* and typed-array element reads that answer NaN out of range. */
function pureExpr(e: IrExpr, locals: ReadonlyMap<string, IrLocal>): boolean {
if (e.type.kind !== "f64" && e.type.kind !== "bool") return false;
switch (e.kind) {
case "numLit":
case "boolLit":
return true;
case "varRef": {
const local = locals.get(e.localId);
if (local) return !local.boxed && !local.tdz;
return e.localId.startsWith("%g.");
}
case "bin":
return PURE_BINARY.has(e.op) && pureExpr(e.left, locals) && pureExpr(e.right, locals);
case "unary":
return PURE_UNARY.has(e.op) && pureExpr(e.operand, locals);
case "bytesIntrinsic":
return (
e.method === "get" &&
e.invalidNaN === true &&
e.receiver.kind === "varRef" &&
e.args.length === 1 &&
pureExpr(e.args[0]!, locals)
);
default:
return false;
}
}
/** A local written by `s` (a declaration or assignment), or null. */
function writtenLocal(s: IrStmt): string | null {
return s.kind === "varDecl" || s.kind === "assign" ? s.localId : null;
}
/** Straight-line declarations and assignments of pure values. */
function pureStatements(list: readonly IrStmt[], locals: ReadonlyMap<string, IrLocal>): boolean {
return list.every(
(s) =>
(s.kind === "varDecl" && s.init !== null && pureExpr(s.init, locals)) ||
(s.kind === "assign" && locals.has(s.localId) && pureExpr(s.value, locals)),
);
}
/** The bits `C` of `id = id | C`, with `C` a non-negative int32 literal, or
* null. */
function orBits(s: IrStmt): number | null {
if (
s.kind !== "assign" ||
s.value.kind !== "bin" ||
s.value.op !== "|" ||
!isRef(s.value.left, s.localId)
)
return null;
const bits = intLit(s.value.right);
return bits !== null && bits >= 0 && bits <= 0x7fffffff ? bits : null;
}
/** The statements of the inner loop's body: pure declarations and
* assignments, `if` statements over them, `acc = acc | C`, and
* `if (acc === full) break` (collected into `breaks`). */
function innerStatements(
list: readonly IrStmt[],
locals: ReadonlyMap<string, IrLocal>,
acc: { id: string | null; bits: number; breaks: number[] },
): boolean {
return list.every((s) => {
if (s.kind === "if") {
// `if (acc === full) break`
if (
s.then.length === 1 &&
s.then[0]!.kind === "break" &&
s.then[0]!.label === undefined &&
(s.else_ === null || s.else_.length === 0) &&
s.cond.kind === "bin" &&
s.cond.op === "===" &&
s.cond.left.kind === "varRef" &&
intLit(s.cond.right) !== null
) {
if (acc.id !== null && acc.id !== s.cond.left.localId) return false;
acc.id = s.cond.left.localId;
acc.breaks.push(intLit(s.cond.right)!);
return true;
}
return (
pureExpr(s.cond, locals) &&
innerStatements(s.then, locals, acc) &&
innerStatements(s.else_ ?? [], locals, acc)
);
}
const bits = orBits(s);
if (s.kind === "assign" && bits !== null) {
if (acc.id !== null && acc.id !== s.localId) return false;
acc.id = s.localId;
acc.bits |= bits;
return true;
}
return pureStatements([s], locals);
});
}
function countNodes(list: readonly IrStmt[]): number {
let n = 0;
everyStmtList(list as IrStmt[], {
expr: () => ++n <= MAX_BODY_NODES,
stmt: () => ++n <= MAX_BODY_NODES,
});
return n;
}
/** Reads of local `id` anywhere in `list`. */
function reads(list: readonly IrStmt[], id: string): boolean {
return !everyStmtList(list as IrStmt[], { expr: (e) => !isRef(e, id), stmt: () => true });
}
/** Locals written anywhere in `list`. */
function writes(list: readonly IrStmt[]): Set<string> {
const out = new Set<string>();
everyStmtList(list as IrStmt[], {
expr: () => true,
stmt: (s) => {
const id = writtenLocal(s);
if (id !== null) out.add(id);
return true;
},
});
return out;
}
/** Locals declared anywhere in `list`. */
function declared(list: readonly IrStmt[]): Set<string> {
const out = new Set<string>();
everyStmtList(list as IrStmt[], {
expr: () => true,
stmt: (s) => {
if (s.kind === "varDecl") out.add(s.localId);
return true;
},
});
return out;
}
/** The jam of `loop`, or null. */
function matchJam(
loop: IrStmt & { kind: "for" },
locals: ReadonlyMap<string, IrLocal>,
): Jam | null {
if (loop.labels?.length || loop.init?.kind !== "varDecl") return null;
const counter = loop.init.localId;
const local = locals.get(counter);
if (local?.type.kind !== "f64" || local.boxed || local.tdz) return null;
const start = intLit(loop.init.init);
const step = constantStep(loop.update, counter);
const cond = loop.cond;
if (
start === null ||
step === null ||
cond?.kind !== "bin" ||
cond.op !== "<" ||
!isRef(cond.left, counter)
)
return null;
const end = intLit(cond.right);
if (end === null) return null;
const values: number[] = [];
for (let v = start; v < end && values.length <= MAX_COPIES; v += step) values.push(v);
if (values.length < 2 || values.length > MAX_COPIES) return null;
// One inner loop, with straight-line code before and after it.
const at = loop.body.findIndex((s) => s.kind === "for");
if (at < 0) return null;
const inner = loop.body[at] as IrStmt & { kind: "for" };
const pre = loop.body.slice(0, at),
post = loop.body.slice(at + 1);
if (!pureStatements(pre, locals) || !pureStatements(post, locals)) return null;
if (countNodes(loop.body) > MAX_BODY_NODES) return null;
if (inner.labels?.length || inner.init?.kind !== "varDecl") return null;
const j = inner.init.localId;
const jLocal = locals.get(j);
if (jLocal?.type.kind !== "f64" || jLocal.boxed || jLocal.tdz) return null;
if (inner.init.init === null || !pureExpr(inner.init.init, locals)) return null;
if (inner.cond === null || !pureExpr(inner.cond, locals) || constantStep(inner.update, j) !== 1)
return null;
const acc = { id: null as string | null, bits: 0, breaks: [] as number[] };
if (!innerStatements(inner.body, locals, acc) || acc.id === null) return null;
const full = acc.bits;
// Every exit tests the accumulator against the value every OR leaves
// unchanged once it is reached.
if (acc.breaks.length === 0 || acc.breaks.some((value) => value !== full)) return null;
const accId = acc.id;
// The accumulator starts at a literal in `pre`, and the inner loop changes
// it only by its ORs.
const accDecl = pre.find((s) => s.kind === "varDecl" && s.localId === accId);
if (accDecl?.kind !== "varDecl" || intLit(accDecl.init) === null) return null;
const onlyOrs = everyStmtList(inner.body, {
expr: () => true,
stmt: (s) => writtenLocal(s) !== accId || orBits(s) !== null,
});
if (!onlyOrs) return null;
// Every copy shares the inner counter: only the inner loop's update writes
// it.
if (writes(inner.body).has(j)) return null;
// The counter, the inner bound and every value the inner loop reads from
// outside the iteration are the same for every copy: only `pre` and the
// counter vary.
const own = declared([...pre, inner]);
own.delete(counter);
const innerWrites = writes([inner]);
innerWrites.delete(j);
for (const id of innerWrites) if (!own.has(id) && id !== accId) return null;
if (writes([inner]).has(counter) || writes(pre).has(counter) || writes(post).has(counter))
return null;
// Copies run `pre` and the inner loop of later iterations before `post`
// of earlier ones: `pre` and the inner loop must not read what `post`
// writes, and `pre` writes only its own declarations.
const postWrites = writes(post);
for (const id of postWrites) if (reads([...pre, inner], id) || own.has(id)) return null;
for (const id of writes(pre)) if (!own.has(id)) return null;
// After the inner loop, iterations past a copy's exit are dead: `post`
// reads no value the inner loop writes besides the accumulator.
for (const id of innerWrites) if (id !== accId && reads(post, id)) return null;
// The copies share the inner loop's start and bound: they read neither the
// counter nor a local of the iteration.
const controls: IrStmt[] = [
{ kind: "exprStmt", expr: inner.init.init, loc: inner.loc },
{ kind: "exprStmt", expr: inner.cond, loc: inner.loc },
];
if (reads(controls, counter)) return null;
for (const id of own) if (id !== j && reads(controls, id)) return null;
return { counter, values, pre, inner, post, acc: accId, full };
}
/** Runs the iterations of a short constant-count loop together when its body
* is an inner loop that exits once an accumulator reaches the value its ORs
* saturate at (mandelbrot's pairs of pixels per output byte):
*
* for (let i = 0; i < 8; i += 2) {
* <pre: pure declarations, including `let b = 0`>
* for (let j = 0; j < M; j++) {
* <pure arithmetic>; if (c) { b |= 2; if (b === 3) break; } ...
* }
* <post: pure assignments reading b>
* }
*
* becomes every iteration's `pre` (with `i` a literal), one inner loop
* running every iteration's body in turn that exits when every accumulator
* is full, and every iteration's `post` in order. Once an accumulator is
* full its ORs leave it full, so the iterations a copy runs past its own
* exit leave its accumulator as its own loop left it, and every other value
* they compute is dead: the bodies only compute numbers, read typed-array
* elements that answer NaN out of range, and write their own locals. `pre`
* and the inner loop read nothing `post` writes, so running later `pre`s
* and inner loops first changes no value. Independent iterations then
* overlap their dependence chains. */
export function jamSaturatingLoops(mod: IrModule): IrModule {
let changed = false;
const functions = mod.functions.map((fn): IrFunction => {
if (fn.async || fn.generator) return fn;
const locals = new Map(fn.locals.map((local) => [local.id, local]));
let found = false;
everyStmtList(fn.body, {
expr: () => !found,
stmt: (s) => {
if (s.kind === "for" && matchJam(s, locals) !== null) found = true;
return !found;
},
});
if (!found) return fn;
const added: IrLocal[] = [];
const ids = new Set(fn.locals.map((local) => local.id));
const fresh = (of: IrLocal, copy: number): string => {
let id = `%jam.${copy}.${of.id}`;
while (ids.has(id)) id += "_";
ids.add(id);
const { source: _source, ...rest } = of;
added.push({ ...rest, id, name: `%jam.${of.name}` });
return id;
};
const body = transformStmtList(fn.body, {
expr: (e) => e,
stmt: (s) => {
if (s.kind !== "for") return s;
const jam = matchJam(s, locals);
return jam === null ? s : emitJam(jam, locals, fresh);
},
});
changed = true;
return { ...fn, locals: [...fn.locals, ...added], body };
});
return changed ? { ...mod, functions } : mod;
}
function emitJam(
jam: Jam,
locals: ReadonlyMap<string, IrLocal>,
fresh: (of: IrLocal, copy: number) => string,
): IrStmt {
const { inner, pre, post, values, counter } = jam;
const loc = inner.loc;
const own = declared([...pre, ...inner.body]);
const j = (inner.init as IrStmt & { kind: "varDecl" }).localId;
own.delete(j);
const copies = values.map((value, copy) => {
const rename = new Map<string, string>();
for (const id of own) rename.set(id, fresh(locals.get(id)!, copy));
const rewrite = (list: readonly IrStmt[]): IrStmt[] =>
transformStmtList(list as IrStmt[], {
expr: (e) => {
if (e.kind !== "varRef") return e;
if (e.localId === counter) return { kind: "numLit", value, type: F64, loc: e.loc };
const to = rename.get(e.localId);
return to === undefined ? e : { ...e, localId: to };
},
stmt: (s) => {
if (s.kind === "varDecl" || s.kind === "assign") {
const to = rename.get(s.localId);
if (to !== undefined) return { ...s, localId: to };
}
return s;
},
});
// The copy's exits become the shared test at the end of the iteration.
const withoutBreaks = (list: readonly IrStmt[]): IrStmt[] =>
list.flatMap((s): IrStmt[] => {
if (s.kind !== "if") return [s];
if (s.then.length === 1 && s.then[0]!.kind === "break") return [];
return [
{ ...s, then: withoutBreaks(s.then), else_: s.else_ ? withoutBreaks(s.else_) : null },
];
});
return {
pre: rewrite(pre),
body: rewrite(withoutBreaks(inner.body)),
post: rewrite(post),
acc: rename.get(jam.acc)!,
};
});
let allFull: IrExpr | null = null;
for (const copy of copies) {
const full: IrExpr = {
kind: "bin",
op: "===",
left: { kind: "varRef", localId: copy.acc, type: F64, loc },
right: { kind: "numLit", value: jam.full, type: F64, loc },
type: BOOL,
loc,
};
allFull =
allFull === null
? full
: { kind: "logical", op: "&&", left: allFull, right: full, type: BOOL, loc };
}
const jammed: IrStmt = {
...inner,
body: [
...copies.flatMap((copy) => copy.body),
{ kind: "if", cond: allFull!, then: [{ kind: "break", loc }], else_: null, loc },
],
};
return {
kind: "block",
body: [...copies.flatMap((copy) => copy.pre), jammed, ...copies.flatMap((copy) => copy.post)],
loc,
};
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"schemaVersion": 2,
"compilerVersion": "0.4.5",
"compilerVersion": "0.4.6",
"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.",
@@ -0,0 +1,75 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
async function emit(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-alias-names-"));
try {
const entry = join(dir, "main.ts"),
output = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath: output, outputKind: "ir" });
if (!result.ok)
throw new Error(result.diagnostics.map((item) => `${item.code}: ${item.message}`).join("\n"));
return emitLlvmModule(deserializeModule(await readFile(output, "utf8")));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
/** The alias type names that a function's tagged loads and stores use, in order. */
function aliasNames(llvm: string, symbol: string): string[] {
const found = new RegExp(`^define [^\\n]*@${symbol}\\([^]*?^}`, "m").exec(llvm);
expect(found, symbol).not.toBeNull();
const node = (id: string) => new RegExp(`^!${id} = !\\{(.*)\\}$`, "m").exec(llvm)?.[1] ?? "";
return [...found![0].matchAll(/!tbaa !(\d+)/g)].map((tag) => {
const type = /^!(\d+),/.exec(node(tag[1]!))?.[1] ?? "";
return /^!"([^"]*)"/.exec(node(type))?.[1] ?? "";
});
}
const points = `
class Point {
x: number;
y: number;
constructor(x: number, y: number) {
this.x = x;
this.y = y;
}
}
function sum(points: Point[]): number {
let total = 0;
for (const p of points) total += p.x * p.y;
return total;
}
`;
test("alias type nodes are named by category, so a field added earlier leaves the others' names", async () => {
const before = await emit(
`${points}const all = [new Point(1, 2), new Point(3, 4)];\nconsole.log(sum(all));\n`,
);
// A class whose field the module uses before Point's.
const after = await emit(`
class Weight {
grams: number;
constructor(grams: number) {
this.grams = grams;
}
}
function heaviest(weights: Weight[]): number {
let most = 0;
for (const w of weights) most = Math.max(most, w.grams);
return most;
}
console.log(heaviest([new Weight(5), new Weight(7)]));
${points}const all = [new Point(1, 2), new Point(3, 4)];
console.log(sum(all));
`);
const names = aliasNames(before, "sc_b?f_sum");
expect(names.length).toBeGreaterThan(0);
expect(names.every((name) => name !== "" && !/^field\.\d+$/.test(name))).toBe(true);
expect(aliasNames(after, "sc_b?f_sum")).toEqual(names);
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6870-node-island-server-fibers/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/async-object-method-contextual-return.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/filter-predicate-model-narrow.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/map-untyped-empty-fallback.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/receiver-before-effectful-key.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/saturating-loop-jams.ts"
],
"diags": []
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/experimental",
"version": "0.4.5",
"version": "0.4.6",
"description": "Experimental scriptc APIs, with Node.js implementations: lifetime regions for ProgramLifetime objects, intern tables shared between threads, per-thread slots on shared objects, and array fields kept in their instances",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
@@ -195,6 +195,26 @@ bool TakeFunction(Isolate *isolate, Local<Context> context, Local<Object> from,
return true;
}
// OpenSSL registers OPENSSL_cleanup with atexit on its first
// initialization. In a scriptc program that loaded Node, threads Node owns
// can still be inside OpenSSL when the process's exit handlers run: the
// threads node:tls starts to parse the root certificates (long with
// NODE_USE_SYSTEM_CA=1; RunLoop waits for them, but an exit that does not
// end the loop does not) are joined only by Node's per-process teardown,
// which cannot run here (see Stop). OPENSSL_cleanup
// freed OpenSSL's locks and method stores under them (SIGSEGV/SIGABRT at
// exit in up to half the runs, NI-7c). Initializing OpenSSL first with
// OPENSSL_INIT_NO_ATEXIT keeps that handler from being registered: OpenSSL's
// process-lifetime state is left to process exit, like the rest of Node's
// per-process state. Packs whose libnode does not export the symbol keep
// OpenSSL's default.
void KeepOpenSSLAtExit() {
using InitCrypto = int (*)(uint64_t, const void *);
constexpr uint64_t kNoAtExit = 0x00080000L; // OPENSSL_INIT_NO_ATEXIT (OpenSSL 3)
auto init = reinterpret_cast<InitCrypto>(dlsym(RTLD_DEFAULT, "OPENSSL_init_crypto"));
if (init != nullptr) init(kNoAtExit, nullptr);
}
uint32_t ExportIdOf(Isolate *isolate, Local<Context> context, Local<Value> v);
// isExport(value): whether a value is the representative of a native value
@@ -214,6 +234,7 @@ char *Start(const ScrNodeBridgeHost *host, const char *bootstrap, const char *ar
Dl_info info;
if (dladdr(reinterpret_cast<void *>(&napi_create_function), &info) && info.dli_fname)
dlopen(info.dli_fname, RTLD_NOW | RTLD_NOLOAD | RTLD_GLOBAL);
KeepOpenSSLAtExit();
std::vector<std::string> args = {argv0};
// The program is not a node process: NODE_OPTIONS from its environment
// does not apply, and Node leaves the process's resource limits and code
@@ -433,10 +454,18 @@ void OnCap(uv_timer_t *) {
SyncIo(SCR_NODE_IO_JOBS);
}
void OnPrepare(uv_prepare_t *) { SyncIo(SCR_NODE_IO_PREPARE); }
// Before each poll phase, while the runtime has live work: one pass over
// its dispatch stations, as the runtime's own loop makes one per turn.
// Some of their work no fd announces (a server's deferred 'listening' or
// 'close', a socket's queued writes), so waiting for an fd could block the
// loop forever with that work pending.
void OnPrepare(uv_prepare_t *handle) {
if (uv_has_ref(reinterpret_cast<uv_handle_t *>(handle))) Dispatch();
SyncIo(SCR_NODE_IO_PREPARE);
}
// The end of an iteration: the next one may dispatch again (from its idle
// phase when work is ready, else from its poll phase).
// or prepare phase, else from its poll phase).
void OnCheck(uv_check_t *) {
g_dispatched = false;
SyncIo(SCR_NODE_IO_CHECK);
@@ -616,6 +645,29 @@ void Checkpoint(int microtasks_first) {
node::CallbackScope scope(e.isolate, Object::New(e.isolate), node::async_context{0, 0});
}
// Loading node:tls starts threads that parse the root certificates into
// function-local statics (bundled, NODE_EXTRA_CA_CERTS, and the system
// store with NODE_USE_SYSTEM_CA=1, which takes long). Only Node's
// CleanupCachedRootCertificates joins them. Once exit() begins, the
// statics' destructors (registered when the threads initialize them, so
// they run before any handler the program registered earlier) free the
// vectors under a thread that is still parsing, and OpenSSL's state goes
// away under it: heap corruption and SIGSEGV/SIGABRT at exit (NI-7c). So
// when the loop is done, before the program returns, the bridge waits for
// them: building a root store reads the same statics under the same
// conditions, and initializing a static blocks until the thread that runs
// it is done. A store built earlier means the statics were initialized
// then. Programs that never loaded node:tls do nothing here.
void AwaitCertificateLoading(Enter &e) {
TryCatch try_catch(e.isolate);
static const char kSource[] =
"(() => { try { if (process.moduleLoadList.includes('NativeModule tls'))"
" process.getBuiltinModule('node:tls').createSecureContext(); } catch {} })()";
Local<v8::Script> script;
if (v8::Script::Compile(e.context, Utf8(e.isolate, kSource)).ToLocal(&script))
(void)script->Run(e.context);
}
int RunLoop(int continue_turn) {
Enter e;
// The rest of the turn the switch interrupted: libuv's UV_RUN_ONCE skips
@@ -624,7 +676,9 @@ int RunLoop(int continue_turn) {
uv_run(g_setup->event_loop(), UV_RUN_ONCE);
g_platform->DrainTasks(e.isolate);
}
return node::SpinEventLoop(g_setup->env()).FromMaybe(0);
int code = node::SpinEventLoop(g_setup->env()).FromMaybe(0);
AwaitCertificateLoading(e);
return code;
}
// An island listener of a process event: through MakeCallback, so a throw
@@ -653,9 +707,16 @@ void Stop() {
delete g_locker;
g_locker = nullptr;
g_setup.reset();
// As Node's own exit does (DefaultProcessExitHandlerInternal): join
// libuv's thread pool, whose work (pbkdf2, key generation, ...) may be
// inside OpenSSL, before the process's exit handlers clean OpenSSL up.
uv_library_shutdown();
v8::V8::Dispose();
v8::V8::DisposePlatform();
node::TearDownOncePerProcess();
// Node's per-process teardown is left to process exit: Stop runs from an
// exit handler registered when Node started, after the destructors of
// the certificate statics (registered later) already ran, so its
// certificate cleanup would free them a second time.
}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/libnode-darwin-arm64",
"version": "0.4.5",
"version": "0.4.6",
"private": true,
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS arm64",
"license": "SEE LICENSE IN LICENSE",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/libnode-darwin-x64",
"version": "0.4.5",
"version": "0.4.6",
"private": true,
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on macOS x64",
"license": "SEE LICENSE IN LICENSE",
@@ -1,6 +1,6 @@
{
"name": "@scriptc/libnode-linux-arm64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"private": true,
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux arm64 glibc",
"license": "SEE LICENSE IN LICENSE",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/libnode-linux-x64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"private": true,
"description": "Prebuilt Node.js shared library and scriptc bridge for Node islands on Linux x64 glibc",
"license": "SEE LICENSE IN LICENSE",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-darwin-arm64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Pinned LLVM code-generation helper for scriptc on macOS arm64",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-darwin-x64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Pinned LLVM code-generation helper for scriptc on macOS x64",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-linux-arm64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"description": "Pinned LLVM code-generation helper for scriptc on Linux arm64",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-linux-arm64-musl",
"version": "0.4.5",
"version": "0.4.6",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-linux-x64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"description": "Pinned LLVM code-generation helper for scriptc on Linux x64",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-linux-x64-musl",
"version": "0.4.5",
"version": "0.4.6",
"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 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/llvm-win32-x64-msvc",
"version": "0.4.5",
"version": "0.4.6",
"description": "Pinned LLVM code-generation helper for scriptc on Windows x64",
"license": "Apache-2.0 WITH LLVM-exception",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/node",
"version": "0.4.5",
"version": "0.4.6",
"description": "Call into Node.js from scriptc programs: run an export of a JavaScript module in a Node.js helper process and copy JSON data both ways. Under Node.js the calls run in-process.",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-android-arm64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc library runtime pack for Android arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-darwin-arm64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for macOS arm64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-darwin-x64",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for macOS x64",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-ios-arm64",
"version": "0.4.5",
"version": "0.4.6",
"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.4.5",
"version": "0.4.6",
"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.4.5",
"version": "0.4.6",
"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.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for Linux arm64 musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-linux-x64-gnu",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for Linux x64 glibc",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-linux-x64-musl",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for Linux x64 musl",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-wasm32-wasi",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for WASI Preview 1",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime-win32-x64-msvc",
"version": "0.4.5",
"version": "0.4.6",
"description": "Precompiled scriptc runtime pack for Windows x64 MSVC",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/runtime",
"version": "0.4.5",
"version": "0.4.6",
"description": "scriptc native runtime sources and vendored dependencies",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+98 -12
View File
@@ -1701,9 +1701,26 @@ static void *scr_win_self(void) {
}
#endif
#ifdef SCR_NODE_ISLANDS
/* The lowest frame of Node's stack that is still live while a fiber runs:
* where runtime code on Node's stack (a job or host callback, however deep
* it went) last switched to a fiber. A call into Node from that fiber runs
* on Node's stack below it (scr_node_island.c), never over those frames.
* 0: none. scr_node_stack_has (set when Node starts) tells Node's stack. */
uintptr_t scr_node_stack_low = 0;
bool (*scr_node_stack_has)(uintptr_t sp) = NULL;
#endif
static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) {
#ifdef SCR_ASAN_FIBERS
ScrFiber *from_fiber = scr_current;
#endif
#ifdef SCR_NODE_ISLANDS
uintptr_t node_low = scr_node_stack_low;
if (to_fiber != NULL && scr_node_stack_has != NULL) {
uintptr_t here = (uintptr_t)__builtin_frame_address(0);
if (scr_node_stack_has(here)) scr_node_stack_low = here;
}
#endif
scr_current = to_fiber;
scr_exc_swap_cell(to_fiber ? &to_fiber->exc : NULL);
@@ -1756,8 +1773,59 @@ static void scr_switch(ScrCtx *from, ScrCtx *to, ScrFiber *to_fiber) {
#if !defined(_WIN32) && !defined(__wasi__)
SCR_STACK_GUARD_SET(guard);
#endif
#ifdef SCR_NODE_ISLANDS
scr_node_stack_low = node_low; /* back on the stack that switched away */
#endif
}
#ifdef SCR_NODE_ISLANDS
/* A nested entry into Node (scr_node_island.c) runs on Node's stack in a
* context of its own while the fiber that made the call stays suspended in
* it. Runtime code that Node calls back there runs as the loop's code does:
* no current fiber, the main exception cell and ALS slot. It may resume
* other fibers, which saves the resumer's context in scr_loop_ctx (and,
* under ASan, its fake stack in the main slot); those belong to whoever
* resumed the calling fiber, so they are saved here and put back when the
* nested entry returns, or the calling fiber's next park would return to a
* context that no longer exists. */
typedef struct {
ScrFiber *current;
ScrAlsCtx **als;
ScrCtx loop_ctx;
#ifdef SCR_ASAN_FIBERS
void *main_fake;
#endif
} ScrNestedSave;
void *scr_loop_nested_enter(void) {
ScrNestedSave *s = malloc(sizeof *s);
if (s == NULL) scr_oom();
s->current = scr_current;
s->als = scr_als_active;
s->loop_ctx = scr_loop_ctx;
#ifdef SCR_ASAN_FIBERS
s->main_fake = scr_main_fake_stack;
scr_main_fake_stack = NULL;
#endif
scr_current = NULL;
scr_exc_swap_cell(NULL);
scr_als_active = &scr_als_main_slot;
return s;
}
void scr_loop_nested_leave(void *save) {
ScrNestedSave *s = save;
scr_loop_ctx = s->loop_ctx;
#ifdef SCR_ASAN_FIBERS
scr_main_fake_stack = s->main_fake;
#endif
scr_current = s->current;
scr_exc_swap_cell(s->current ? &s->current->exc : NULL);
scr_als_active = s->als;
free(s);
}
#endif
static void scr_promise_settle_wake(ScrPromise *p);
/* Copies src's settlement into a still-pending dst (payload RETAINED —
@@ -3423,20 +3491,25 @@ ScrPromise *scr_fsp_rename(ScrStr *oldpath, ScrStr *newpath) {
* platform mutex publishes each result before the loop removes it from the
* completion queue. */
#if !defined(SCR_LIB) && !defined(__wasi__)
/* The completion wake: a worker signals it once per published completion,
/* The completion wake: a worker signals it when it publishes a completion,
* so the loop's sleep ends for the completion — a pipe the poll(2) sleep
* watches on POSIX, an auto-reset event the Windows sleep waits on. */
* watches on POSIX, an auto-reset event the Windows sleep waits on. On
* POSIX, `posted` (guarded by the queue lock) records that the pipe holds
* a byte: a worker writes one only when none is there, and the loop reads
* it when it takes the last completion, so the pipe holds a byte exactly
* while completions wait. */
typedef struct ScrFsRenameWake {
#ifdef _WIN32
HANDLE event; /* NULL until created */
#else
int fds[2]; /* -1 until created */
bool posted;
#endif
} ScrFsRenameWake;
#ifdef _WIN32
#define SCR_FS_RENAME_WAKE_NONE {NULL}
#else
#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}}
#define SCR_FS_RENAME_WAKE_NONE {{-1, -1}, false}
#endif
#ifdef SCR_WORKERS
typedef struct ScrFsRenameOwner ScrFsRenameOwner;
@@ -3530,28 +3603,29 @@ static void scr_fs_rename_wake_open(ScrFsRenameWake *wake) {
#endif
}
/* Called with the queue lock held. */
static void scr_fs_rename_wake_post(ScrFsRenameWake *wake) {
#ifdef _WIN32
if (wake->event != NULL) (void)SetEvent(wake->event);
#else
if (wake->fds[1] >= 0) {
if (wake->fds[1] >= 0 && !wake->posted) {
char byte = 1;
ssize_t n = write(wake->fds[1], &byte, 1); /* a full pipe already wakes the loop */
(void)n;
wake->posted = write(wake->fds[1], &byte, 1) == 1;
}
#endif
}
/* POSIX: empty the pipe before taking the queue, so a completion published
* afterwards leaves its byte for the next poll. The Windows event resets
* when a wait consumes it. */
/* POSIX, with the queue lock held: the loop took the last completion, so
* the pipe's byte is read and the next completion writes a new one. The
* Windows event resets when a wait consumes it. */
static void scr_fs_rename_wake_drain(ScrFsRenameWake *wake) {
#ifdef _WIN32
(void)wake;
#else
if (wake->fds[0] < 0) return;
if (wake->fds[0] < 0 || !wake->posted) return;
wake->posted = false;
char buf[64];
while (read(wake->fds[0], buf, sizeof buf) > 0) {
while (read(wake->fds[0], buf, sizeof buf) == (ssize_t)sizeof buf) {
}
#endif
}
@@ -3661,6 +3735,18 @@ static DWORD WINAPI scr_fs_rename_worker(LPVOID payload) {
#else
static void *scr_fs_rename_worker(void *payload) {
(void)payload;
/* A pool thread keeps SIGPIPE and SIGXFSZ blocked: its file writes then
* report EPIPE and EFBIG without changing the signal mask per write
* (scr_fs_write_once). */
sigset_t write_set;
sigemptyset(&write_set);
#ifdef SIGPIPE
sigaddset(&write_set, SIGPIPE);
#endif
#ifdef SIGXFSZ
sigaddset(&write_set, SIGXFSZ);
#endif
(void)pthread_sigmask(SIG_BLOCK, &write_set, NULL);
scr_fs_rename_worker_loop();
return NULL;
}
@@ -3759,13 +3845,13 @@ static bool scr_fs_renames_dispatch(void) {
#ifdef SCR_WORKERS
if (!scr_fs_rename_owner) return false;
#endif
scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner));
scr_fs_rename_lock_enter();
ScrFsRenameOp *op = scr_fs_rename_done;
if (op != NULL) {
scr_fs_rename_done = op->next;
if (scr_fs_rename_done == NULL) scr_fs_rename_done_tail = &scr_fs_rename_done;
}
if (scr_fs_rename_done == NULL) scr_fs_rename_wake_drain(SCR_FS_RENAME_WAKE(scr_fs_rename_owner));
scr_fs_rename_lock_leave();
if (op == NULL) return false;
scr_fs_rename_pending_count--;
+25 -3
View File
@@ -235,6 +235,7 @@ typedef struct {
bool falling;
bool idle_points;
size_t death_due_live;
bool probe_due;
bool keep_base;
size_t trigger;
size_t alloc_trigger;
@@ -286,6 +287,7 @@ static _Thread_local ScrCycThread scr_cyc_thread = {.gen_limit = SCR_CYC_OLD};
#define scr_cyc_falling (scr_cyc_thread.falling)
#define scr_cyc_idle_points (scr_cyc_thread.idle_points)
#define scr_cyc_death_due_live (scr_cyc_thread.death_due_live)
#define scr_cyc_probe_due (scr_cyc_thread.probe_due)
#define scr_cyc_keep_base (scr_cyc_thread.keep_base)
#define scr_cyc_kinds (scr_cyc_thread.kinds)
#define scr_cyc_kinds_cap (scr_cyc_thread.kinds_cap)
@@ -640,6 +642,10 @@ static SCR_TL bool scr_cyc_falling = false, scr_cyc_keep_base = false;
#ifndef SCR_WORKERS
static SCR_TL bool scr_cyc_idle_points = false;
static SCR_TL size_t scr_cyc_death_due_live = 0;
/* A nursery probe window opened since the last scheduled pass (see "level
* bypass"): the next pass the release path would run at the nursery runs at
* the mature level instead. */
static SCR_TL bool scr_cyc_probe_due = false;
#endif
/* The nursery trigger a program sets for itself
@@ -976,9 +982,11 @@ static SCR_CYC_SCHEDULING void scr_cyc_collect_due(void) {
if (!by_growth && !by_death && scr_roots[SCR_CYC_OLD].n < scr_cyc_backlog_threshold(SCR_CYC_OLD)) {
gen = SCR_CYC_MATURE;
by_growth = scr_cyc_grown(SCR_CYC_MATURE);
if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE))
if (!by_growth && scr_roots[SCR_CYC_MATURE].n < scr_cyc_backlog_threshold(SCR_CYC_MATURE) &&
!scr_cyc_probe_due)
gen = SCR_CYC_NURSERY;
}
scr_cyc_probe_due = false;
scr_cyc_scheduled_pass(gen, by_growth, by_death);
}
@@ -1059,6 +1067,16 @@ bool scr_cyc_collect_idle(void) {
* a probe window every candidate of the type stays at the level, so the
* cycles it builds during the window die there.
*
* Those young cycles can still be held by older garbage: a dead structure
* the bypass promoted before the window that points at objects built in it.
* A restricted pass reads that edge as external, so the window frees none
* of them, judges the type long-lived again, and the garbage on both sides
* waits for a mature pass, which the growth schedule may not run until the
* heap has grown a multiple of its size. So a nursery window that opens
* makes the next release-triggered pass that would run at the nursery run
* at the mature level: it reclaims the promoted garbage once, and the window
* judges the young cycles on their own.
*
* This changes only which generation an object is judged in. Any
* generation assignment is sound (a restricted pass treats edges from older
* objects as external), and a bypassing candidate is buffered like any
@@ -1110,7 +1128,8 @@ static inline ScrCycKind *scr_cyc_kind(uint32_t kind) {
/* Whether a candidate of this type at level `gen` (nursery or mature)
* skips that level's passes (see above). The last candidate a bypass sends
* past the level opens a probe window, with fresh counts. */
* past the level opens a probe window, with fresh counts, and a nursery
* window asks for a mature pass first. */
static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycType *type,
unsigned gen) {
uint32_t kind = type->kind;
@@ -1119,9 +1138,12 @@ static inline __attribute__((always_inline)) bool scr_cyc_bypassed(const ScrCycT
if (k->bypass == 0) return false;
if (--k->bypass == 0) {
k->walked = k->freed = 0;
if (gen == SCR_CYC_NURSERY) {
scr_cyc_probe_due = true;
#if SCR_CYC_INLINE_BUFFERING
if (gen == SCR_CYC_NURSERY) scr_cyc_nursery_bypassing--;
scr_cyc_nursery_bypassing--;
#endif
}
}
return true;
}
+44
View File
@@ -4101,6 +4101,50 @@ static ssize_t scr_fs_write_once(int fd, const void *data, size_t length,
#ifdef SIGXFSZ
sigaddset(&write_set, SIGXFSZ);
#endif
/* A thread that keeps both signals blocked (the fs pool's threads) writes
* without changing its mask; a write that fails with EPIPE or EFBIG then
* consumes the signal it left pending. The answer is per OS thread and
* computed once: _Thread_local, not SCR_TL, which is empty in builds
* without thread instances while the pool threads still exist. */
static _Thread_local signed char thread_blocks = -1;
if (thread_blocks < 0) {
sigset_t current;
bool blocks = pthread_sigmask(SIG_BLOCK, NULL, &current) == 0;
#ifdef SIGPIPE
blocks = blocks && sigismember(&current, SIGPIPE) == 1;
#endif
#ifdef SIGXFSZ
blocks = blocks && sigismember(&current, SIGXFSZ) == 1;
#endif
thread_blocks = blocks ? 1 : 0;
}
if (thread_blocks == 1) {
ssize_t n = positioned
? scr_fs_pwrite(fd, data, length, position)
: write(fd, data, length);
if (n < 0) {
int write_errno = errno;
int raised = 0;
#ifdef SIGPIPE
if (write_errno == EPIPE) raised = SIGPIPE;
#endif
#ifdef SIGXFSZ
if (write_errno == EFBIG) raised = SIGXFSZ;
#endif
if (raised != 0 && sigpending(&pending) == 0 && sigismember(&pending, raised) == 1) {
sigset_t raised_set;
sigemptyset(&raised_set);
sigaddset(&raised_set, raised);
int caught = 0;
int wait_err;
do {
wait_err = sigwait(&raised_set, &caught);
} while (wait_err == EINTR);
}
errno = write_errno;
}
return n;
}
if (pthread_sigmask(SIG_BLOCK, &write_set, &old_set) != 0) {
return positioned
? scr_fs_pwrite(fd, data, length, position)
+31 -2
View File
@@ -93,6 +93,25 @@ void __sanitizer_finish_switch_fiber(void *fake_stack_save, const void **bottom_
size_t *size_old);
void __lsan_register_root_region(const void *p, size_t size) __attribute__((weak));
void __asan_unpoison_memory_region(void const volatile *addr, size_t size);
/* LeakSanitizer's report at exit, with Node loaded: Node keeps its root
* certificate stores (the bundled roots, the system's with
* NODE_USE_SYSTEM_CA, the extra ones of NODE_EXTRA_CA_CERTS) for the
* process's life and never frees them, and OpenSSL keeps the compression
* methods it loads with its default library context. Only those loaders
* are suppressed; a leak anywhere else, in libnode or in the program,
* still reports. */
__attribute__((used)) const char *__lsan_default_suppressions(void) {
return "leak:node::crypto::LoadCACertificates\n"
"leak:node::crypto::GetBundledRootCertificates\n"
"leak:node::crypto::LoadSystemCACertificates\n"
"leak:node::crypto::GetExtraCACertificates\n"
"leak:ossl_load_builtin_compressions\n";
}
/* Without the table of suppressions used, which LeakSanitizer otherwise
* prints to stderr at every exit (LSAN_OPTIONS still overrides it). */
__attribute__((used)) const char *__lsan_default_options(void) { return "print_suppressions=0"; }
#endif
#endif
@@ -954,8 +973,15 @@ static void ni_nested_trampoline(void) {
static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) {
ucontext_t nested, caller;
char *lo = ni_stack + NI_GUARD;
/* Below the red zone and 16-byte aligned, as makecontext expects. */
uintptr_t hi = (ni_mark - 512) & ~(uintptr_t)15;
/* Below every live frame of Node's stack: the mark the host callback
* recorded, or lower where runtime code it ran went deeper before it
* switched to this fiber (an async call's eager start, a job: the
* spawner's frame and its saved context are still in use there). Below
* the red zone and the swap's own frame, 16-byte aligned, as makecontext
* expects. */
uintptr_t top = ni_mark;
if (scr_node_stack_low != 0 && scr_node_stack_low < top) top = scr_node_stack_low;
uintptr_t hi = (top - 1024) & ~(uintptr_t)15;
ni_make_context(&nested, lo, hi - (uintptr_t)lo, ni_nested_trampoline);
#ifdef NI_ASAN
ni_nested_top = hi;
@@ -965,6 +991,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) {
ni_nested_return = &caller;
uintptr_t guard = scr_stack_guard;
SCR_STACK_GUARD_SET(scr_stack_guard_for((uintptr_t)lo, hi - (uintptr_t)lo));
void *loop_save = scr_loop_nested_enter();
#ifdef NI_ASAN
void *caller_fake = NULL;
__sanitizer_start_switch_fiber(&caller_fake, lo, hi - (uintptr_t)lo);
@@ -975,6 +1002,7 @@ static void ni_on_node_stack_below_mark(void (*job)(void *), void *arg) {
size_t size;
__sanitizer_finish_switch_fiber(caller_fake, &bottom, &size);
#endif
scr_loop_nested_leave(loop_save);
SCR_STACK_GUARD_SET(guard);
}
@@ -994,6 +1022,7 @@ static void ni_on_node_stack(void (*job)(void *), void *arg) {
ni_stack = mmap(NULL, NI_STACK, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
if (ni_stack == MAP_FAILED) ni_oom();
(void)mprotect(ni_stack, NI_GUARD, PROT_NONE);
scr_node_stack_has = &ni_stack_has;
#ifdef NI_ASAN
if (__lsan_register_root_region) __lsan_register_root_region(ni_stack, NI_STACK);
#endif
+10
View File
@@ -43,6 +43,16 @@ ScrPromise *scr_node_island_call(ScrStr *file, ScrStr *specifier, ScrStr *name,
struct ScrNodeBridge;
struct ScrNodeIoState;
void scr_loop_host(const struct ScrNodeBridge *bridge, double since_ms);
/* The lowest live frame of Node's stack while a fiber runs (the SCR_NODE_ISLANDS
* build of scr_async.c records it at each switch from Node's stack to a
* fiber; 0: none), and the test for Node's stack it uses. */
extern uintptr_t scr_node_stack_low;
extern bool (*scr_node_stack_has)(uintptr_t sp);
/* Around a nested entry: the runtime's scheduling state of the suspended
* calling fiber, saved and put back (the SCR_NODE_ISLANDS build of
* scr_async.c). */
void *scr_loop_nested_enter(void);
void scr_loop_nested_leave(void *save);
bool scr_loop_is_hosted(void);
void scr_loop_hosted_run(uint32_t kind, uint32_t token);
void scr_loop_hosted_io_state(struct ScrNodeIoState *state);
@@ -1424,6 +1424,10 @@ static const ScrDynHandleOps nh_ops = {
* own objects. */
static void nh_cleanup(void) {
nh_drain_freed();
/* What the last conversion for the export unit kept alive. */
NhKeep last = nh_out_keep;
memset(&nh_out_keep, 0, sizeof nh_out_keep);
nh_keep_done(&last);
if (nh_inspect_fn != NULL) {
scr_dyn_release(nh_inspect_fn);
nh_inspect_fn = NULL;
+70
View File
@@ -1039,6 +1039,75 @@ static void check_nursery_bypass(void) {
scr_collect_cycles();
}
/* A nursery probe window that opens makes the next release-triggered pass
* that would run at the nursery run at the mature level, which reclaims
* dead promoted rings a nursery pass stops at. */
static size_t window_kind_freed, window_ring_freed;
static void window_kind_free(void *obj) {
window_kind_freed++;
node_free(obj);
}
static void window_ring_free(void *obj) {
window_ring_freed++;
node_free(obj);
}
static ScrCycType window_kind_type = { &node_trace, &window_kind_free, 9 };
static ScrCycType window_ring_type = { &node_trace, &window_ring_free, 0 };
/* A candidate of the window kind that dies once it has been routed, as the
* next pass would route it: it counts down a bypass without growing the
* heap. */
static void release_window_candidate(void) {
Node *node = scr_cyc_alloc(sizeof(*node), &window_kind_type);
node->rc = 2;
release_live(node);
scr_cyc_route_bypassed_at();
node->rc = 0;
scr_cyc_on_dead(node);
window_kind_free(node);
}
static void check_probe_window_runs_mature_pass(void) {
/* Every release collects at a threshold of 1, and passes with only a few
* candidates walk too few of them to decide a window. */
if (configured_nursery_threshold() < 8) return;
enum { WINDOW = 8192, INTERVAL = 16, LIVE = WINDOW, RING = 4 };
Node **live = calloc(LIVE, sizeof(*live));
if (!live) scr_trap("cycle test: out of memory\n");
for (size_t i = 0; i < LIVE; i++) {
live[i] = scr_cyc_alloc(sizeof(*live[i]), &window_kind_type);
live[i]->rc = 1;
read_live(live[i]);
scr_cyc_pass_at(SCR_CYC_NURSERY);
}
scr_collect_cycles();
/* A pass that an earlier window asked for runs now. */
run_release_passes();
/* A dead ring promoted to the mature level: nursery passes stop at it. */
Node *ring = make_ring_of(RING, &window_ring_type);
ring->rc++;
release_live(ring);
scr_cyc_pass_at(SCR_CYC_NURSERY);
release_live(ring);
run_release_passes();
check(window_ring_freed == 0, "a nursery pass freed a dead mature ring");
/* The window kind's candidates go past the nursery until a probe window
* opens; the next release-triggered pass runs at the mature level. */
for (size_t i = 0; i < (size_t)WINDOW * INTERVAL; i++) release_window_candidate();
run_release_passes();
check(window_ring_freed == RING, "the pass after a probe window opened missed a dead mature ring");
for (size_t i = 0; i < LIVE; i++) {
live[i]->rc = 0;
scr_cyc_on_dead(live[i]);
window_kind_free(live[i]);
}
free(live);
scr_collect_cycles();
}
/* The mature level's bypass: a type whose mature candidates keep surviving
* goes straight to the old generation, its dead cycles are found by full
* passes, and once its objects die at the mature level again it is judged
@@ -1719,6 +1788,7 @@ int main(void) {
check_death_brings_full_pass();
check_nursery_bypass();
check_mature_bypass();
check_probe_window_runs_mature_pass();
check_probe_sees_candidate_rings();
check_rebuffer_past_mature();
check_allocation_growth_runs_passes();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/threads",
"version": "0.4.5",
"version": "0.4.6",
"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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@scriptc/types",
"version": "0.4.5",
"version": "0.4.6",
"description": "Contract types for scriptc: type-only annotations (Int32, NoThrow, Immutable) that scriptc checks and optimizes, with no runtime semantics",
"license": "Apache-2.0",
"homepage": "https://scriptc.dev",
+6
View File
@@ -164,6 +164,12 @@ const result = await compile(join(root, "packages/compiler/src/native/cli.ts"),
...(profileGenerate === undefined ? {} : { profileGenerate }),
...(profileUse === undefined ? {} : { profileUse }),
...(functionLayout === undefined ? {} : { functionLayout }),
// Most of the compiler's runs are short (an unchanged rebuild takes tens of
// milliseconds), and a run maps every 2 MiB text window it touches whole. Text
// on 4 KiB pages keeps an unchanged rebuild at about a third of the peak RSS,
// and builds of tsc-ts take the same time. Executables the compiler builds
// keep huge text.
hugeText: false,
emitIr: process.env["SCRIPTC_NATIVE_EMIT_IR"] === "1",
});
if (!result.ok)
+32 -15
View File
@@ -92,9 +92,11 @@ const scriptc = join(cli, "bin/scriptc");
const baseEnv = { ...process.env, SCRIPTC_TIMING: "1" };
delete baseEnv["SCRIPTC_LLVM_HELPER"];
/** Runs the instrumented compiler, writing its profile to `options.profile`. */
/** Runs the instrumented compiler, writing its profile to `options.profile`.
* Throws when the profile runtime reports that it could not write the
* profile (a full disk, for example): the compiler still exits with 0. */
function run(args, options) {
return new Promise((done) => {
return new Promise((done, fail) => {
const child = spawn(scriptc, args, {
cwd: options.cwd,
stdio: ["ignore", "pipe", "pipe"],
@@ -109,7 +111,9 @@ function run(args, options) {
child.stderr.on("data", (chunk) => (text += chunk));
child.on("close", (code) => {
appendFileSync(log, `$ scriptc ${args.join(" ")}\n${text}exit ${code}\n`);
done({ ok: code === 0, text });
const profileError = /^LLVM Profile Error: .*$/m.exec(text)?.[0];
if (profileError !== undefined) fail(new Error(`scriptc ${args.join(" ")}: ${profileError}`));
else done({ ok: code === 0, text });
});
});
}
@@ -142,8 +146,24 @@ const training = [
// rebuild that missed a cache (it shows no `frontend-cache-hit` or
// `executable-cache-hit` phase) is left out. The first builds each write
// their own profile (cold-<program>-*), merged across the build's processes.
// Each profile is read and deleted when its build finishes: a profile is
// about 23 MB, and keeping every build's until the end takes about 9 GB.
const warm = new Set();
let skipped = 0;
// For each function, how many of the first builds ran it.
const runs = new Map();
let coldBuilds = 0;
function countColdRun(index) {
const profiles = readdirSync(raw).filter((name) => name.startsWith(`cold-${index}-`));
if (profiles.length === 0) return;
const ran = new Set();
for (const name of profiles) {
for (const fn of executedFunctions(join(raw, name))) ran.add(fn);
rmSync(join(raw, name));
}
for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1);
coldBuilds++;
}
async function warmRun(args, cache, name, cacheHits) {
const profile = join(raw, `warm-${name}.profraw`);
const { ok, text } = await run(args, { cwd: root, cache, profile });
@@ -166,6 +186,7 @@ const passed = await pool(
cache,
profile: join(raw, `cold-${index}-%1m.profraw`),
});
countColdRun(index);
if (ok)
await warmRun(args, cache, String(index), ["frontend-cache-hit", "executable-cache-hit"]);
rmSync(cache, { recursive: true, force: true });
@@ -196,6 +217,12 @@ function executedFunctions(path) {
const dataStart = 16 * 8 + binaryIdsBytes;
const countersStart = dataStart + records * recordBytes + beforeCounters;
const namesStart = countersStart + counters * 8 + afterCounters + bitmapBytes + afterBitmap;
// The profile runtime reports a failed write (a full disk, for example) on
// stderr, and the compiler still exits with 0, leaving a partial file.
if (buf.length < namesStart + namesBytes)
throw new Error(
`${path} is ${buf.length} bytes, shorter than the ${namesStart + namesBytes} bytes its header describes`,
);
const countersDelta = buf.readBigInt64LE(10 * 8);
const names = new Map();
let at = namesStart;
@@ -241,18 +268,8 @@ function executedFunctions(path) {
// tier 2 for functions at least 90% of the builds run, tier 3 for 60%, and so
// on; tier 7 holds the functions fewer than 3% of the builds run.
const COLD_TIERS = [0.9, 0.6, 0.3, 0.1, 0.03];
const coldRuns = new Map();
for (const name of readdirSync(raw)) {
const program = /^cold-(\d+)-/.exec(name)?.[1];
if (program === undefined) continue;
const ran = coldRuns.get(program) ?? new Set();
for (const fn of executedFunctions(join(raw, name))) ran.add(fn);
coldRuns.set(program, ran);
}
const runs = new Map();
for (const ran of coldRuns.values()) for (const fn of ran) runs.set(fn, (runs.get(fn) ?? 0) + 1);
const coldTier = (count) => {
const share = count / coldRuns.size;
const share = count / coldBuilds;
const index = COLD_TIERS.findIndex((least) => share >= least);
return 2 + (index < 0 ? COLD_TIERS.length : index);
};
@@ -269,7 +286,7 @@ const perTier = new Map();
for (const line of lines)
perTier.set(line.split(" ")[0], (perTier.get(line.split(" ")[0]) ?? 0) + 1);
console.log(
`wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldRuns.size} builds (${statSync(output).size} bytes)`,
`wrote ${output}: ${[...perTier].map(([tier, count]) => `tier ${tier}: ${count}`).join(", ")} functions, from ${coldBuilds} builds (${statSync(output).size} bytes)`,
);
rmSync(builds, { recursive: true, force: true });
if (values.work === undefined) rmSync(work, { recursive: true, force: true });
@@ -0,0 +1,91 @@
// @requires-libnode
// An HTTP server whose async request handler awaits island promises, calls
// island functions, extends an island class and arms timers, while an
// island client drives it (a long-running service's shape): the handler's
// continuations run as runtime jobs that Node's loop
// dispatches, possibly on an async fiber's stack, and every entry into Node
// from there must run on Node's stack, where V8 scans it during the
// collections the island's allocations cause.
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
import { churn, delay, depth, get, Handler } from "server-kit";
class CountingHandler extends Handler {
served = 0;
async handle(path: string): Promise<string> {
this.served++;
return `${await super.handle(path)}#${this.served}`;
}
}
const handler = new CountingHandler("kit");
const timers: string[] = [];
// Called from a continuation (a runtime job on Node's stack): its eager
// start runs on a fiber and calls into the island, deep, before its first
// await returns to the caller's frame on Node's stack.
async function listFiles(path: string, wait: (n: number) => Promise<number>): Promise<unknown> {
const out: string[] = [];
try {
for (let i = 0; i < 3; i++) {
out.push(`${path}:${depth(300 + i)}`);
}
const more = await wait(out.length);
out.push(String(more));
} finally {
out.push("done");
}
return out;
}
// Starts listFiles a few native frames deeper on the current stack.
function startDeep(levels: number, path: string): Promise<unknown> {
const parts = [path, String(levels), path.length.toString(16)];
if (levels > 0) return startDeep(levels - 1, parts[0]!);
return listFiles(parts.join("").slice(0, path.length), (n) => delay(1, n));
}
async function respond(path: string): Promise<string> {
const waited = await delay(1, path);
const files = (await startDeep(24, waited)) as string[];
let total = 0;
for (let i = 0; i < 3; i++) {
total += churn(i) > 0 ? 1 : 0;
await delay(0);
}
const handled = await handler.handle(waited);
await new Promise<void>((resolve) =>
setTimeout(() => {
timers.push(path);
resolve();
}, 1),
);
return `${handled} churn=${total} files=${files.length}`;
}
const server = createServer((req: IncomingMessage, res: ServerResponse) => {
const path = req.url ?? "/";
respond(path).then(
(text) => {
res.writeHead(200, { "content-type": "text/plain" });
res.end(text);
},
(error: unknown) => {
res.writeHead(500);
res.end(String(error));
},
);
});
async function main(): Promise<void> {
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
const address = server.address();
const port = typeof address === "object" && address !== null ? address.port : 0;
for (let i = 0; i < 40; i++) {
const reply = await get(port, `/r${i}`);
if (i % 10 === 0 || reply.status !== 200) console.log(reply.status, reply.body);
}
console.log("timers", timers.length, "served", handler.served, "count", handler.count);
server.close(() => console.log("closed"));
}
void main();
@@ -0,0 +1,14 @@
export interface Reply {
status: number;
body: string;
}
export declare function get(port: number, path: string): Promise<Reply>;
export declare function delay<T>(ms: number, value?: T): Promise<T>;
export declare function churn(seed: number): number;
export declare function depth(n: number): number;
export declare class Handler {
name: string;
count: number;
constructor(name: string);
handle(path: string): Promise<string>;
}
@@ -0,0 +1,53 @@
"use strict";
// An island package shaped like a service's HTTP helpers: a
// client that requests over HTTP and answers promises, a base class that
// native code extends, and work that allocates enough to collect garbage.
const http = require("node:http");
function get(port, path) {
return new Promise((resolve, reject) => {
http
.get({ host: "127.0.0.1", port, path, agent: false }, (res) => {
let body = "";
res.setEncoding("utf8");
res.on("data", (chunk) => (body += chunk));
res.on("end", () => resolve({ status: res.statusCode, body }));
})
.on("error", reject);
});
}
function delay(ms, value) {
return new Promise((resolve) => setTimeout(() => resolve(value), ms));
}
// Allocates and drops a few megabytes, so collections run while native
// callers are on the stack.
function churn(seed) {
let keep = 0;
for (let i = 0; i < 2000; i++) {
const a = new Array(64).fill(seed + i);
keep += a.length + JSON.stringify({ i, a: a.slice(0, 4) }).length;
}
return keep;
}
// Recursion deep enough to use tens of kilobytes of the stack it runs on.
function depth(n) {
const pad = [n, n + 1, n + 2, n + 3];
return n <= 0 ? pad.length : depth(n - 1) + (pad[n % 4] > 0 ? 1 : 0);
}
class Handler {
constructor(name) {
this.name = name;
this.count = 0;
}
async handle(path) {
this.count++;
await delay(1);
return `${this.name}:${path}`;
}
}
module.exports = { get, delay, churn, depth, Handler };
@@ -0,0 +1 @@
{ "name": "server-kit", "version": "1.0.0", "main": "index.js", "types": "index.d.ts" }
@@ -0,0 +1 @@
{ "name": "island-server-fibers", "private": true, "type": "module", "scriptc": { "packages": { "server-kit": "node" } } }
@@ -0,0 +1,40 @@
type Text = { type: "text"; text: string; textSignature?: string };
type Image = { type: "image"; data: string; mimeType: string };
interface ToolResult<D> { content: (Text | Image)[]; details: D; isError?: boolean }
interface ToolDefinition<D> { name: string; execute(id: string, q: string): Promise<ToolResult<D>> }
function define(): ToolDefinition<{ loaded: string[] }> {
return {
name: "search",
async execute(_id, q) {
const text = `found ${q}`;
return { content: [{ type: "text", text }], details: { loaded: [q] } };
},
};
}
define().execute("1", "x").then((r) => console.log(JSON.stringify(r)));
// Slots whose return is not a promise: an async method there still returns
// its own promise, and a union slot contributes only its promise arm.
interface Hooks {
start(): void;
stop(): Promise<void> | void;
load(q: string): Promise<{ n: number; q: string }> | undefined;
}
const hooks: Hooks = {
async start() {
console.log("start");
},
async stop() {
console.log("stop");
},
async load(q) {
return { n: q.length, q };
},
};
async function run(): Promise<void> {
await hooks.start();
await hooks.stop();
const loaded = await hooks.load("abc");
console.log(loaded?.n, loaded?.q);
}
run();
@@ -0,0 +1,54 @@
// `.filter` with a written type predicate whose target is no arm of the
// element union but the same fields as one, with a nested union field the
// predicate narrows (`Model<"a">` from the `Model<Api>` arm). Kept elements
// convert field by field; a kept element of another shape would throw.
interface CompatA {
strict?: boolean;
}
interface CompatB {
effort?: string;
}
interface CompatMap {
a: CompatA;
b: CompatB;
}
type Api = keyof CompatMap;
interface Model<A extends Api> {
kind: "chat";
api: A;
id: string;
window: number;
compat?: CompatMap[A];
}
interface Image {
kind: "image";
id: string;
}
type AnyModel = Model<Api> | Image;
function restore(stored: AnyModel[]): Model<"a">[] {
return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a");
}
const stored: AnyModel[] = [
{ kind: "chat", api: "a", id: "1", window: 8, compat: { strict: true } },
{ kind: "image", id: "2" },
{ kind: "chat", api: "b", id: "3", window: 4, compat: { effort: "hi" } },
{ kind: "chat", api: "a", id: "4", window: 2 },
];
const restored = restore(stored);
for (const model of restored) console.log(model.id, model.api, model.window, model.compat?.strict, "compat" in model);
console.log(restored.length, restored.map((model) => model.window).reduce((a, b) => a + b, 0));
// A target that is itself an arm of the union keeps that arm's elements as
// they are, while another arm with its fields converts.
type Mixed = Model<"a"> | Model<Api> | Image;
function pick(stored: Mixed[]): Model<"a">[] {
return stored.filter((model): model is Model<"a"> => model.kind === "chat" && model.api === "a");
}
const exact: Model<"a"> = { kind: "chat", api: "a", id: "5", window: 3, compat: { strict: false } };
const picked = pick([exact, ...stored]);
console.log(picked.map((model) => `${model.id}:${model.window}:${model.compat?.strict}`).join(" "), picked[0] === exact);
export {};
@@ -0,0 +1,41 @@
// An untyped `new Map()` in a `??` fallback literal: the fallback builds
// as the left operand's record, so the empty map takes its key and value
// types. `new Map(result.errors)` then copies a typed map, and writes to
// the copy (directly and from a callback) keep that layout.
interface RefreshResult {
aborted: boolean;
errors: ReadonlyMap<string, Error>;
}
async function refresh(fail: boolean): Promise<RefreshResult | undefined> {
if (fail) return undefined;
const errors = new Map<string, Error>();
errors.set("openai", new Error("boom"));
return { aborted: false, errors };
}
async function run(fail: boolean, providers: string[]): Promise<{ aborted: boolean; errors: Map<string, Error> }> {
const result = (await refresh(fail)) ?? { aborted: fail, errors: new Map() };
const errors = new Map(result.errors);
errors.set("local", new Error("extra"));
await Promise.all(
providers.map(async (provider) => {
if (provider.startsWith("bad")) errors.set(provider, new Error(`${provider} failed`));
}),
);
return { aborted: result.aborted, errors };
}
async function main(): Promise<void> {
for (const fail of [false, true]) {
const r = await run(fail, ["ok", "bad-a", "bad-b"]);
const names = [...r.errors.keys()].sort().join(",");
console.log(r.aborted, names, r.errors.get("local")?.message, r.errors.get("bad-b")?.message);
}
const source = new Map<string, number>([["a", 1]]);
const copy = new Map(source);
copy.set("b", 2);
console.log(source.size, copy.size, copy !== source);
}
void main();
@@ -0,0 +1,59 @@
// A receiver is evaluated once, before its key, even when the key's
// evaluation rebinds the receiver's binding or has other effects: a tuple
// read with a runtime index, a keyed read through a finite union of field
// names, and an inherited-key `in` test on a computed index-signature
// receiver.
const log: string[] = [];
// Tuple read with a runtime index that rebinds the tuple.
let pair: [string, number] = ["first", 1];
function rebindPair(i: number): number {
log.push(`rebind pair ${i}`);
pair = ["second", 2];
return i;
}
const viaIndex = pair[rebindPair(0)];
console.log("tuple", viaIndex, pair[0]);
// A field holding a tuple, replaced by the index.
const holder: { t: [number, number, number] } = { t: [10, 20, 30] };
function replaceHeld(i: number): number {
holder.t = [-1, -2, -3];
return i;
}
console.log("tuple field", holder.t[replaceHeld(2)], holder.t[2]);
// Keyed read through a finite union of keys naming a subset of the fields,
// with a key that rebinds the local or replaces the field holding the record.
type Row = { width?: number; label: string; tags: string[] };
function finiteKeys(): void {
let row: Row = { width: 640, label: "old", tags: [] };
const pick = (): "width" | "label" => {
log.push("pick key");
row = { width: 1, label: "new", tags: ["x"] };
return "label";
};
const picked = row[pick()];
console.log("keyed", picked, row.label);
const box: { row: Row } = { row: { label: "boxed", tags: [] } };
const replace = (): "width" | "label" => {
box.row = { width: 7, label: "replaced", tags: [] };
return "width";
};
const width = box.row[replace()];
console.log("keyed field", width, box.row.width);
}
finiteKeys();
// `in` on an inherited key of a computed null-prototype record: the
// receiver call runs once.
let groups = 0;
function grouped(): Partial<Record<string, number[]>> {
groups++;
log.push("grouped");
return Object.groupBy([1, 2, 3], (n) => (n % 2 === 0 ? "even" : "odd"));
}
console.log("in", "toString" in grouped(), "hasOwnProperty" in grouped(), groups);
console.log(log.join(" | "));
+164
View File
@@ -0,0 +1,164 @@
// Escape-time loops in the shape of mandelbrot's rows: a short constant-count
// loop over pixel pairs whose inner loop exits once both pixels of the pair
// have escaped (`b |= 2`, `b |= 1`, `if (b === 3) break`). Pixels escape at
// different iterations or never; each byte packs the pairs' bits in order.
const LIMIT = 4;
function row(initial: Float64Array, ci: number, bytes: number, iterations: number): number[] {
const out: number[] = [];
for (let xByte = 0; xByte < bytes; xByte++) {
let res = 0;
for (let i = 0; i < 8; i += 2) {
const x = xByte << 3;
const cr1 = initial[x + i]!;
const cr2 = initial[x + i + 1]!;
let zr1 = cr1;
let zi1 = ci;
let zr2 = cr2;
let zi2 = ci;
let b = 0;
for (let j = 0; j < iterations; j++) {
const tr1 = zr1 * zr1;
const ti1 = zi1 * zi1;
zi1 = 2 * zr1 * zi1 + ci;
zr1 = tr1 - ti1 + cr1;
if (tr1 + ti1 > LIMIT) {
b |= 2;
if (b === 3) break;
}
const tr2 = zr2 * zr2;
const ti2 = zi2 * zi2;
zi2 = 2 * zr2 * zi2 + ci;
zr2 = tr2 - ti2 + cr2;
if (tr2 + ti2 > LIMIT) {
b |= 1;
if (b === 3) break;
}
}
res = (res << 2) | b;
}
out.push(~res & 255);
}
return out;
}
/** Two pairs per value: the loop runs two iterations. */
function quads(initial: Float64Array, ci: number): number {
let res = 0;
for (let i = 0; i < 4; i += 2) {
let zr1 = initial[i]!;
let zr2 = initial[i + 1]!;
let b = 0;
for (let j = 0; j < 30; j++) {
zr1 = zr1 * zr1 + ci;
if (zr1 > 2) {
b |= 2;
if (b === 3) break;
}
zr2 = zr2 * zr2 + ci;
if (zr2 > 2) {
b |= 1;
if (b === 3) break;
}
}
res = res * 4 + b;
}
return res;
}
// Loops the jam keeps as they are. The inner loop clears the accumulator.
function resets(initial: Float64Array): number {
let res = 0;
for (let i = 0; i < 4; i += 2) {
let zr1 = initial[i]!;
let zr2 = initial[i + 1]!;
let b = 0;
for (let j = 0; j < 30; j++) {
zr1 = zr1 * zr1 + 0.2;
if (zr1 > 2) {
b |= 2;
if (b === 3) break;
}
zr2 = zr2 * zr2 + 0.2;
if (zr2 > 2) {
b |= 1;
if (b === 3) break;
}
if (j > 20) b = 0;
}
res = res * 4 + b;
}
return res;
}
// The inner loop's body advances its counter.
function skips(initial: Float64Array): number {
let res = 0;
for (let i = 0; i < 4; i += 2) {
let zr1 = initial[i]!;
let zr2 = initial[i + 1]!;
let b = 0;
let steps = 0;
for (let j = 0; j < 16; j++) {
zr1 = zr1 * zr1 + 0.2;
if (zr1 > 2) {
b |= 2;
if (b === 3) break;
}
zr2 = zr2 * zr2 + 0.2;
if (zr2 > 2) {
b |= 1;
if (b === 3) break;
}
if (zr1 < 0.5) j = j + 1;
}
res = res * 4 + b;
}
return res;
}
// The inner loop's start and bound depend on the outer iteration.
function shifted(initial: Float64Array): number {
let res = 0;
for (let i = 0; i < 4; i += 2) {
let zr1 = initial[i]!;
let zr2 = initial[i + 1]!;
let b = 0;
const start = i * 3;
for (let j = start; j < 10 + i; j++) {
zr1 = zr1 * zr1 + 0.2;
if (zr1 > 2) {
b |= 2;
if (b === 3) break;
}
zr2 = zr2 * zr2 + 0.2;
if (zr2 > 2) {
b |= 1;
if (b === 3) break;
}
}
res = res * 4 + b;
}
return res;
}
const size = 64;
const initial = new Float64Array(size);
for (let x = 0; x < size; x++) initial[x] = (2 * x) / size - 1.5;
let checksum = 0;
const lines: string[] = [];
for (let y = 0; y < size; y += 8) {
const ci = (2 * y) / size - 1;
const bytes = row(initial, ci, size >> 3, y % 16 === 0 ? 50 : 3);
lines.push(bytes.map((v) => v.toString(16).padStart(2, "0")).join(""));
for (const v of bytes) checksum = (checksum * 31 + v) % 1000003;
}
console.log(lines.join("\n"));
console.log(checksum);
const q = new Float64Array([0.1, 1.5, -0.3, 3]);
console.log(quads(q, 0.2), quads(q, -1.9), quads(new Float64Array(4), 0));
// A row past the end of the coordinates reads NaN, which never escapes.
console.log(row(initial, 0.5, (size >> 3) + 1, 50).slice(-2));
const r = new Float64Array([1.2, 0.5, 1.3, 0.9]);
const s = new Float64Array([0.1, 0.1, 0.73, 0.74]);
console.log(resets(q), resets(r), skips(q), skips(s), shifted(q), shifted(r));
+32 -2
View File
@@ -5,12 +5,42 @@ import * as os from "node:os";
import * as path from "node:path";
const mode = process.argv.length > 2 ? process.argv[2] : "sigpipe";
if (mode === "sigpipe") {
if (mode === "sigpipe" || mode === "stream-sigpipe" || mode === "stream-then-sync-sigpipe") {
const sleeper = new Int32Array(new SharedArrayBuffer(4));
Atomics.wait(sleeper, 0, 0, 100);
}
if (mode === "eagain") {
if (mode === "stream-sigpipe") {
// a write stream's writes run on the fs pool's threads
const out = fs.createWriteStream("", { fd: 1 });
out.on("error", (err: NodeJS.ErrnoException) => {
console.error("stream caught:", err.name, err.code, err.message);
});
out.write("probe\n");
} else if (mode === "stream-then-sync-sigpipe") {
// a pool thread writes first; the calling thread's writeSync after it
// keeps its own guard
const scratch = path.join(os.tmpdir(), `scr-write-then-sync-${process.pid}`);
const file = fs.createWriteStream(scratch);
file.end("pool\n", () => {
fs.unlinkSync(scratch);
try {
fs.writeSync(1, "probe\n");
} catch (e) {
const err = e as NodeJS.ErrnoException;
console.error("sync caught:", err.name, err.code);
}
});
} else if (mode === "stream-efbig") {
const scratch = path.join(os.tmpdir(), `scr-write-stream-${process.pid}`);
const out = fs.createWriteStream(scratch);
out.on("error", (err: NodeJS.ErrnoException) => {
console.error("stream caught:", err.name, err.code, err.message);
fs.unlinkSync(scratch);
});
out.write(Buffer.alloc(512));
out.write(Buffer.alloc(4096));
} else if (mode === "eagain") {
const data = Buffer.alloc(65536);
for (;;) {
try {
+16
View File
@@ -0,0 +1,16 @@
// Island work that keeps OpenSSL busy while the program exits
// (exit-tls.mjs): loading node:tls starts Node's off-thread loading of the
// root certificates (system ones with NODE_USE_SYSTEM_CA=1, extra ones with
// NODE_EXTRA_CA_CERTS); pbkdf2 and key generation run on libuv's pool.
"use strict";
const crypto = require("node:crypto");
const tls = require("node:tls");
exports.warm = function warm(mode) {
if (mode === "load") return tls.DEFAULT_MIN_VERSION.length > 0;
tls.createSecureContext();
const roots = tls.rootCertificates.length > 0;
for (let i = 0; i < 8; i++) crypto.pbkdf2("password", "salt", 20000, 64, "sha512", () => {});
if (mode === "keys") crypto.generateKeyPair("ec", { namedCurve: "P-256" }, () => {});
return roots;
};
+20
View File
@@ -0,0 +1,20 @@
// Exit while Node has OpenSSL work in flight (node-island-exit.test.ts):
// Node must stop its threads before the process's exit handlers tear
// OpenSSL down. argv[2]: "early" (exit right after Node starts, while it
// loads the system root certificates on a thread of its own with
// NODE_USE_SYSTEM_CA=1), "load" (node:tls loaded, extra certificates
// loading off-thread), "exit" (process.exit with pool work in flight),
// "keys", or "return" (fall off the end). A "-return" suffix ends the
// program by returning instead of process.exit (C exit and its handlers).
import { requireModule } from "@scriptc/node";
const arg = process.argv[2] ?? "exit";
const returns = arg.endsWith("-return") || arg === "return";
const mode = arg.replace(/-return$/, "");
if (mode === "early") {
console.log("roots", requireModule("./helper.cjs") !== null);
} else {
const lib = requireModule("./exit-tls.cjs");
console.log("roots", lib.warm(mode));
}
if (!returns) process.exit(0);
@@ -0,0 +1 @@
{ "plain": 12154, "san": 118497 }
@@ -0,0 +1 @@
{ "plain": 12080, "san": 11582 }
@@ -0,0 +1 @@
{ "plain": 3815, "san": 6037 }
@@ -0,0 +1 @@
{ "plain": 2500, "san": 3000 }
@@ -0,0 +1 @@
{ "plain": 5955, "san": 72997 }
+18 -4
View File
@@ -1,7 +1,9 @@
/* fs.writeSync contracts that need harness control over the native process's
* descriptors. The ordinary byte/error behavior stays in differential corpus
* 2685; this test controls stdout's pipe state, nonblocking mode, and the
* process file-size limit to pin the signal-backed descriptor errors. */
/* fs.writeSync and write-stream contracts that need harness control over the
* native process's descriptors. The ordinary byte/error behavior stays in
* differential corpus 2685; this test controls stdout's pipe state,
* nonblocking mode, and the process file-size limit to pin the signal-backed
* descriptor errors, on the calling thread (writeSync) and on the fs pool's
* threads (write streams). */
import { execFileSync, spawn } from "node:child_process";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
@@ -80,6 +82,18 @@ describe.skipIf(process.platform === "win32")(
"positioned first: 512\n" +
"positioned caught: Error EFBIG EFBIG: file too large, write\n",
);
const streamPipe = await run(result.binaryPath, ["stream-sigpipe"], true);
expect(streamPipe.outcome).toEqual({ code: 0, signal: null });
expect(streamPipe.stderr).toBe("stream caught: Error EPIPE EPIPE: broken pipe, write\n");
const thenSync = await run(result.binaryPath, ["stream-then-sync-sigpipe"], true);
expect(thenSync.outcome).toEqual({ code: 0, signal: null });
expect(thenSync.stderr).toBe("sync caught: Error EPIPE\n");
const streamLimit = await run(launcher, ["rlimit", result.binaryPath, "stream-efbig"]);
expect(streamLimit.outcome).toEqual({ code: 0, signal: null });
expect(streamLimit.stderr).toBe("stream caught: Error EFBIG EFBIG: file too large, write\n");
});
},
);
+21
View File
@@ -2,3 +2,24 @@
# (scripts/ci-test-plan.mjs). One `leak:<function>` line per intentional
# leak, under a comment that gives the reason. Empty: no corpus program
# leaks.
# Node islands (scope-B NI-7c): OpenSSL inside libnode is initialized with
# OPENSSL_INIT_NO_ATEXIT (scr_node_bridge.cc, KeepOpenSSLAtExit), so its
# process-lifetime state is never freed: OPENSSL_cleanup at exit raced the
# threads Node owns that can still be inside OpenSSL.
leak:OPENSSL_init_crypto
# Node islands (scope-B NI-7c): Node's root certificates (bundled, system
# store, NODE_EXTRA_CA_CERTS) live in function-local statics that Node's
# per-process teardown frees; a scriptc program leaves that teardown to
# process exit (scr_node_bridge.cc, Stop), and the vectors' own destructors
# drop the last pointers at exit.
leak:node::crypto::LoadCACertificates
leak:node::crypto::LoadSystemCACertificates
leak:node::crypto::GetBundledRootCertificates
leak:node::crypto::GetSystemStoreCACertificates
leak:node::crypto::GetExtraCACertificates
leak:node::crypto::GetOpenSSLSystemCertificates
leak:node::crypto::LoadCertsFromBIO
# The certificates' lazily decoded keys, cached in them when a root store
# is built.
leak:node::crypto::NewRootCertStore
+87
View File
@@ -0,0 +1,87 @@
/* Exit with Node loaded (scope-B NI-7c): island code loads node:tls (Node
* then loads the root certificates on threads of its own) or leaves OpenSSL
* work on libuv's thread pool, and the program exits at once. The bridge's
* stop runs Node's own exit sequence (join the pool, dispose the platform,
* join the certificate threads) before the process's exit handlers run
* OpenSSL's cleanup; without it a certificate thread inside OpenSSL crashed
* the exit (SIGSEGV/SIGABRT in up to half the runs).
* Many runs per mode; SCRIPTC_NODE_ISLAND_EXIT_RUNS scales them. Needs a
* libnode pack in SCRIPTC_LIBNODE. */
import { execFile } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { join } from "node:path";
import { promisify } from "node:util";
import { beforeAll, describe, expect, test } from "vitest";
import { compile } from "@scriptc/compiler";
import { libnodePack } from "./libnode.js";
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../..");
const fixture = join(repoRoot, "tests/fixtures/node-island/exit-tls.mjs");
const outDir = join(repoRoot, "node_modules/.cache/scriptc-tests/node-island-exit");
const sanitize = process.env["SCRIPTC_SAN"] === "1";
const pack = libnodePack();
const runs = Number(process.env["SCRIPTC_NODE_ISLAND_EXIT_RUNS"] ?? (sanitize ? "10" : "40"));
let binary = "";
// Node loads the system and extra root certificates on their own threads
// when node:tls loads, with these set: the threads the exit must wait for.
const caFile = ["/etc/ssl/certs/ca-certificates.crt", "/etc/ssl/cert.pem"].find((f) =>
existsSync(f),
);
const env: NodeJS.ProcessEnv = {
...process.env,
NODE_USE_SYSTEM_CA: "1",
...(caFile === undefined ? {} : { NODE_EXTRA_CA_CERTS: caFile }),
};
async function runOnce(mode: string): Promise<{ code: number | string; stdout: string }> {
try {
const { stdout } = await execFileAsync(binary, [mode], { encoding: "utf8", env });
return { code: 0, stdout };
} catch (err) {
const e = err as { code?: number; signal?: string; stdout?: string };
return { code: e.signal ?? e.code ?? "?", stdout: e.stdout ?? "" };
}
}
describe.skipIf(pack === null)("Node islands: exit with OpenSSL work in flight", () => {
beforeAll(async () => {
mkdirSync(outDir, { recursive: true });
const result = await compile(fixture, {
outPath: join(outDir, "exit-tls"),
outDir,
sanitize,
});
if (!result.ok)
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
binary = result.binaryPath;
}, 300_000);
test.each([
"early",
"load",
"exit",
"keys",
"return",
"early-return",
"load-return",
"keys-return",
])(
"%s: every run exits 0",
async (mode) => {
const outcomes: (number | string)[] = [];
for (let i = 0; i < runs; i += 4) {
const batch = await Promise.all(
Array.from({ length: Math.min(4, runs - i) }, () => runOnce(mode)),
);
for (const r of batch) {
outcomes.push(r.code);
expect(r.stdout).toBe(`roots true\n`);
}
}
expect(outcomes.filter((c) => c !== 0)).toEqual([]);
},
600_000,
);
});