mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-01 07:33:12 +08:00
Merge main
This commit is contained in:
+40
-2
@@ -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,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,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,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,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,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,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,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,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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",
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,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);
|
||||
});
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6870-node-island-server-fibers/main.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/async-object-method-contextual-return.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/filter-predicate-model-narrow.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/map-untyped-empty-fallback.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/receiver-before-effectful-key.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/saturating-loop-jams.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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,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",
|
||||
|
||||
@@ -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--;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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, ¤t) == 0;
|
||||
#ifdef SIGPIPE
|
||||
blocks = blocks && sigismember(¤t, SIGPIPE) == 1;
|
||||
#endif
|
||||
#ifdef SIGXFSZ
|
||||
blocks = blocks && sigismember(¤t, 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,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,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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
+14
@@ -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>;
|
||||
}
|
||||
+53
@@ -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 };
|
||||
+1
@@ -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(" | "));
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 }
|
||||
@@ -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");
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
Reference in New Issue
Block a user