Merge main

This commit is contained in:
Chris Tate
2026-10-30 03:40:32 -05:00
1032 changed files with 44893 additions and 2937 deletions
+6 -1
View File
@@ -5,7 +5,12 @@
* checks annotated listener parameters against it (unannotated non-empty
* parameter lists have no static types and fence at the registration). */
declare module "events" {
class EventEmitter {
/* Generic like @types/node's class: the type argument is the listener
* MAP, a pure typing aid with no runtime layout (Node erases it too), so
* `class Sink extends EventEmitter<Map>` adopts the same emitter base.
* The map stays unconstrained here — the members below are the untyped
* `...args: any[]` surface either way. */
class EventEmitter<EventMap = Record<string, any[]>> {
constructor();
static defaultMaxListeners: number;
/* Listeners registered under this symbol observe 'error' emits first
+1 -1
View File
@@ -210,7 +210,7 @@ declare module "fs/promises" {
export function access(path: string | URL, mode?: number): Promise<void>;
export function truncate(path: string | URL, len?: number): Promise<void>;
export function appendFile(path: string | URL, data: string | Uint8Array): Promise<void>;
export function copyFile(src: string | URL, dest: string | URL): Promise<void>;
export function copyFile(src: string | URL, dest: string | URL, mode?: number): Promise<void>;
export function mkdtemp(prefix: string | URL): Promise<string>;
export function rmdir(path: string | URL): Promise<void>;
export function rename(oldPath: string | URL, newPath: string | URL): Promise<void>;
+54 -10
View File
@@ -30,8 +30,10 @@ declare module "node:fs" {
export function writeFileSync(
path: string | URL,
data: string,
/* The options-record stance (see http.RequestOptions): flag fences
* by name at the call site; undocumented keys drop like Node. */
/* The options-record stance (see http.RequestOptions): an unsupported
* flag fences by name at the call site; undocumented keys drop like
* Node. The lowered flags are "w" (truncate-or-create) and "wx"
* (exclusive create). */
options?: {
mode?: number;
encoding?: "utf8" | "utf-8";
@@ -157,9 +159,11 @@ declare module "node:fs" {
export function lchownSync(path: string | URL, uid: number, gid: number): void;
export function fchownSync(fd: number, uid: number, gid: number): void;
export function truncateSync(path: string | URL, len?: number): void;
/* The 2-argument form only (Node's mode flags have no lowering). The
* destination is created or truncated carrying the SOURCE's mode. */
export function copyFileSync(src: string | URL, dest: string | URL): void;
/* The destination is created or truncated carrying the SOURCE's mode.
* `mode` is libuv's copyfile flag bits: COPYFILE_EXCL (1) fails when the
* destination exists, COPYFILE_FICLONE (2) requests a reflink and falls
* back to a copy, COPYFILE_FICLONE_FORCE (4) requires one. */
export function copyFileSync(src: string | URL, dest: string | URL, mode?: number): void;
export function rename(
oldPath: string,
newPath: string,
@@ -604,11 +608,29 @@ declare module "node:fs" {
): void;
/* fs.watch — the file-watching slice (scr_watch.c, kqueue EVFILT_VNODE):
* "rename"/"change" events on ONE path, watcher.close() to stop. The
* listener takes zero parameters or the eventType string (the filename
* parameter has no lowering); an open watcher keeps the loop alive. A
* path that cannot be opened THROWS Node's fs error synchronously. */
* listener takes (), (eventType) or (eventType, filename); an open
* watcher keeps the loop alive. A path that cannot be opened THROWS
* Node's fs error synchronously. */
export type WatchEventType = "rename" | "change";
/* (eventType, filename): filename is the changed entry's name (null when
* the platform does not provide one). */
export type WatchListener<T> = (event: WatchEventType, filename: T | null) => void;
export interface FSWatcher {
close(): void;
on(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this;
on(event: "error", listener: (error: Error) => void): this;
on(event: "close", listener: () => void): this;
once(event: "change", listener: (eventType: string, filename: string | Buffer) => void): this;
once(event: "error", listener: (error: Error) => void): this;
once(event: "close", listener: () => void): this;
addListener(
event: "change",
listener: (eventType: string, filename: string | Buffer) => void,
): this;
addListener(event: "error", listener: (error: Error) => void): this;
addListener(event: "close", listener: () => void): this;
off(event: "change" | "error" | "close", listener: (...args: any[]) => void): this;
removeListener(event: "change" | "error" | "close", listener: (...args: any[]) => void): this;
}
/* The options-record stance (see http.RequestOptions): persistent: true,
* recursive: false, and encoding: "utf8" state the lowered behavior and
@@ -620,12 +642,34 @@ declare module "node:fs" {
encoding?: string;
[option: string]: unknown;
}
export function watch(path: string, listener?: (eventType: string) => void): FSWatcher;
export function watch(path: string, listener?: WatchListener<string>): FSWatcher;
export function watch(
path: string,
options: WatchOptions,
listener?: (eventType: string) => void,
listener?: WatchListener<string>,
): FSWatcher;
/* fs.watchFile — stat polling (scr_watch_file.c, libuv's uv_fs_poll
* shape): the listener runs with (current, previous) Stats when a poll
* sees a difference, the first poll only records the baseline, and a
* path that cannot be stat'd polls as all-zero Stats. One poller per
* resolved path is shared by every listener on it. interval defaults to
* 5007 ms, persistent defaults to true; bigint: true (BigIntStats) has
* no lowering. The call is a statement: Node's StatWatcher has no
* lowering. */
export interface StatWatcher {}
export function watchFile(
filename: string,
options: { interval?: number; persistent?: boolean; bigint?: false },
listener: (current: Stats, previous: Stats) => void,
): StatWatcher;
export function watchFile(
filename: string,
listener: (current: Stats, previous: Stats) => void,
): StatWatcher;
export function unwatchFile(
filename: string,
listener?: (current: Stats, previous: Stats) => void,
): void;
/* fs.promises IS the fs/promises module (Node's rule) — the namespace-
* import form `fs.promises.readFile(...)` lowers through the same
* fs/promises table as `import { readFile } from "node:fs/promises"`. */
+17
View File
@@ -257,6 +257,23 @@ declare module "net" {
event: "request" | "checkContinue" | "checkExpectation",
listener: (req: import("http").IncomingMessage, res: import("http").ServerResponse) => void,
): void;
/* off / removeListener: the LAST listener standing for the same
* function leaves that event's list (Node's EventEmitter order). The
* server registries that keep a removable list: error, close,
* listening, connection/secureConnection, timeout, and drop. */
off(event: "error", listener: (err: Error) => void): void;
off(event: "close" | "listening", listener: () => void): void;
off(event: "connection" | "secureConnection", listener: (socket: Socket) => void): void;
off(event: "timeout", listener: (socket: Socket) => void): void;
off(event: "drop", listener: (data?: DropArgument) => void): void;
removeListener(event: "error", listener: (err: Error) => void): void;
removeListener(event: "close" | "listening", listener: () => void): void;
removeListener(
event: "connection" | "secureConnection",
listener: (socket: Socket) => void,
): void;
removeListener(event: "timeout", listener: (socket: Socket) => void): void;
removeListener(event: "drop", listener: (data?: DropArgument) => void): void;
}
/* The 'drop' payload: the turned-away connection's addresses. */
export interface DropArgument {
+57 -13
View File
@@ -246,12 +246,13 @@ declare var process: {
/* "v" plus versions.node: the compatibility target's release. */
readonly version: string;
/* The build-configuration snapshot Node exposes from its gyp config.
* A scriptc binary has no V8 and no gyp, so `variables` answers the
* honest capability record (v8_enable_i18n_support: 0 — no ICU; asan
* mirrors the sanitizer build flag) and `target_defaults` is absent —
* probe code takes its documented fallbacks. Reads of undeclared
* variables go through the index signature (string | number | boolean
* | undefined) and answer undefined. */
* A scriptc binary has no V8 and no gyp, so `variables` answers what is
* true of a compiled binary: v8_enable_i18n_support is 1 like Node's
* (the read links ICU, which the binary then carries), while the gyp
* variables it cannot answer (icu_path, asan, …) read undefined through
* the index signature (string | number | boolean | undefined), and
* `target_defaults` is absent — probe code takes its documented
* fallbacks. */
readonly config: {
readonly variables: { readonly [name: string]: string | number | boolean | undefined };
readonly target_defaults?: { readonly default_configuration: string };
@@ -340,6 +341,12 @@ declare var process: {
* (chunks stay bytes; decode with TextDecoder). */
stdin: {
readonly isTTY: boolean;
/* The descriptor the stream is built over (Node reports 0 here and
* 1/2 on stdout/stderr, whatever the underlying file is). */
readonly fd: 0;
/* True while a TTY stdin runs in raw mode; Node's own property is
* undefined on a non-TTY stdin (the `x || false` idiom). */
readonly isRaw: boolean;
on(event: "data", listener: (chunk: Uint8Array) => void): void;
on(event: "end", listener: () => void): void;
on(event: "error", listener: (err: Error) => void): void;
@@ -374,6 +381,12 @@ declare var process: {
* removal see the same value. */
on(event: "SIGINT" | "SIGTERM", listener: () => void): void;
on(event: "exit", listener: (code: number) => void): void;
/* prependListener — the same registration at the FRONT of the event's
* listener list (Node's EventEmitter order), for the registries that
* own a prepend entry ('exit' and the signal family). prependOnceListener
* has no lowering. */
prependListener(event: "SIGINT" | "SIGTERM", listener: () => void): void;
prependListener(event: "exit", listener: (code: number) => void): void;
/* 'warning' — Node's process-warning channel: listeners receive the
* warning Error (as a dyn value); emitWarning and the runtime's
* deprecation sites dispatch synchronously (SEMANTICS.md). */
@@ -1115,9 +1128,9 @@ declare var ReadableStream: {
from(iterable: string): ReadableStream<string>;
readonly prototype: ReadableStream<unknown>;
};
/* The writable side of a TransformStream and its writer. The
* WritableStream constructor has no native lowering; writable streams come
* from `new TransformStream()`. */
/* The writable side of a TransformStream and its writer. A program that
* constructs a WritableStream, or a TransformStream with a transformer,
* builds its streams with the web stream classes (scr_webstreams.c). */
interface WritableStreamDefaultWriter<W = unknown> {
readonly closed: Promise<void>;
readonly desiredSize: number | null;
@@ -1133,14 +1146,25 @@ interface WritableStream<W = unknown> {
close(): Promise<void>;
getWriter(): WritableStreamDefaultWriter<W>;
}
/* The identity transform: chunks written to `writable` are read from
* `readable`, and a write completes when its chunk is read. */
/* Without a transformer, the identity transform: chunks written to
* `writable` are read from `readable`, and a write completes when its chunk
* is read. */
interface TransformStream<I = unknown, O = unknown> {
readonly readable: ReadableStream<O>;
readonly writable: WritableStream<I>;
}
interface Transformer<I = unknown, O = unknown> {
start?(controller: TransformStreamDefaultController<O>): unknown;
transform?(chunk: I, controller: TransformStreamDefaultController<O>): unknown;
flush?(controller: TransformStreamDefaultController<O>): unknown;
cancel?(reason?: unknown): unknown;
}
declare var TransformStream: {
new <I = unknown, O = I>(): TransformStream<I, O>;
new <I = unknown, O = I>(
transformer?: Transformer<I, O>,
writableStrategy?: QueuingStrategy<I>,
readableStrategy?: QueuingStrategy<O>,
): TransformStream<I, O>;
readonly prototype: TransformStream<unknown, unknown>;
};
/* The other web stream interfaces. JavaScript sources construct and read
@@ -1158,8 +1182,14 @@ interface WritableStreamDefaultController {
readonly signal: AbortSignal;
error(reason?: unknown): void;
}
interface UnderlyingSink<W = unknown> {
start?(controller: WritableStreamDefaultController): unknown;
write?(chunk: W, controller: WritableStreamDefaultController): unknown;
close?(): unknown;
abort?(reason?: unknown): unknown;
}
declare var WritableStream: {
new <W = unknown>(sink?: object, strategy?: QueuingStrategy<W>): WritableStream<W>;
new <W = unknown>(sink?: UnderlyingSink<W>, strategy?: QueuingStrategy<W>): WritableStream<W>;
readonly prototype: WritableStream<unknown>;
};
declare var WritableStreamDefaultWriter: {
@@ -1747,6 +1777,20 @@ declare module "tty" {
/* The bytes queued behind the last write: the compiled binary's fd
* 1/2 writes are synchronous, so it is always 0. */
readonly writableLength: number;
/* The descriptor behind the stream (1 for stdout, 2 for stderr). */
readonly fd: number;
/* Node's default for the process streams (65536 for a pipe, a file and a
* TTY): a constant, because nothing is ever queued. */
readonly writableHighWaterMark: number;
/* Always false: a synchronous write never returns false, so no 'drain'
* is ever pending. */
readonly writableNeedDrain: boolean;
/* 'drain' and the other events by name. Node returns the stream. */
on(event: string, listener: (...args: any[]) => void): this;
once(event: string, listener: (...args: any[]) => void): this;
addListener(event: string, listener: (...args: any[]) => void): this;
off(event: string, listener: (...args: any[]) => void): this;
removeListener(event: string, listener: (...args: any[]) => void): this;
getWindowSize(): [number, number];
}
export function isatty(fd: number): boolean;
@@ -19,10 +19,17 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
...(features.eventTarget ? { eventTarget: true } : {}),
...(features.wasm ? { wasm: true } : {}),
...(features.warningReplace ? { warningReplace: true } : {}),
...(features.recordViewPartial ? { recordViewPartial: true } : {}),
...(features.stdioExtended ? { stdioExtended: true } : {}),
...(features.fsFlags ? { fsFlags: true } : {}),
...(features.strdecDyn ? { strdecDyn: true } : {}),
...(features.processExtras ? { processExtras: true } : {}),
...(features.streamWriteErrors ? { streamWriteErrors: true } : {}),
...(features.stdioWriteSlot ? { stdioWriteSlot: true } : {}),
...(features.nodeIsland ? { nodeIsland: true } : {}),
...(features.moduleNamespace ? { moduleNamespace: true } : {}),
...(features.moduleNamespaceDyn ? { moduleNamespaceDyn: true } : {}),
...(features.primitiveWrapper ? { primitiveWrapper: true } : {}),
zlib: features.zlib,
...(features.brotli ? { brotli: true } : {}),
assert: features.assert,
@@ -67,6 +74,7 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
dgramDyn: features.dgramDyn,
textCodecDyn: features.textCodecDyn,
watch: features.fsWatch,
watchFile: features.fsWatchFile,
foreignFfi: hasForeignFfiCallback(mod.ffiImports ?? []),
nodeTest: features.nodeTest,
tls: features.tls,
@@ -79,5 +87,6 @@ export function executableLinkFeatures(mod: IrModule, largePages = false): Nativ
x509: features.x509,
webcrypto: features.webcrypto,
perfLoop: features.perfLoop,
...(features.icu ? { intl: true } : {}),
};
}
@@ -56,6 +56,7 @@ export async function compilePackedLibrary(
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
+39 -2
View File
@@ -7,7 +7,7 @@
import { execFile } from "node:child_process";
import { createHash, randomUUID } from "node:crypto";
import { existsSync, realpathSync, statSync } from "node:fs";
import { mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
import { copyFile, mkdtemp, readFile, rename, rm, stat } from "node:fs/promises";
import { delimiter } from "node:path";
import { basename, dirname, isAbsolute, join, resolve } from "node:path";
import { promisify } from "node:util";
@@ -21,7 +21,11 @@ import {
toolchainEnvironmentFingerprint,
} from "./toolchain-environment.js";
import { type NativeArtifactDependency } from "./native/contracts.js";
import { RuntimePackError, stageRuntimePackArtifacts } from "./runtime-pack.js";
import {
RuntimePackError,
stageRuntimePackArtifacts,
type RuntimePackSelection,
} from "./runtime-pack.js";
import type { NativeLinkPlan } from "./link-plan.js";
import { inputsWithoutLld, lldLinkerArgs } from "./lld.js";
import { hugeTextLinkerArgs, rewriteHugeTextExecutable } from "./huge-text.js";
@@ -225,6 +229,38 @@ async function objectLinkerDependencyPaths(
.sort();
}
/** Install the selected runtime pack's RUN-time data files (ICU's
* icudt78l.dat) beside the executable that links them. The compiler's link
* step is the only moment the executable's directory is known, and a data
* file next to the binary is what the runtime looks for first.
*
* A file already present at the pinned size is left alone: the pack ships
* one data set per file name, and re-copying 11.1 MiB on every build would
* be pure cost. The copy is atomic (private temporary, then rename) so two
* concurrent builds of the same program never observe a partial file. */
export async function installRuntimeDataFiles(
selection: RuntimePackSelection,
outputPath: string,
): Promise<void> {
if (selection.dataFiles.length === 0) return;
const outputDir = dirname(resolve(outputPath));
for (const source of selection.dataFiles) {
const destination = join(outputDir, basename(source));
const sourceSize = (await stat(source)).size;
const existing = await stat(destination).catch(() => null);
if (existing?.isFile() && existing.size === sourceSize) continue;
const temporary = join(
outputDir,
`.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`,
);
await copyFile(source, temporary);
await rename(temporary, destination).catch(async () => {
await rm(destination, { force: true });
await rename(temporary, destination);
});
}
}
export async function linkNativeExecutable(
plan: NativeLinkPlan,
options: {
@@ -314,6 +350,7 @@ export async function linkNativeExecutable(
await rm(plan.outputPath, { force: true });
await rename(privateOut, plan.outputPath);
});
await installRuntimeDataFiles(plan.runtimePack, plan.outputPath);
if (plan.target.platform === "darwin" && !plan.darwinDebugSymbols) {
await rm(`${plan.outputPath}.dSYM`, { recursive: true, force: true });
}
@@ -1,7 +1,10 @@
import { expect, test } from "vitest";
import {
BOOL,
BYTES_U8,
DYN,
F64,
STRING,
VOID,
funcOf,
type IrExpr,
@@ -260,6 +263,59 @@ test("receiver elision admits only synchronous receiver-free work", () => {
expect(callbackIgnoresReceiver(fn)).toBe(false);
});
test("receiver elision admits library work that runs no user code", () => {
const str: IrExpr = { kind: "strLit", value: "a", type: STRING, loc };
const num = (value: number): IrExpr => ({ kind: "numLit", value, type: F64, loc });
const dyn = ref("d", DYN);
const ignores = (expr: IrExpr): boolean => {
const fn = fixture().functions[0]!;
fn.body.unshift({ kind: "exprStmt", expr, loc });
return callbackIgnoresReceiver(fn);
};
const lib = (fn: string, args: IrExpr[]): IrExpr =>
({ kind: "libCall", fn, args, type: F64, loc }) as IrExpr;
const intrinsic = (name: string, args: IrExpr[]): IrExpr =>
({ kind: "intrinsic", name, args, type: VOID, loc }) as IrExpr;
// String and number members over primitives; a callback operand may run.
expect(ignores(lib("string.lastIndexOfFrom", [str, str, num(0)]))).toBe(true);
expect(ignores(lib("num.sameValue", [num(0), num(1)]))).toBe(true);
expect(ignores(lib("string.lastIndexOf", [str, closure("predicate")]))).toBe(false);
expect(ignores(lib("perf.now", []))).toBe(true);
// `new Error(message, options)` with a primitive message and no options.
const message: IrExpr = { kind: "dynFrom", value: str, type: DYN, loc };
expect(ignores(lib("error.newOptions", [message, message]))).toBe(true);
expect(ignores(lib("error.newOptions", [message, dyn]))).toBe(false);
// The module binding check only throws; console output may inspect.
const initialized: IrExpr = { kind: "boolLit", value: true, type: BOOL, loc };
expect(ignores(intrinsic("module.tdzCheck", [initialized, str]))).toBe(true);
expect(ignores(intrinsic("console.log", [dyn]))).toBe(false);
// A typed array built from nothing or a typed source; a dynamic source
// may be an iterable.
const bytes = (source: IrExpr | null): IrExpr => ({
kind: "bytesNew",
source,
type: BYTES_U8,
loc,
});
expect(ignores(bytes(null))).toBe(true);
expect(ignores(bytes(dyn))).toBe(false);
});
test("receiver elision admits array for-of loops", () => {
const fn = fixture().functions[0]!;
const numbers = { kind: "array", elem: F64 } as const;
const one: IrExpr = { kind: "numLit", value: 1, type: F64, loc };
fn.locals.push({ id: "x", name: "x", type: F64, mutable: false });
fn.body.unshift({
kind: "forOf",
localId: "x",
iterable: { kind: "arrayLit", elems: [one], type: numbers, loc },
body: [],
loc,
});
expect(callbackIgnoresReceiver(fn)).toBe(true);
});
function callsOf(name: string, callee: string, extra: IrStmt[] = []): IrFunction {
return {
name,
@@ -12,17 +12,18 @@ export const CPU_DISPATCH_TIERS: readonly TargetCpu[] = ["x86-64-v3"];
const LEVEL = "@sc_cpu_level";
/** The attribute groups of the x86-64-v3 copies: #0's and #2's (see
* emit-module.ts functionAttributes) with the level's `target-cpu`. */
* emit-module.ts functionAttributes) with the level's `target-cpu`, and
* `scriptc-cpu-copy`, by which the LLVM helper finds the copies. After
* optimization, a copy whose code gains nothing from x86-64-v3 takes its
* baseline copy's code (native/llvm-codegen/src/cpu-copies.h). */
const COPY_GROUPS: ReadonlyMap<string, string> = new Map([
["0", "3"],
["2", "4"],
]);
export function cpuDispatchAttributes(common: string): string[] {
return [
`attributes #3 = {${common} "target-cpu"="x86-64-v3" }`,
`attributes #4 = { noinline${common} "target-cpu"="x86-64-v3" }`,
];
const copy = `"target-cpu"="x86-64-v3" "scriptc-cpu-copy"`;
return [`attributes #3 = {${common} ${copy} }`, `attributes #4 = { noinline${common} ${copy} }`];
}
/** Bits of CPUID leaf 1's ECX that x86-64-v3 needs: SSE3 (0), SSSE3 (9),
+151 -2
View File
@@ -57,6 +57,7 @@ import { dynDesc, streamTypedRefEligible } from "../../ir/analysis.js";
import { mangleRecordNew, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { llvmCommentText } from "./common.js";
import { JS_BITS } from "./jsval-encoding.js";
import {
arrNewCall,
elemAccess,
@@ -219,6 +220,21 @@ export interface DynHost extends WalkerHost {
classValueSubtypes(className: string): readonly string[];
/** emitter.ts's pendingTestLines: the inline pending-exception test. */
pendingTestLines(dest: string): string[];
/** The closures with a jsval body (IrFunction.jsEntry) on targets with
* jsval words, which the box builders give a jsval entry. */
jsEntryClosures?(): readonly JsEntryClosure[];
}
/** A closure with a jsval body (IrFunction.jsEntry), for the box builders. */
export interface JsEntryClosure {
/** The closure's entry symbol (ScrClosure.fn) and signature. */
closure: string;
params: readonly IrType[];
ret: IrType;
/** The jsval body's entry symbol and signature. */
body: string;
bodyParams: readonly IrType[];
bodyRet: IrType;
}
const FN_ATTRS = "#0";
@@ -247,6 +263,7 @@ export class LlDyn {
private readonly toDynFns = new Map<string, string>();
private readonly dynFuncThunks = new Map<string, string>();
private readonly dynFuncBoxes = new Map<string, string>();
private readonly jsEntryThunks = new Map<string, string>();
private readonly dynFuncAdapters = new Map<string, string>();
private readonly messageListeners = new Map<string, string | null>();
private readonly messageReaders = new Map<string, string>();
@@ -290,6 +307,8 @@ export class LlDyn {
return this.dynFuncThunkHelper(args[0] as IrType & { kind: "func" });
case "dyn.dynFuncBoxHelper":
return this.dynFuncBoxHelper(args[0] as IrType & { kind: "func" });
case "dyn.jsEntryThunk":
return this.jsEntryThunk(args[0] as string, args[1] as IrType[], args[2] as IrType);
case "dyn.dynFuncAdapterHelper":
return this.dynFuncAdapterHelper(args[0] as IrType & { kind: "func" });
case "dyn.dynToStrHelper":
@@ -1179,8 +1198,11 @@ export class LlDyn {
const struct = mangleRecordStruct(sourceId);
const base = host.nullableUnions.fieldBase(sourceId);
const offsets = target.fields.map((field) => {
const index = source.fields.findIndex((f) => f.name === field.name) + base;
return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index}) to ${S})`;
const index = source.fields.findIndex((f) => f.name === field.name);
// A partial view's target field the source does not declare has
// no slot: all ones (SIZE_MAX) marks it absent (record-views.ts).
if (index < 0) return `${S} -1`;
return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index + base}) to ${S})`;
});
const offsetTable = `${table}_${k}`;
this.defs.push(
@@ -5534,6 +5556,101 @@ export class LlDyn {
return name;
}
/** `sc_jt_<n>(ptr c, ptr args, size argc) -> ptr`, a function value's
* jsval entry (ScrDynJsEntry) into a closure's jsval body `target`
* (IrFunction.jsEntry, 64-bit targets): passes each argument word to the
* body, a missing one as undefined and a dyn parameter's boxed, and
* returns the body's result as an owned word. Like the closure's own
* entry, the body consumes its arguments. With an exception pending the
* result is undefined. */
jsEntryThunk(target: string, params: readonly IrType[], ret: IrType): string {
const existing = this.jsEntryThunks.get(target);
if (existing) return existing;
const name = `sc_jt_${this.jsEntryThunks.size}`;
this.jsEntryThunks.set(target, name);
this.host.journalAllocated?.(name, "dyn.jsEntryThunk", [target, params, ret]);
const host = this.host;
const B = new BlockBuilder();
const undefinedWord = `inttoptr (i64 ${JS_BITS.undefined} to ptr)`;
const args = params.map((p, i) => {
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ptr`);
B.line(`store ptr ${undefinedWord}, ptr ${slot}`);
const has = B.tmp();
B.line(`${has} = icmp ult ${host.sizeType} ${i}, %argc`);
const lHas = B.newLabel("jt.h");
const lj = B.newLabel("jt.j");
B.condBr(has, lHas, lj);
B.startBlock(lHas);
const ap = B.tmp();
const av = B.tmp();
B.line(`${ap} = getelementptr inbounds ptr, ptr %args, ${host.sizeType} ${i}`);
B.line(`${av} = load ptr, ptr ${ap}`);
B.line(`store ptr ${av}, ptr ${slot}`);
B.br(lj);
B.startBlock(lj);
const word = B.tmp();
B.line(`${word} = load ptr, ptr ${slot}`);
const owned = B.tmp();
if (p.kind === "jsval") {
host.declare(`declare ptr @scr_js_retain(ptr)`);
B.line(`${owned} = call ptr @scr_js_retain(ptr ${word})`);
} else if (p.kind === "dyn") {
host.declare(`declare ptr @scr_js_to_dyn_borrow(ptr)`);
B.line(`${owned} = call ptr @scr_js_to_dyn_borrow(ptr ${word})`);
} else {
throw new InternalCompilerError(`llvm emitter bug: jsval entry parameter ${p.kind}`);
}
return `ptr ${owned}`;
});
const callArgs = [`ptr %c`, ...args].join(", ");
if (ret.kind === "void") {
B.line(`call void @${target}(${callArgs})`);
this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`);
B.terminate(`ret ptr ${undefinedWord}`);
} else {
const r = B.tmp();
B.line(`${r} = call ${this.valTy(ret)} @${target}(${callArgs})`);
this.pendingBail(B, "jtr", () => {}, `ptr ${undefinedWord}`);
if (ret.kind === "jsval") {
B.terminate(`ret ptr ${r}`);
} else if (ret.kind === "f64") {
const raw = B.tmp();
const nan = B.tmp();
const bits = B.tmp();
const word = B.tmp();
B.line(`${raw} = bitcast double ${r} to i64`);
B.line(`${nan} = fcmp uno double ${r}, ${r}`);
B.line(`${bits} = select i1 ${nan}, i64 ${JS_BITS.canonicalNan}, i64 ${raw}`);
B.line(`${word} = inttoptr i64 ${bits} to ptr`);
B.terminate(`ret ptr ${word}`);
} else if (ret.kind === "bool") {
const bits = B.tmp();
const word = B.tmp();
B.line(`${bits} = select i1 ${r}, i64 ${JS_BITS.true}, i64 ${JS_BITS.false}`);
B.line(`${word} = inttoptr i64 ${bits} to ptr`);
B.terminate(`ret ptr ${word}`);
} else {
// dyn, or a typed result boxed as the closure's own entry would.
const d = ret.kind === "dyn" ? r : this.toDynExpr(B, ret, r);
if (ret.kind !== "dyn" && isRefCounted(ret))
B.line(`call void ${releaseSym(host, ret)}(ptr ${r})`);
host.declare(`declare ptr @scr_js_from_dyn(ptr)`);
const word = B.tmp();
B.line(`${word} = call ptr @scr_js_from_dyn(ptr ${d})`);
B.terminate(`ret ptr ${word}`);
}
}
this.defs.push(
`define internal ptr @${name}(ptr %c, ptr %args, ${host.sizeType} %argc) ${FN_ATTRS} { ; jsval entry into ${llvmCommentText(target)}`,
B.render(),
`}`,
``,
);
this.host.journalBuilt?.();
return name;
}
/** The box builder dynFrom emits for one closure signature. */
dynFuncBoxHelper(t: IrType & { kind: "func" }): string {
const key = typeKey(t);
@@ -5547,11 +5664,43 @@ export class LlDyn {
host.declare(`declare ptr @scr_closure_retain_v(ptr)`);
host.declare(`declare ptr @scr_dyn_new_func(ptr, ptr, i32, ptr, ptr)`);
const sigLit = host.cstr(key);
// A closure of this signature with a jsval body gets the function
// value's jsval entry, however the value is boxed: the closure's
// entry pointer names it.
const entries =
t.rest === true
? []
: (host.jsEntryClosures?.() ?? []).filter(
(c) =>
c.params.length === t.params.length &&
c.params.every((p, i) => typeKey(p) === typeKey(t.params[i]!)) &&
typeKey(c.ret) === typeKey(t.ret),
);
const register: string[] = [];
if (entries.length > 0) {
host.declare(`declare void @scr_dyn_func_set_js(ptr, ptr, ptr)`);
register.push(
` %fnp = getelementptr inbounds %ScrClosure, ptr %c, i64 0, i32 1`,
` %fn = load ptr, ptr %fnp`,
);
entries.forEach((c, i) => {
const jsEntry = this.jsEntryThunk(c.body, c.bodyParams, c.bodyRet);
register.push(
` %is${i} = icmp eq ptr %fn, @${c.closure}`,
` br i1 %is${i}, label %set${i}, label %next${i}`,
`set${i}:`,
` call void @scr_dyn_func_set_js(ptr %r, ptr @${thunk}, ptr @${jsEntry})`,
` ret ptr %r`,
`next${i}:`,
);
});
}
this.defs.push(
`define internal ptr @${name}(ptr %v, ptr %fname) ${FN_ATTRS} { ; box ${key} into dyn`,
`entry:`,
` %c = call ptr @scr_closure_retain_v(ptr %v)`,
` %r = call ptr @scr_dyn_new_func(ptr %c, ptr @${thunk}, i32 ${t.params.length - (t.restAbi === "typed" ? 1 : 0)}, ptr ${sigLit}, ptr %fname)`,
...register,
` ret ptr %r`,
`}`,
``,
@@ -148,7 +148,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.integerTempsEnabled = emitter.debug === null && !fn.async && !fn.generator;
beginFunctionLoops(emitter, fn, numericFn);
emitter.speculativeLoopsEnabled = LOOPS.of(emitter).countedLoopsEnabled;
emitter.loopCopiesEnabled = true;
emitter.loopCopiesEnabled = fn.plainLoops !== true;
emitter.speculativeFunction = fn.name;
emitter.speculation = null;
emitter.speculationResume = null;
+163 -121
View File
@@ -478,6 +478,10 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
const usesEvents = runtimeFeatures.processEvents;
const usesChildProcess = runtimeFeatures.childProcess;
const usesFsWatch = runtimeFeatures.fsWatch;
// fs.watchFile programs register the poller unit's exit cleanup — the
// same one-line gating (native/executable.ts links scr_watch_file.c on
// the same predicate).
const usesFsWatchFile = runtimeFeatures.fsWatchFile;
// Programs that call into Node record the libnode pack and its Node
// version before %main (scr_node_island.c).
const usesNodeIsland = runtimeFeatures.nodeIsland;
@@ -541,6 +545,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
if (workerStdioBox || workerStdinBox)
emitter.declare(`declare void @scr_worker_stdio_install(ptr, ptr, ptr)`);
if (usesFsWatch) emitter.declare(`declare void @scr_watch_install()`);
if (usesFsWatchFile) emitter.declare(`declare void @scr_watch_file_install()`);
if (usesNodeIsland) emitter.declare(`declare void @scr_node_island_install(ptr, ptr)`);
// Interned now: the C-string constants flush before main assembles.
const nodeIslandConfig = usesNodeIsland
@@ -1226,6 +1231,9 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// fs.watch programs fill the loop's watch hooks the same way —
// scr_watch.c links only when this line is emitted.
...(usesFsWatch ? [` call void @scr_watch_install()`] : []),
// fs.watchFile programs register the poller unit's atexit cleanup the
// same way — scr_watch_file.c links only when this line is emitted.
...(usesFsWatchFile ? [` call void @scr_watch_file_install()`] : []),
...(nodeIslandConfig
? [
` call void @scr_node_island_install(ptr ${nodeIslandConfig[0]}, ptr ${nodeIslandConfig[1]})`,
@@ -1377,25 +1385,147 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
return out;
}
/** The out-of-line hashed probe of a number-key map (scr_map_find_f64):
* `keyLines` define the normalized key word %kb from the parameter, and
* `runtimeKey` is the double passed to the runtime lookup. A map with at
* most SCR_MAP_LINEAR_LIMIT entries keeps no bucket table: the absent-key
* filter is tested before its entries are scanned. A bucket table is
* probed without the filter: the filter keeps two of its 64 bits per key,
* so past a few dozen keys it rejects few absent keys, and the probe's
* first bucket read answers those. Compact tables tag each 24-bit entry
* index (+1) with the hash's top byte; wider tables take the runtime
* lookup. */
/** The `.loop` lookup helpers only read memory and always return, as
* declared: LLVM then merges two lookups of one key (a has() followed by
* get()) before it inlines them. */
const INLINED_LOOKUP_ATTRS = "alwaysinline memory(read) nounwind willreturn";
/** The number-key entry lookups (sc_map_entry_f64 and sc_map_entry_f64_int)
* and their hashed probes. With `loop` they are the `.loop` flavor, which
* lookups inside a loop call (emitNumberMapEntry): each of its functions is
* inlined, so a lookup that finds its key in a bucket table makes no call,
* and the loop keeps no argument moves or spills around one. */
function numberMapEntryHelpers(sz: string, loop: boolean): string[] {
const flavor = loop ? ".loop" : "";
const attrs = loop ? `${INLINED_LOOKUP_ATTRS} ${FN_ATTRS}` : FN_ATTRS;
let mapTmp = 0;
const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`);
return [
`define internal ptr @sc_map_entry_f64${flavor}(ptr %m, double %k) ${attrs} {`,
`entry:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
...(sz === "i64"
? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`]
: [` %nd = load i32, ptr %ndp`]),
// A non-integral key inside the table is as absent as one outside it.
...mapIndex.lines.map((line) => ` ${line}`),
` br i1 ${mapIndex.ok}, label %slot, label %slow`,
`slot:`,
sz === "i64"
? ` %i = add i64 ${mapIndex.wide}, 0`
: ` %i = trunc i64 ${mapIndex.wide} to ${sz}`,
` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
` %dense = load ptr, ptr %dp`,
` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
` %s = load i32, ptr %sp`,
` %empty = icmp eq i32 %s, 0`,
` br i1 %empty, label %miss, label %hit`,
`hit:`,
` %e32 = sub i32 %s, 1`,
sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %entries = load ptr, ptr %ep`,
` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
` ret ptr %found`,
`miss:`,
` ret ptr null`,
`slow:`,
` %hashed = call ptr @sc_map_entry_f64_hashed${flavor}(ptr %m, double %k)`,
` ret ptr %hashed`,
`}`,
``,
// The same lookup for a key the emitter knows is an integer (`%ki`,
// the double `%k`'s exact value): the direct index tests the integer
// itself, so the double is converted only by the hashed probe. A
// negative key is not below the index's size as an unsigned value.
`define internal ptr @sc_map_entry_f64_int${flavor}(ptr %m, i64 %ki, double %k) ${attrs} {`,
`entry:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
` %nd32 = load i32, ptr %ndp`,
` %nd = zext i32 %nd32 to i64`,
` %inside = icmp ult i64 %ki, %nd`,
` br i1 %inside, label %slot, label %slow`,
`slot:`,
sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`,
` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
` %dense = load ptr, ptr %dp`,
` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
` %s = load i32, ptr %sp`,
` %empty = icmp eq i32 %s, 0`,
` br i1 %empty, label %miss, label %hit`,
`hit:`,
` %e32 = sub i32 %s, 1`,
sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %entries = load ptr, ptr %ep`,
` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
` ret ptr %found`,
`miss:`,
` ret ptr null`,
`slow:`,
` %hashed = call ptr @sc_map_entry_f64_hashed_int${flavor}(ptr %m, i64 %ki, double %k)`,
` ret ptr %hashed`,
`}`,
``,
// Hashing (scr_map_find_f64): the SameValueZero key word and its
// scr_key_mix hash. sc_map_entry_f64_int's probe takes the key's double
// with its integer view: its bits are the key word, never NaN, and +0
// for the integer 0. The caller converts the integer: cvtsi2sd writes
// only the low half of its register, so in a function entry, where
// LLVM cannot clear the register first, it waits for the register's
// last value, in a loop of lookups often a value the previous
// iteration loaded.
...hashedNumberProbe(
`sc_map_entry_f64_hashed${flavor}`,
loop,
"double %k",
sz,
[
` %nan = fcmp uno double %k, %k`,
` %zero = fcmp oeq double %k, 0.0`,
` %kz = select i1 %zero, double 0.0, double %k`,
` %kzb = bitcast double %kz to i64`,
` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`,
],
"%k",
),
...hashedNumberProbe(
`sc_map_entry_f64_hashed_int${flavor}`,
loop,
"i64 %ki, double %k",
sz,
[
` %kzero = icmp eq i64 %ki, 0`,
` %kbits = bitcast double %k to i64`,
` %kb = select i1 %kzero, i64 0, i64 %kbits`,
],
"%k",
),
];
}
/** The hashed probe of a number-key map (scr_map_find_f64), out of line,
* or inlined with `loop`: `keyLines` define the normalized key word %kb
* from the parameter, and `runtimeKey` is the double passed to the runtime
* lookup. A map with at most SCR_MAP_LINEAR_LIMIT entries keeps no bucket
* table, and neither does a map with the direct index, whose lookups reach
* the probe only for a key outside the index, which is absent. In a small
* map the absent-key filter is tested before its entries are scanned.
* A bucket table is probed without the filter: the filter keeps two of its
* 64 bits per key, so past a few dozen keys it rejects few absent keys, and
* the probe's first bucket read answers those. Compact tables tag each
* 24-bit entry index (+1) with the hash's top byte; wider tables take the
* runtime lookup. */
function hashedNumberProbe(
name: string,
loop: boolean,
param: string,
sz: string,
keyLines: string[],
runtimeKey: string,
): string[] {
return [
`define internal ptr @${name}(ptr %m, ${param}) noinline ${FN_ATTRS} {`,
`define internal ptr @${name}(ptr %m, ${param}) ${loop ? INLINED_LOOKUP_ATTRS : "noinline"} ${FN_ATTRS} {`,
`entry:`,
...keyLines,
` %x1s = lshr i64 %kb, 30`,
@@ -1415,7 +1545,14 @@ function hashedNumberProbe(
` %eps = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %ents = load ptr, ptr %eps`,
` %linear = icmp eq ${sz} %nb, 0`,
` br i1 %linear, label %filter, label %buckets`,
` br i1 %linear, label %unbucketed, label %buckets`,
// A map without a bucket table either indexes its keys directly, and
// then holds none outside the index, or scans its entries.
`unbucketed:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
` %nd = load i32, ptr %ndp`,
` %indexed = icmp ne i32 %nd, 0`,
` br i1 %indexed, label %miss, label %filter`,
`filter:`,
` %f1s = lshr i64 %h, 44`,
` %f1a = and i64 %f1s, 63`,
@@ -1582,117 +1719,22 @@ export function helperDefs(emitter: LlEmitter): string[] {
}
// Number-key Map/Set lookups probe the runtime's direct index inline:
// while ndense is nonzero every live key is an integer below it, so an
// in-range integral key reads its slot (entry index + 1, 0 = absent) and
// any other key is absent. Maps without the index (ndense 0) hash the
// key in sc_map_entry_f64_hashed, which scans the entries of a small map
// or probes a compact bucket table as scr_map_find_f64 does, and calls
// the runtime lookup for a wide table. It stays out of line so that the
// direct-index path stays small at every call site. Both only read
// memory, so LLVM merges lookups of one key when nothing is stored in
// between (a has() followed by get(), for example).
if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL)) {
const sz = emitter.sizeType;
let mapTmp = 0;
const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`);
// in-range integral key reads its slot (entry index + 1, 0 = absent).
// Any other key goes to sc_map_entry_f64_hashed, which probes a compact
// bucket table as scr_map_find_f64 does, calls the runtime lookup for a
// wide table, and in a map without a bucket table answers absent when the
// map has the index (a map with buckets keeps no index, scr_map.h) and
// scans the entries otherwise. A lookup in a hashed map so tests the
// index once. The probe stays out of line so that the direct-index path
// stays small at every call site, except in the `.loop` flavor
// (numberMapEntryHelpers). Both only read memory, so LLVM merges lookups
// of one key when nothing is stored in between (a has() followed by
// get(), for example).
if (emitter.decls.has(NUMBER_MAP_ENTRY_DECL))
defs.push(
`define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`,
`entry:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
...(sz === "i64"
? [` %nd32 = load i32, ptr %ndp`, ` %nd = zext i32 %nd32 to i64`]
: [` %nd = load i32, ptr %ndp`]),
// A non-integral key inside the table is as absent as one outside it.
...mapIndex.lines.map((line) => ` ${line}`),
` br i1 ${mapIndex.ok}, label %slot, label %outside`,
`slot:`,
sz === "i64"
? ` %i = add i64 ${mapIndex.wide}, 0`
: ` %i = trunc i64 ${mapIndex.wide} to ${sz}`,
` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
` %dense = load ptr, ptr %dp`,
` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
` %s = load i32, ptr %sp`,
` %empty = icmp eq i32 %s, 0`,
` br i1 %empty, label %miss, label %hit`,
`hit:`,
` %e32 = sub i32 %s, 1`,
sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %entries = load ptr, ptr %ep`,
` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
` ret ptr %found`,
`miss:`,
` ret ptr null`,
`outside:`,
` %dense.any = icmp ne ${sz} %nd, 0`,
` br i1 %dense.any, label %miss, label %slow`,
`slow:`,
` %hashed = call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)`,
` ret ptr %hashed`,
`}`,
``,
// The same lookup for a key the emitter knows is an integer (`%ki`,
// the double `%k`'s exact value): the direct index tests the integer
// itself, so the double is converted only by the hashed probe. A
// negative key is not below the index's size as an unsigned value.
`define internal ptr @sc_map_entry_f64_int(ptr %m, i64 %ki, double %k) ${FN_ATTRS} {`,
`entry:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 16`,
` %nd32 = load i32, ptr %ndp`,
` %nd = zext i32 %nd32 to i64`,
` %inside = icmp ult i64 %ki, %nd`,
` br i1 %inside, label %slot, label %outside`,
`slot:`,
sz === "i64" ? ` %i = add i64 %ki, 0` : ` %i = trunc i64 %ki to ${sz}`,
` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 15`,
` %dense = load ptr, ptr %dp`,
` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
` %s = load i32, ptr %sp`,
` %empty = icmp eq i32 %s, 0`,
` br i1 %empty, label %miss, label %hit`,
`hit:`,
` %e32 = sub i32 %s, 1`,
sz === "i32" ? ` %e = add i32 %e32, 0` : ` %e = zext i32 %e32 to ${sz}`,
` %ep = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 11`,
` %entries = load ptr, ptr %ep`,
` %found = getelementptr inbounds %ScrMapNumEntry, ptr %entries, ${sz} %e`,
` ret ptr %found`,
`miss:`,
` ret ptr null`,
`outside:`,
` %dense.any = icmp ne i64 %nd, 0`,
` br i1 %dense.any, label %miss, label %slow`,
`slow:`,
` %hashed = call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)`,
` ret ptr %hashed`,
`}`,
``,
// Hashing (scr_map_find_f64): the SameValueZero key word and its
// scr_key_mix hash. sc_map_entry_f64_int's probe converts the integer
// view to the key word, which is never NaN or -0, and so skips the
// normalization.
...hashedNumberProbe(
"sc_map_entry_f64_hashed",
"double %k",
sz,
[
` %nan = fcmp uno double %k, %k`,
` %zero = fcmp oeq double %k, 0.0`,
` %kz = select i1 %zero, double 0.0, double %k`,
` %kzb = bitcast double %kz to i64`,
` %kb = select i1 %nan, i64 9221120237041090560, i64 %kzb`,
],
"%k",
),
...hashedNumberProbe(
"sc_map_entry_f64_hashed_int",
"i64 %ki",
sz,
[` %kf = sitofp i64 %ki to double`, ` %kb = bitcast double %kf to i64`],
"%kf",
),
...numberMapEntryHelpers(emitter.sizeType, false),
...numberMapEntryHelpers(emitter.sizeType, true),
);
}
// Map/Set iteration steps read the entries array inline: an index is
// live when it is below the entry count and its entry's cached hash is
// nonzero (a tombstone's is zero), as scr_map_iter_live decides, or for
@@ -1147,6 +1147,14 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
(countedLoop?.guarded || pairedLoop?.guarded) &&
!LOOPS.of(emitter).provenCountedGuards.has(s)
) {
// A function that emits each loop once takes only the copy without
// the integer form, which needs no safety test.
if (LOOPS.of(emitter).plainLoops) {
LOOPS.of(emitter).countedLoopsEnabled = false;
emitSlowCopy(emitter, s);
LOOPS.of(emitter).countedLoopsEnabled = true;
break;
}
const safe = countedLoop
? emitCountedLoopSafety(emitter, countedLoop)
: emitPairedLoopSafety(emitter, pairedLoop!);
@@ -1278,26 +1286,26 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
}
};
// A counted loop over typed arrays, or a loop to a typed array's
// length, polls between blocks of WORKER_POLL_BLOCK iterations instead
// length, polls between blocks of workerPollBlock iterations instead
// of in its body: the inner loop over a block then has no exit
// besides its own bound, which LLVM can vectorize. A typed
// array's length is fixed, so each block can take it as its limit when
// the loop does not assign the array's binding.
//
// Inside another loop of this function, which polls at least once
// every WORKER_POLL_BLOCK of its own iterations, a loop needs less:
// every workerPollBlock of its own iterations, a loop needs less:
// - a loop whose counters' ranges bound its runs, and those of the
// loops in its body, to WORKER_POLL_BLOCK^2 needs no poll;
// loops in its body, to workerPollBlock^2 needs no poll;
// - an innermost counted loop is strip-mined, typed arrays or not,
// and does not poll after its last block. A run of at most
// WORKER_POLL_BLOCK iterations then never polls, and the countdown
// workerPollBlock iterations then never polls, and the countdown
// leaves its body. A run of at most WORKER_UNPOLLED_RUN iterations
// is one block, polled once as it leaves.
const pollBlock = workerPollBlock(emitter);
const enclosed =
emitter.mod.workers === true &&
emitter.jumpTargets.some((target) => target.kind === "loop");
const bounded =
enclosed && boundedLoopRuns(emitter, s) <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK;
const bounded = enclosed && boundedLoopRuns(emitter, s) <= pollBlock * pollBlock;
const innermost = enclosed && !containsLoop(s.body);
const stripMined =
!bounded &&
@@ -1305,7 +1313,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
integerSlot !== null &&
((countedLoop !== null &&
countedLimit !== null &&
Math.abs(countedLoop.step) * WORKER_POLL_BLOCK <= 2 ** 52) ||
Math.abs(countedLoop.step) * pollBlock <= 2 ** 52) ||
(integerLoop !== null && integerArrayLoop === null && integerLoop.stableReceiver)) &&
emitter.workerLoopPolls(s) &&
(loopAccessesTypedArrays(s) || (countedLoop !== null && innermost));
@@ -1314,10 +1322,11 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
// step on each side, has a run whose length its counters fix on entry.
// With a small body, a run of at most WORKER_UNPOLLED_RUN steps takes a
// plain copy of the loop without a poll, which polls once as it leaves
// when it ran more than WORKER_POLL_BLOCK steps, like a strip-mined run;
// when it ran more than workerPollBlock steps, like a strip-mined run;
// longer runs take the loop as it is emitted otherwise.
const shortRuns: { stride: number; left: () => string } | null =
!innermost ||
LOOPS.of(emitter).plainLoops ||
bounded ||
capturedLets.length > 0 ||
!emitter.workerLoopPolls(s) ||
@@ -1491,7 +1500,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
emitPlainLoop(shortEnd, null);
// A run longer than one block polls once as it leaves.
B.startBlock(shortEnd);
B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * WORKER_POLL_BLOCK}`);
B.line(`${pollsAfter} = icmp sgt i64 ${left}, ${shortRuns.stride * pollBlock}`);
B.condBr(pollsAfter, shortPoll, exit);
B.startBlock(shortPoll);
emitter.emitWorkerPoll();
@@ -2811,8 +2820,15 @@ function emitInt32KindLoop(
/** At the top of a loop copy's header: lets a speculative loop find its
* ordinary copy's header, and keeps its third copy out of the loop. */
/** Iterations between cancellation polls of a strip-mined counted loop. */
const WORKER_POLL_BLOCK = 64;
/** Iterations between cancellation polls of a strip-mined counted loop. A
* block holds at least one step of the vectorized loop over bytes, so that
* a full block runs vector code: 64 iterations at the baseline (16-byte
* vectors, interleaved 4 times), and 128 when the program's code is compiled
* for x86-64-v3 or above, whose vectors take 32 bytes. */
function workerPollBlock(emitter: LlEmitter): number {
const cpu = emitter.targetCpu;
return emitter.cpuDispatch || cpu === "x86-64-v3" || cpu === "x86-64-v4" ? 128 : 64;
}
/** The longest run of a strip-mined loop inside another loop that polls
* only as it leaves. */
@@ -2855,7 +2871,8 @@ function boundedLoopRuns(emitter: LlEmitter, loop: IrStmt): number {
if (stepped && range && exactSteps(range, stepped.step))
trips = Math.floor((range.max - range.min) / Math.abs(stepped.step)) + 1;
}
if (!(trips <= WORKER_POLL_BLOCK * WORKER_POLL_BLOCK)) return Infinity;
const pollBlock = workerPollBlock(emitter);
if (!(trips <= pollBlock * pollBlock)) return Infinity;
let nested = 0;
const visit = (s: IrStmt): boolean => {
if (s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf") {
@@ -2909,17 +2926,17 @@ interface StripMinedCounter {
limit: () => string;
}
/** A counted `for` loop of a worker program as blocks of WORKER_POLL_BLOCK
/** A counted `for` loop of a worker program as blocks of workerPollBlock
* iterations, with the cancellation poll between blocks: the outer header
* tests the counter against the loop's limit, each block runs the body up
* to the nearer of the limit and WORKER_POLL_BLOCK steps on, and the poll
* to the nearer of the limit and workerPollBlock steps on, and the poll
* follows the block. It polls once per block, and the inner loop's only
* exit besides `break` and `return` is its bound.
*
* With `runSlot` (for a loop that another loop of the function encloses), a
* block that ends at the limit leaves the loop without polling, and a run
* of at most WORKER_UNPOLLED_RUN iterations is one block, polled once as
* it leaves when it took more than WORKER_POLL_BLOCK iterations. `runSlot`
* it leaves when it took more than workerPollBlock iterations. `runSlot`
* holds whether the current block is such a run. */
function emitStripMinedCountedLoop(
emitter: LlEmitter,
@@ -2932,6 +2949,7 @@ function emitStripMinedCountedLoop(
const B = emitter.B;
const { slot, type, signed, step } = counter;
const ascending = step > 0;
const pollBlock = workerPollBlock(emitter);
const compare = !signed ? "ult" : ascending ? "slt" : "sgt";
const saturating = `llvm.${signed ? "sadd" : "uadd"}.sat.${type}`;
const nearer = `llvm.${!signed ? "umin" : ascending ? "smin" : "smax"}.${type}`;
@@ -2956,9 +2974,7 @@ function emitStripMinedCountedLoop(
B.startBlock(block);
const stepped = B.tmp();
const bound = B.tmp();
B.line(
`${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * WORKER_POLL_BLOCK})`,
);
B.line(`${stepped} = call ${type} @${saturating}(${type} ${start}, ${type} ${step * pollBlock})`);
B.line(`${bound} = call ${type} @${nearer}(${type} ${stepped}, ${type} ${limit})`);
if (runSlot === null) B.line(`store ${type} ${bound}, ptr ${boundSlot}`);
else {
@@ -2975,7 +2991,7 @@ function emitStripMinedCountedLoop(
: `${left} = sub ${wrap} ${type} ${start}, ${limit}`,
);
B.line(`${whole} = icmp ule ${type} ${left}, ${stride * WORKER_UNPOLLED_RUN}`);
B.line(`${long} = icmp ugt ${type} ${left}, ${stride * WORKER_POLL_BLOCK}`);
B.line(`${long} = icmp ugt ${type} ${left}, ${stride * pollBlock}`);
B.line(`${polled} = and i1 ${whole}, ${long}`);
B.line(`${end} = select i1 ${whole}, ${type} ${limit}, ${type} ${bound}`);
B.line(`store ${type} ${end}, ptr ${boundSlot}`);
+48 -3
View File
@@ -5,7 +5,7 @@ import type { CompilationTiming } from "../../timing.js";
import { relieveHeapPressure } from "../../heap-pressure.js";
import { analyzeFrameEscapes, type FrameEscapes } from "./frame-escapes.js";
import { wasmBoundsChecks } from "./wasm-modules.js";
import { jsTruthy } from "./jsval.js";
import { jsImmediates, jsTruthy } from "./jsval.js";
import { JsIcSites } from "../../ir/js-ic.js";
import { type ArraySearchKind } from "./array-search.js";
import { emitStringInputs } from "./string-lifetimes.js";
@@ -286,6 +286,8 @@ import {
cryptoIntThunkFor,
type BytesThunkValue,
fsRenameThunkFor,
watchNameThunkFor,
watchErrorThunkFor,
streamCbThunkFor,
zlibBytesThunkFor,
} from "./expr-stream-callbacks.js";
@@ -374,7 +376,7 @@ import {
type ArenaPlan,
} from "./arenas.js";
import { children, type Node } from "./region-ir.js";
import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js";
import { LlDyn, recordDynKeepsIdentity, type DynHost, type JsEntryClosure } from "./dyn.js";
import { VirtualBorrows } from "./virtual-borrows.js";
import { planRecordViews, recordViewFieldPtr } from "./record-views.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -1485,6 +1487,10 @@ export class LlEmitter {
private readonly rcHelpersNoinline: boolean;
readonly fnByName = new Map<string, IrFunction>();
/** Whether some closure has a jsval body (IrFunction.jsEntry): the
* dynamic tier's dynCall and dynInvoke sites then call a function value's
* jsval entry when it has one. */
jsEntries = false;
/** Empty literals that construct with inline slots (appended-array-literals.ts). */
appendedArrayLiterals: ReadonlySet<IrExpr> = new Set();
/** Emitted LLVM text of every IR function body, by function name: the
@@ -2180,7 +2186,10 @@ export class LlEmitter {
this.ffiCallbackAdapters = allocateFfiCallbackAdapters(mod.ffiImports ?? []);
this.ffiHasRetainedCallback = hasRetainedFfiCallback(mod.ffiImports ?? []);
this.ffiHasForeignCallback = hasForeignFfiCallback(mod.ffiImports ?? []);
for (const fn of mod.functions) this.fnByName.set(fn.name, fn);
for (const fn of mod.functions) {
this.fnByName.set(fn.name, fn);
if (fn.jsEntry !== undefined) this.jsEntries = true;
}
this.appendedArrayLiterals = findAppendedArrayLiterals(mod, this.nodeIndex);
for (const entry of mod.ffiImports ?? []) {
this.ffiByName.set(entry.name, entry);
@@ -2396,6 +2405,7 @@ export class LlEmitter {
? undefined
: (table, key, value) => this.journal?.entry(table, key, value),
compactArrayElem: (elem) => this.compactArrayElem(elem),
jsEntryClosures: () => this.jsEntryClosures(),
compactRegion: () => this.compactRegion,
sizeType: this.sizeType,
cycleBufOffset: this.cycleBufOffset,
@@ -5064,6 +5074,8 @@ export class LlEmitter {
idx,
(decl) => this.declare(decl),
this.rcMode === undefined,
(text) => this.cstr(text),
this.shapeHost.recordViews?.partialTargets.has(shapeId) === true,
);
} else {
p = this.B.tmp();
@@ -5568,6 +5580,30 @@ export class LlEmitter {
* the fiber eagerly to its first suspension and returns the promise);
* generator bodies via theirs (which only ALLOCATES the suspended
* fiber and returns the generator object). */
private jsEntryClosureList: readonly JsEntryClosure[] | null = null;
/** The closures with a jsval body (IrFunction.jsEntry), on targets with
* jsval words. */
jsEntryClosures(): readonly JsEntryClosure[] {
if (this.jsEntryClosureList !== null) return this.jsEntryClosureList;
const list: JsEntryClosure[] = [];
if (this.jsEntries && jsImmediates(this))
for (const fn of this.fnByName.values()) {
const body = fn.jsEntry === undefined ? undefined : this.fnByName.get(fn.jsEntry);
if (body === undefined) continue;
list.push({
closure: this.callTarget(fn.name),
params: fn.params.map((p) => p.type),
ret: fn.returnType,
body: this.callTarget(body.name),
bodyParams: body.params.map((p) => p.type),
bodyRet: body.returnType,
});
}
this.jsEntryClosureList = list;
return list;
}
callTarget(fnName: string): string {
const fn = this.fnByName.get(fnName);
if (fn?.generator !== undefined) return mangleGenSpawn(fnName);
@@ -8161,6 +8197,14 @@ export class LlEmitter {
return fsRenameThunkFor(this, cbT);
}
watchNameThunkFor(cbT: IrType & { kind: "func" }): string {
return watchNameThunkFor(this, cbT);
}
watchErrorThunkFor(cbT: IrType & { kind: "func" }): string {
return watchErrorThunkFor(this, cbT);
}
cryptoBytesThunkFor(
cbT: IrType & { kind: "func" },
arrayBuffer = false,
@@ -8553,6 +8597,7 @@ for (const op of [
"dyn.promiseDynAdapterHelper",
"dyn.dynFuncThunkHelper",
"dyn.dynFuncBoxHelper",
"dyn.jsEntryThunk",
"dyn.dynFuncAdapterHelper",
"dyn.dynToStrHelper",
"dyn.dynIterNHelper",
@@ -1003,6 +1003,16 @@ export function emitCallExpr(
} else if (needsReceiver) B.line("call void @scr_dyn_this_pop()");
return result;
};
// The direct entry of a known target brackets the call when the target
// may observe the receiver. A signature adapter that only forwards the
// receiver to its original carries the original's RECEIVER_FREE bit
// (set where the adapter closure is built), so its entry tests the bit,
// like the entry through the closure's own pointer.
const invokeKnown = (target: string): string => {
const readers = host.receiverReaders;
if (readers.ignores(known!)) return invoke(target, false);
return invoke(target, true, args, readers.forwardsOnly(known!));
};
const lending =
lent && !dynamic
? known!.captures !== undefined
@@ -1025,7 +1035,7 @@ export function emitCallExpr(
(e.callee.kind === "closure" ||
(e.callee.kind === "varRef" && host.knownCallbacks.has(e.callee.localId)))
) {
t = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
t = invokeKnown(lending ?? direct);
if (quiet) pending = false;
} else {
const fnp = B.tmp(),
@@ -1072,7 +1082,7 @@ export function emitCallExpr(
B.line(`${matches} = icmp eq ptr ${fn}, ${direct}`);
B.condBr(matches, fast, fallback);
B.startBlock(fast);
const specialized = invoke(lending ?? direct, !host.receiverReaders.ignores(known!));
const specialized = invokeKnown(lending ?? direct);
// `fast` may have split for exception checks; join from the end.
const fastEnd = B.currentLabel();
B.br(done);
@@ -27,11 +27,13 @@ import {
} from "../../ir/ir.js";
import { BYTES_ELEM_NUM, closureIdentityEqual } from "./common.js";
import { DYN_KIND } from "./dyn.js";
import { elemAccess, vAdapters } from "./shapes.js";
import { elemAccess, releaseSym, vAdapters } from "./shapes.js";
import { JS_BITS } from "./jsval-encoding.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { emitUnionWiden } from "./expr-records.js";
import { emitJsIcLiteralMember, emitJsIcMethod, jsIcMethods } from "./js-ic.js";
import { jsImmediates } from "./jsval.js";
import { jsIcLiteralKey } from "../../ir/js-ic.js";
import { DYN_BUFFER_METHODS } from "../../ir/runtime-features.js";
@@ -189,6 +191,30 @@ export function emitDynamicExpr(
return out;
}
const args = e.args.map((a) => emitBorrowedInput(host, a));
host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`);
const fast = jsEntrySites(host);
if (fast || args.some((a) => a.type.kind === "jsval")) {
const name = calleeName();
const t = emitJsvalArgumentsCall(
host,
callee.name,
receiver?.name ?? "null",
args,
fast,
(argsPtr) => {
const r = B.tmp();
B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`);
B.line(
`${r} = call ptr @scr_dyn_call(ptr ${callee.name}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
);
B.line("call void @scr_dyn_this_pop()");
return r;
},
);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
let argsPtr = "null";
if (args.length > 0) {
const arr = B.slot();
@@ -202,7 +228,6 @@ export function emitDynamicExpr(
});
argsPtr = arr;
}
host.declare(`declare ptr @scr_dyn_call(ptr, ptr, ${host.sizeType}, ptr)`);
const t = B.tmp();
const name = calleeName();
B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver?.name ?? "null"})`);
@@ -246,6 +271,39 @@ export function emitDynamicExpr(
);
}
}
// Buffer.prototype method names take their scr_bytes_dyn.c entry, which
// runs them on Buffer receivers and dispatches any other receiver.
// Each call passes its method name's id slot, so only the first call
// looks the name up.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
const discardedPush =
discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id";
const fast = !discardedPush && invoke === "scr_dyn_invoke_prepared_id" && jsEntrySites(host);
if (fast || args.some((a) => a.type.kind === "jsval")) {
const name = discardedPush ? host.cstr(e.calleeName) : dynInvokeCalleeName(host, e);
if (discardedPush)
host.declare(
`declare void @scr_dyn_invoke_discard_id(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`,
);
else host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
const t = emitJsvalArgumentsCall(host, prepared, recv.name, args, fast, (argsPtr) => {
const site = `ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name}`;
if (discardedPush) {
B.line(`call void @scr_dyn_invoke_discard_id(${site})`);
return "null";
}
const r = B.tmp();
B.line(`${r} = call ptr @${invoke}(${site})`);
return r;
});
if (discardedPush) {
host.emitPendingCheck();
return { name: "", type: VOID };
}
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
let argsPtr = "null";
if (args.length > 0) {
const arr = B.slot();
@@ -259,11 +317,6 @@ export function emitDynamicExpr(
});
argsPtr = arr;
}
// Buffer.prototype method names take their scr_bytes_dyn.c entry, which
// runs them on Buffer receivers and dispatches any other receiver.
// Each call passes its method name's id slot, so only the first call
// looks the name up.
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared_id";
// A statement-position push drops the new length, so its entry
// need not box it.
if (discarded && e.method === "push" && invoke === "scr_dyn_invoke_prepared_id") {
@@ -277,12 +330,7 @@ export function emitDynamicExpr(
return { name: "", type: VOID };
}
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
let name = host.cstr(e.calleeName);
if (e.calleeNameValue !== undefined) {
const value = emitBorrowedInput(host, e.calleeNameValue);
name = B.tmp();
B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`);
}
const name = dynInvokeCalleeName(host, e);
const t = B.tmp();
B.line(
`${t} = call ptr @${invoke}(ptr ${recv.name}, ptr ${prepared}, ptr ${host.cstr(e.method)}, ptr ${host.methodIdSlot(e.method)}, ptr ${argsPtr}, ${host.sizeType} ${args.length}, ptr ${name})`,
@@ -547,6 +595,19 @@ export function emitDynamicExpr(
B.br(join);
return;
}
// A declared-only arm without the literal key: the object has no
// such own property, so the read answers undefined.
if (literal !== null && !shape.indexValue) {
const tag = resultDef?.arms.findIndex((a) => a.kind === "undefinedT") ?? -1;
if (tag < 0 || e.type.kind !== "union") {
throw new InternalCompilerError(
"llvm emitter bug: unionKeyGet missing key without an undefined result arm",
);
}
B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, tag)}, ptr ${slot}`);
B.br(join);
return;
}
// Index-signature arm (or declared-only shape under a runtime
// key): the shared keyed-read chain — a literal key naming no
// declared field touches only the overflow map.
@@ -1389,3 +1450,135 @@ function messageListenerArg(e: ExprOf<"dynInvoke">): (IrType & { kind: "func" })
if (listener?.kind !== "dynFrom" || listener.value.type.kind !== "func") return null;
return listener.value.type;
}
/** Whether a dynCall or dynInvoke site of the current function tests its
* callee for a jsval entry (emitJsvalArgumentsCall): dynamic-tier code of
* a module with closure jsval bodies, on targets with jsval words. */
function jsEntrySites(host: LlvmEmitterContext): boolean {
return (
host.jsEntries &&
jsImmediates(host) &&
host.fnByName.get(host.currentFunctionName)?.tier === "dynamic"
);
}
/** A call of the function value `callee` (null for a site without one)
* with `args`, which may include jsval words (ir/jsval.ts). With `fast`,
* a callee boxed from a closure with a jsval body (ScrDyn.v.fn.js, +72)
* takes the arguments as words, a dyn argument under the cell tag, with
* `receiver` bound as `this` around the call. Every other call goes
* through `slow`, which takes an array of dyn arguments: the words boxed
* for it and released after it. Returns the owned dyn result (null on
* the slow path with an exception pending). */
function emitJsvalArgumentsCall(
host: LlvmEmitterContext,
callee: string | null,
receiver: string,
args: readonly LlValue[],
fast: boolean,
slow: (argsPtr: string) => string,
): string {
const B = host.B;
const n = args.length;
const result = B.slot();
B.entryAllocas.push(`${result} = alloca ptr`);
const lSlow = B.newLabel("jse.slow");
const lJoin = B.newLabel("jse.join");
if (fast && callee !== null) {
let words = "null";
if (n > 0) {
words = B.slot();
B.entryAllocas.push(`${words} = alloca [${n} x ptr]`);
args.forEach((a, i) => {
let word = a.name;
if (a.type.kind !== "jsval") {
const bits = B.tmp();
const tagged = B.tmp();
word = B.tmp();
B.line(`${bits} = ptrtoint ptr ${a.name} to i64`);
B.line(`${tagged} = or i64 ${bits}, ${JS_BITS.cellTag}`);
B.line(`${word} = inttoptr i64 ${tagged} to ptr`);
}
const p = B.tmp();
B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${words}, i64 0, i64 ${i}`);
B.line(`store ptr ${word}, ptr ${p}`);
});
}
const lKind = B.newLabel("jse.kind");
const lFn = B.newLabel("jse.fn");
const lEntry = B.newLabel("jse.entry");
const present = B.tmp();
B.line(`${present} = icmp ne ptr ${callee}, null`);
B.condBr(present, lKind, lSlow);
B.startBlock(lKind);
const kindPtr = B.tmp();
const kind = B.tmp();
const isFn = B.tmp();
B.line(`${kindPtr} = getelementptr inbounds i8, ptr ${callee}, i64 8`);
B.line(`${kind} = load i32, ptr ${kindPtr}`);
B.line(`${isFn} = icmp eq i32 ${kind}, ${DYN_KIND.FUNC}`);
B.condBr(isFn, lFn, lSlow);
B.startBlock(lFn);
const entryPtr = B.tmp();
const entry = B.tmp();
const hasEntry = B.tmp();
B.line(`${entryPtr} = getelementptr inbounds i8, ptr ${callee}, i64 72 ; ->v.fn.js`);
B.line(`${entry} = load ptr, ptr ${entryPtr}`);
B.line(`${hasEntry} = icmp ne ptr ${entry}, null`);
B.condBr(hasEntry, lEntry, lSlow);
B.startBlock(lEntry);
const cloPtr = B.tmp();
const clo = B.tmp();
const word = B.tmp();
const value = B.tmp();
host.declare("declare void @scr_dyn_this_push_dyn(ptr)");
host.declare("declare void @scr_dyn_this_pop()");
host.declare("declare ptr @scr_js_to_dyn(ptr)");
B.line(`${cloPtr} = getelementptr inbounds i8, ptr ${callee}, i64 16 ; ->v.fn.clo`);
B.line(`${clo} = load ptr, ptr ${cloPtr}`);
B.line(`call void @scr_dyn_this_push_dyn(ptr ${receiver})`);
B.line(`${word} = call ptr ${entry}(ptr ${clo}, ptr ${words}, ${host.sizeType} ${n})`);
B.line("call void @scr_dyn_this_pop()");
B.line(`${value} = call ptr @scr_js_to_dyn(ptr ${word})`);
B.line(`store ptr ${value}, ptr ${result}`);
B.br(lJoin);
} else {
B.br(lSlow);
}
B.startBlock(lSlow);
let cells = "null";
const boxed: string[] = [];
if (n > 0) {
cells = B.slot();
B.entryAllocas.push(`${cells} = alloca [${n} x ptr]`);
args.forEach((a, i) => {
let cell = a.name;
if (a.type.kind === "jsval") {
host.declare("declare ptr @scr_js_to_dyn_borrow(ptr)");
cell = B.tmp();
B.line(`${cell} = call ptr @scr_js_to_dyn_borrow(ptr ${a.name})`);
boxed.push(cell);
}
const p = B.tmp();
B.line(`${p} = getelementptr inbounds [${n} x ptr], ptr ${cells}, i64 0, i64 ${i}`);
B.line(`store ptr ${cell}, ptr ${p}`);
});
}
const r = slow(cells);
for (const cell of boxed) B.line(`call void ${releaseSym(host.shapeHost, DYN)}(ptr ${cell})`);
B.line(`store ptr ${r}, ptr ${result}`);
B.br(lJoin);
B.startBlock(lJoin);
const out = B.tmp();
B.line(`${out} = load ptr, ptr ${result}`);
return out;
}
/** A dynInvoke's callee spelling for the runtime's error texts. */
function dynInvokeCalleeName(host: LlvmEmitterContext, e: ExprOf<"dynInvoke">): string {
if (e.calleeNameValue === undefined) return host.cstr(e.calleeName);
const value = emitBorrowedInput(host, e.calleeNameValue);
const name = host.B.tmp();
host.B.line(`${name} = getelementptr inbounds %ScrStr, ptr ${value.name}, i32 1`);
return name;
}
@@ -10,6 +10,7 @@ import { matchStringSelfConcat, undefinedArmTag } from "../../ir/analysis.js";
import { isRefCounted, type IrExpr, type IrStrCmpOp, type IrType } from "../../ir/ir.js";
import { mangleRecordClone, mangleRecordNew } from "../mangle.js";
import { arrNewCall, elemAccess } from "./shapes.js";
import { recordViewTestLines } from "./record-views.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import { offsetIndex } from "./expr-bytes.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
@@ -1473,16 +1474,70 @@ export function emitRecordExpr(
return host.emitRecordKeyGet(e);
case "recordHas": {
// Own-property presence: only an undefined-armed union slot can
// lack its property, by holding the ABSENT state.
// lack its property, by holding the ABSENT state, and a PARTIAL
// record view (record-views.ts) can lack a field its viewed record
// does not declare.
const fieldType = host.recordsById
.get(e.shapeId)
?.fields.find((f) => f.name === e.field)?.type;
const obj = host.emitReadReceiver(e.obj);
if (host.shapeHost.recordViews?.partialMissing(e.shapeId, e.field) !== true) {
const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field);
if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0)
return { name: "true", type: e.type };
const v = host.loadField(ptr, type, inline);
const absent = host.fieldAbsentTest(v, type.unionId);
const t = B.tmp();
B.line(`${t} = xor i1 ${absent}, true`);
return { name: t, type: e.type };
}
// A field some partial view of this shape lacks: ask the view first.
// Only a present field's slot may be looked up — a partial view's
// absent field answers no slot at all (scr_record_view_slot).
const position = host.recordsById.get(e.shapeId)!.fields.findIndex((f) => f.name === e.field);
host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`);
const isView = B.tmp();
const rhFrom = B.currentLabel();
const view = B.newLabel("rh.view");
const done = B.newLabel("rh.done");
for (const line of recordViewTestLines(host.sizeType, obj.name, isView))
B.line(line.trimStart());
// Not a view: every field of the target has its slot.
B.condBr(isView, view, done, `, !prof !{!"branch_weights", i32 1, i32 1000}`);
B.startBlock(view);
const slot = B.tmp();
B.line(
`${slot} = call ptr @scr_record_view_slot(ptr ${obj.name}, ${host.sizeType} ${position})`,
);
const hasSlot = B.tmp();
B.line(`${hasSlot} = icmp ne ptr ${slot}, null`);
B.br(done);
B.startBlock(done);
const hasView = B.tmp();
B.line(`${hasView} = phi i1 [ true, %${rhFrom} ], [ ${hasSlot}, %${view} ]`);
if (fieldType === undefined || fieldType.kind !== "union")
return { name: hasView, type: e.type };
const unionId = fieldType.unionId;
if (undefinedArmTag(fieldType, host.unionsById) < 0) return { name: hasView, type: e.type };
// The field is also undefined-armed: a present slot still answers by
// its ABSENT state, a field the view lacks by the view.
const check = B.newLabel("rh.present");
const join = B.newLabel("rh.join");
const from = B.currentLabel();
B.condBr(hasView, check, join);
B.startBlock(check);
const { ptr, type, inline } = host.recordFieldPtr(obj.name, e.shapeId, e.field);
if (type.kind !== "union" || undefinedArmTag(type, host.unionsById) < 0)
return { name: "true", type: e.type };
const v = host.loadField(ptr, type, inline);
const absent = host.fieldAbsentTest(v, type.unionId);
const t = B.tmp();
B.line(`${t} = xor i1 ${absent}, true`);
return { name: t, type: e.type };
const absent = host.fieldAbsentTest(v, unionId);
const present = B.tmp();
B.line(`${present} = xor i1 ${absent}, true`);
// The field load can end in a later block (a view's slot read).
const checked = B.currentLabel();
B.br(join);
B.startBlock(join);
const answer = B.tmp();
B.line(`${answer} = phi i1 [ false, %${from} ], [ ${present}, %${checked} ]`);
return { name: answer, type: e.type };
}
case "fieldAbsent":
return { name: host.absentInstanceRef(e.unionId), type: e.type };
@@ -40,6 +40,7 @@ import {
llFieldType,
loadStructField,
releaseSym,
traceAdapter,
traceArg,
vAdapters,
type ShapeHost,
@@ -754,6 +755,50 @@ function symbolKeyLines(
};
}
/** The ScrStreamTypedDesc constant (scr_stream.h) of a record type an
* object-mode stream holds as the record itself, or null for a type whose
* chunks stay boxed: records without declared fields, with an index
* signature or accessor slots, tuples, the text codec records (they box as
* handles), and records the collector traces (a stream does not trace the
* records it holds). The constant's `box` converts the record as a
* live-reference conversion to dyn does (streamTypedRefBoxValue). */
export function streamTypedDesc(host: LlvmEmitterContext, t: IrType): string | null {
if (t.kind !== "record") return null;
const shape = host.recordsById.get(t.shapeId);
if (
!shape ||
shape.fields.length === 0 ||
shape.indexValue !== undefined ||
shape.tuple ||
shapeHasAccessorSlots(shape) ||
shape.fields.some((field) => field.name === "handleEvent" && field.type.kind === "func") ||
textCodecRecordClass(t, (id) => host.recordsById.get(id)) !== null ||
traceAdapter(host.shapeHost, t) !== null
)
return null;
const key = typeKey(t);
const cacheKey = `std:${key}`;
const existing = host.resolveThunks.get(cacheKey);
if (existing) return `@${existing}`;
const sym = host.contentSymbol("sc_std_", key);
host.resolveThunks.set(cacheKey, sym);
host.journal?.allocated(sym, "streamTypedDesc", [t]);
const B = new BlockBuilder();
const boxed = streamTypedRefBoxValue(host, B, t, "%p");
B.terminate(`ret ptr ${boxed}`);
const rc = vAdapters(host.shapeHost, t);
host.resolveThunkDefs.push(
`define internal ptr @${sym}_box(ptr %p) ${FN_ATTRS} { ; box a typed stream chunk ${key}`,
B.render(),
`}`,
``,
`@${sym} = internal constant { ptr, ${host.sizeType}, ptr, ptr, ptr } { ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr ${rc.retain}, ptr ${rc.release}, ptr @${sym}_box }`,
``,
);
host.journal?.built();
return `@${sym}`;
}
export function streamTypedRefBoxValue(
host: LlvmEmitterContext,
B: BlockBuilder,
@@ -2018,6 +2063,9 @@ export function streamFromArrayAdapter(
}
registerReplay("dynPromiseAdapter", (host, args) => dynPromiseAdapter(host, args[0] as IrType));
registerReplay("streamTypedDesc", (host, args) =>
(streamTypedDesc(host, args[0] as IrType) ?? "").slice(1),
);
registerReplay("liveDynUnionRefAdapter", (host, args) =>
liveDynUnionRefAdapter(host, args[0] as IrType & { kind: "union" }),
);
@@ -1,10 +1,12 @@
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { type IrType, isRefCounted, typeKey } from "../../ir/ir.js";
import { FN_ATTRS, releaseSym, retainSym, traceArg } from "./shapes.js";
import { type IrType, isRefCounted, STRING, typeKey } from "../../ir/ir.js";
import { streamTypedRefEligible } from "../../ir/analysis.js";
import { FN_ATTRS, releaseSym, retainSym, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext } from "./expr-context.js";
import { unionPeekLines, unionTagLines } from "./union-repr.js";
import { registerReplay } from "./emit-journal.js";
import { streamTypedDesc } from "./expr-stream-bridges.js";
export function streamDataAdapter(
host: LlvmEmitterContext,
@@ -259,6 +261,31 @@ export function streamDoneFnFor(
strTag = def!.arms.findIndex((a) => a.kind === "string");
}
}
// data of one record type or nothing (a typed completion,
// pinTypedDataCompletion): a record with a typed-chunk descriptor
// completes as a typed chunk, and another record boxes by reference
// (the conversion a dyn `data` parameter receives).
let typedTag = -1;
let typedDesc: string | null = null;
let liveData = false;
if (valueData && dataT?.kind === "union") {
const arms = host.unionsById.get(dataT.unionId)?.arms ?? [];
const record = arms.findIndex((a) => a.kind === "record");
if (arms.length === 2 && record >= 0 && arms.some((a) => a.kind === "undefinedT")) {
typedDesc = streamTypedDesc(host, arms[record]!);
if (typedDesc !== null) typedTag = record;
else {
// the records a conversion to dyn keeps by reference (Lowerer's
// liveReference)
const armT = arms[record]!;
const shape = armT.kind === "record" ? host.recordsById.get(armT.shapeId) : undefined;
liveData =
streamTypedRefEligible(armT) &&
shape !== undefined &&
(shape.fields.length > 0 || shape.indexValue !== undefined);
}
}
}
const entry =
kind === "c"
? "scr_stream_construct_done"
@@ -269,18 +296,20 @@ export function streamDoneFnFor(
: kind === "d"
? "scr_stream_destroy_done"
: kind === "t"
? valueData
? "scr_stream_transform_done_value"
: "scr_stream_transform_done"
: valueData
? "scr_stream_flush_done_value"
: "scr_stream_flush_done";
? typedDesc !== null
? "scr_stream_transform_done_typed"
: valueData
? "scr_stream_transform_done_value"
: "scr_stream_transform_done"
: typedDesc !== null
? "scr_stream_flush_done_typed"
: valueData
? "scr_stream_flush_done_value"
: "scr_stream_flush_done";
const twoSlot = (kind === "t" || kind === "l") && !valueData;
host.declare(
`declare void @${entry}(ptr, ptr${twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`,
`declare void @${entry}(ptr, ptr${typedDesc !== null ? ", ptr, ptr" : twoSlot ? ", ptr, ptr" : valueData ? ", ptr" : ""})`,
);
host.declare(`declare ptr @scr_box_get_ref(ptr)`);
host.declare(`declare void @scr_stream_release_v(ptr)`);
host.declare(`declare ptr @scr_error_retain_v(ptr)`);
host.declare(`declare void @scr_union_release(ptr)`);
const params = [
@@ -293,15 +322,21 @@ export function streamDoneFnFor(
`entry:`,
` %eslot = alloca ptr`,
...(twoSlot ? [` %bslot = alloca ptr`, ` %sslot = alloca ptr`] : []),
...(typedDesc !== null ? [` %tslot = alloca ptr`] : []),
` %capsp = getelementptr inbounds %ScrClosure, ptr %self, i64 1`,
` %bx = load ptr, ptr %capsp`,
` %s = call ptr @scr_box_get_ref(ptr %bx) ; the stream, +1`,
// the stream, borrowed: the box holds it, and the closure (alive for
// the call) holds the box
` %sp = getelementptr inbounds %ScrBox, ptr %bx, i64 0, i32 5`,
` %s = load ptr, ptr %sp`,
` store ptr null, ptr %eslot`,
...(twoSlot ? [` store ptr null, ptr %bslot`, ` store ptr null, ptr %sslot`] : []),
...(typedDesc !== null ? [` store ptr null, ptr %tslot`] : []),
];
// A tag-guarded retained peek out of a union argument into a slot.
// A tag-guarded peek out of a union argument into a slot, retained
// unless retain is null.
let arm = 0;
const unwrap = (u: string, tag: number, retain: string, slot: string): void => {
const unwrap = (u: string, tag: number, retain: string | null, slot: string): void => {
const a = arm++;
const union = u === "%e" ? errT : dataT;
const nullable = union?.kind === "union" ? host.nullableUnions.get(union.unionId) : null;
@@ -314,8 +349,9 @@ export function streamDoneFnFor(
` br i1 %hit${a}, label %yes${a}, label %done${a}`,
`yes${a}:`,
...unionPeekLines(nullable, u, `%pv${a}`),
` %rt${a} = call ptr ${retain}(ptr %pv${a})`,
` store ptr %rt${a}, ptr ${slot}`,
...(retain !== null
? [` %rt${a} = call ptr ${retain}(ptr %pv${a})`, ` store ptr %rt${a}, ptr ${slot}`]
: [` store ptr %pv${a}, ptr ${slot}`]),
` br label %done${a}`,
`done${a}:`,
);
@@ -329,8 +365,21 @@ export function streamDoneFnFor(
host.declare(`declare ptr @scr_str_retain_v(ptr)`);
unwrap("%d", strTag, "@scr_str_retain_v", "%sslot");
}
if (typedDesc !== null) unwrap("%d", typedTag, null, "%tslot");
d.push(` %ev = load ptr, ptr %eslot`);
if (valueData) {
if (typedDesc !== null) {
d.push(
` %tv = load ptr, ptr %tslot`,
` call void @${entry}(ptr %s, ptr %ev, ptr %tv, ptr ${typedDesc}) ; moves err; borrows s and the record`,
);
} else if (liveData) {
host.declare(`declare void @scr_dyn_release(ptr)`);
d.push(
` %dv = call ptr @${host.liveDynUnionRefAdapter(dataT as IrType & { kind: "union" })}(ptr %d)`,
` call void @${entry}(ptr %s, ptr %ev, ptr %dv) ; moves err; borrows s and the value`,
` call void @scr_dyn_release(ptr %dv)`,
);
} else if (valueData) {
const conv = host.dyn.toDynHelper(dataT!);
host.declare(`declare void @scr_dyn_release(ptr)`);
d.push(
@@ -347,7 +396,6 @@ export function streamDoneFnFor(
} else {
d.push(` call void @${entry}(ptr %s, ptr %ev) ; moves err; borrows s`);
}
d.push(` call void @scr_stream_release_v(ptr %s)`);
if (errT !== undefined) d.push(` call void ${releaseSym(host.shapeHost, errT)}(ptr %e)`);
if (dataT !== undefined && isRefCounted(dataT))
d.push(` call void ${releaseSym(host.shapeHost, dataT)}(ptr %d)`);
@@ -840,18 +888,42 @@ export function streamCbThunkFor(
host.declare(`declare void @scr_dyn_release(ptr)`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`);
// A typed chunk of the declared record type (scr_stream_push_typed)
// is the record itself; any other chunk converts.
const desc = streamTypedDesc(host, p);
if (desc !== null) {
host.declare(`declare ptr @scr_stream_chunk_typed(ptr, ptr, ptr)`);
d.push(
` br label %ctchk${i}`,
`ctchk${i}:`,
` %ctt${i} = call ptr @scr_stream_chunk_typed(ptr %s, ptr %chunk, ptr ${desc})`,
` %cthit${i} = icmp ne ptr %ctt${i}, null`,
` br i1 %cthit${i}, label %ctok${i}, label %ctconv${i}`,
`ctconv${i}:`,
);
}
d.push(
` %cv${i} = call ptr @scr_stream_chunk_value(ptr %s, ptr %chunk)`,
` %ct${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %cv${i}, ptr null)`,
` call void @scr_dyn_release(ptr %cv${i})`,
` %ctbad${i} = call zeroext i1 @scr_exc_pending()`,
` br i1 %ctbad${i}, label %ctfail${i}, label %ctok${i}`,
` br i1 %ctbad${i}, label %ctfail${i}, label %${desc !== null ? "ctconvok" : "ctok"}${i}`,
`ctfail${i}:`,
...(hasThis ? [` call void @scr_stream_release_v(ptr %sr)`] : []),
` ret void`,
`ctok${i}:`,
);
passed.push(`${ty} %ct${i}`);
if (desc !== null) {
d.push(
`ctconvok${i}:`,
` br label %ctok${i}`,
`ctok${i}:`,
` %ctv${i} = phi ptr [ %ctt${i}, %ctchk${i} ], [ %ct${i}, %ctconvok${i} ]`,
);
passed.push(`ptr %ctv${i}`);
} else {
d.push(`ctok${i}:`);
passed.push(`${ty} %ct${i}`);
}
} else if (isEncPos && p.kind === "string") {
// Node's encoding for decoded (Buffer) chunks is 'buffer'; an
// object-mode side passes the write's encoding argument ("" for
@@ -868,18 +940,46 @@ export function streamCbThunkFor(
host.declare(`declare void @scr_dyn_release(ptr)`);
host.declare(`declare zeroext i1 @scr_exc_pending()`);
if (hasThis) host.declare(`declare void @scr_stream_release_v(ptr)`);
// An object-mode write without an encoding argument passes
// undefined: a pointer union whose unit arm is undefined holds it as
// the null pointer, so that case skips the conversion.
const pointer = p.kind === "union" ? host.nullableUnions.get(p.unionId) : null;
const undefinedIsNull =
pointer !== null &&
p.kind === "union" &&
host.unionsById.get(p.unionId)?.arms[pointer.unitTag]?.kind === "undefinedT";
if (undefinedIsNull) {
host.declare(`declare zeroext i1 @scr_stream_chunk_encoding_undefined(ptr)`);
d.push(
` br label %etchk${i}`,
`etchk${i}:`,
` %eu${i} = call zeroext i1 @scr_stream_chunk_encoding_undefined(ptr %s)`,
` br i1 %eu${i}, label %etok${i}, label %etconv${i}`,
`etconv${i}:`,
);
}
d.push(
` %ev${i} = call ptr @scr_stream_chunk_encoding_value(ptr %s)`,
` %et${i} = call ${ty === "i1" ? "zeroext i1" : ty} @${helper}(ptr %ev${i}, ptr null)`,
` call void @scr_dyn_release(ptr %ev${i})`,
` %etbad${i} = call zeroext i1 @scr_exc_pending()`,
` br i1 %etbad${i}, label %etfail${i}, label %etok${i}`,
` br i1 %etbad${i}, label %etfail${i}, label %${undefinedIsNull ? "etconvok" : "etok"}${i}`,
`etfail${i}:`,
...(hasThis ? [` call void @scr_stream_release_v(ptr %sr)`] : []),
` ret void`,
`etok${i}:`,
);
passed.push(`${ty} %et${i}`);
if (undefinedIsNull) {
d.push(
`etconvok${i}:`,
` br label %etok${i}`,
`etok${i}:`,
` %etv${i} = phi ptr [ null, %etchk${i} ], [ %et${i}, %etconvok${i} ]`,
);
passed.push(`${ty} %etv${i}`);
} else {
d.push(`etok${i}:`);
passed.push(`${ty} %et${i}`);
}
} else if (isErrPos) {
// destroy's error argument: `Error | null` — wrap type-directedly.
// The finished/pipeline callback ("e") may declare `Error | null |
@@ -966,6 +1066,161 @@ registerReplay("streamDoneFnFor", (host, args) =>
registerReplay("fsRenameThunkFor", (host, args) =>
fsRenameThunkFor(host, args[0] as IrType & { kind: "func" }),
);
/** The fs.watch listener adapter for an (eventType, filename) listener, or
* for an (eventType) listener whose parameter is dynamic: the runtime hands
* the borrowed C strings, the thunk builds what the listener declared. The
* listener owns its +1 arguments (the universal convention
* scr_watch_thunk_event follows), so a string the thunk boxes into a dynamic
* value is released once the box holds it. */
export function watchNameThunkFor(
host: LlvmEmitterContext,
cbT: IrType & { kind: "func" },
): string {
const key = `watchname:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = host.contentSymbol("sc_watchname_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "watchNameThunkFor", [cbT]);
const S = host.sizeType;
host.declare(`declare ${S} @strlen(ptr)`);
host.declare(`declare ptr @scr_str_new(ptr, ${S})`);
host.declare(`declare void @scr_str_release(ptr)`);
const d: string[] = [
`define internal void @${sym}(ptr %cb, ptr %event, ptr %name) ${FN_ATTRS} { ; fs.watch listener ${typeKey(cbT)}`,
`entry:`,
` %evlen = call ${S} @strlen(ptr %event)`,
` %evstr = call ptr @scr_str_new(ptr %event, ${S} %evlen)`,
];
const named = cbT.params.length === 2;
if (named) {
d.push(
` %slot = alloca ptr`,
` %has = icmp ne ptr %name, null`,
` br i1 %has, label %yes, label %no`,
`yes:`,
);
const nameParam = cbT.params[1]!;
if (nameParam.kind === "dyn") {
host.declare(`declare ptr @scr_dyn_new_null()`);
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` %ndyn = call ptr @scr_dyn_new_str(ptr %nstr)`,
` call void @scr_str_release(ptr %nstr)`,
` store ptr %ndyn, ptr %slot`,
` br label %go`,
`no:`,
` %dn = call ptr @scr_dyn_new_null()`,
` store ptr %dn, ptr %slot`,
);
} else {
if (nameParam.kind !== "union")
throw new InternalCompilerError("llvm emitter bug: fs.watch filename param is not a union");
const def = host.unionsById.get(nameParam.unionId);
const strTag = def ? def.arms.findIndex((a) => a.kind === "string") : -1;
const nullTag = def ? def.arms.findIndex((a) => a.kind === "nullT") : -1;
if (strTag < 0 || nullTag < 0)
throw new InternalCompilerError("llvm emitter bug: fs.watch filename union lacks its arms");
if (host.nullableUnions.has(nameParam.unionId)) {
// `string | null` erases: the owned payload IS the union's value.
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` store ptr %nstr, ptr %slot`,
` br label %go`,
`no:`,
` store ptr null, ptr %slot`,
);
} else {
const rc = vAdapters(host.shapeHost, STRING);
host.declare(`declare ptr @scr_union_new_ref(i32, ptr, ptr, ptr, ptr)`);
d.push(
` %nlen = call ${S} @strlen(ptr %name)`,
` %nstr = call ptr @scr_str_new(ptr %name, ${S} %nlen)`,
` %nu = call ptr @scr_union_new_ref(i32 ${strTag}, ptr %nstr, ptr ${rc.retain}, ptr ${rc.release}, ptr ${traceArg(host.shapeHost, STRING)})`,
` store ptr %nu, ptr %slot`,
` br label %go`,
`no:`,
` store ptr ${host.unitInstanceRef(nameParam.unionId, nullTag)}, ptr %slot`,
);
}
}
d.push(` br label %go`, `go:`);
}
let ev: string;
if (cbT.params[0]?.kind === "dyn") {
// The box retains the string; the thunk's own reference goes.
host.declare(`declare ptr @scr_dyn_new_str(ptr)`);
d.push(
` %evd = call ptr @scr_dyn_new_str(ptr %evstr)`,
` call void @scr_str_release(ptr %evstr)`,
);
ev = "%evd";
} else {
ev = "%evstr";
}
d.push(
` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
` %fn = load ptr, ptr %fnp`,
);
if (named)
d.push(` %arg = load ptr, ptr %slot`, ` call void %fn(ptr %cb, ptr ${ev}, ptr %arg)`);
else d.push(` call void %fn(ptr %cb, ptr ${ev})`);
d.push(` ret void`, `}`, ``);
host.resolveThunkDefs.push(...d);
host.journal?.built();
return sym;
}
/** The FSWatcher 'error' listener adapter: the runtime hands a borrowed
* ScrError, the listener receives the declared dyn value or an Error
* object it owns. A zero-parameter listener uses the runtime's
* scr_watch_err_thunk0 and never reaches here. */
export function watchErrorThunkFor(
host: LlvmEmitterContext,
cbT: IrType & { kind: "func" },
): string {
const key = `watcherr:${typeKey(cbT)}`;
let sym = host.resolveThunks.get(key);
if (sym) return sym;
sym = host.contentSymbol("sc_watcherr_", key);
host.resolveThunks.set(key, sym);
host.journal?.allocated(sym, "watchErrorThunkFor", [cbT]);
const param = cbT.params[0]!;
const d: string[] = [
`define internal void @${sym}(ptr %cb, ptr %err) ${FN_ATTRS} { ; FSWatcher error listener ${typeKey(cbT)}`,
`entry:`,
];
if (param.kind === "dyn") {
host.declare(`declare ptr @scr_dyn_from_error(ptr)`);
d.push(` %de = call ptr @scr_dyn_from_error(ptr %err)`);
} else if (param.kind === "object" && param.className === "%Error") {
host.declare(`declare ptr @scr_error_retain(ptr)`);
d.push(` %de = call ptr @scr_error_retain(ptr %err)`);
} else {
throw new InternalCompilerError("llvm emitter bug: FSWatcher error param is not an Error");
}
d.push(
` %fnp = getelementptr inbounds %ScrClosure, ptr %cb, i64 0, i32 1`,
` %fn = load ptr, ptr %fnp`,
` call void %fn(ptr %cb, ptr %de)`,
` ret void`,
`}`,
``,
);
host.resolveThunkDefs.push(...d);
host.journal?.built();
return sym;
}
registerReplay("watchNameThunkFor", (host, args) =>
watchNameThunkFor(host, args[0] as IrType & { kind: "func" }),
);
registerReplay("watchErrorThunkFor", (host, args) =>
watchErrorThunkFor(host, args[0] as IrType & { kind: "func" }),
);
registerReplay("cryptoIntThunkFor", (host, args) =>
cryptoIntThunkFor(host, args[0] as IrType & { kind: "func" }),
);
+1 -1
View File
@@ -57,7 +57,7 @@ export function jsIcMethods(host: Host): boolean {
return (
jsImmediates(host) &&
host.currentTier === "dynamic" &&
jsIcMethodSites(host.currentFunctionName)
jsIcMethodSites(host.currentFunctionName, host.loopDepth > 0)
);
}
@@ -214,6 +214,9 @@ export function emitIoLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue
export function emitGenericLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
// The generic emitter registrations queue the maxListeners leak warning
// for the next tick like emitter.on (lib-process.ts).
if (e.fn === "emitter.onFlex" || e.fn === "emitter.onAny") host.usesTimers = true;
if (host.coroAwait && e.fn === "async.hop") {
host.emitCoroAwait(null);
return { name: "", type: e.type };
@@ -1185,6 +1188,10 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
case "str":
case "regexp":
case "intl":
// ICU-backed Intl (scr_intl.c): ordinary libCalls whose C symbols the
// runtime provides — the same generic emission the engine-free intl
// forms use.
case "icu":
case "sym":
case "perf":
case "number":
@@ -1272,6 +1279,9 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return host.emitGenericLibCall(e);
case "nodeIsland":
return host.emitGenericLibCall(e);
// Primitive wrapper objects (scr_js_wrapper.c).
case "wrapper":
return host.emitGenericLibCall(e);
case "webstream":
// A stream's start, pull and cancel steps run as promise jobs, so
// a program with the classes runs its event loop.
@@ -1,7 +1,7 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { DYN, F64, STRING, type IrExpr } from "../../ir/ir.js";
import { DYN, F64, STRING, type IrExpr, type IrType } from "../../ir/ir.js";
import { mangleRecordNew } from "../mangle.js";
import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
@@ -173,6 +173,22 @@ function emitObjEntryPairs(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return out;
}
/** The adapter of an fs.watch 'change' listener, by its declared shape: the
* runtime's for no parameters and for a string eventType, and one this
* module emits for a dynamic eventType or an (eventType, filename) listener.
* Only the runtime's adapters need a declaration. */
function watchChangeAdapter(host: LlvmEmitterContext, cbT: IrType & { kind: "func" }): string {
const runtime =
cbT.params.length === 0
? "scr_watch_thunk0"
: cbT.params.length === 1 && cbT.params[0]!.kind !== "dyn"
? "scr_watch_thunk_event"
: null;
if (runtime === null) return host.watchNameThunkFor(cbT);
host.declare(`declare void @${runtime}(ptr, ptr, ptr)`);
return runtime;
}
export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
const alwaysThrowSym = FS_ALWAYS_THROW_SYMS[e.fn];
@@ -448,17 +464,19 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
// loop (usesTimers).
host.usesTimers = true;
const path = host.emitExpr(e.args[0]!);
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`);
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null)`);
B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr null, ptr null, i1 false)`);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
if (e.fn === "fs.watchCb") {
// The callback MOVES into the watcher's registry; the adapter is
// runtime-provided per listener shape (zero-param, or the eventType
// string). May throw (ENOENT) — the standard pending check.
// runtime-provided per listener shape (zero-param, the eventType
// string, or the compiler's (eventType, filename) adapter). May
// throw (ENOENT, or the macOS directory-names refusal) — the
// standard pending check.
host.usesTimers = true;
const cbT = e.args[1]!.type;
if (cbT.kind !== "func")
@@ -466,15 +484,64 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
const path = host.emitExpr(e.args[0]!);
const cb = host.emitExpr(e.args[1]!);
host.toHeap(cb);
const adapter = cbT.params.length === 0 ? "scr_watch_thunk0" : "scr_watch_thunk_event";
host.declare(`declare void @${adapter}(ptr, ptr)`);
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr)`);
const adapter = watchChangeAdapter(host, cbT);
host.declare(`declare ptr @scr_fs_watch(ptr, ptr, ptr, i1 zeroext)`);
const t = B.tmp();
B.line(`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter})`);
B.line(
`${t} = call ptr @scr_fs_watch(ptr ${path.name}, ptr ${cb.name}, ptr @${adapter}, i1 ${cbT.params.length === 2 ? "true" : "false"})`,
);
const out = host.own({ name: t, type: e.type });
host.emitPendingCheck();
return out;
}
if (e.fn === "watcher.on") {
// (recv, event, flags, listener) — flags: 1 once, 2 prepend. The
// listener MOVES in; the adapter follows the event (0 change, 1
// error, 2 close — CLOSE takes no adapter, a plain zero-arg call).
// Registration can throw (the macOS directory-names refusal).
const flagsArg = e.args[2]!;
if (flagsArg.kind !== "numLit")
throw new InternalCompilerError("llvm emitter bug: watcher.on flags not a literal");
const flags = flagsArg.value;
const codeArg = e.args[1]!;
if (codeArg.kind !== "numLit")
throw new InternalCompilerError("llvm emitter bug: watcher.on event not a literal");
const code = codeArg.value;
const cbT = e.args[3]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: watcher.on listener not a func");
const w = host.emitExpr(e.args[0]!);
const event = host.emitExpr(e.args[1]!);
const cb = host.emitExpr(e.args[3]!);
host.toHeap(cb);
let adapter = "null";
let wantsName = false;
if (code === 0) {
adapter = watchChangeAdapter(host, cbT);
wantsName = cbT.params.length === 2;
} else if (code === 1) {
adapter = cbT.params.length === 0 ? "scr_watch_err_thunk0" : host.watchErrorThunkFor(cbT);
if (cbT.params.length === 0) host.declare(`declare void @scr_watch_err_thunk0(ptr, ptr)`);
}
host.declare(
`declare void @scr_watcher_on(ptr, double, ptr, ptr, i1 zeroext, i1 zeroext, i1 zeroext)`,
);
B.line(
`call void @scr_watcher_on(ptr ${w.name}, double ${event.name}, ptr ${cb.name}, ptr ${adapter === "null" ? "null" : `@${adapter}`}, i1 ${flags & 1 ? "true" : "false"}, i1 ${flags & 2 ? "true" : "false"}, i1 ${wantsName ? "true" : "false"})`,
);
host.emitPendingCheck();
return { name: "", type: e.type };
}
if (e.fn === "watcher.off") {
// Removal is by pointer identity: the listener stays borrowed
// (never heap-moved), exactly like process.offExit's.
const w = host.emitExpr(e.args[0]!);
const event = host.emitExpr(e.args[1]!);
const cb = host.emitExpr(e.args[2]!);
host.declare(`declare void @scr_watcher_off(ptr, double, ptr)`);
B.line(`call void @scr_watcher_off(ptr ${w.name}, double ${event.name}, ptr ${cb.name})`);
return { name: "", type: e.type };
}
if (e.fn === "watcher.close") {
// Idempotent; never throws.
const w = host.emitExpr(e.args[0]!);
@@ -482,6 +549,45 @@ export function emitFilesystemLibCall(host: LlvmEmitterContext, e: LibCallExpr):
B.line(`call void @scr_watcher_close(ptr ${w.name})`);
return { name: "", type: e.type };
}
if (e.fn === "fs.watchFile") {
// (path, interval, persistent, listener) — the listener MOVES into
// the poller's registry and the adapter is runtime-provided per
// listener shape (zero, one or two Stats parameters). The poller
// re-arms a timer every interval, so this is a timer program. May
// throw (a null byte in the path, or an interval that is not a
// uint32) — the standard pending check.
host.usesTimers = true;
const cbT = e.args[3]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: fs.watchFile listener not a func");
const path = host.emitExpr(e.args[0]!);
const interval = host.emitExpr(e.args[1]!);
const persistent = host.emitExpr(e.args[2]!);
const cb = host.emitExpr(e.args[3]!);
host.toHeap(cb);
const adapter = `scr_watch_file_thunk${cbT.params.length}`;
host.declare(`declare void @${adapter}(ptr, ptr, ptr)`);
host.declare(`declare void @scr_fs_watch_file(ptr, double, i1 zeroext, ptr, ptr)`);
B.line(
`call void @scr_fs_watch_file(ptr ${path.name}, double ${interval.name}, i1 ${persistent.name}, ptr ${cb.name}, ptr @${adapter})`,
);
host.emitPendingCheck();
return { name: "", type: e.type };
}
if (e.fn === "fs.unwatchFile") {
// Removal is by closure identity: the listener stays borrowed
// (never heap-moved), exactly like watcher.off's; an absent one is
// the NULL that removes every listener on the path. A null byte in
// the path throws.
const path = host.emitExpr(e.args[0]!);
const cb = e.args[1] === undefined ? null : host.emitExpr(e.args[1]!);
host.declare(`declare void @scr_fs_unwatch_file(ptr, ptr)`);
B.line(
`call void @scr_fs_unwatch_file(ptr ${path.name}, ptr ${cb === null ? "null" : cb.name})`,
);
host.emitPendingCheck();
return { name: "", type: e.type };
}
if (e.fn === "fs.globTypesSync" || e.fn === "fsp.globTypes") {
// Dirent rows from one glob snapshot (scr_glob.c): mode 1 is the
// globSync form, 3 the fs/promises form with resolved parent paths.
@@ -1,7 +1,7 @@
/* Focused LLVM library-call emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { undefinedArmTag } from "../../ir/analysis.js";
import { F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js";
import { BOOL, F64, type IrType, RUNTIME_ERROR_CLASSES, STRING, typeKey } from "../../ir/ir.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
import { f64Lit } from "./common.js";
import { mockTimerSym } from "./lib-shared.js";
@@ -679,32 +679,46 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
host.emitPendingCheck();
return { name: "", type: e.type };
}
if (e.fn === "process.onExit") {
if (e.fn === "process.onExit" || e.fn === "process.prependExit") {
// Runtime adapters cover both shapes (the code is a plain double);
// the registry owns the callback.
const cbT = e.args[0]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: process.onExit callback not a func");
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} callback not a func`);
const cb = host.emitExpr(e.args[0]!);
const once = host.emitExpr(e.args[1]!);
host.toHeap(cb);
const adapter = cbT.params.length === 0 ? "scr_exit_thunk0" : "scr_exit_thunk_code";
host.declare(`declare void @${adapter}(ptr, double)`);
if (e.fn === "process.prependExit") {
host.declare(`declare void @scr_process_prepend_exit(ptr, ptr)`);
B.line(`call void @scr_process_prepend_exit(ptr ${cb.name}, ptr @${adapter})`);
return { name: "", type: e.type };
}
const once = host.emitExpr(e.args[1]!);
host.declare(`declare void @scr_process_on_exit(ptr, ptr, i1 zeroext)`);
B.line(`call void @scr_process_on_exit(ptr ${cb.name}, ptr @${adapter}, i1 ${once.name})`);
return { name: "", type: e.type };
}
if (e.fn === "process.envGet" || e.fn === "process.columns" || e.fn === "process.rows") {
// getenv(3) / ioctl(TIOCGWINSZ): the runtime answers a +1 string or
// NULL (a width or a negative sentinel); the union construction is
// type-directed HERE — present wraps the value arm, absent yields
if (
e.fn === "process.envGet" ||
e.fn === "process.columns" ||
e.fn === "process.rows" ||
e.fn === "process.stdinIsRaw"
) {
// getenv(3) / ioctl(TIOCGWINSZ) / termios: the runtime answers a +1
// string or NULL, or a number with a negative sentinel (the raw-mode
// flag rides the same double channel, 1/0/-1); the union construction
// is type-directed HERE — present wraps the value arm, absent yields
// the interned immortal undefined-arm instance.
if (e.type.kind !== "union")
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} result is not a union`);
const def = host.unionsById.get(e.type.unionId);
const undefTag = undefinedArmTag(e.type, host.unionsById);
const isEnv = e.fn === "process.envGet";
const valTag = def ? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : "f64")) : -1;
const isRaw = e.fn === "process.stdinIsRaw";
const valTag = def
? def.arms.findIndex((a) => a.kind === (isEnv ? "string" : isRaw ? "bool" : "f64"))
: -1;
if (valTag < 0 || undefTag < 0)
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} union lacks its arms`);
const args = e.args.map((a) => host.emitExpr(a));
@@ -719,6 +733,10 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
host.declare(`declare ptr @scr_env_get(ptr)`);
B.line(`${raw} = call ptr @scr_env_get(ptr ${args[0]!.name})`);
B.line(`${present} = icmp ne ptr ${raw}, null`);
} else if (isRaw) {
host.declare(`declare double @scr_process_stdin_is_raw()`);
B.line(`${raw} = call double @scr_process_stdin_is_raw()`);
B.line(`${present} = fcmp oge double ${raw}, ${f64Lit(0)}`);
} else {
const runtimeFn = e.fn === "process.rows" ? "scr_process_rows" : "scr_process_columns";
host.declare(`declare double @${runtimeFn}(double)`);
@@ -727,9 +745,18 @@ export function emitProcessLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
}
B.condBr(present, lp, la);
B.startBlock(lp);
B.line(
`store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`,
);
if (isRaw) {
// The raw flag: 1/0 becomes the bool arm's i1.
const flag = B.tmp();
B.line(`${flag} = fcmp one double ${raw}, ${f64Lit(0)}`);
B.line(
`store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: flag, type: BOOL })}, ptr ${slot}`,
);
} else {
B.line(
`store ptr ${host.unionNewOwned(e.type.unionId, valTag, { name: raw, type: isEnv ? STRING : F64 })}, ptr ${slot}`,
);
}
B.br(lj);
B.startBlock(la);
B.line(`store ptr ${host.unitInstanceRef(e.type.unionId, undefTag)}, ptr ${slot}`);
@@ -811,10 +838,19 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const kind = host.emitExpr(e.args[0]!);
const code = host.emitExpr(e.args[1]!);
const msg = host.emitExpr(e.args[2]!);
host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`,
);
if (e.args.length > 3) {
// The fourth argument: the error's own `url` property.
const url = host.emitExpr(e.args[3]!);
host.declare(`declare void @scr_throw_node_coded_url(double, ptr, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded_url(double ${kind.name}, ptr ${code.name}, ptr ${msg.name}, ptr ${url.name})`,
);
} else {
host.declare(`declare void @scr_throw_node_coded(double, ptr, ptr)`);
B.line(
`call void @scr_throw_node_coded(double ${kind.name}, ptr ${code.name}, ptr ${msg.name})`,
);
}
const ty = host.llType(e.type);
if (ty === "void") {
host.emitAlwaysPendingUnwind();
@@ -867,6 +903,10 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const cbT = e.args[2]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: emitter.on listener not a func");
// A registration past the emitter's maxListeners queues the leak
// warning for the next tick (scr_events_emitter.c), which the loop
// delivers before the process exits.
host.usesTimers = true;
const args = e.args.map((a) => host.emitExpr(a));
const { fn: adapterFn, shim } = host.emitterFixedAdapter(cbT);
// The wrapper's capture box owns its OWN +1 of the listener; the
@@ -897,6 +937,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
const adT = e.args[3]!.type;
if (adT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: emitter.onDyn adapter not a func");
host.usesTimers = true; // the leak warning's tick (emitter.on)
const args = e.args.map((a) => host.emitExpr(a));
const { fn: adapterFn, shim } = host.emitterFixedAdapter(adT);
host.toHeap(args[3]!); // the frame's +1 moves into the wrapper's box
@@ -946,6 +987,34 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitErrorValue") {
// (recv, ...payload as dyn): emit('error') with a non-Error payload.
// The unhandled case renders the payload with util.inspect
// (scr_insp_dyn), which this call links.
const args = e.args.map((a) => host.emitExpr(a));
const n = args.length - 1;
let payload = "null";
if (n > 0) {
const arr = B.slot();
B.entryAllocas.push(`${arr} = alloca [${n} x ptr]`);
for (let i = 0; i < n; i++) {
const slot = B.tmp();
B.line(`${slot} = getelementptr inbounds [${n} x ptr], ptr ${arr}, i64 0, i64 ${i}`);
B.line(`store ptr ${args[i + 1]!.name}, ptr ${slot}`);
}
payload = arr;
}
host.declare(`declare ptr @scr_insp_dyn(ptr, double, double)`);
host.declare(
`declare zeroext i1 @scr_emitter_emit_error_value(ptr, ptr, ${host.sizeType}, ptr)`,
);
const t = B.tmp();
B.line(
`${t} = call i1 @scr_emitter_emit_error_value(ptr ${args[0]!.name}, ptr ${payload}, ${host.sizeType} ${n}, ptr @scr_insp_dyn)`,
);
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") {
const args = e.args.map((a) => host.emitExpr(a));
const n = args.length - 2;
@@ -1046,6 +1115,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
// emitter.on/onDyn, but the DATA adapter (the three-slot payload ABI
// — scr_stream_emit_data) behind the arity-3 fixed shim.
const isDyn = e.fn === "emitter.onDataDyn";
host.usesTimers = true; // the leak warning's tick (emitter.on)
const cbT = e.args[isDyn ? 3 : 2]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError(`llvm emitter bug: ${e.fn} listener not a func`);
@@ -67,6 +67,11 @@ export const LIB_FN_SYMS: Record<string, string> = {
"http.reqH2StreamOrThrow": "scr_http_req_h2_stream_or_throw",
"intl.segmenterNew": "scr_intl_segmenter_new",
"intl.segmenterRefusing": "scr_intl_segmenter_refusing",
"icu.segmenterWord": "scr_intl_segmenter_word_new",
"icu.numFormatDefault": "scr_intl_num_format_default",
"icu.i18nSupport": "scr_intl_i18n_support",
"icu.dateLocaleString": "scr_intl_date_locale_string",
"icu.dateLocaleTimeString": "scr_intl_date_locale_time_string",
"weakMap.is": "scr_weak_map_is",
"weakSet.is": "scr_weak_set_is",
"weakMap.new": "scr_weak_map_new",
@@ -261,6 +266,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"os.userName": "scr_os_user_name",
"os.userShell": "scr_os_user_shell",
"os.userHomedir": "scr_os_user_homedir",
"os.userUid": "scr_os_user_uid",
"os.userGid": "scr_os_user_gid",
"os.tmpdir": "scr_os_tmpdir",
"process.argv": "scr_process_argv",
"process.execArgv": "scr_process_exec_argv",
@@ -304,9 +311,19 @@ export const LIB_FN_SYMS: Record<string, string> = {
"navigator.languages": "scr_navigator_languages",
"navigator.platform": "scr_navigator_platform",
"process.builtinModule": "scr_process_builtin_module",
"process.builtinObject": "scr_process_builtin_object",
"process.builtinUnsupported": "scr_process_builtin_unsupported",
"module.namespaceValue": "scr_module_namespace_value",
"module.namespaceDyn": "scr_module_namespace_dyn_value",
"wrapper.new": "scr_js_wrapper_new",
"wrapper.toObject": "scr_js_wrapper_to_object",
"wrapper.instanceOf": "scr_js_wrapper_instance_of",
"wrapper.objectTag": "scr_js_wrapper_object_tag",
"wrapper.typeIs": "scr_js_wrapper_type_is",
"wrapper.guard": "scr_js_wrapper_guard",
"wrapper.guardList": "scr_js_wrapper_guard_list",
"wrapper.isStringMethod": "scr_js_wrapper_is_string_method",
"wrapper.thisString": "scr_js_wrapper_this_string",
"fs.callbackValue": "scr_fs_callback_value",
"fs.callbackCall": "scr_fs_callback_call",
"process.hrtimeValue": "scr_process_hrtime_value",
@@ -379,9 +396,14 @@ export const LIB_FN_SYMS: Record<string, string> = {
"process.envPairs": "scr_env_pairs",
"process.stdoutWrite": "scr_process_stdout_write",
"process.stdio": "scr_process_stdio",
"process.stdioSlot": "scr_process_stdio",
"process.stdioWriteDyn": "scr_process_stdio_write_dyn",
"process.stderrWrite": "scr_process_stderr_write",
"process.isTTY": "scr_process_is_tty",
"process.isTTYValue": "scr_process_is_tty_value",
// A promise that never settles (events.once of an event a synchronous
// stream never emits); a fresh pending promise is its owned result.
"events.pendingForever": "scr_promise_new",
"date.now": "scr_date_now",
"date.newNow": "scr_date_now",
"date.newMs": "scr_date_new_ms",
@@ -441,10 +463,17 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fsp.globError": "scr_fsp_glob_error",
"fs.realpathSync": "scr_fs_realpath",
"fs.realpathValue": "scr_fs_realpath_value",
"dyn.validateString": "scr_value_string",
"dyn.validateStrings": "scr_value_strings",
"path.resolveValue": "scr_path_resolve_value",
"path.parseValue": "scr_path_parse_value",
"path.formatValue": "scr_path_format_value",
"path.toNamespacedValue": "scr_path_to_namespaced_value",
"fs.realpathNativeSync": "scr_fs_realpath_promise",
"fs.unlinkSync": "scr_fs_unlink",
"fs.chmodSync": "scr_fs_chmod",
"fs.copyFileSync": "scr_fs_copyfile",
"fs.copyFileFlagsSync": "scr_fs_copyfile_flags",
"fs.renameSync": "scr_fs_rename",
"fs.accessSync": "scr_fs_access",
// node:assert (scr_assert.c): all args borrowed; failures throw the
@@ -593,6 +622,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
// string_decoder (scr_bytes.c): stateless helpers over (enc, pending,
// chunk) — never throw.
"strdec.write": "scr_strdec_write",
"strdec.writeDyn": "scr_strdec_write_dyn",
"strdec.nextDyn": "scr_strdec_next_dyn",
"strdec.next": "scr_strdec_next",
"strdec.end": "scr_strdec_end",
"strdec.encoding": "scr_strdec_encoding",
@@ -868,6 +899,11 @@ export const LIB_FN_SYMS: Record<string, string> = {
// identity); registration is special-cased above (ownership moves).
"process.offSignal": "scr_signal_off",
"process.offExit": "scr_process_off_exit",
// Front-of-list registrations (process.prependListener): the signal and
// uncaught-exception registries. prependExit is special-cased below with
// the exit adapters, like onExit.
"process.prependSignal": "scr_signal_prepend",
"process.prependUncaughtException": "scr_process_prepend_uncaught_exception",
// child_process (scr_child.c): spawnSync/exec forms are synchronous
// (execSync/execCapture throw — the may-throw check); handle getters
// are pure reads. Registration forms are special-cased (moves + per-
@@ -908,6 +944,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fsp.readFileBytes": "scr_fsp_read_file_bytes",
"fsp.writeFile": "scr_fsp_write_file",
"fsp.writeFileMode": "scr_fsp_write_file_mode",
"fsp.writeFileFlags": "scr_fsp_write_file_flags",
"fsp.copyFileFlags": "scr_fsp_copyfile_flags",
"fsp.mkdir": "scr_fsp_mkdir",
"fsp.mkdirMode": "scr_fsp_mkdir_mode",
"fsp.mkdirRecursive": "scr_fsp_mkdir_recursive",
@@ -1113,6 +1151,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"dyn.keyDelete": "scr_dyn_key_delete",
"dyn.keyDeleteComputed": "scr_dyn_key_delete_computed",
"dyn.reflectDeleteProperty": "scr_dyn_reflect_delete_property",
"dyn.reflectHasTarget": "scr_dyn_reflect_has_target",
"dyn.hasKeyComputed": "scr_dyn_has_key_computed",
"dyn.hasOwnComputed": "scr_dyn_has_own_computed",
"dyn.propertyIsEnumerableComputed": "scr_dyn_property_is_enumerable_computed",
@@ -1262,6 +1301,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"crypto.x509Verify": "scr_crypto_x509_verify",
"crypto.x509CheckPrivateKey": "scr_crypto_x509_check_private_key",
"fs.writeFileModeSync": "scr_fs_write_file_mode",
"fs.writeFileFlagsSync": "scr_fs_write_file_flags",
"fs.chownSync": "scr_fs_chown",
"fs.lchownSync": "scr_fs_lchown",
"fs.fchownSync": "scr_fs_fchown",
@@ -1450,6 +1490,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"net.serverListening": "scr_net_server_listening",
"net.sockSetRef": "scr_net_sock_set_ref",
"net.serverSetRef": "scr_net_server_set_ref",
"net.serverOff": "scr_net_server_off",
"net.sockWrite": "scr_net_sock_write_str",
"net.sockWriteBytes": "scr_net_sock_write_bytes",
"net.sockEnd": "scr_net_sock_end",
@@ -1896,6 +1937,11 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"worker.receiveMessageOnPort",
"worker.broadcastChannel",
"process.stdio",
// The typed write slot and the run-time-typed chunk write keep the loop
// live for the write callbacks they queue: both reach the same stream
// object as process.stdio and call back from a nextTick.
"process.stdioSlot",
"process.stdioWriteDyn",
"fetch.start",
"fetch.input",
"fetch.function",
@@ -1981,6 +2027,7 @@ export const USES_TIMERS_LIB_FNS = new Set<string>([
"writable.writeU",
"writable.writeDyn",
"writable.writeEnc",
"writable.writeEcb",
"writable.end",
"writable.uncork",
"stream.destroy",
@@ -3,6 +3,8 @@ import { InternalCompilerError } from "../../errors.js";
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
import { f64Lit } from "./common.js";
import { pipelinePumpedRuns } from "../../ir/builtin-signatures.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { streamTypedDesc } from "./expr-stream-bridges.js";
const STREAM_CANONICAL_CBS: Record<string, ("r" | "w" | "f" | "d" | "t" | "l")[]> = {
readable: ["r", "d"],
@@ -14,6 +16,10 @@ const STREAM_CANONICAL_CBS: Record<string, ("r" | "w" | "f" | "d" | "t" | "l")[]
export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
const B = host.B;
if (e.fn === "readable.pushDyn" || e.fn === "writable.writeDyn") {
const typed = emitTypedChunkCall(host, e);
if (typed !== null) return typed;
}
if (e.fn === "stream.addAbortSignal" || e.fn === "stream.addAbortSignalFn") {
// stream.addAbortSignal: [stream, signal] — the constructors' signal
// option chained over the fresh stream (a falsy signal is ignored);
@@ -455,6 +461,44 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
host.emitPendingCheck();
return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type };
}
if (e.fn === "writable.writeEcb") {
// (recv, mode, chunk, encoding, cb): mode 0 bytes, 1 string, 2 dyn. The
// callback MOVES into an error-first wrapper (scr_stream_ecb_wrap); the
// adapter turns the runtime's ScrError (NULL = success) into the
// callback's Error | null argument, as fs.rename callbacks do.
const modeArg = e.args[1]!;
if (modeArg.kind !== "numLit")
throw new InternalCompilerError("llvm emitter bug: writable.writeEcb mode not a literal");
const cbT = e.args[4]!.type;
if (cbT.kind !== "func")
throw new InternalCompilerError("llvm emitter bug: writable.writeEcb callback not a func");
const args = e.args.map((a) => host.emitExpr(a));
host.toHeap(args[4]!);
const adapter = host.fsRenameThunkFor(cbT);
host.declare(`declare ptr @scr_stream_ecb_wrap(ptr, ptr)`);
const wrapped = B.tmp();
B.line(`${wrapped} = call ptr @scr_stream_ecb_wrap(ptr ${args[4]!.name}, ptr @${adapter})`);
const entry =
modeArg.value === 0
? "scr_stream_write"
: modeArg.value === 1
? "scr_stream_write_str"
: null;
const t = B.tmp();
if (entry !== null) {
host.declare(`declare zeroext i1 @${entry}(ptr, ptr, ptr)`);
B.line(
`${t} = call zeroext i1 @${entry}(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${wrapped})`,
);
} else {
host.declare(`declare zeroext i1 @scr_stream_write_enc(ptr, ptr, ptr, ptr)`);
B.line(
`${t} = call zeroext i1 @scr_stream_write_enc(ptr ${args[0]!.name}, ptr ${args[2]!.name}, ptr ${args[3]!.name}, ptr ${wrapped})`,
);
}
host.emitPendingCheck();
return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type };
}
if (e.fn === "writable.end") {
// (recv, flags[, chunk][, encoding][, cb]) — flags: 1 bytes chunk, 2
// string chunk, 8 dyn chunk (write first, Node's end(chunk)
@@ -834,6 +878,34 @@ export function emitStreamLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlV
return host.emitGenericLibCall(e);
}
/** push(value) or write(value) of a record that converts to dyn by
* reference: the stream receives the record as a typed chunk
* (scr_stream_push_typed / scr_stream_write_typed), boxed only for a
* consumer that reads it as a dyn value. Null for another chunk. */
function emitTypedChunkCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue | null {
const chunk = e.args[1];
if (chunk === undefined || chunk.kind !== "dynFrom" || !chunk.liveRef) return null;
const desc = streamTypedDesc(host, chunk.value.type);
if (desc === null) return null;
const B = host.B;
const recv = host.emitExpr(e.args[0]!);
const rec = emitBorrowedInput(host, chunk.value);
const t = B.tmp();
if (e.fn === "readable.pushDyn") {
host.declare(`declare zeroext i1 @scr_stream_push_typed(ptr, ptr, ptr)`);
B.line(
`${t} = call zeroext i1 @scr_stream_push_typed(ptr ${recv.name}, ptr ${rec.name}, ptr ${desc})`,
);
} else {
host.declare(`declare zeroext i1 @scr_stream_write_typed(ptr, ptr, ptr, ptr)`);
B.line(
`${t} = call zeroext i1 @scr_stream_write_typed(ptr ${recv.name}, ptr ${rec.name}, ptr ${desc}, ptr null)`,
);
}
host.emitPendingCheck();
return e.type.kind === "void" ? { name: "", type: e.type } : { name: t, type: e.type };
}
/** The encoding argument a write passes when the call has none: undefined
* (a null pointer), or the null value when a callback took its place. */
function writeEncNull(host: LlvmEmitterContext, which: "null" | "undef"): string {
@@ -3,11 +3,19 @@
* local arrays they rely on. */
import { findUnaliasedLocalArrays } from "../../ir/array-length-stability.js";
import type { IrFunction, IrStmt } from "../../ir/ir.js";
import { everyStmtList } from "../../ir/traverse.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
import type { DenseArrayLoop } from "./dense-array-loops.js";
import type { LlEmitter } from "./emitter.js";
import { defineModuleState } from "./emitter-state.js";
/** The most IR nodes a function may have for its in-loop number-key
* lookups to inline the hashed probe (emitNumberMapEntry). Each inlined
* probe adds code to its function, and LLVM's passes over a large function
* take longer for it; a small function's loop of lookups, such as a k-mer
* count, gains the most from it. */
const MAX_INLINED_LOOKUP_NODES = 200;
/** Sets the loop state for `fn` at the start of its emission; `numericFn`
* is `fn` with its initializer bindings (emitFunction). */
export function beginFunctionLoops(
@@ -19,10 +27,11 @@ export function beginFunctionLoops(
loops.unaliasedArrays = findUnaliasedLocalArrays(fn);
loops.countedLoopsEnabled = emitter.debug === null && !fn.async && !fn.generator;
loops.byteWindowsEnabled = loops.countedLoopsEnabled;
loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled;
loops.plainLoops = fn.plainLoops === true;
loops.bytesLoopVersionsEnabled = loops.countedLoopsEnabled && !loops.plainLoops;
loops.bytesLoopEntries = null;
loops.bytesLoopFunction = fn;
loops.denseLoopsEnabled = loops.countedLoopsEnabled;
loops.denseLoopsEnabled = loops.countedLoopsEnabled && !loops.plainLoops;
loops.denseLoop = null;
loops.denseLoopEntries =
loops.denseLoopsEnabled && numericFn.locals.some((l) => l.type.kind === "array")
@@ -32,6 +41,15 @@ export function beginFunctionLoops(
loops.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
? findInitializedByteLoopBindings(fn)
: new Map();
loops.inlinedLookups = fewNodes(fn.body, MAX_INLINED_LOOKUP_NODES);
}
/** Whether `body` has at most `max` statements and expressions. */
function fewNodes(body: IrStmt[], max: number): boolean {
let count = 0;
const visit = (): boolean => ++count <= max;
everyStmtList(body, { expr: visit, stmt: visit });
return count <= max;
}
/** The state LOOPS holds on each emitter. */
@@ -48,8 +66,11 @@ export interface LoopState {
byteWindowsEnabled: boolean;
byteWindowEntries: ReturnType<typeof findInitializedByteLoopBindings>;
/** Typed-array loop versioning (bytes-loop-versions.ts) for the current
* function: off in debug and suspending functions and inside a copy. */
* function: off in debug and suspending functions, in a function that
* emits each loop once and inside a copy. */
bytesLoopVersionsEnabled: boolean;
/** The current function emits each loop once (IrFunction.plainLoops). */
plainLoops: boolean;
/** Loops being versioned around the statement being emitted: a loop is
* never versioned inside its own copies, and versions nest boundedly. */
readonly bytesLoopVersioning: Set<IrStmt>;
@@ -58,6 +79,9 @@ export interface LoopState {
/** Counted loops whose integer-form safety test an enclosing version
* guard already passed. */
readonly provenCountedGuards: Set<IrStmt>;
/** Number-key lookups in this function's loops inline the hashed probe:
* the function has at most MAX_INLINED_LOOKUP_NODES nodes. */
inlinedLookups: boolean;
}
export const LOOPS = defineModuleState<LoopState>(() => ({
@@ -69,8 +93,10 @@ export const LOOPS = defineModuleState<LoopState>(() => ({
byteWindowsEnabled: true,
byteWindowEntries: new Map(),
bytesLoopVersionsEnabled: false,
plainLoops: false,
bytesLoopVersioning: new Set<IrStmt>(),
bytesLoopEntries: null,
bytesLoopFunction: null,
provenCountedGuards: new Set<IrStmt>(),
inlinedLookups: false,
}));
@@ -117,8 +117,9 @@ test("string keys and reference values are retained inline, boolean slots compar
expect(text).toContain("call ptr @scr_map_iter_val_ref(");
});
/** work(map, k): map.has(k) on a number-keyed map. */
function lookupFixture(key: IrExpr = ref("k", F64)): IrModule {
/** work(map, k): map.has(k) on a number-keyed map, or with `loop`, the
* condition of `while (map.has(k)) {}`, after `filler` statements `k;`. */
function lookupFixture(key: IrExpr = ref("k", F64), loop = false, filler = 0): IrModule {
const map: IrType = { kind: "map", key: F64, value: F64 };
const params = [
{ localId: "map", name: "map", type: map },
@@ -138,7 +139,17 @@ function lookupFixture(key: IrExpr = ref("k", F64)): IrModule {
returnType: BOOL,
loc,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: true })),
body: [{ kind: "return", value: has, loc }],
body: loop
? [
...Array.from({ length: filler }, (): IrStmt => ({
kind: "exprStmt",
expr: ref("k", F64),
loc,
})),
{ kind: "while", cond: has, body: [], loc },
{ kind: "return", value: { kind: "boolLit", value: false, type: BOOL, loc }, loc },
]
: [{ kind: "return", value: has, loc }],
};
return {
irVersion: 17,
@@ -159,6 +170,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide
module,
)![0];
expect(helper).toContain("call ptr @sc_map_entry_f64_hashed(ptr %m, double %k)");
// A key outside the direct index goes to the probe, whose small-map
// branch tells a map with the index (field 16) from one without it.
expect(helper).toMatch(/br i1 %ix\d+, label %slot, label %slow\n/);
expect(helper).not.toContain("dense.any");
const hashed =
/^define internal ptr @sc_map_entry_f64_hashed\(ptr %m, double %k\) noinline [^]*?^}/m.exec(
module,
@@ -171,7 +186,10 @@ test("hashed number-key lookups probe inline and call the runtime only for wide
// Filter (field 10) before the small-map scan of the entry count (field
// 8) only, and compact buckets (fields 13 and 14) tagged with the hash's
// top byte.
expect(hashed).toContain("br i1 %linear, label %filter, label %buckets");
expect(hashed).toContain("br i1 %linear, label %unbucketed, label %buckets");
expect(hashed).toMatch(
/^unbucketed:\n[^]*i32 0, i32 16\n[^]*br i1 %indexed, label %miss, label %filter\n/m,
);
expect(hashed).toContain("getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 10");
expect(hashed).toContain(`load i32, ptr %nep`);
expect(hashed).toContain(`load i32, ptr %nbp`);
@@ -194,22 +212,27 @@ test("a key with an integer view tests the direct index on the integer", () => {
integral.module,
)![0];
// An unsigned compare also rejects negative keys; the hashed probe takes
// the integer.
// the integer and its double.
expect(helper).toContain("icmp ult i64 %ki, %nd");
expect(helper).toContain(`getelementptr inbounds i32, ptr %dense, ${sz} %i`);
expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki)");
expect(helper).toContain("call ptr @sc_map_entry_f64_hashed_int(ptr %m, i64 %ki, double %k)");
expect(helper).toContain("br i1 %inside, label %slot, label %slow");
expect(helper).not.toContain("fptosi");
// Its key word is the integer's double, which needs no NaN or -0
// normalization; the runtime lookup gets that double.
// Its key word is the bits of the integer's double, which needs no NaN
// normalization and is +0 for the integer 0; the runtime lookup gets
// that double. The probe converts nothing itself (cvtsi2sd would wait
// for its register's last value).
const hashed =
/^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki\) noinline [^]*?^}/m.exec(
/^define internal ptr @sc_map_entry_f64_hashed_int\(ptr %m, i64 %ki, double %k\) noinline [^]*?^}/m.exec(
integral.module,
)![0];
expect(hashed).toContain("sitofp i64 %ki to double");
expect(hashed).toContain("bitcast double %k to i64");
expect(hashed).toContain("select i1 %kzero, i64 0, i64 %kbits");
expect(hashed).not.toContain("sitofp");
expect(hashed).not.toContain("fcmp");
expect(hashed).toContain("mul i64 %x1, -4658895280553007687");
expect(hashed).toContain(
"call ptr @scr_map_entry_f64(ptr %m, double %kf) memory(read) nounwind willreturn",
"call ptr @scr_map_entry_f64(ptr %m, double %k) memory(read) nounwind willreturn",
);
// A fractional key and a key without a proof keep the double lookup.
for (const key of [{ kind: "numLit", value: 0.5, type: F64, loc } as IrExpr, ref("k", F64)]) {
@@ -220,6 +243,36 @@ test("a key with an integer view tests the direct index on the integer", () => {
}
});
test("a lookup inside a loop calls the flavor that inlines the hashed probe", () => {
for (const key of [ref("k", F64), { kind: "numLit", value: 12, type: F64, loc } as IrExpr]) {
const { work, module } = emitted(lookupFixture(key, true), 64);
const name = key.kind === "numLit" ? "sc_map_entry_f64_int" : "sc_map_entry_f64";
expect(work).toContain(`call ptr @${name}.loop(`);
expect(work).not.toContain(`call ptr @${name}(`);
// They only read memory and always return, so a has() and get() of one
// key merge before they are inlined.
const helper = new RegExp(
`^define internal ptr @${name}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn [^]*?^}`,
"m",
).exec(module)![0];
const hashed =
key.kind === "numLit" ? "sc_map_entry_f64_hashed_int" : "sc_map_entry_f64_hashed";
expect(helper).toContain(`call ptr @${hashed}.loop(`);
expect(module).toMatch(
new RegExp(
`^define internal ptr @${hashed}\\.loop\\([^)]*\\) alwaysinline memory\\(read\\) nounwind willreturn `,
"m",
),
);
// A lookup outside a loop keeps the out-of-line probe, and so does a
// lookup in a loop of a function of more than 200 nodes.
expect(emitted(lookupFixture(key), 64).work).toContain(`call ptr @${name}(`);
const large = emitted(lookupFixture(key, true, 100), 64).work;
expect(large).toContain(`call ptr @${name}(`);
expect(large).not.toContain(".loop(");
}
});
test("a key view converted in a branch is not reused by a later lookup of the same constant", () => {
// work(map, k, taken): if (taken) return map.has((k = 2 ** 40)); return map.has(2 ** 40).
const wide = 2 ** 40;
@@ -32,6 +32,7 @@
import type { IrExpr, IrType } from "../../ir/ir.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { LOOPS } from "./loop-state.js";
import { mapKeyAccess } from "./shapes.js";
export const NUMBER_MAP_ENTRY_DECL = "declare ptr @scr_map_entry_f64(ptr, double)";
@@ -55,19 +56,22 @@ export function noteNumberMapKey(host: LlvmEmitterContext, key: LlValue, expr: I
/** The entry for `key`, or null when absent. Valid until the next mutation.
* A key with an integer view (noteNumberMapKey) tests the direct index on
* that integer instead of converting the double back. */
* that integer instead of converting the double back. A lookup inside a
* loop of a small function (LOOPS.inlinedLookups) calls the helpers' `.loop`
* flavor, which inlines the hashed probe. */
export function emitNumberMapEntry(host: LlvmEmitterContext, map: string, key: string): string {
host.declare(NUMBER_MAP_ENTRY_DECL);
const entry = host.B.tmp();
const flavor = host.loopDepth > 0 && LOOPS.of(host).inlinedLookups ? ".loop" : "";
const integer = host.numberMapKeyIntegers.get(key);
if (integer) {
const wide = widenInteger(host, integer);
host.B.line(
`${entry} = call ptr @sc_map_entry_f64_int(ptr ${map}, i64 ${wide}, double ${key})`,
`${entry} = call ptr @sc_map_entry_f64_int${flavor}(ptr ${map}, i64 ${wide}, double ${key})`,
);
return entry;
}
host.B.line(`${entry} = call ptr @sc_map_entry_f64(ptr ${map}, double ${key})`);
host.B.line(`${entry} = call ptr @sc_map_entry_f64${flavor}(ptr ${map}, double ${key})`);
return entry;
}
@@ -0,0 +1,68 @@
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 "../../index.js";
import { emitLlvmModule } from "./emitter.js";
async function llvmFor(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-receiver-brackets-"));
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 });
}
}
function body(ir: string, name: string): string {
const match = new RegExp(`^define [^\\n]*@${name}\\(.*?^}`, "ms").exec(ir);
if (!match) throw new Error(`no function ${name}`);
return match[0];
}
test("a known signature adapter's direct entry brackets the receiver only when its original reads it", async () => {
const ir = await llvmFor(`
class Node {
kids: Node[] = [];
constructor(readonly kind: number) {}
}
function walk(root: Node, cb: (node: Node, depth: number) => void | "skip" | "stop"): void {
const stack: Node[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
const verdict = cb(node, stack.length);
if (verdict === "stop") return;
if (verdict === "skip") continue;
for (const kid of node.kids) stack.push(kid);
}
}
class Counter {
count = 0;
run(root: Node): number {
walk(root, (node) => {
if (node.kind === 3) this.count++;
});
return this.count;
}
}
const root = new Node(0);
root.kids.push(new Node(3));
console.log(new Counter().run(root));
`);
const walk = body(ir, "sc_bf_walk");
const blocks = walk.split(/^(?=[\w.]+:$)/m);
const direct = blocks.find((block) => block.startsWith("callback.direct"));
// The adapter forwards the receiver to the arrow function, which never
// reads it: the direct entry tests the closure's RECEIVER_FREE bit, and
// every receiver push is behind such a test.
expect(direct).toMatch(/ = and i32 %\S+, 16\n/);
const pushes = blocks.filter((block) => block.includes("@scr_dyn_this_push_dyn"));
expect(pushes.length).toBeGreaterThan(0);
for (const block of pushes) expect(block).toMatch(/^receiver\.push\d+:/);
});
@@ -3,8 +3,11 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../../index.js";
import type { IrModule } from "../../ir/ir.js";
import { DYN } from "../../ir/ir.js";
import type { IrExpr, IrModule } from "../../ir/ir.js";
import { computeTraced } from "../cycle-analysis.js";
import { everyStmtList } from "../../ir/traverse.js";
import { emitLlvmModule } from "./emitter.js";
import { planRecordViews } from "./record-views.js";
/** The views planned for `source`'s module, as target field names → source
@@ -128,3 +131,98 @@ ${conversions}
Object.freeze(wide);`);
expect(views).toEqual(new Map());
});
/** `source`'s module shaped the way the frontend lowers a partial
* assertion (C2): the checked cast of the source record literal carries
* `partialView` over a `dynFrom{liveRef}` of that literal. */
async function partialModule(
source: string,
sourceFields: string,
targetFields: string,
): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-record-views-"));
try {
const entry = join(dir, "main.ts"),
outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n"));
const mod: IrModule = deserializeModule(await readFile(outPath, "utf8"));
const idOf = (fields: string): string =>
(mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
const exprs: IrExpr[] = [];
for (const fn of mod.functions)
everyStmtList(fn.body, {
stmt: () => true,
expr: (e) => {
exprs.push(e);
return true;
},
});
const lit = exprs.find(
(e) =>
e.kind === "recordLit" && e.type.kind === "record" && e.type.shapeId === idOf(sourceFields),
);
const check = exprs.find(
(e) =>
e.kind === "dynCheck" && e.type.kind === "record" && e.type.shapeId === idOf(targetFields),
);
if (!lit || !check || check.kind !== "dynCheck") throw new Error("shapes not found");
check.partialView = true;
check.value = { kind: "dynFrom", liveRef: true, value: lit, type: DYN, loc: check.loc };
return mod;
} finally {
await rm(dir, { recursive: true, force: true });
}
}
test("a partial assertion plans a partial view with an absent-field sentinel", async () => {
const mod = await partialModule(
`
type Wide = { a: number; b: string };
type Narrow = { a: number; z: boolean };
const wide: Wide = { a: 1, b: "x" };
const o: unknown = wide;
const v = o as Narrow;
console.log(v.a);
`,
"a,b",
"a,z",
);
const idOf = (fields: string): string =>
(mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
const target = idOf("a,z");
const source = idOf("a,b");
const views = planRecordViews(mod, { traced: () => false, excluded: () => false });
expect(views.targets.has(target)).toBe(true);
expect(views.sources(target)).toEqual([source]);
expect(views.partial(target, source)).toBe(true);
expect(views.partialTargets.has(target)).toBe(true);
expect(views.partialMissing(target, "z")).toBe(true);
expect(views.partialMissing(target, "a")).toBe(false);
// The offset table marks the target field the source does not declare
// with the all-ones sentinel.
const llvm = emitLlvmModule(mod);
expect(llvm).toMatch(/@sc_rv_\d+_0 = internal constant \[2 x i64\] \[i64 ptrtoint .*, i64 -1\]/);
});
test("a full source keeps an ordinary view, not a partial one", async () => {
const mod = await partialModule(
`
type Wide = { a: number; b: string };
type Narrow = { a: number };
const wide: Wide = { a: 1, b: "x" };
const o: unknown = wide;
const v = o as Narrow;
console.log(v.a);
`,
"a,b",
"a",
);
const idOf = (fields: string): string =>
(mod.records ?? []).find((shape) => shape.fields.map((f) => f.name).join(",") === fields)!.id;
const views = planRecordViews(mod, { traced: () => false, excluded: () => false });
expect(views.sources(idOf("a"))).toEqual([idOf("a,b")]);
expect(views.partial(idOf("a"), idOf("a,b"))).toBe(false);
expect(views.partialTargets.size).toBe(0);
});
@@ -15,7 +15,15 @@
* checked conversion can produce (the closure of dynCheck targets), and
* some other record type whose values reach untyped code as live capsules
* (the closure of live dynFrom types) has all of B's fields. A program
* without checked conversions has none, so its code is unchanged. */
* without checked conversions has none, so its code is unchanged.
*
* A PARTIAL view (`dynCheck.partialView`) pairs a record literal's shape A
* with the wider record B it was asserted to, though A lacks some of B's
* fields. The view is a B value whose record-source entry names A, with
* the sentinel SIZE_MAX for every B field A does not declare: a slot lookup
* answers NULL there (scr_record_view_slot), so a presence test answers false
* and an address lookup stops the program. The frontend's checked reads of
* those fields throw their TypeError first. */
import {
isDirentShape,
KEY_ORDER_FIELD,
@@ -34,9 +42,28 @@ export interface RecordViews {
readonly targets: ReadonlySet<string>;
/** The record shapes whose capsules convert to a view of `target`. */
sources(target: string): readonly string[];
/** Whether `target` may be a PARTIAL view of `source`: some of its
* fields are absent, because `source` does not declare them (a partial
* record literal asserted to a wider type). */
partial(target: string, source: string): boolean;
/** Shape ids a partial record assertion may produce a view of. */
readonly partialTargets: ReadonlySet<string>;
/** Whether some partial source of view target `target` lacks `field`. */
partialMissing(target: string, field: string): boolean;
}
export const NO_RECORD_VIEWS: RecordViews = { targets: new Set(), sources: () => [] };
/** The trap of an internal reader of a field a partial view's viewed record
* lacks (a width copy, say): a source-level read of the field threw first. */
export const PARTIAL_VIEW_ABSENT =
"scriptc: a value asserted as a wider record has no property at this field (copy it explicitly)\n";
export const NO_RECORD_VIEWS: RecordViews = {
targets: new Set(),
sources: () => [],
partial: () => false,
partialTargets: new Set(),
partialMissing: () => false,
};
interface ViewOptions {
readonly traced: (shapeId: string) => boolean;
@@ -156,16 +183,41 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
};
const checked: IrType[] = [];
const boxed: IrType[] = [];
// (target B, source A) of a partial record assertion's checked cast.
const partialPairs: { target: string; source: string }[] = [];
for (const fn of mod.functions)
everyStmtList(fn.body, {
stmt: () => true,
expr: (e) => {
if (e.kind === "dynCheck") checked.push(e.type);
else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type);
if (e.kind === "dynCheck") {
checked.push(e.type);
if (
e.partialView === true &&
e.type.kind === "record" &&
e.value.kind === "dynFrom" &&
e.value.value.type.kind === "record"
)
partialPairs.push({
target: e.type.shapeId,
source: e.value.value.type.shapeId,
});
} else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type);
else if (e.kind === "libCall" && /^(transform|passthrough)\.(new|init)$/.test(e.fn)) {
// A transform or flush callback's typed completion data
// (pinTypedDataCompletion) reaches untyped consumers as a live
// capsule: scr_stream_push_typed boxes it for them.
for (const arg of e.args) {
if (arg.type.kind !== "func") continue;
for (const param of arg.type.params)
if (param.kind === "func" && param.params[1] !== undefined)
boxed.push(param.params[1]);
}
}
return true;
},
});
if (checked.length === 0 || boxed.length === 0) return NO_RECORD_VIEWS;
if ((checked.length === 0 || boxed.length === 0) && partialPairs.length === 0)
return NO_RECORD_VIEWS;
const reachable = closure(checked);
const live = closure(boxed);
if (reachable.size === 0) return NO_RECORD_VIEWS;
@@ -183,6 +235,24 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
}
if (found.length > 0) sources.set(id, found);
}
// Partial pairs join the plan before the cycle analysis, which owns their
// view's capsule reference like any other. A pair whose source has every
// target field is an ordinary full view and is already planned above.
const partials = new Map<string, Set<string>>();
for (const pair of partialPairs) {
const target = shapes.get(pair.target);
const source = shapes.get(pair.source);
if (!target || !source || !plainShape(target) || !plainShape(source)) continue;
if (target.freezable === true || source.freezable === true) continue;
if (options.traced(pair.target) || options.excluded(pair.target)) continue;
if (covers(source, target)) continue;
const list = sources.get(pair.target) ?? [];
if (!list.includes(pair.source)) list.push(pair.source);
sources.set(pair.target, list);
const set = partials.get(pair.target);
if (set) set.add(pair.source);
else partials.set(pair.target, new Set([pair.source]));
}
// A view's record-source entry owns the source's capsule, an edge the
// cycle analysis does not see. A view stays off every cycle only when its
// source cannot hold a value of the target type, such as the view itself,
@@ -200,11 +270,34 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView
changed = true;
if (kept.length > 0) sources.set(id, kept);
else sources.delete(id);
const partial = partials.get(id);
if (!partial) continue;
for (const source of [...partial]) if (!kept.includes(source)) partial.delete(source);
if (partial.size === 0) partials.delete(id);
}
}
if (sources.size === 0) return NO_RECORD_VIEWS;
const targets = new Set(sources.keys());
return { targets, sources: (target) => sources.get(target) ?? [] };
// The target fields some partial source lacks: their presence test is a
// runtime view probe, and their slot lookup traps.
const missing = new Map<string, Set<string>>();
for (const [id, list] of partials) {
const absent = new Set<string>();
const target = shapes.get(id);
for (const source of list) {
const declared = new Set((shapes.get(source)?.fields ?? []).map((field) => field.name));
for (const field of target?.fields ?? [])
if (!declared.has(field.name)) absent.add(field.name);
}
missing.set(id, absent);
}
return {
targets,
sources: (target) => sources.get(target) ?? [],
partial: (target, source) => partials.get(target)?.has(source) === true,
partialTargets: new Set(partials.keys()),
partialMissing: (target, field) => missing.get(target)?.has(field) === true,
};
}
/** Lines setting `dest` (i1) to whether the record `obj` of a view target
@@ -229,7 +322,12 @@ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest:
* `obj` of view-target shape `struct`: the slot itself, or the viewed
* record's slot when `obj` is a view. Optimized builds test the mark
* inline and look the slot up out of line; unoptimized builds, which give
* every value a frame slot, make the address one call. */
* every value a frame slot, make the address one call.
*
* `partial` names a shape a PARTIAL view may present: its viewed record can
* lack the field, and the slot lookup answers NULL there (scr_record_view_slot),
* so the address stops with `scr_trap` and `cstr(PARTIAL_VIEW_ABSENT)`. The
* message is the program's own constant; the runtime carries none. */
export function recordViewFieldPtr(
B: BlockBuilder,
sizeType: "i32" | "i64",
@@ -239,13 +337,25 @@ export function recordViewFieldPtr(
position: number,
declare: (decl: string) => void,
outOfLine: boolean,
cstr: (text: string) => string,
partial = false,
): string {
if (outOfLine) {
declare(`declare ptr @scr_record_view_field(ptr, ${sizeType}, ${sizeType})`);
const field = "scr_record_view_field";
declare(`declare ptr @${field}(ptr, ${sizeType}, ${sizeType})`);
const ptr = B.tmp();
B.line(
`${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`,
`${ptr} = call ptr @${field}(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`,
);
if (!partial) return ptr;
const absent = B.tmp();
B.line(`${absent} = icmp eq ptr ${ptr}, null`);
const trap = B.newLabel("rv.absent");
const join = B.newLabel("rv.present");
B.condBr(absent, trap, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`);
B.startBlock(trap);
absentTrapLines(B, cstr, declare);
B.startBlock(join);
return ptr;
}
const own = B.tmp();
@@ -261,9 +371,34 @@ export function recordViewFieldPtr(
declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`);
const viewed = B.tmp();
B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`);
let viewFrom = view;
if (partial) {
const absent = B.tmp();
B.line(`${absent} = icmp eq ptr ${viewed}, null`);
const trap = B.newLabel("rv.absent");
const present = B.newLabel("rv.present");
B.condBr(absent, trap, present);
B.startBlock(trap);
absentTrapLines(B, cstr, declare);
viewFrom = present;
B.startBlock(present);
}
B.br(join);
B.startBlock(join);
const ptr = B.tmp();
B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${view} ]`);
B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${viewFrom} ]`);
return ptr;
}
/** The block of an address lookup of a field a partial view lacks: the
* program stops with its own message (PARTIAL_VIEW_ABSENT) and does not
* return. */
export function absentTrapLines(
B: BlockBuilder,
cstr: (text: string) => string,
declare: (decl: string) => void,
): void {
declare(`declare void @scr_trap(ptr)`);
B.line(`call void @scr_trap(ptr ${cstr(PARTIAL_VIEW_ABSENT)})`);
B.terminate("unreachable");
}
@@ -103,6 +103,7 @@ const INERT_LIB_CALLS = new Set([
"json.parse",
"process.stderrWrite",
"process.stdoutWrite",
"process.stdioWriteDyn",
"process.envGet",
"process.envPairs",
"regex.groupEquals",
@@ -28,6 +28,10 @@ function declarationHost(
declare(decl) {
declarations.push(decl);
},
cstr(text) {
declarations.push(`@cstr ${text}`);
return "@sc_cstr";
},
needOom() {},
mapOps: (operands) => (operands.every((op) => op === "null") ? "null" : "@sc_map_ops"),
sizeType: pointerBits === 32 ? "i32" : "i64",
+20 -3
View File
@@ -73,7 +73,7 @@ import {
disposeFieldLines,
plainRefType,
} from "./arenas.js";
import { recordViewTestLines, type RecordViews } from "./record-views.js";
import { PARTIAL_VIEW_ABSENT, recordViewTestLines, type RecordViews } from "./record-views.js";
import { recordMayKeepSource } from "./record-sources.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -109,6 +109,9 @@ export interface ShapeHost {
/** Byte offset from an object pointer back to ScrCycHdr.buf. */
readonly cycleBufOffset: number;
declare(decl: string): void;
/** The module constant for a string (LlEmitter.cstr), for a trap message
* an emitted accessor passes to the runtime. */
cstr(text: string): string;
/** Inline the small-object allocator's fast paths (llvm/alloc.ts). */
readonly inlineAlloc?: boolean;
/** Record and class fields that store a scalar union's tag and bits
@@ -2451,15 +2454,29 @@ export function emitRecordShapes(
const inline = host.inlineUnions.record(field.type);
const fieldTy = llFieldType(field.type, inline);
if (host.recordViews?.targets.has(shape.id) === true) {
// A view's fields live in the viewed record (record-views.ts).
// A view's fields live in the viewed record (record-views.ts); a
// shape a partial view may present answers NULL for a field the
// viewed record lacks, so the copy stops with the program's own
// message (a source-level read threw first).
const partial = host.recordViews.partialTargets.has(shape.id);
host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`);
const fromView = partial
? [
` %sn${i} = icmp eq ptr %sw${i}, null`,
` br i1 %sn${i}, label %sv${i}.absent, label %sv${i}.join`,
`sv${i}.absent:`,
` call void @scr_trap(ptr ${host.cstr(PARTIAL_VIEW_ABSENT)})`,
` unreachable`,
]
: [` br label %sv${i}.join`];
if (partial) host.declare(`declare void @scr_trap(ptr)`);
clone.push(
` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`,
...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`),
` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`,
`sv${i}.view:`,
` %sw${i} = call ptr @scr_record_view_slot(ptr %src, ${host.sizeType} ${i})`,
` br label %sv${i}.join`,
...fromView,
`sv${i}.join:`,
` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`,
` %sv${i} = load ${fieldTy}, ptr %sp${i}`,
@@ -234,6 +234,17 @@ function powerOfTwo(e: IrExpr): boolean {
/** Typed-array element kinds whose elements are integers. */
const INTEGER_ELEMENTS = new Set(["u8", "u8c", "i8", "u16", "i16", "u32", "i32"]);
/** The values of each integer element kind. */
const ELEMENT_RANGES: Record<string, { min: number; max: number }> = {
u8: { min: 0, max: 255 },
u8c: { min: 0, max: 255 },
i8: { min: -128, max: 127 },
u16: { min: 0, max: 65535 },
i16: { min: -32768, max: 32767 },
u32: { min: 0, max: 2 ** 32 - 1 },
i32: { min: -(2 ** 31), max: 2 ** 31 - 1 },
};
/** A typed-array element read of an integer kind with no effect: an
* out-of-range read gives NaN, which fails its check. Float elements would
* only bail. */
@@ -904,6 +915,17 @@ export function emitSafeIntegerCheck(host: LlvmEmitterContext, value: string): [
return [integer, ok];
}
/** The condition that a bounded value's exponent is above `limit`. A
* value test it replaces can fail only past that limit; testing the
* exponent alone, which is usually the same in every iteration of a loop,
* keeps the value out of the test. Past the limit the write bails to the
* double copy, also for a value the replaced test would have passed. */
function exponentAbove(host: LlvmEmitterContext, bound: MaskBound, limit: number): string {
const above = host.B.tmp();
host.B.line(`${above} = icmp sgt i64 ${bound.exponent}, ${limit}`);
return above;
}
/** A condition that is true when an i64 lies outside the safe range. */
function unsafeInteger(host: LlvmEmitterContext, value: string): string {
const B = host.B;
@@ -932,6 +954,29 @@ function outsideRange(host: LlvmEmitterContext, value: string, low: number, high
return outside;
}
/** The range of an operand once its speculative evaluation continued: a
* literal's value, the range analysis' proof (well inside the safe
* integers), or for an integer element read, which bails on the NaN of an
* out-of-range index, the element kind's values. */
function operandRange(
host: LlvmEmitterContext,
e: IrExpr,
): { min: number; max: number } | undefined {
const range = staticRange(host, e);
if (range || !isSpeculativeByteRead(e) || e.kind !== "bytesIntrinsic") return range;
return e.receiver.type.kind === "bytes" ? ELEMENT_RANGES[e.receiver.type.elem] : undefined;
}
/** An i64 the speculative evaluation has bounded to [0, 2^(exponent +
* shift)): the floor-mod of a value by 2^exponent, shifted left by `shift`.
* `exponent` is an i64 already checked to be in [0, 52]; in a loop it is
* usually the same value on every iteration, so a test on it alone is
* loop-invariant. */
interface MaskBound {
exponent: string;
shift: number;
}
/** A literal's value or the range analysis' proof for an expression whose
* range lies well inside the safe integers. */
function staticRange(
@@ -962,6 +1007,7 @@ export function emitSpeculativeInteger(
bail: (condition: string) => void,
reads: ReadonlyMap<IrExpr, LlValue> = new Map(),
integral = false,
bounds = new Map<string, MaskBound>(),
): string {
const B = host.B;
// A value the range analysis already proves takes the ordinary integer
@@ -1000,12 +1046,13 @@ export function emitSpeculativeInteger(
// bits, also for a negative value: in [0, 2^n), and never -0.
const mod = floorModPowerOfTwo(e);
if (mod) {
const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral);
const value = emitSpeculativeInteger(host, mod.value, bail, reads, integral, bounds);
const n = exponentOf(host, mod.power, bail, reads, integral);
const mask = B.tmp(),
low = B.tmp();
B.line(`${mask} = sub i64 ${shiftOne(host, n)}, 1`);
B.line(`${low} = and i64 ${value}, ${mask}`);
bounds.set(low, { exponent: n, shift: 0 });
return low;
}
// A product with 2^n shifts: it is safe iff the other factor's
@@ -1013,8 +1060,19 @@ export function emitSpeculativeInteger(
// integer and a positive power is +0.
if (e.op === "*" && (powerOfTwo(e.right) || powerOfTwo(e.left))) {
const [factor, power] = powerOfTwo(e.right) ? [e.left, e.right] : [e.right, e.left];
const value = emitSpeculativeInteger(host, factor, bail, reads, integral);
const value = emitSpeculativeInteger(host, factor, bail, reads, integral, bounds);
const n = exponentOf(host, power, bail, reads, integral);
// A factor below 2^(m + s) times 2^k is below 2^(m + s + k), so
// safe when m + s + k <= 53.
const bound = bounds.get(value);
const k = power.kind === "numLit" ? exactPowerOfTwo(power.value) : null;
if (bound && k !== null) {
bail(exponentAbove(host, bound, 53 - bound.shift - k));
const product = B.tmp();
B.line(`${product} = shl nuw nsw i64 ${value}, ${n}`);
bounds.set(product, { exponent: bound.exponent, shift: bound.shift + k });
return product;
}
const limit = B.tmp(),
magnitude = B.tmp(),
outside = B.tmp(),
@@ -1027,16 +1085,24 @@ export function emitSpeculativeInteger(
B.line(`${product} = shl nsw i64 ${value}, ${n}`);
return product;
}
const a = emitSpeculativeInteger(host, e.left, bail, reads, integral);
const b = emitSpeculativeInteger(host, e.right, bail, reads, integral);
const a = emitSpeculativeInteger(host, e.left, bail, reads, integral, bounds);
const b = emitSpeculativeInteger(host, e.right, bail, reads, integral, bounds);
const result = B.tmp();
if (e.op === "+" || e.op === "-") {
// With one operand's range known, the other is checked against the
// narrowed bounds before the operation: the check then depends on
// no new value, and LLVM keeps the operation itself simple.
const right = staticRange(host, e.right);
const left = e.op === "+" ? staticRange(host, e.left) : undefined;
if (right) {
const right = operandRange(host, e.right);
const left = e.op === "+" ? operandRange(host, e.left) : undefined;
// A bounded operand, in [0, 2^(m + s)), plus one in [lo, hi] with
// |lo|, hi <= 2^52 is safe when m + s <= 52.
const bound = (value: string): MaskBound | undefined =>
e.op === "+" ? bounds.get(value) : undefined;
const aBound = right ? bound(a) : undefined;
const bBound = !right && left ? bound(b) : undefined;
if (aBound) bail(exponentAbove(host, aBound, 52 - aBound.shift));
else if (bBound) bail(exponentAbove(host, bBound, 52 - bBound.shift));
else if (right) {
// a + b with b in [lo, hi] is safe iff a in [-SAFE - lo, SAFE - hi];
// a - b iff a in [-SAFE + hi, SAFE + lo].
const low = e.op === "+" ? -SAFE - right.min : -SAFE + right.max;
@@ -72,9 +72,9 @@ function recursiveFunctions(edges: ReadonlyMap<string, readonly string[]>): Set<
* `call` from emitted code that can lower it as an `invoke`: it is not
* async or a generator, not a constructor, not a method a virtual call can
* reach or one a vtable holds (an overridden method), not a closure
* target, not the entry or a library export, not named by any node other
* than a `call`'s callee, and not called from an async or generator body
* (coroutine bodies keep plain calls).
* target or a closure's jsval body, not the entry or a library export,
* not named by any node other than a `call`'s callee, and not called from
* an async or generator body (coroutine bodies keep plain calls).
*
* A native unwind costs about 0.05 µs a raise and 10 to 25 ns a frame on
* x86-64 (scr_unwind.c's cached plans) and about 2 µs a raise and 0.1 µs a
@@ -103,6 +103,9 @@ export function unwindAbiFunctions(
blocked.add("%error.name.read");
blocked.add("%error.message.read");
for (const entry of mod.lib?.exports ?? []) blocked.add(entry.fnName);
// A closure's jsval body is called through the function value's jsval
// entry (IrFunction.jsEntry), which runtime calls reach.
for (const fn of mod.functions) if (fn.jsEntry !== undefined) blocked.add(fn.jsEntry);
// Methods a virtual call can reach, and the overridden methods of class
// hierarchies (whose vtables list them), are called through pointers; the
// others are called directly, like functions. A virtual call reaches its listed
@@ -293,6 +293,8 @@ export class LlWalkers {
index - base,
(decl) => this.host.declare(decl),
this.host.rcMode === undefined,
(text) => this.host.cstr(text),
this.host.recordViews?.partialTargets.has(shapeId) === true,
);
} else {
p = B.tmp();
@@ -563,6 +563,9 @@ function helperProfileMatch(match: NativeProfileMatch): Record<string, number> {
missing: match.missing,
recovered: match.recovered,
unknown_partitions: match.unknownPartitions,
...(match.annotatedCounts === undefined || match.mismatchedCounts === undefined
? {}
: { annotated_counts: match.annotatedCounts, mismatched_counts: match.mismatchedCounts }),
};
}
@@ -31,9 +31,28 @@ export interface NativeLinkFeatures {
/** The program assigns process.emitWarning: links the
* SCR_WARNING_REPLACE variant of the dyn-async unit. */
warningReplace?: boolean;
/** The program asserts a record literal to a wider record type (a partial
* record view): links the SCR_RECORD_VIEW_PARTIAL variant of scr_json.c.
* Absent for every program without one, so their objects stay identical. */
recordViewPartial?: boolean;
/** The program uses process.stdin.setEncoding, string 'data' listeners
* or stdio 'resize' events: links the SCR_STDIO_EXTENDED events unit. */
stdioExtended?: boolean;
/** writeFile's `flag` disposition or copyFile's mode argument:
* scr_fs_flags.c and the SCR_FS_FLAGS build of scr_fs_cp.c. */
fsFlags?: boolean;
/** string_decoder's checked-dynamic chunk forms: scr_bytes_strdec_dyn.c. */
strdecDyn?: boolean;
/** process.prependListener, stdin.isRaw or server.off: the
* SCR_PROCESS_EXTRAS builds of scr_events.c and scr_async_dyn.c and the
* SCR_NET_SERVER_OFF build of scr_net.c. */
processExtras?: boolean;
/** A write completion callback takes the error argument: links the
* SCR_STREAM_WRITE_ERRORS build of the stream unit. */
streamWriteErrors?: boolean;
/** The program reaches the process output streams' write slot: links the
* SCR_STDIO_WRITE_SLOT build of the events unit. */
stdioWriteSlot?: boolean;
/** Calls into Node.js (@scriptc/node): scr_node_island.c and the
* SCR_NODE_ISLANDS variant of the event loop unit. */
nodeIsland?: boolean;
@@ -43,6 +62,9 @@ export interface NativeLinkFeatures {
/** A module namespace boxed for typed code links
* scr_module_namespace_dyn.c; absent (false) otherwise. */
moduleNamespaceDyn?: boolean;
/** Primitive wrapper objects link scr_js_wrapper.c; absent (false)
* otherwise. */
primitiveWrapper?: boolean;
zlib: boolean;
/** node:zlib's Brotli forms: scr_brotli.c and the vendored brotli archive. */
brotli?: boolean;
@@ -120,6 +142,8 @@ export interface NativeLinkFeatures {
* scr_text_codec_dyn.c. */
textCodecDyn: boolean;
watch: boolean;
/** fs.watchFile/fs.unwatchFile: scr_watch_file.c. */
watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -144,6 +168,10 @@ export interface NativeLinkFeatures {
x509: boolean;
/** perf_hooks event-loop metrics: scr_perf_loop.c. */
perfLoop: boolean;
/** Intl over ICU (scr_intl.c plus the ICU archive and its data
* file): the `icu.*` libCalls of the first slice. Absent (false) for
* programs without them, so their link features stay unchanged. */
intl?: boolean;
tlsCa: boolean;
/** crypto.subtle importKey/sign/verify/digest and CryptoKey values:
* scr_webcrypto.c. */
@@ -34,6 +34,7 @@ export async function createNativeLinkInfo(options: {
features: pack.features,
runtime: pack.selectedRuntimeArtifacts,
archives: pack.selectedArchiveArtifacts,
data: pack.selectedDataArtifacts,
systemLibraries: pack.systemLibraries,
},
});
@@ -145,6 +145,20 @@ test("the helper's profile match is read from its output", () => {
'{"ok":true,"profile":{"annotated":1,"mismatched":0,"missing":0,"recovered":-1,"unknown_partitions":0}}\n',
),
).toBeNull();
// With the profile's counters on the annotated and the mismatched functions.
expect(
parseNativeProfileMatch(
'{"ok":true,"profile":{"annotated":120,"mismatched":7,"missing":40,"recovered":0,"unknown_partitions":0,"annotated_counts":9000,"mismatched_counts":1000}}\n',
),
).toEqual({
annotated: 120,
mismatched: 7,
missing: 40,
recovered: 0,
unknownPartitions: 0,
annotatedCounts: 9000,
mismatchedCounts: 1000,
});
// A helper that predates the report prints nothing.
expect(parseNativeProfileMatch("")).toBeNull();
expect(parseNativeProfileMatch('{"ok":true}\n')).toBeNull();
@@ -111,13 +111,16 @@ export function nativeProfileHelperArgs(profile: NativeProfileOptions | undefine
* profile has no record of (new, or never run while it was collected),
* mismatched functions that the block-count file annotated (never more
* than the mismatched ones), and partitions reused from the cache without
* their counts. */
* their counts. Helpers that weigh the match also report the profile's
* counters on the annotated and on the mismatched functions. */
export interface NativeProfileMatch {
annotated: number;
mismatched: number;
missing: number;
recovered: number;
unknownPartitions: number;
annotatedCounts?: number;
mismatchedCounts?: number;
}
/** The profile match in a successful helper emit's output, or null when the
@@ -150,7 +153,18 @@ export function parseNativeProfileMatch(stdout: string): NativeProfileMatch | nu
unknownPartitions === null
)
continue;
return { annotated, mismatched, missing, recovered, unknownPartitions };
const annotatedCounts = count("annotated_counts");
const mismatchedCounts = count("mismatched_counts");
return {
annotated,
mismatched,
missing,
recovered,
unknownPartitions,
...(annotatedCounts === null || mismatchedCounts === null
? {}
: { annotatedCounts, mismatchedCounts }),
};
}
return null;
}
@@ -83,6 +83,17 @@ export interface CcOptions {
/** The program uses node:zlib's Brotli forms (with zlib): compiles
* scr_brotli.c and links the vendored brotli archive. */
brotli?: boolean;
/** The program uses an Intl form that only ICU can answer (the `icu.*`
* libCalls: default-locale number/date formats, word segmentation):
* compiles scr_intl.c and links the ICU archive, and the
* executable gets icudt78l.dat next to it. Every engine-free form keeps
* linking none of it. */
intl?: boolean;
/** With `intl`: the installed runtime pack's ICU archive and the include
* root of the ICU headers it ships. The C-source path then links that
* archive and compiles scr_intl.c against those headers, instead of
* building ICU from its fetched release with Zig's C++ driver. */
icuPack?: { archive: string; include: string };
/** The program uses node:assert (index.ts detects assert.* libCalls on
* the IR): compiles scr_assert.c — the zlib gating precedent, so
* assert-free binaries keep their exact size class. scr_regex.c calls
@@ -159,8 +170,26 @@ export interface CcOptions {
wasm?: boolean;
/** SCR_WARNING_REPLACE: process.emitWarning replacement support. */
warningReplace?: boolean;
/** SCR_RECORD_VIEW_PARTIAL: the scr_json.c build whose view slot answers
* NULL for a field a partial record view does not have. */
recordViewPartial?: boolean;
/** SCR_STDIO_EXTENDED: stdin encoding, string data listeners, resize. */
stdioExtended?: boolean;
/** SCR_FS_FLAGS: writeFile's `flag` option and copyFile's mode argument
* (scr_fs_flags.c and that build of scr_fs_cp.c). */
fsFlags?: boolean;
/** string_decoder's checked-dynamic chunk forms: compiles
* scr_bytes_strdec_dyn.c. */
strdecDyn?: boolean;
/** SCR_PROCESS_EXTRAS / SCR_NET_SERVER_OFF: process.prependListener,
* stdin.isRaw and server.off. */
processExtras?: boolean;
/** SCR_STREAM_WRITE_ERRORS: the stream unit publishes the error each
* write completion callback receives (error-taking write callbacks). */
streamWriteErrors?: boolean;
/** SCR_STDIO_WRITE_SLOT: the events unit carries the typed write slot
* (the write method as a value, its replacement, run-time-typed chunks). */
stdioWriteSlot?: boolean;
/** Calls into Node.js (@scriptc/node): compiles scr_node_island.c and
* the event loop with SCR_NODE_ISLANDS (its helper slot). */
nodeIsland?: boolean;
@@ -170,6 +199,8 @@ export interface CcOptions {
/** A module namespace boxed for typed code: compiles
* scr_module_namespace_dyn.c. */
moduleNamespaceDyn?: boolean;
/** Primitive wrapper objects: compiles scr_js_wrapper.c. */
primitiveWrapper?: boolean;
/** The program uses the process-events surface (signal/exit listeners,
* stdin events, for-await over stdin — moduleUsesProcessEvents on the
* IR): compiles scr_events.c into the binary. Event-free binaries keep
@@ -258,6 +289,11 @@ export interface CcOptions {
* scr_watch.c into the binary — the net gating precedent, so watch-free
* binaries keep their exact link line. */
watch?: boolean;
/** The program uses fs.watchFile or fs.unwatchFile (moduleUsesFsWatchFile
* on the IR): compiles scr_watch_file.c into the binary — its own gated
* unit under the same precedent, so polling-free binaries keep their
* exact link line. */
watchFile?: boolean;
/** The executable manifest has a format-5 foreign callback descriptor:
* compiles the MPSC queue/self-pipe unit. Other FFI and non-FFI binaries
* keep their existing runtime size class. */
@@ -84,6 +84,9 @@ import {
vendorBuildCacheRoot,
tlsArchivePath,
brotliArchivePath,
ensureIcuSources,
icuArchivePath,
ensureIcuArchive,
lreObjectPaths,
zlibObjectPaths,
stageVendorInputs,
@@ -124,6 +127,10 @@ interface RuntimeUnitContext {
zlibObjects: readonly string[];
tlsArchive: string | null;
brotliArchive: string | null;
icuArchive: string | null;
/** The include roots of the ICU headers scr_intl.c compiles against: the
* runtime pack's, or the fetched release's common/ and i18n/. */
icuIncludeDirs: readonly string[];
}
/** An optional runtime input: runtime sources compiled when `when` holds,
@@ -153,6 +160,11 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
),
unit((u) => u.opts.copying, "scr_copying.c"),
unit((u) => u.opts.fileHandle, "scr_file_handle.c"),
// writeFile's `flag` option and the fs/promises twins; the copyFile mode
// argument is the SCR_FS_FLAGS build of scr_fs_cp.c.
unit((u) => u.opts.fsFlags === true, "scr_fs_flags.c"),
// string_decoder's checked-dynamic chunk forms, beside their typed twins.
unit((u) => u.opts.strdecDyn === true, "scr_bytes_strdec_dyn.c"),
// BigIntStats member reads (`statSync(p, { bigint: true })` integers and
// `*Ns`/`*Ms` members). Gated on the reads themselves (the bigstats
// feature), not on bigint: every bigint value or wasm conversion turns
@@ -174,9 +186,10 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
// SCR_MUSL flag and this target-selected TU travel together.
unit((u) => isMuslTarget(u.driver), "scr_musl.c"),
// scr_glob.c (path.matchesGlob) compiles its segments through the
// regex unit and scr_idna.c (node:url's IDNA) normalizes through it,
// so both ride the same switch; section GC drops them from binaries
// that never use them.
// regex unit, scr_idna.c (node:url's IDNA) normalizes through it and
// scr_path_win32_relative.c lowercases through its case mapping, so all
// three ride the same switch; section GC drops them from binaries that
// never use them.
unitArgs((u) =>
u.regex
? [
@@ -185,6 +198,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
u.rt(join(u.rtDir, "scr_regex.c")),
u.rt(join(u.rtDir, "scr_glob.c")),
u.rt(join(u.rtDir, "scr_idna.c")),
u.rt(join(u.rtDir, "scr_path_win32_relative.c")),
...u.lreObjects,
]
: [],
@@ -212,6 +226,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.moduleNamespace === true, "scr_module_namespace.c"),
// A module namespace as the frozen null-prototype object typed code sees.
unit((u) => u.opts.moduleNamespaceDyn === true, "scr_module_namespace_dyn.c"),
// Primitive wrapper objects (new String/Number/Boolean, Object(primitive)).
unit((u) => u.opts.primitiveWrapper === true, "scr_js_wrapper.c"),
unit(
(u) =>
u.opts.dynAsync ||
@@ -253,6 +269,20 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
]
: [],
),
// Intl over ICU (scr_intl.c): ICU's C API only, at the default locale,
// reading its data file from beside the executable. The ICU headers come
// from the installed runtime pack beside its archive, or, when ICU is
// built from its fetched release, from that source tree (common/unicode
// for the core API, i18n/unicode for udat.h and unumberformatter.h).
unitArgs((u) =>
u.icuIncludeDirs.length === 0 || u.icuArchive === null
? []
: [
...u.icuIncludeDirs.flatMap((dir) => ["-I", dir]),
u.rt(join(u.rtDir, "scr_intl.c")),
u.icuArchive,
],
),
unit((u) => u.opts.events, "scr_events.c", "scr_readline.c"),
// readline's terminal mode: rl.terminal sets the regex unit it
// measures with.
@@ -344,6 +374,9 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.dgram && u.opts.dgramDyn, "scr_dgram_dyn.c"),
unit((u) => u.opts.textCodecDyn, "scr_text_codec_dyn.c"),
unit((u) => u.opts.watch, "scr_watch.c"),
// fs.watchFile's stat poller: its own gated unit (no loop hook — the
// re-armed timers are the liveness), linked only by programs that poll.
unit((u) => u.opts.watchFile, "scr_watch_file.c"),
unit((u) => u.opts.foreignFfi, "scr_ffi_queue.c"),
unit((u) => u.opts.nodeTest, "scr_test.c"),
unit((u) => u.opts.webcrypto, "scr_webcrypto.c"),
@@ -389,6 +422,7 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
"scr_node_island_handle.c",
"scr_node_island_export.c",
"scr_node_island_boot.c",
"scr_node_island_gc.c",
),
// Readable.fromWeb reads a web ReadableStream through its reader.
unit((u) => u.nativeFetch && u.opts.stream, "scr_stream_from_web.c"),
@@ -416,13 +450,30 @@ const RUNTIME_DEFINES: readonly {
// programs with dynamic-tier code select it.
{ define: "SCR_JS_TIER", when: (opts) => opts.dynTier === true },
{ define: "SCR_WARNING_REPLACE", when: (opts) => opts.warningReplace },
// scr_json.c's view slot answers NULL for a field a partial record view
// does not have; only a program with a partial assertion selects it.
{ define: "SCR_RECORD_VIEW_PARTIAL", when: (opts) => opts.recordViewPartial },
{ define: "SCR_STDIO_EXTENDED", when: (opts) => opts.stdioExtended },
// The wave's prepend registrations and stdin.isRaw (scr_events.c,
// scr_async_dyn.c), server.off (scr_net.c) and copyFile's mode form
// (scr_fs_cp.c). The source build compiles these units from source, so the
// defines must name the same builds runtime-pack-matrix.mjs selects.
{ define: "SCR_PROCESS_EXTRAS", when: (opts) => opts.processExtras },
{ define: "SCR_NET_SERVER_OFF", when: (opts) => opts.processExtras },
{ define: "SCR_FS_FLAGS", when: (opts) => opts.fsFlags },
// scr_stream.c's write-callback error publication, which the compiler's
// error-first write adapter reads.
{ define: "SCR_STREAM_WRITE_ERRORS", when: (opts) => opts.streamWriteErrors },
// scr_events.c's typed process output write slot.
{ define: "SCR_STDIO_WRITE_SLOT", when: (opts) => opts.stdioWriteSlot },
// scr_async.c's native promise reactions, which the web streams unit uses,
// scr_json.c's and scr_dyn_builtin_class.c's instanceof over its classes,
// and scr_fetch.c's bodies of its streams.
{ define: "SCR_NATIVE_REACTIONS", when: (opts) => opts.webStreams },
{ define: "SCR_HANDLE_CLASSES", when: (opts) => opts.webStreams },
{ define: "SCR_YIN", when: (opts) => opts.youngNursery },
// scr_lib.c's path stat and zero Stats for the fs.watchFile poll.
{ define: "SCR_FS_WATCH_FILE", when: (opts) => opts.watchFile },
{ define: "SCR_NODE_ISLANDS", when: (opts) => opts.nodeIsland },
];
@@ -455,6 +506,7 @@ export async function compileCInternal(
const pki = opts.pki ?? false;
const mbedtls = tls || cipher || ecdh || dh || pki;
const brotli = (opts.zlib ?? false) && opts.brotli === true;
const icu = opts.intl ?? false;
const driver = resolveCc();
const darwinDebugSymbols = needsDarwinDebugSymbols(
targetPlatform(driver),
@@ -697,6 +749,24 @@ export async function compileCInternal(
let brotliArchive = brotli
? brotliArchivePath(sanitize, driver, vendorBuildIdentity, vendorCacheRoot)
: null;
// The installed runtime pack's ICU archive is the one this path would
// build (uninstrumented either way), so a pack that ships it and its
// headers spares the Zig C++ build and the source download.
const icuPack = icu ? (opts.icuPack ?? null) : null;
let icuArchive = icu
? (icuPack?.archive ?? icuArchivePath(driver, vendorBuildIdentity, vendorCacheRoot))
: null;
// scr_intl.c's ICU headers. They are known before the runtime-object flags
// are: those flags compile every runtime unit, so without them a cached
// scr_intl.o would take whatever ICU headers the host has.
const icuSource =
icu && icuPack === null ? join(await ensureIcuSources(vendorCacheRoot), "source") : null;
const icuIncludeDirs: readonly string[] =
icuPack !== null
? [icuPack.include]
: icuSource !== null
? [join(icuSource, "common"), join(icuSource, "i18n")]
: [];
// ours would collide; see scr_regex.c) — the standalone objects are for
// static builds only.
@@ -751,6 +821,18 @@ export async function compileCInternal(
: await stageVendorInputs(materialize, join(stageRoot, "brotli"));
brotliArchive = paths[0]!;
}
if (icu && icuPack === null) {
const cachedArchive = icuArchivePath(driver, vendorBuildIdentity, materializeCacheRoot);
protectCachedArtifact(cacheWarmPaths, cachedArchive);
const materialize = async (): Promise<string[]> => [
await ensureIcuArchive(driver, vendorBuildIdentity, materializeCacheRoot),
];
const paths =
stageRoot === undefined
? await materialize()
: await stageVendorInputs(materialize, join(stageRoot, "icu"));
icuArchive = paths[0]!;
}
if (regex) {
for (const object of lreObjectPaths(
sanitize,
@@ -838,6 +920,8 @@ export async function compileCInternal(
zlibObjects,
tlsArchive,
brotliArchive,
icuArchive,
icuIncludeDirs,
}),
// A .ll program TU (the LLVM backend) deliberately carries no target
// triple (byte-stable output; clang supplies the host/SCRIPTC_TARGET
@@ -886,6 +970,7 @@ export async function compileCInternal(
...(regex ? ["-I", vendorEngineDir()] : []),
...(zlibObjects.length > 0 ? ["-I", vendorZlibDir()] : []),
...(tlsArchive !== null ? ["-I", join(vendorTlsDir(), "include")] : []),
...icuIncludeDirs.flatMap((dir) => ["-I", dir]),
];
// Keep sanitizer runtime objects optimized in both postures. Large dev
// programs still compile at O0, avoiding an expensive LLVM optimization
@@ -104,7 +104,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
// Zig's musl sysroot does not provide arc4random_buf. Keep the fallback
// inside the archive so library embedders need no extra system library.
...(isMuslTarget(driver) ? ["scr_musl.c"] : []),
...(regex ? ["scr_regex.c", "scr_glob.c"] : []),
...(regex ? ["scr_regex.c", "scr_glob.c", "scr_path_win32_relative.c"] : []),
...(opts.assert || regex || opts.symbol ? ["scr_assert.c"] : []),
...(opts.inspect ? ["scr_inspect.c", "scr_console_native.c"] : []),
...(opts.symbol ? ["scr_symbol.c"] : []),
@@ -3,7 +3,13 @@ import { createRequire } from "node:module";
import { readdir, readFile, realpath, stat } from "node:fs/promises";
import { basename, dirname, extname, join, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { BROTLI_VERSION, MBEDTLS_VERSION, QJS_COMMIT, ZLIB_VERSION } from "../vendor-inputs.js";
import {
BROTLI_VERSION,
ICU_VERSION,
MBEDTLS_VERSION,
QJS_COMMIT,
ZLIB_VERSION,
} from "../vendor-inputs.js";
import { stableTestMemo } from "./session.js";
const implementationPath = fileURLToPath(import.meta.url);
@@ -139,6 +145,7 @@ async function runtimeFingerprintFresh(rtDir: string): Promise<string> {
.update(MBEDTLS_VERSION)
.update(ZLIB_VERSION)
.update(BROTLI_VERSION)
.update(ICU_VERSION)
.update("\0backend-recipe\0");
for (const path of NATIVE_RECIPE_IMPLEMENTATION_PATHS) {
h.update(basename(path))
@@ -159,19 +166,29 @@ async function runtimeFingerprintFresh(rtDir: string): Promise<string> {
return h.digest("hex");
}
interface RuntimeFingerprintGroup {
label: string;
dir: string;
}
async function runtimeFingerprintInputGroups(
rtDir: string,
): Promise<{ label: string; dir: string; names: string[] }[]> {
return Promise.all(
[
{ label: "runtime", dir: rtDir },
{ label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") },
{ label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
{ label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
{ label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
{ label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") },
{ label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
].map(async (group) => {
(
[
{ label: "runtime", dir: rtDir },
{ label: "ryu", dir: join(rtDir, "..", "vendor", "ryu") },
{ label: "quickjs-ng", dir: join(rtDir, "..", "vendor", "quickjs-ng") },
{ label: "mbedtls", dir: join(rtDir, "..", "vendor", "mbedtls") },
{ label: "zlib", dir: join(rtDir, "..", "vendor", "zlib") },
{ label: "brotli", dir: join(rtDir, "..", "vendor", "brotli", "c") },
// ICU is fetched, not vendored: its pinned version and sha256 key the
// archive's cache path (vendor-archives.ts icuArchivePath), so it is
// not part of the runtime tree's fingerprint.
{ label: "curl", dir: join(rtDir, "..", "vendor", "curl") },
] satisfies RuntimeFingerprintGroup[]
).map(async (group) => {
const names = (await nativeSourceFiles(group.dir, true))
.map((path) => relative(group.dir, path))
.sort();
@@ -9,6 +9,9 @@ export const {
vendorBrotliDir,
brotliArchivePath,
ensureBrotliArchive,
ensureIcuSources,
icuArchivePath,
ensureIcuArchive,
vendorBuildCacheRoot,
vendorCacheTargetFlavor,
vendorCacheBuildIdentity,
+2 -2
View File
@@ -2,8 +2,8 @@
* of the link contract: a program object keeps this symbol undefined and the
* matching runtime defines it, so incompatible manual links fail before the
* program can start. */
export const RUNTIME_ABI_VERSION = 47 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v47" as const;
export const RUNTIME_ABI_VERSION = 48 as const;
export const RUNTIME_ABI_MARKER = "scr_runtime_abi_v48" as const;
/** Object consumption is useful today, but the complete scr_* surface may
* still change before 1.0. The versioned marker prevents accidental mixing;
@@ -34,7 +34,7 @@ interface RuntimePackUnit {
}
interface RuntimePackArchive extends RuntimePackArtifact {
id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls";
id: "quickjs" | "libregexp" | "zlib" | "brotli" | "mbedtls" | "icu";
predicate: RuntimePredicate;
}
@@ -74,6 +74,15 @@ export interface RuntimePackManifest {
};
flavors: Partial<Record<RuntimePackFlavor, RuntimePackFlavorManifest>>;
archives: RuntimePackArchive[];
/** Files the linked executable needs at RUN time rather than at link time,
* installed beside it on every build. ICU's data file (icudt78l.dat, 11.1
* MiB) is the only one; Intl programs copy it, every other program links
* neither ICU nor its data. */
data?: RuntimePackArtifact[];
/** C headers under one include root (`<root>/unicode/*.h`): the ICU C API
* headers scr_intl.c includes, which a sanitized build compiles that unit
* against (native/sanitizer.ts). Only a pack that ships ICU lists them. */
headers?: RuntimePackArtifact[];
system_libraries: { name: string; predicate: RuntimePredicate }[];
licenses: { path: string; license: string }[];
}
@@ -100,11 +109,19 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
builtinValues: boolean;
computedRequire: boolean;
warningReplace: boolean;
recordViewPartial: boolean;
stdioExtended: boolean;
/** The program's write completion callbacks take the error argument:
* the SCR_STREAM_WRITE_ERRORS build of the stream unit. */
streamWriteErrors: boolean;
/** The program reaches the process output streams' write slot: the
* SCR_STDIO_WRITE_SLOT build of the events unit. */
stdioWriteSlot: boolean;
nodeIsland: boolean;
brotli: boolean;
moduleNamespace: boolean;
moduleNamespaceDyn: boolean;
primitiveWrapper: boolean;
nativeFetch: boolean;
abortSignalOnly: boolean;
netEffective: boolean;
@@ -245,6 +262,10 @@ export function parseRuntimePackManifest(value: unknown): RuntimePackManifest {
validArtifact(raw) && typeof archive?.id === "string" && validPredicate(archive.predicate)
);
}) ||
(manifest.data !== undefined &&
(!Array.isArray(manifest.data) || !manifest.data.every(validArtifact))) ||
(manifest.headers !== undefined &&
(!Array.isArray(manifest.headers) || !manifest.headers.every(validArtifact))) ||
!Array.isArray(manifest.system_libraries) ||
!manifest.system_libraries.every((raw) => {
const library = object(raw);
@@ -311,11 +332,15 @@ export function effectiveRuntimeFeatures(
builtinValues: features.builtinValues === true,
computedRequire: features.computedRequire === true,
warningReplace: features.warningReplace === true,
recordViewPartial: features.recordViewPartial === true,
stdioExtended: features.stdioExtended === true,
streamWriteErrors: features.streamWriteErrors === true,
stdioWriteSlot: features.stdioWriteSlot === true,
nodeIsland: features.nodeIsland === true,
brotli: features.brotli === true,
moduleNamespace: features.moduleNamespace === true,
moduleNamespaceDyn: features.moduleNamespaceDyn === true,
primitiveWrapper: features.primitiveWrapper === true,
nativeFetch,
// The fetch unit has AbortController and AbortSignal; scr_abort.c is
// the same implementation without the fetch transport.
@@ -395,6 +420,8 @@ export interface RuntimePackArtifacts {
/** Selected unit variants; each may carry its bitcode artifact. */
runtime: (RuntimePackArtifact & { bitcode?: RuntimePackArtifact })[];
archives: RuntimePackArtifact[];
/** Files to install beside the executable (ICU's data file for Intl). */
data: RuntimePackArtifact[];
systemLibraries: string[];
}
@@ -447,12 +474,26 @@ export function selectRuntimePackArtifacts(
`runtime pack has no ${key} flavor; reinstall the matching runtime package`,
"invalid",
);
// Intl needs the ICU objects only the host-flavored packs carry (ICU is
// C++, so a pack builds it with Zig's C++ driver for its own glibc target);
// say so instead of linking a program whose scr_intl.c references would go
// undefined.
if (
features.intl === true &&
!selectedFlavor.runtime_units.some((unit) => unit.source === "scr_intl.c")
)
throw new RuntimePackError(
`the ${manifest.target.name} runtime pack has no ICU objects, which Intl needs; reinstall a runtime package that supports Intl`,
"unsupported",
);
return {
features,
runtime: selectedRuntimeUnits(selectedFlavor, features).map(({ variant }) => variant),
archives: manifest.archives.filter((archive) =>
evaluateRuntimePredicate(archive.predicate, features),
),
// ICU's data file is 11.1 MiB: only a program that links ICU installs it.
data: features.intl === true ? (manifest.data ?? []) : [],
systemLibraries: manifest.system_libraries
.filter((entry) => evaluateRuntimePredicate(entry.predicate, features))
.map((entry) => entry.name),
@@ -1,8 +1,12 @@
import { createHash } from "node:crypto";
import {
chmodSync,
existsSync,
mkdirSync,
readdirSync,
readFileSync,
renameSync,
rmSync,
statSync,
symlinkSync,
unlinkSync,
@@ -148,10 +152,12 @@ async function packFixture() {
store,
);
let stages = 0;
const stage = (selection: NativeRuntimeSelection) => {
const directory = join(root, `stage-${stages++}`);
const stage = (selection: NativeRuntimeSelection, with_: RuntimePackStore = store) => {
const directory = join(tmpdir(), `scriptc-runtime-pack-stage-${process.pid}-${stages++}`);
rmSync(directory, { recursive: true, force: true });
mkdirSync(directory);
return stageNativeRuntimeSelection(selection, directory, store);
dirs.push(directory);
return stageNativeRuntimeSelection(selection, directory, with_);
};
return { root, object, manifestText, entries, select, stage };
}
@@ -241,3 +247,180 @@ test.skipIf(process.platform === "win32")(
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
},
);
/** A store that keeps nothing: a build with an empty cache. */
function emptyStore(): RuntimePackStore {
const entries = new Map<string, string>();
return {
read: (key) => entries.get(key) ?? null,
write: (key, text) => void entries.set(key, text),
};
}
const packRecord = (root: string) => join(root, "runtime-pack.verified.json");
const hashes = () => vi.mocked(createHash).mock.calls.length;
test("a build with an empty cache trusts the record beside the pack", async () => {
const f = await packFixture();
const selection = f.select();
let start = hashes();
f.stage(selection);
const firstStage = hashes() - start;
const record = JSON.parse(readFileSync(packRecord(f.root), "utf8"));
expect(record.schema).toBe(1);
expect(record.artifacts.map((artifact: { path: string }) => artifact.path)).toEqual([
"artifacts/base.o",
]);
start = hashes();
const staged = f.stage(selection, emptyStore());
// Only the store key is hashed.
expect(hashes() - start).toBe(firstStage - 1);
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
});
test.each(["replaced", "rewritten in place", "resized"])(
"the record beside the pack does not trust an artifact that was %s",
async (change) => {
const f = await packFixture();
const selection = f.select();
f.stage(selection);
const before = statSync(f.object);
const changed = change === "replaced" ? f.object + ".new" : f.object;
writeFileSync(changed, "x".repeat(before.size + (change === "resized" ? 1 : 0)));
utimesSync(changed, before.atime, before.mtime);
if (changed !== f.object) renameSync(changed, f.object);
expect(() => f.stage(selection, emptyStore())).toThrow(
"runtime pack artifact hash mismatch: artifacts/base.o",
);
},
);
test("an artifact whose mtime changed is hashed again and recorded again", async () => {
const f = await packFixture();
const selection = f.select();
f.stage(selection);
const before = statSync(f.object);
utimesSync(f.object, before.atime, new Date(before.mtimeMs + 5000));
const start = hashes();
f.stage(selection, emptyStore());
// The store key and the artifact.
expect(hashes() - start).toBe(2);
const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts;
expect(entry.mtime).toBe(statSync(f.object).mtimeMs);
const again = hashes();
f.stage(selection, emptyStore());
expect(hashes() - again).toBe(1);
});
test.each([
["truncated", () => "{"],
["null", () => "null"],
["another schema", (text: string) => text.replace('"schema":1', '"schema":2')],
[
"an entry with a string ctime",
(text: string) => text.replace(/"ctime":([\d.]+)/, '"ctime":"$1"'),
],
["an entry without an inode", (text: string) => text.replace(/"ino":[\d.]+,/, "")],
[
"another digest",
(text: string) => text.replace(/"sha256":"[0-9a-f]+"/, `"sha256":"${"0".repeat(64)}"`),
],
])("a record beside the pack that is %s is not trusted", async (_name, rewrite) => {
const f = await packFixture();
const selection = f.select();
f.stage(selection);
const text = readFileSync(packRecord(f.root), "utf8");
const rewritten = rewrite(text);
expect(rewritten).not.toBe(text);
writeFileSync(packRecord(f.root), rewritten);
const start = hashes();
const staged = f.stage(selection, emptyStore());
expect(hashes() - start).toBe(2);
expect(readFileSync(staged.runtimeObjects[0]!, "utf8")).toBe("base object");
// The record is written again, whole.
const [entry] = JSON.parse(readFileSync(packRecord(f.root), "utf8")).artifacts;
expect(entry.ino).toBe(statSync(f.object).ino);
});
test("an unreadable record beside the pack is not trusted, and staging still succeeds", async () => {
const f = await packFixture();
const selection = f.select();
// A directory where the record would be: it can be neither read nor replaced.
mkdirSync(packRecord(f.root));
const start = hashes();
f.stage(selection, emptyStore());
f.stage(selection, emptyStore());
expect(hashes() - start).toBe(4);
expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
});
test.skipIf(process.platform === "win32")(
"a pack directory that cannot be written keeps the record in the cache only",
async (context) => {
const f = await packFixture();
const selection = f.select();
chmodSync(f.root, 0o555);
try {
// A process that may write any directory cannot run this case.
try {
writeFileSync(join(f.root, "probe"), "");
rmSync(join(f.root, "probe"));
context.skip();
} catch {
/* The directory is read-only for this process. */
}
const store = emptyStore();
let start = hashes();
f.stage(selection, store);
expect(hashes() - start).toBe(2);
expect(existsSync(packRecord(f.root))).toBe(false);
expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
// The cache's record still answers the next build with that cache.
start = hashes();
f.stage(selection, store);
expect(hashes() - start).toBe(1);
} finally {
chmodSync(f.root, 0o755);
}
},
);
test("writing the record beside the pack keeps the entries another build wrote", async () => {
const f = await packFixture();
const selection = f.select();
const other = {
path: "artifacts/other.o",
sha256: "1".repeat(64),
dev: 1,
ino: 2,
size: 3,
mtime: 4,
ctime: 5,
};
writeFileSync(packRecord(f.root), JSON.stringify({ schema: 1, artifacts: [other] }));
f.stage(selection, emptyStore());
const record = JSON.parse(readFileSync(packRecord(f.root), "utf8"));
expect(record.artifacts.map((artifact: { path: string }) => artifact.path).sort()).toEqual([
"artifacts/base.o",
"artifacts/other.o",
]);
expect(
record.artifacts.find((artifact: { path: string }) => artifact.path === other.path),
).toEqual(other);
expect(readdirSync(f.root).filter((name) => name.endsWith(".tmp"))).toEqual([]);
});
test("a cache that already trusts the artifacts writes the record beside the pack", async () => {
const f = await packFixture();
const selection = f.select();
f.stage(selection);
rmSync(packRecord(f.root));
const start = hashes();
f.stage(selection);
// The cache's record answers; only the store key is hashed.
expect(hashes() - start).toBe(1);
expect(existsSync(packRecord(f.root))).toBe(true);
const empty = hashes();
f.stage(selection, emptyStore());
expect(hashes() - empty).toBe(1);
});
@@ -2,22 +2,27 @@
* selected artifact is verified and copied from the same read into private
* storage. With a store, a pack's selections and the identities of the
* artifacts it verified are kept between builds: an artifact whose file
* identity is unchanged since it was hashed is not hashed again. */
* identity is unchanged since it was hashed is not hashed again. The
* identities are also kept in a record beside the pack when its directory
* can be written, which builds with an empty store read too. */
import type { NativeOptimization } from "./optimization.js";
import { createHash } from "node:crypto";
import { createHash, randomBytes, randomUUID } from "node:crypto";
import {
closeSync,
constants,
copyFileSync,
existsSync,
fstatSync,
mkdirSync,
openSync,
readFileSync,
renameSync,
rmSync,
statSync,
writeSync,
type Stats,
} from "node:fs";
import { dirname, join } from "node:path";
import { basename, dirname, join, resolve } from "node:path";
import type { NativeLinkFeatures } from "./native-link-info.js";
import type { NativeTargetSpec } from "./targets.js";
import {
@@ -94,7 +99,9 @@ function storeKey(text: string): string {
/** A stored selection: what selecting from the pack's files, with these
* identities, returned. */
interface StoredSelection {
schema: 1;
/** 2 adds the selection's data artifacts: a stored selection from before
* them would make an Intl build stage none of them, so it is discarded. */
schema: 2;
licenses: RuntimePackManifest["licenses"];
selected: RuntimePackArtifacts;
}
@@ -172,7 +179,7 @@ export function selectNativeRuntimePack(
sameIdentity(fileIdentity(packagePath), identities[0]!) &&
sameIdentity(fileIdentity(manifestPath), identities[1]!)
) {
const entry: StoredSelection = { schema: 1, licenses: manifest.licenses, selected };
const entry: StoredSelection = { schema: 2, licenses: manifest.licenses, selected };
store.write(key, JSON.stringify(entry));
}
} catch {
@@ -201,13 +208,14 @@ function readStoredSelection(text: string | null): StoredSelection | null {
if (value === null || typeof value !== "object") return null;
const entry = value as Partial<StoredSelection>;
if (
entry.schema !== 1 ||
entry.schema !== 2 ||
!Array.isArray(entry.licenses) ||
entry.selected === undefined ||
entry.selected === null ||
typeof entry.selected !== "object" ||
!Array.isArray(entry.selected.runtime) ||
!Array.isArray(entry.selected.archives) ||
!Array.isArray(entry.selected.data) ||
!Array.isArray(entry.selected.systemLibraries)
)
return null;
@@ -239,21 +247,53 @@ function readVerified(
try {
const text = store?.read(key) ?? null;
if (text === null) return verified;
const stored = JSON.parse(text) as StoredVerification;
if (stored.schema === 1)
for (const artifact of stored.artifacts) verified.set(artifact.path, artifact);
for (const artifact of storedArtifacts(text)) verified.set(artifact.path, artifact);
} catch {
/* A malformed entry verifies everything again. */
}
return verified;
}
/** The well-formed entries of a stored verification; none when the text is
* not a stored verification. */
function storedArtifacts(text: string): VerifiedArtifact[] {
const stored = JSON.parse(text) as Partial<StoredVerification> | null;
if (stored === null || stored.schema !== 1 || !Array.isArray(stored.artifacts)) return [];
const number = (value: unknown): boolean => typeof value === "number" && Number.isFinite(value);
return stored.artifacts.filter(
(artifact): artifact is VerifiedArtifact =>
artifact !== null &&
typeof artifact === "object" &&
typeof artifact.path === "string" &&
typeof artifact.sha256 === "string" &&
number(artifact.dev) &&
number(artifact.ino) &&
number(artifact.size) &&
number(artifact.mtime) &&
number(artifact.ctime),
);
}
/** `known` records `artifact`'s digest and size for a file with `identity`. */
function verifiedAs(
known: VerifiedArtifact | undefined,
artifact: { sha256: string; size: number },
identity: FileIdentity,
): boolean {
return (
known !== undefined &&
known.sha256 === artifact.sha256 &&
known.size === artifact.size &&
sameIdentity(known, identity)
);
}
function stageArtifact(
root: string,
stage: string,
artifact: RuntimePackArtifact,
verified: Map<string, VerifiedArtifact>,
newlyVerified: VerifiedArtifact[],
records: readonly Map<string, VerifiedArtifact>[],
verified: VerifiedArtifact[],
): string {
const source = join(root, artifact.path);
const destination = join(stage, artifact.path);
@@ -276,26 +316,17 @@ function stageArtifact(
}
// The bytes are those last hashed when the file kept the identity it had
// then for the whole read.
const known = verified.get(artifact.path);
const unchanged = sameIdentity(before, after);
const trusted =
known !== undefined &&
known.sha256 === artifact.sha256 &&
known.size === artifact.size &&
unchanged &&
known.dev === before.dev &&
known.ino === before.ino &&
known.size === before.size &&
known.mtime === before.mtime &&
known.ctime === before.ctime;
unchanged && records.some((record) => verifiedAs(record.get(artifact.path), artifact, before));
if (
bytes.length !== artifact.size ||
(!trusted && createHash("sha256").update(bytes).digest("hex") !== artifact.sha256)
) {
throw new RuntimePackError(`runtime pack artifact hash mismatch: ${artifact.path}`, "invalid");
}
if (!trusted && unchanged)
newlyVerified.push({
if (unchanged)
verified.push({
path: artifact.path,
sha256: artifact.sha256,
dev: before.dev,
@@ -353,10 +384,12 @@ export function stageNativeRuntimeSelection(
const packagePath = join(root, "package.json");
const manifestPath = join(root, "runtime-pack.json");
const key = storeKey(JSON.stringify({ schema: 1, verified: root }));
const verified = readVerified(store, key);
const newlyVerified: VerifiedArtifact[] = [];
const cacheRecord = readVerified(store, key);
const packRecordPath = join(root, PACK_VERIFIED_FILE);
const packRecord = readPackVerified(packRecordPath);
const verified: VerifiedArtifact[] = [];
const stage = (artifact: RuntimePackArtifact): string =>
stageArtifact(root, stageRoot, artifact, verified, newlyVerified);
stageArtifact(root, stageRoot, artifact, [cacheRecord, packRecord], verified);
const runtimeObjects = selected.runtime.map(stage);
const archives = selected.archives.map(stage);
for (const license of licenses) {
@@ -372,14 +405,112 @@ export function stageNativeRuntimeSelection(
) {
throw new RuntimePackError("runtime pack changed while staging verified artifacts", "invalid");
}
if (store !== undefined && newlyVerified.length > 0) {
for (const artifact of newlyVerified) verified.set(artifact.path, artifact);
const entry: StoredVerification = { schema: 1, artifacts: [...verified.values()] };
// Each record learns the artifacts verified here that it does not list
// with their current identity.
const missing = (record: Map<string, VerifiedArtifact>): VerifiedArtifact[] =>
verified.filter((artifact) => !verifiedAs(record.get(artifact.path), artifact, artifact));
const cacheMissing = missing(cacheRecord);
if (store !== undefined && cacheMissing.length > 0) {
for (const artifact of cacheMissing) cacheRecord.set(artifact.path, artifact);
const entry: StoredVerification = { schema: 1, artifacts: [...cacheRecord.values()] };
try {
store.write(key, JSON.stringify(entry));
} catch {
/* A failed write only means hashing again next time. */
}
}
const packMissing = missing(packRecord);
if (packMissing.length > 0) writePackVerified(packRecordPath, packMissing);
return { runtimeObjects, archives, systemLibraries: selected.systemLibraries };
}
/** Install the selected RUN-time data artifacts (ICU's icudt78l.dat) beside an
* executable this host linked. The in-process linker does the same from its
* own selection (backend/linker.ts); this host writes executables
* synchronously and has no link plan, so it installs them here.
*
* A file already present at the pinned size is left alone (11.1 MiB of pure
* cost otherwise), and the copy is atomic — a private temporary, then a
* rename — so two concurrent builds of one program never read a partial
* file. */
export function installNativeRuntimeData(
selection: NativeRuntimeSelection,
outputPath: string,
): void {
const data = selection.selected.data ?? [];
if (data.length === 0) return;
const outputDir = dirname(resolve(outputPath));
for (const artifact of data) {
const destination = join(outputDir, basename(artifact.path));
const existing = statSync(destination, { throwIfNoEntry: false });
if (existing?.isFile() === true && existing.size === artifact.size) continue;
const temporary = join(
outputDir,
`.${basename(destination)}.scriptc-${process.pid}-${randomUUID()}`,
);
copyFileSync(join(selection.root, artifact.path), temporary);
try {
renameSync(temporary, destination);
} catch {
rmSync(destination, { force: true });
renameSync(temporary, destination);
}
}
}
/** The verification record kept beside the runtime pack, so a build with an
* empty cache does not hash the pack's artifacts again. Like the cache's
* record, it trusts an artifact only while its file keeps the identity
* (device, inode, size, mtime, ctime) the record lists. */
export const PACK_VERIFIED_FILE = "runtime-pack.verified.json";
/** The pack record's well-formed entries; none when it is missing,
* unreadable or malformed. */
function readPackVerified(path: string): Map<string, VerifiedArtifact> {
const verified = new Map<string, VerifiedArtifact>();
try {
for (const artifact of storedArtifacts(readFileSync(path, "utf8")))
verified.set(artifact.path, artifact);
} catch {
/* The artifacts are hashed again. */
}
return verified;
}
/** Adds `artifacts` to the pack record: the record as it is now, with these
* entries replacing the ones for the same paths, written to a new file and
* renamed over the record, so a reader sees a whole record. Of two builds
* writing at once, the last rename wins; the entries the other added are
* hashed again by a later build. When the pack directory cannot be written,
* the cache's record is the only one. */
function writePackVerified(path: string, artifacts: readonly VerifiedArtifact[]): void {
const temporary = `${path}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`;
try {
const record = readPackVerified(path);
for (const artifact of artifacts) record.set(artifact.path, artifact);
const entry: StoredVerification = { schema: 1, artifacts: [...record.values()] };
const bytes = Buffer.from(JSON.stringify(entry));
const fd = openSync(
temporary,
constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL,
0o644,
);
try {
let offset = 0;
while (offset < bytes.length) {
const written = writeSync(fd, bytes, offset, bytes.length - offset);
if (written === 0) throw new Error("the pack record was not written");
offset += written;
}
} finally {
closeSync(fd);
}
renameSync(temporary, path);
} catch {
try {
rmSync(temporary, { force: true });
} catch {
/* Cleanup is best effort. */
}
}
}
@@ -52,7 +52,7 @@ async function fixture(target: NativeTargetSpec, workers = false) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: { command: "fixture", identity: "fixture", target: target.llvmTriple },
macros: { executable: [], excluded: ["SCR_LIB"], sanitizer: "external-toolchain-required" },
flavors: {
@@ -120,6 +120,7 @@ const features = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -62,6 +62,7 @@ const BASE: NativeLinkFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -135,7 +136,7 @@ async function fixture(target: NativeTargetSpec = MACOS_ARM64_TARGET) {
object_format: target.objectFormat,
minimum_os: target.minimumOs,
},
runtime_abi: { version: 47, marker: "scr_runtime_abi_v47" },
runtime_abi: { version: 48, marker: "scr_runtime_abi_v48" },
compiler: {
command: "clang",
identity: "fixture clang",
+32 -5
View File
@@ -40,12 +40,19 @@ export interface RuntimePackSelection {
features: RuntimeFeatureSet;
runtimeObjects: string[];
archives: string[];
/** Files the executable loads at run time, installed beside it. */
dataFiles: string[];
/** The ICU headers an Intl selection ships (manifest `headers`), all under
* `<include root>/unicode/`, verified like the other artifacts. */
headerFiles: string[];
systemLibraries: string[];
dependencyPaths: string[];
/** Exact installed inputs observed while the selected artifacts were verified. */
sourceDependencies: NativeArtifactDependency[];
selectedRuntimeArtifacts: RuntimePackArtifact[];
selectedArchiveArtifacts: RuntimePackArtifact[];
selectedDataArtifacts: RuntimePackArtifact[];
selectedHeaderArtifacts: RuntimePackArtifact[];
}
async function verifyArtifact(root: string, artifact: RuntimePackArtifact): Promise<string> {
@@ -209,18 +216,27 @@ export async function loadRuntimePack(options: {
const features = selected.features;
const selectedVariants = selected.runtime;
const selectedArchives = selected.archives;
const selectedArtifactPaths = [...selectedVariants, ...selectedArchives].map((artifact) =>
join(root, artifact.path),
);
const selectedData = selected.data;
// The C-source path compiles scr_intl.c against these headers and links
// the pack's ICU archive (index.ts runtimePackIcuInputs).
const selectedHeaders = features.intl === true ? (manifest.headers ?? []) : [];
const selectedArtifactPaths = [
...selectedVariants,
...selectedArchives,
...selectedData,
...selectedHeaders,
].map((artifact) => join(root, artifact.path));
let artifactDependencies: NativeArtifactDependency[];
try {
artifactDependencies = await snapshotDependencies(selectedArtifactPaths);
} catch {
throw new RuntimePackError("runtime pack artifact set changed during selection", "invalid");
}
const [runtimeObjects, archives] = await Promise.all([
const [runtimeObjects, archives, dataFiles, headerFiles] = await Promise.all([
Promise.all(selectedVariants.map((artifact) => verifyArtifact(root, artifact))),
Promise.all(selectedArchives.map((artifact) => verifyArtifact(root, artifact))),
Promise.all(selectedData.map((artifact) => verifyArtifact(root, artifact))),
Promise.all(selectedHeaders.map((artifact) => verifyArtifact(root, artifact))),
]);
await Promise.all(manifest.licenses.map((license) => readFile(join(root, license.path)))).catch(
() => {
@@ -242,10 +258,21 @@ export async function loadRuntimePack(options: {
features,
runtimeObjects,
archives,
dataFiles,
headerFiles,
systemLibraries: selected.systemLibraries,
dependencyPaths: [packagePath, manifestPath, ...runtimeObjects, ...archives],
dependencyPaths: [
packagePath,
manifestPath,
...runtimeObjects,
...archives,
...dataFiles,
...headerFiles,
],
sourceDependencies,
selectedRuntimeArtifacts: selectedVariants,
selectedArchiveArtifacts: selectedArchives,
selectedDataArtifacts: selectedData,
selectedHeaderArtifacts: selectedHeaders,
};
}
@@ -1,6 +1,6 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readdir, rm } from "node:fs/promises";
import { mkdir, mkdtemp, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
import { availableParallelism, tmpdir } from "node:os";
import { basename, join, resolve } from "node:path";
import { promisify } from "node:util";
@@ -11,9 +11,32 @@ import {
validCachedFile,
} from "./build-cache.js";
import type { CcDriver } from "./native/driver.js";
import { NativeCodegenError } from "./native-codegen-core.js";
const execFileAsync = promisify(execFile);
/** One C++ translation unit of the ICU archive: the source, the archive member
* name it compiles to, and the flags of its group. */
interface IcuUnit {
path: string;
member: string;
flags: string[];
define?: string;
}
/** Zig's library directory, which carries the libc++/libc++abi sources the
* ICU archive merges: `ZIG_LIB_DIR` when the caller pins one, else the pinned
* toolchain's own answer. */
async function zigLibDir(): Promise<string> {
const pinned = process.env["ZIG_LIB_DIR"];
if (pinned !== undefined && pinned !== "") return pinned;
const { stdout } = await execFileAsync("zig", ["env"]);
const match = /\.lib_dir\s*=\s*"([^"]+)"/.exec(stdout);
if (match === null)
throw new Error("Intl needs Zig 0.16.0's C++ toolchain: `zig env` named no lib_dir");
return match[1]!;
}
/** The section flags the libregexp/libunicode objects build with (the
* runtime pack's executable_section_elimination flags). */
const LRE_SECTION_FLAGS = ["-ffunction-sections", "-fdata-sections"];
@@ -24,13 +47,33 @@ function vendorObjectName(source: string): string {
return source.replaceAll("/", "-").replace(/\.c$/, ".o");
}
/** The sha256 of a buffer, as lowercase hex. */
function sha256(bytes: Uint8Array): string {
return createHash("sha256").update(bytes).digest("hex");
}
/** Whether `path` is a non-empty file. */
async function presentFile(path: string): Promise<boolean> {
try {
return (await stat(path)).size > 0;
} catch {
return false;
}
}
import {
QJS_COMMIT,
MBEDTLS_VERSION,
ZLIB_VERSION,
BROTLI_VERSION,
ICU_VERSION,
ICU_SOURCE_URL,
ICU_SOURCE_SHA256,
ICU_SOURCE_SIZE,
ICU_CXX_TARGET,
LRE_SOURCES,
ZLIB_SOURCES,
icuSourceList,
zlibInputs,
} from "./vendor-inputs.js";
export {
@@ -38,6 +81,7 @@ export {
MBEDTLS_VERSION,
ZLIB_VERSION,
BROTLI_VERSION,
ICU_VERSION,
LRE_SOURCES,
ZLIB_SOURCES,
} from "./vendor-inputs.js";
@@ -532,9 +576,271 @@ export function createVendorArchives(context: VendorArchiveContext) {
return archive;
}
/** The fetched ICU release root (`icu/`, holding `source/` and LICENSE),
* downloaded once per cache root and reused after that. ICU is not
* vendored: the release's own source tarball is pinned by version and
* sha256 in vendor-inputs.ts, verified on every use, and extracted by the
* system `tar` as the libnode build does. An existing extracted tree is
* enough, so an offline build works from a pre-populated cache. */
const icuSourcesByCacheRoot = new Map<string, Promise<string>>();
function ensureIcuSources(cacheRoot: string = vendorBuildCacheRoot()): Promise<string> {
const pending = icuSourcesByCacheRoot.get(cacheRoot);
if (pending !== undefined) return pending;
const pendingFetch = (async (): Promise<string> => {
const tree = join(cacheRoot, `icu4c-${ICU_VERSION}-source`);
if (await presentFile(join(tree, "source", "common", "sources.txt"))) return tree;
const tarball = join(cacheRoot, `icu4c-${ICU_VERSION}-sources.tgz`);
let archive: Buffer;
try {
archive = await readFile(tarball);
if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256)
throw new Error("cached ICU source tarball does not match its pin");
} catch {
let response: Response;
try {
response = await fetch(ICU_SOURCE_URL);
} catch (cause) {
throw new Error(
`could not download ICU's sources (${ICU_SOURCE_URL}); ` +
`populate ${tarball} or allow network access`,
{ cause },
);
}
if (!response.ok)
throw new Error(
`could not download ICU's sources (${ICU_SOURCE_URL}): ` +
`${response.status} ${response.statusText}`,
);
archive = Buffer.from(await response.arrayBuffer());
if (archive.length !== ICU_SOURCE_SIZE || sha256(archive) !== ICU_SOURCE_SHA256)
throw new Error(
`ICU source tarball from ${ICU_SOURCE_URL} is ${archive.length} bytes with sha256 ` +
`${sha256(archive)}, expected ${ICU_SOURCE_SIZE} bytes with sha256 ` +
`${ICU_SOURCE_SHA256}`,
);
await mkdir(cacheRoot, { recursive: true });
await writeFile(tarball, archive);
}
await mkdir(cacheRoot, { recursive: true });
const stage = await mkdtemp(join(cacheRoot, ".icu-sources-"));
try {
await execFileAsync("tar", ["-xzf", tarball, "-C", stage]);
if (!(await presentFile(join(stage, "icu", "source", "common", "sources.txt"))))
throw new Error(`ICU source tarball ${ICU_SOURCE_URL} does not hold icu/source`);
await rm(tree, { recursive: true, force: true });
await rename(join(stage, "icu"), tree);
} finally {
await rm(stage, { recursive: true, force: true });
}
return tree;
})();
icuSourcesByCacheRoot.set(cacheRoot, pendingFetch);
pendingFetch.catch(() => icuSourcesByCacheRoot.delete(cacheRoot));
return pendingFetch;
}
/** The archive is the same for sanitized and plain builds: it is never
* instrumented (see ensureIcuArchive), so neither the path nor the build
* carries a sanitizer flavor. */
function icuArchivePath(
driver: CcDriver,
buildIdentity: string,
cacheRoot: string = vendorBuildCacheRoot(),
): string {
const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
return join(cacheRoot, `icu-${ICU_VERSION}-${flavor}`, "libicu.a");
}
/** The ICU archive for one flavor, compiled lazily on the first
* Intl-using build (~470 C++ TUs, parallelized) and cached under
* <build-cache>/vendor/icu-<version>-<flavor>-<target>-<toolchain>/
* libicu.a with the mbedTLS archive's atomic-rename publish.
*
* Three things make this archive different from every other vendored one:
*
* - It is C++, so the pack's C driver cannot compile it: the TUs (and the
* standard library they use) build with Zig's C++ driver for the glibc
* target the runtime packs pin. That target is the point. In C++ mode,
* glibc 2.38+ headers redirect strtol/strtoul to the C23 __isoc23_*
* names, and a host libstdc++ binds those names too, so a host-compiled
* C++ archive would raise the glibc floor above the packs'. Zig's
* bundled libc++ and libc++abi compile from the toolchain's own sources
* for the target and bind the target's names only.
* - ICU requires RTTI (`typeid` in unifiedcache.h and ustrenum.cpp,
* `dynamic_cast` in uniset_props.cpp), so the design note's
* `-fno-rtti` is not available; `-fno-exceptions` is, and ICU, libc++,
* and libc++abi all build so. libc++abi's Itanium personality routine
* (cxa_personality.cpp) is the one unit that needs exceptions, so it is
* left out: nothing can throw into it.
* - Both the standard library and the C++ runtime it needs are merged INTO
* the archive, so the final link stays C-only: a plain `clang`/`zig` C
* link with the runtime pack's `-lm` resolves every undefined symbol.
* Members are prefixed by source (`cxx-libcxx-`, `cxx-libcxxabi-`) so
* their names stay unique.
*
* ICU builds with symbol renaming on (version suffixes `_78`), which is
* what lets the zero-cost gate assert that only Intl programs link ICU
* symbols.
*
* Sanitized builds link this same uninstrumented archive. AddressSanitizer
* cannot instrument it: Zig's bundled libc++ compiles with
* `-fsanitize=address` only against a `sanitizer/asan_interface.h` its
* tree does not ship, and compiling ~600 C++ TUs again for the sanitizer
* lane would add minutes to the first Intl program of every sanitized
* build. The runtime's own ICU code (scr_intl.c) still compiles with the
* sanitizer, and AddressSanitizer's interceptors cover ICU's allocations,
* so a sanitized Intl program reports heap errors from ICU data as it
* does everywhere else — the ICU code itself is simply not instrumented. */
async function ensureIcuArchive(
driver: CcDriver,
buildIdentity: string,
cacheRoot: string = vendorBuildCacheRoot(),
): Promise<string> {
// The archive is host-targeted ELF for the packs' glibc floor; a cross
// target would need a Zig C++ link recipe this slice has not validated.
// Both refusals are compile diagnostics (SC3002), not internal errors:
// an Intl program on any other host or target is a documented "not
// here yet", the same answer the missing runtime pack gives.
if (driver.target !== null)
throw new NativeCodegenError(
"SC3002",
`Intl needs ICU, which scriptc builds for the host's linux-x64 gnu target only: ` +
`the ${driver.target} target is not supported yet`,
);
if (process.platform !== "linux" || process.arch !== "x64")
throw new NativeCodegenError(
"SC3002",
"Intl needs ICU, which this compiler builds for linux-x64 only: " +
`the ${process.platform}-${process.arch} host is not supported yet`,
);
const flavor = `plain-${vendorCacheTargetFlavor(driver)}-${buildIdentity}`;
const archive = icuArchivePath(driver, buildIdentity, cacheRoot);
if (await validVendorArtifact(archive)) return archive;
await mkdir(cacheRoot, { recursive: true });
const buildDir = await mkdtemp(join(tmpdir(), `scriptc-vendor-icu-${flavor}-`));
// The tarball extracts to `icu/source/...` with ICU's LICENSE beside it.
const vendor = join(await ensureIcuSources(cacheRoot), "source");
const libDir = await zigLibDir();
const cxx = ["zig", "c++", "-target", ICU_CXX_TARGET];
const optimize = ["-Os"];
const commonFlags = [
"-std=c++17",
...optimize,
"-fno-exceptions",
"-ffunction-sections",
"-fdata-sections",
];
const icuFlags = [
...commonFlags,
"-DU_STATIC_IMPLEMENTATION",
"-DU_ATTRIBUTE_DEPRECATED=",
"-DNDEBUG",
"-Wno-deprecated-declarations",
"-I",
join(vendor, "common"),
"-I",
join(vendor, "i18n"),
];
// libc++ 20 sources need C++20 (`constinit`, ranges) and libc++'s
// charconv reads a header from `libcxx/libc`.
const cxxLibraryFlags = [
"-std=c++23",
...optimize,
"-fno-exceptions",
"-ffunction-sections",
"-fdata-sections",
"-I",
join(libDir, "libcxx", "include"),
"-I",
join(libDir, "libcxx", "src"),
"-I",
join(libDir, "libcxx", "libc"),
"-I",
join(libDir, "libcxxabi", "include"),
"-D_LIBCPP_BUILDING_LIBRARY",
"-D_LIBCXXABI_BUILDING_LIBRARY",
"-DLIBCXX_BUILDING_LIBCXXABI",
"-Wno-unknown-warning-option",
];
const cxxLibraryUnits = async (
subdirectory: string,
memberPrefix: string,
skip: ReadonlySet<string>,
): Promise<IcuUnit[]> => {
const sources = (await readdir(join(libDir, subdirectory, "src")))
.filter((name) => name.endsWith(".cpp") && !skip.has(name))
.sort();
return sources.map((source) => ({
path: join(libDir, subdirectory, "src", source),
member: `${memberPrefix}${source}`,
flags: cxxLibraryFlags,
}));
};
try {
const units: IcuUnit[] = [
...icuSourceList(await readFile(join(vendor, "common", "sources.txt"), "utf8")).map(
(source) => ({
path: join(vendor, "common", source),
member: `common-${source}`,
flags: icuFlags,
define: "U_COMMON_IMPLEMENTATION",
}),
),
{
path: join(vendor, "stubdata", "stubdata.cpp"),
member: "stubdata-stubdata.cpp",
flags: icuFlags,
define: "U_COMMON_IMPLEMENTATION",
},
...icuSourceList(await readFile(join(vendor, "i18n", "sources.txt"), "utf8")).map(
(source) => ({
path: join(vendor, "i18n", source),
member: `i18n-${source}`,
flags: icuFlags,
define: "U_I18N_IMPLEMENTATION",
}),
),
...(await cxxLibraryUnits("libcxx", "cxx-libcxx-", new Set())),
...(await cxxLibraryUnits("libcxxabi", "cxx-libcxxabi-", new Set(["cxa_personality.cpp"]))),
];
const width = availableParallelism();
for (let i = 0; i < units.length; i += width) {
await Promise.all(
units.slice(i, i + width).map(async (unit) => {
const output = join(buildDir, unit.member.replace(/\.cpp$/, ".o"));
await execFileAsync(cxx[0]!, [
...cxx.slice(1),
...unit.flags,
...(unit.define === undefined ? [] : [`-D${unit.define}`]),
"-g0",
"-c",
unit.path,
"-o",
output,
]);
return output;
}),
);
}
await execFileAsync("ar", [
"rcs",
join(buildDir, "libicu.a"),
...units.map((unit) => join(buildDir, unit.member.replace(/\.cpp$/, ".o"))),
]);
await publishVendorArtifact(join(buildDir, "libicu.a"), archive);
} finally {
await rm(buildDir, { recursive: true, force: true });
}
return archive;
}
return {
vendorEngineDir,
vendorTlsDir,
ensureIcuSources,
icuArchivePath,
ensureIcuArchive,
vendorBrotliDir,
brotliArchivePath,
ensureBrotliArchive,
@@ -2,7 +2,21 @@ import { existsSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath, pathToFileURL } from "node:url";
import { expect, test } from "vitest";
import { ZLIB_SOURCES, zlibInputs } from "./vendor-inputs.js";
import {
ZLIB_SOURCES,
ICU_VERSION,
ICU_SOURCE_URL,
ICU_SOURCE_SHA256,
ICU_SOURCE_SIZE,
ICU_DATA_URL,
ICU_DATA_FILE,
ICU_DATA_SHA256,
ICU_DATA_SIZE,
ICU_DATA_ZIP_SHA256,
ICU_DATA_ZIP_SIZE,
icuSourceList,
zlibInputs,
} from "./vendor-inputs.js";
const repoRoot = fileURLToPath(new URL("../../../..", import.meta.url));
const zlibDir = join(repoRoot, "packages/runtime/vendor/zlib");
@@ -78,3 +92,39 @@ test("zlib inputs follow Node's deps/zlib/zlib.gyp", () => {
expect(wasm.sources).not.toContain("contrib/optimizations/inflate.c");
expect(wasm.defines).toEqual(["HAVE_HIDDEN", "CPU_NO_SIMD"]);
});
test("the pinned ICU is the release the oracle Node bundles", () => {
// Node's own Intl answers from the ICU it was built with, so the fetched
// release must be that version exactly: the differential corpus compares
// scriptc's ICU output against Node's, byte for byte.
expect(process.versions.icu).toBe(ICU_VERSION);
});
test("the compiler's ICU pins match the runtime packs'", async () => {
const pack = (await import(
pathToFileURL(join(repoRoot, "packages/runtime-pack-common/scripts/icu-inputs.mjs")).href
)) as Record<string, string | number>;
expect(pack["ICU_VERSION"]).toBe(ICU_VERSION);
expect(pack["ICU_SOURCE_URL"]).toBe(ICU_SOURCE_URL);
expect(pack["ICU_SOURCE_SHA256"]).toBe(ICU_SOURCE_SHA256);
expect(pack["ICU_SOURCE_SIZE"]).toBe(ICU_SOURCE_SIZE);
expect(pack["ICU_DATA_URL"]).toBe(ICU_DATA_URL);
expect(pack["ICU_DATA_FILE"]).toBe(ICU_DATA_FILE);
expect(pack["ICU_DATA_SHA256"]).toBe(ICU_DATA_SHA256);
expect(pack["ICU_DATA_SIZE"]).toBe(ICU_DATA_SIZE);
expect(pack["ICU_DATA_ZIP_SHA256"]).toBe(ICU_DATA_ZIP_SHA256);
expect(pack["ICU_DATA_ZIP_SIZE"]).toBe(ICU_DATA_ZIP_SIZE);
// Both pins name the official release assets for the pinned version.
expect(ICU_SOURCE_URL).toBe(
`https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-sources.tgz`,
);
expect(ICU_DATA_URL).toBe(
`https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}/icu4c-${ICU_VERSION}-data-bin-l.zip`,
);
expect(ICU_SOURCE_SHA256).toMatch(/^[0-9a-f]{64}$/);
expect(ICU_DATA_SHA256).toMatch(/^[0-9a-f]{64}$/);
});
test("ICU source lists parse to the units a build compiles", () => {
expect(icuSourceList("# a comment\n\n b.cpp \nc.cpp\n")).toEqual(["b.cpp", "c.cpp"]);
});
@@ -6,6 +6,49 @@ export const MBEDTLS_VERSION = "3.6.7";
export const ZLIB_VERSION = "1.3.2.1-motley-285e94b";
/** The brotli release Node v24.22.0 bundles (packages/runtime/vendor/brotli). */
export const BROTLI_VERSION = "1.2.0";
/** ICU4C release 78.3 — the exact version Node v24.22.0 bundles
* (`process.versions.icu` there), from the `release-78.3` tag. ICU is not
* vendored: the build fetches the release's own source tarball and data zip
* from unicode-org/icu's GitHub release, pinning each by version and sha256,
* and caches the download. The runtime packs ship its data file icudt78l.dat
* (33,107,232 B) without the categories no Intl form reads (11,653,984 B),
* beside the executable; see packages/runtime-pack-common/scripts/
* icu-data.mjs. */
export const ICU_VERSION = "78.3";
const ICU_RELEASE_URL = `https://github.com/unicode-org/icu/releases/download/release-${ICU_VERSION}`;
/** The release's source tarball (icu, not icu4j), the only build input that
* is not vendored. */
export const ICU_SOURCE_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-sources.tgz`;
export const ICU_SOURCE_SHA256 = "3a2e7a47604ba702f345878308e6fefeca612ee895cf4a5f222e7955fabfe0c0";
export const ICU_SOURCE_SIZE = 27_977_255;
/** The release's prebuilt little-endian data zip. 969 source files are
* reviewable; 33 MiB of compiled CLDR tables are not, so the data is fetched
* too, pinned the same way. */
export const ICU_DATA_URL = `${ICU_RELEASE_URL}/icu4c-${ICU_VERSION}-data-bin-l.zip`;
export const ICU_DATA_ZIP_SHA256 =
"982619632b78887f1895b063e96e8c3cc7f99283337c8abbd05aa71635de613c";
export const ICU_DATA_ZIP_SIZE = 12_836_659;
export const ICU_DATA_FILE = "icudt78l.dat";
export const ICU_DATA_SHA256 = "d5cf2a40dccbe471781ec7af85693bff542ff12f0b670c9630c4e72d60714b8b";
export const ICU_DATA_SIZE = 33_107_232;
/** The glibc target the ICU archive compiles for: the floor the linux-x64-gnu
* runtime pack builds against (packages/runtime-pack-common/scripts/
* glibc-toolchain.mjs GLIBC_RUNTIME_FLOOR), spelled for Zig's C++ driver.
* ICU is the one C++ archive, and C++ compiled against a newer host glibc
* binds the C23 strtol names (__isoc23_*) that floor forbids. */
export const ICU_CXX_TARGET = "x86_64-linux-gnu.2.34";
/** The translation units one of ICU's own source lists names, in its order.
* The fetched tarball carries the lists (`source/common/sources.txt`,
* `source/i18n/sources.txt`) verbatim, so the units a build compiles are
* exactly the units ICU's own autoconf build compiles for this release.
* Entries are bare file names. The pack build reads the same files
* (packages/runtime-pack-common/scripts/icu-data.mjs). */
export function icuSourceList(text: string): string[] {
return text
.split("\n")
.map((line) => line.trim())
.filter((line) => line.length > 0 && !line.startsWith("#"));
}
/** Exact translation-unit membership shared by cache builds and external
* source-pack recipes. */
export const LRE_SOURCES = ["libregexp.c", "libunicode.c"] as const;
@@ -63,6 +63,7 @@ export async function warmNativeCaches(
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
+28 -23
View File
@@ -155,6 +155,12 @@ const typedInterfaceUnsupported = "typed source has no compiler bridge for this
const metadataExclusion =
"WebIDL brand metadata is observable reflection, not an executable compatibility operation";
/** ReadableStream methods a typed program reaches through the web stream
* classes (scr_webstreams.c): on a stream it constructs with them, and on
* the native stream through its web view (scr_fetch.c, sf_stream_web_view).
* A program that calls one links the classes. */
export const READABLE_STREAM_WEB_VIEW_CALLS = ["pipeThrough", "pipeTo", "tee"] as const;
function buildFetchCompatProfile() {
return {
schemaVersion: 1,
@@ -233,7 +239,7 @@ function buildFetchCompatProfile() {
],
responseCalls: ["json", "text", "bytes", "clone"],
readableStreamReads: ["locked"],
readableStreamCalls: ["cancel", "getReader"],
readableStreamCalls: ["cancel", "getReader", ...READABLE_STREAM_WEB_VIEW_CALLS],
},
operations: [
{
@@ -447,6 +453,13 @@ function buildFetchCompatProfile() {
facets: ["webidl-conversion", "identity", "state-machine", "error-shape"],
evidence: [generated("stream-traces"), fixture("static-stream")],
},
...READABLE_STREAM_WEB_VIEW_CALLS.map((member): FetchCompatOperation => ({
id: `stdlib.readable-stream.${member}`,
name: `ReadableStream.${member}`,
kind: "method",
facets: ["identity", "promise-settlement", "state-machine", "error-shape"],
evidence: [fixture("static-web-streams")],
})),
{
id: "stdlib.readable-stream-default-reader.closed",
name: "ReadableStreamDefaultReader.closed",
@@ -757,25 +770,15 @@ function buildFetchCompatProfile() {
"getReader",
"prototype",
),
...["pipeThrough", "values"].map((member) =>
unsupportedEntry(
`stdlib.readable-stream.${member}`,
"ReadableStream",
member,
"prototype",
member === "values"
? "direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported"
: "the wider Web Streams graph is outside the native readable-stream slice",
),
...READABLE_STREAM_WEB_VIEW_CALLS.map((member) =>
staticEntry(`stdlib.readable-stream.${member}`, "ReadableStream", member, "prototype"),
),
...["pipeTo", "tee"].map((member) =>
unsupportedEntry(
`stdlib.readable-stream.${member}`,
"ReadableStream",
member,
"prototype",
"this operation is outside the native readable-stream implementation",
),
unsupportedEntry(
"stdlib.readable-stream.values",
"ReadableStream",
"values",
"prototype",
"direct for-await consumption compiles statically; storing or driving the first-class iterator handle is unsupported",
),
unsupportedEntry(
"stdlib.readable-stream.symbol.asyncIterator",
@@ -954,7 +957,8 @@ export interface StaticFetchMembers {
readableStreamReads: ReadonlySet<string>;
readableStreamCalls: ReadonlySet<string>;
/** ReadableStream prototype members the native stream does not
* implement, symbol-keyed ones as `[Symbol.name]`. */
* implement itself, symbol-keyed ones as `[Symbol.name]`: the unsupported
* ones, and the calls it answers through its web view. */
readableStreamUnsupported: ReadonlySet<string>;
}
@@ -979,8 +983,8 @@ export function staticFetchMembers(): StaticFetchMembers {
),
readableStreamReads: new Set(profile.members.readableStreamReads),
readableStreamCalls: new Set(profile.members.readableStreamCalls),
readableStreamUnsupported: new Set(
profile.inventory.entries
readableStreamUnsupported: new Set([
...profile.inventory.entries
.filter(
(entry) =>
entry.owner === "ReadableStream" &&
@@ -988,7 +992,8 @@ export function staticFetchMembers(): StaticFetchMembers {
entry.status === "unsupported",
)
.map((entry) => entry.member),
),
...READABLE_STREAM_WEB_VIEW_CALLS,
]),
};
return staticFetchMemberSets;
}
@@ -98,7 +98,7 @@ function coverageNotes(): string[] {
"Entries with status 'unsupported' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.",
`The engine-free fetch projection targets Node ${fetchTarget.node} with bundled Undici ${fetchTarget.undici}. Each projected row names the differential evidence that guards it; changing the pinned Node or Undici version is an explicit profile update.`,
"The fetch profile also contains a runtime-reflected census of the selected fetch, abort, Headers, and readable-stream interfaces plus RequestInit/ResponseInit dictionary reads. Static and unsupported census rows are projected here; its explicitly out-of-scope metadata rows and adjacent-interface exclusions remain in the profile so absence is deliberate rather than ambiguous.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0007 are preflight gates (SC0005-SC0007 are the ECMAScript early errors), SC1110 is a comptime evaluation failure, SC3001/SC3002 are backend/target tier refusals, SC9001/SC9002 are internal errors.",
"Entry statuses are projected for the desktop targets. The mobile targets (aarch64-apple-ios, aarch64-apple-ios-simulator, aarch64-linux-android) compile library-mode archives only: the library-admissible surface (what SC4005's async_free requirement and the library link set admit) is supported there, the executable lane refuses those triples with SC3002, and no entry outside the library-admissible surface carries a mobile support claim. iOS archives build for iOS 15.0 on darwin hosts; Android archives build against NDK API level 26.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];
@@ -19,6 +19,9 @@ export const DIAGNOSTIC_CODES = {
SC0002: "project configuration incompatible with scriptc",
SC0003: "malformed imported JSON module",
SC0004: "TypeScript checker panic",
SC0005: "duplicate lexical declaration in a block or switch statement list",
SC0006: "lexical declaration colliding with a var in the same block",
SC0007: "declaration in a single-statement position",
SC1010: "package imports outside the supported set",
SC1012: "default exports/imports (residual edges)",
SC1013: "namespace imports (residual edges)",
@@ -40,6 +43,7 @@ export const DIAGNOSTIC_CODES = {
SC1071: "generators",
SC1080: "'this' outside a class method",
SC1090: "this syntax (generic construct fence)",
SC1091: "write to an undeclared name in a JavaScript source",
SC1100: "operations on 'unknown' values",
SC1101: "converting typed values to 'unknown'",
SC1110: "comptime evaluation failure",
@@ -7,8 +7,12 @@
* Code bands:
* SC0xxx preflight gates: TypeScript errors passed through (SC0001),
* project-config incompatibilities (SC0002), malformed imported
* JSON modules (SC0003), and upstream tsgo checker panics
* surfaced as source-anchored diagnostics (SC0004)
* JSON modules (SC0003), upstream tsgo checker panics surfaced
* as source-anchored diagnostics (SC0004), and the ECMAScript
* early errors the checker does not model: a duplicated or
* `var`-colliding lexical declaration in a block or switch
* statement list (SC0005/SC0006) and a declaration where the
* grammar admits only a Statement (SC0007)
* SC1xxx supported-TypeScript-not-yet: valid TS outside the current subset
* SC2xxx scriptc type rules (types we cannot compile yet)
* SC3xxx backend/target coverage: the program is valid, but the selected
@@ -366,6 +370,24 @@ export function sharedModuleDuplicatedDiag(
};
}
/** SC8020 — the program replaces a process output stream's write method
* while a Node island exists: the island's `process.stdout` is its own
* object, so the replacement and the island's writes cannot see each other.
* H2 shares the native stream object with the islands and removes this. */
export function stdioPatchAcrossIslandsDiag(
stream: "stdout" | "stderr" | null,
loc: SrcLoc,
): ScrDiagnostic {
const what =
stream === null ? "process.stdout.write or process.stderr.write" : `process.${stream}.write`;
return {
code: "SC8020",
message: `${what} is replaced while a Node island exists: the island's process output stream would not see this patch, and island writes would bypass it`,
loc,
hint: "the island's process.stdout would not see this patch",
};
}
/** SC8022 — island code extends a class of a module the program compiles.
* A native instance cannot take an island subclass's overrides, so the
* module is not owned by the program: a package runs in Node for both
@@ -687,6 +709,11 @@ export const UNSUPPORTED: Record<string, UnsupportedEntry> = {
// remains is `this` outside any method (top level, plain functions).
SC1080: { feature: "'this' outside a class method", milestone: "later" },
SC1090: { feature: "this syntax", milestone: "later" }, // generic fallback; message names the construct
SC1091: {
feature: "writing an undeclared name in a JavaScript source",
milestone: "later",
hint: "a write to an undeclared name has no native lowering: sloppy mode makes it a property on Node's global object, which a native binary has none of, and strict mode makes it throw; declare the name, or keep the module in a Node island (sloppy-mode programs route there)",
},
SC1100: {
feature: "operations on 'unknown' values",
milestone: "later",
@@ -60,6 +60,7 @@ const native: EarlyExecutableNativeFeatures = {
dgramDyn: false,
textCodecDyn: false,
watch: false,
watchFile: false,
foreignFfi: false,
nodeTest: false,
tls: false,
@@ -220,7 +221,7 @@ test("early executable cache misses on source and resolution changes", async ()
expect(await readEarlyExecutableCache(f.root, f.options)).toBeNull();
});
test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, and FFI manifests", async () => {
test("early executable keys isolate compile modes, paths, implementation, checker, function layouts, shape inference, and FFI manifests", async () => {
const f = await fixture();
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
@@ -245,6 +246,8 @@ test("early executable keys isolate compile modes, paths, implementation, checke
{ ...f.options, profile: "profile-use:digest" },
{ ...f.options, functionLayout: "layout-digest" },
{ ...f.options, checker: "tsc-ts-executable-identity" },
{ ...f.options, shapeOverlay: true },
{ ...f.options, jsInference: true },
{ ...f.options, npmStatic: "auto" },
{ ...f.options, npmStatic: ["commander"] },
{ ...f.options, outPath: join(f.options.outDir, "other") },
@@ -64,10 +64,19 @@ export interface EarlyExecutableNativeFeatures {
eventTarget?: boolean;
wasm?: boolean;
warningReplace?: boolean;
recordViewPartial?: boolean;
stdioExtended?: boolean;
fsFlags?: boolean;
strdecDyn?: boolean;
processExtras?: boolean;
/** Error-taking write completion callbacks: SCR_STREAM_WRITE_ERRORS. */
streamWriteErrors?: boolean;
/** The process output write slot: SCR_STDIO_WRITE_SLOT. */
stdioWriteSlot?: boolean;
nodeIsland?: boolean;
moduleNamespace?: boolean;
moduleNamespaceDyn?: boolean;
primitiveWrapper?: boolean;
zlib: boolean;
brotli?: boolean;
assert: boolean;
@@ -111,6 +120,7 @@ export interface EarlyExecutableNativeFeatures {
dgramDyn: boolean;
textCodecDyn: boolean;
watch: boolean;
watchFile: boolean;
foreignFfi: boolean;
nodeTest: boolean;
tls: boolean;
@@ -123,6 +133,8 @@ export interface EarlyExecutableNativeFeatures {
perfLoop: boolean;
tlsCa: boolean;
webcrypto: boolean;
/** Intl over ICU (scr_intl.c, the ICU archive and its data file). */
intl?: boolean;
}
interface EarlyExecutableCacheStamp {
@@ -198,6 +210,9 @@ export interface EarlyExecutableCacheOptions {
/** Omitted unless shape inference's overlay compiles the packages
* (frontend/shape-inference). */
shapeOverlay?: true;
/** Omitted unless shape inference types the program's own JavaScript
* (SCRIPTC_INFER_JS=1, frontend/js-inference.ts). */
jsInference?: true;
/** Raw manifest identity: path and bytes. Native archives remain under the
* stricter native cache's independent dependency validation. */
ffiProfile: { path: string; bytes: Uint8Array } | null;
@@ -299,6 +314,7 @@ const BOOLEAN_NATIVE_KEYS = [
"dgramDyn",
"textCodecDyn",
"watch",
"watchFile",
"foreignFfi",
"nodeTest",
"tls",
@@ -355,6 +371,8 @@ function cacheKey(options: EarlyExecutableCacheOptions): string {
...(options.youngNursery ? ["young-nursery"] : []),
...(options.nodeIslands !== undefined ? [`node-islands:${options.nodeIslands}`] : []),
...(options.heapCensus ? ["heap-census"] : []),
...(options.shapeOverlay ? ["shape-overlay"] : []),
...(options.jsInference ? ["js-inference"] : []),
options.npmStatic === null
? "<npm-static-off>"
: options.npmStatic === "auto"
@@ -406,6 +424,8 @@ function routeKey(options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment"
if (options.youngNursery) hash.update("young-nursery\0");
if (options.nodeIslands !== undefined) hash.update(`node-islands:${options.nodeIslands}\0`);
if (options.heapCensus) hash.update("heap-census\0");
if (options.shapeOverlay) hash.update("shape-overlay\0");
if (options.jsInference) hash.update("js-inference\0");
hash
.update(
options.npmStatic === null
+18 -1
View File
@@ -8,6 +8,7 @@ import {
npmStaticRefusalDiag,
iceDiag,
isCheckerPanic,
stdioPatchAcrossIslandsDiag,
type ScrDiagnostic,
} from "../diagnostics/diagnostic.js";
import type { IrModule, SrcLoc } from "../ir/ir.js";
@@ -20,6 +21,7 @@ import {
} from "../backend/target-diagnostics.js";
import type { LowerResult, LowerStats } from "../frontend/lowering/lowerer.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import type { UnitStore } from "../frontend/lowering/unit-reuse.js";
import type { CompilationTiming } from "../timing.js";
import { readProjectConfig } from "../project-config.js";
import { nodeIslandPolicy } from "../node-islands/policy.js";
@@ -42,7 +44,9 @@ export interface PreparedExecutableModule {
warnings: ScrDiagnostic[];
}
/** Release the parser/checker before native object generation begins. */
/** Release the parser/checker before native object generation begins.
* `unitStore` keeps the lowered units of this build for the next one
* (unit-reuse.ts). */
export function prepareExecutableModule(
entryPath: string,
opts: CompileRequestOptions,
@@ -52,6 +56,7 @@ export function prepareExecutableModule(
timing: CompilationTiming = () => {},
lowerIslands: ReadonlyMap<string, IslandPackage> = new Map(),
retryFences = true,
unitStore: UnitStore | null = null,
): PreparedExecutableModule | CompileFailure {
// The island decision reads the detector's scan unless islands are off.
const islandScan =
@@ -115,6 +120,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
return fail(fe.preflight);
@@ -127,6 +133,7 @@ export function prepareExecutableModule(
targetPlatform: buildPlatform,
...(ffi !== null ? { ffiImports: ffi.functions } : {}),
...(opts.tier !== undefined ? { tierMode: opts.tier } : {}),
...(unitStore !== null ? { unitStore } : {}),
},
(module) => validateModule(module).length === 0,
);
@@ -165,6 +172,7 @@ export function prepareExecutableModule(
timing,
grown,
retryFences,
unitStore,
);
}
// A failed program whose compiled packages need the runtime or a
@@ -195,6 +203,14 @@ export function prepareExecutableModule(
return fail(lowered.diagnostics);
}
// A program that replaces a process output stream's write method has
// nowhere to route an island's writes: the island's process.stdout is
// its own object. Until islands share the native stream object (H2),
// refuse the combination instead of silently missing the patch.
if (islandDecision !== null && lowered.stdioPatches !== undefined) {
return fail(lowered.stdioPatches.map((p) => stdioPatchAcrossIslandsDiag(p.stream, p.loc)));
}
const validation = validateModule(lowered.module);
timing("ir-validate");
if (validation.length > 0) {
@@ -267,6 +283,7 @@ export function prepareExecutableModule(
timing,
islands,
false,
unitStore,
);
if (islanded.ok) return islanded;
firstError ??= islanded.diagnostics[0] ?? null;
@@ -0,0 +1,43 @@
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { checkPreflight, loadProgram } from "./program-node.js";
/** The preflight diagnostics of a CommonJS script (sloppy unless it says
* "use strict"). */
function diagnostics(source: string) {
const directory = mkdtempSync(join(tmpdir(), "scriptc-early-errors-"));
writeFileSync(join(directory, "main.cjs"), source);
const load = loadProgram(join(directory, "main.cjs"));
try {
return checkPreflight(load).map((diagnostic) => diagnostic.code);
} finally {
load.dispose();
rmSync(directory, { recursive: true, force: true });
}
}
test.for([
// A function body's top-level function declarations are var-scoped, so
// duplicates are legal in strict code too.
'function outer() { "use strict"; function d() {} function d() {} }\nouter();',
"class C { static { function s() {} function s() {} } }\nnew C();",
// Object literal methods and accessors are sloppy unless they say otherwise.
"var o = { m() { if (true) function f() {} } };\no.m();",
"var o = { get g() { { function h() {} function h() {} } return 1; } };\no.g;",
// A function's own directive makes its body strict, not the code declaring it.
'if (true) function k() { "use strict"; }',
'{ function z() { "use strict"; } function z() {} }',
])("Node accepts: %s", (source) => {
expect(diagnostics(source)).toEqual([]);
});
test.for([
["function outer() { let d; function d() {} }\nouter();", "SC0006"],
['(() => { "use strict"; if (true) function f() {} })();', "SC0007"],
["class C { m() { if (true) function f() {} } }\nnew C().m();", "SC0007"],
['var o = { m() { "use strict"; { function h() {} function h() {} } } };\no.m();', "SC0005"],
])("Node refuses: %s", ([source, code]) => {
expect(diagnostics(source!)).toContain(code);
});
@@ -0,0 +1,384 @@
/* ECMAScript early errors the TypeScript checker does not model.
*
* Static semantics reject a program before it runs, and until this pass
* existed scriptc borrowed that rejection from whatever tsc happened to
* report (`Let x` declarations at an odd position, a redeclared block name
* TypeScript refuses). Test262's negative-parse suites show where the
* borrowing stops: 1,198 programs the spec makes SyntaxErrors compile — the
* constructs below are the ones the corpus covers, in descending variant
* count (plan §7.7 D-4 option (c)).
*
* One code per construct, so C1's checker relaxation stays safe: relaxing a
* tsc code that doubles as an early error removes the ONLY rejection some
* parse-negative test had, which is why these codes live beside
* JS_RELAXED_TSC_CODES in tsc-codes.ts and never depend on it.
*
* Scope: JavaScript sources only. A TypeScript source is type-checked, and
* its checker diagnostics (TS2451 and friends) are the gate; re-reporting
* the same collision under a second code would only duplicate them.
*
* The pass runs at preflight, before any lowering, and reports through
* SC0xxx — the preflight band — so a rejected program fails the same way a
* tsc error does. */
import * as ts from "./ts7/adapter.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import { isJsSourceFileName } from "./tsc-codes.js";
/** The early-error codes. SC0005 and SC0006 are the Block/CaseBlock static
* semantics (a statement list may not declare one lexical name twice, and a
* lexical name may not be `var`-declared in the same list); SC0007 is a
* declaration where the grammar admits only a Statement. */
export const EARLY_ERROR_CODES = ["SC0005", "SC0006", "SC0007"] as const;
const NO_DUPLICATE_LEXICAL = "SC0005";
const LEXICAL_VAR_COLLISION = "SC0006";
const NOT_A_STATEMENT = "SC0007";
/** Whether the code around a node is strict — the one input this pass needs
* from outside: Node's own module classification (program.ts's
* isNodeEsmFile7: fixed extensions, package type, then syntax markers). The
* checker's external-module bit cannot answer it, because
* moduleDetection=force gives every file module scope, CommonJS included. */
export type IsStrictCode = (sf: ts.SourceFile, node: ts.Node) => boolean;
export function earlyErrorDiagnostics7(
files: readonly ts.SourceFile[],
isModuleCode: (sf: ts.SourceFile) => boolean,
): ScrDiagnostic[] {
const diags: ScrDiagnostic[] = [];
const isStrict: IsStrictCode = (sf, node) => isStrictCode(sf, node, isModuleCode);
for (const sf of files) {
if (!isJsSourceFileName(sf.fileName)) continue;
// Iterative walk: a deeply nested program must not overflow the stack
// before the lowering's own nesting fence runs.
const stack: ts.Node[] = [sf];
while (stack.length > 0) {
const node = stack.pop()!;
checkStatementListScope(sf, node, diags, isStrict);
checkStatementSlot(node, diags, isStrict);
ts.forEachChild(node, (child) => {
stack.push(child);
return undefined;
});
}
}
return diags;
}
function locOf(node: ts.Node): SrcLoc {
const sf = ts.getSourceFileOfNode(node);
return { file: sf.fileName, start: node.getStart(sf), end: node.end };
}
function report(diags: ScrDiagnostic[], code: EarlyErrorCode, node: ts.Node, message: string) {
diags.push({ code, message, loc: locOf(node) });
}
type EarlyErrorCode = (typeof EARLY_ERROR_CODES)[number];
/* ── SC0005 / SC0006: Block and CaseBlock static semantics ───────────────
*
* Block : { StatementList }
* CaseBlock : { CaseClauses_opt DefaultClause CaseClauses_opt }
*
* * It is a Syntax Error if the LexicallyDeclaredNames of StatementList
* contains any duplicate entries.
* * It is a Syntax Error if any element of the LexicallyDeclaredNames of
* StatementList also occurs in the VarDeclaredNames of StatementList.
*
* A case block is ONE lexical scope across its clauses, and VarDeclaredNames
* reach into nested blocks (a `var` anywhere below the block belongs to it)
* but never into a function or class body. The duplicate rule's one carve-out
* is Annex B's B.3.3.1: two plain function declarations of the same name in
* the same block are legal in non-strict code.
*
* A function body and a class static block have the same two rules over
* their top-level declarations (FunctionStatementList and
* ClassStaticBlockStatementList), but there a function declaration is
* var-scoped: it is one of the VarDeclaredNames, so two of them are legal in
* strict code too, while `let f; function f() {}` still collides. */
function checkStatementListScope(
sf: ts.SourceFile,
node: ts.Node,
diags: ScrDiagnostic[],
isStrict: IsStrictCode,
): void {
let statements: readonly ts.Statement[] | null = null;
if (ts.isBlock(node)) statements = node.statements.nodes;
else if (ts.isCaseBlock(node)) {
statements = node.clauses.nodes.flatMap((clause) => clause.statements.nodes);
}
if (statements === null) return;
if (statements.length < 2) return;
const functionBody = isFunctionBody(node);
const lexical = new Map<string, ts.Node[]>();
for (const stmt of statements) {
for (const [name, decl] of lexicalDeclarationsOf(stmt, functionBody)) {
const list = lexical.get(name);
if (list === undefined) lexical.set(name, [decl]);
else list.push(decl);
}
}
if (lexical.size === 0) return;
const varNames = new Set<string>();
for (const stmt of statements) {
collectVarDeclaredNames(stmt, varNames);
if (functionBody && ts.isFunctionDeclaration(stmt) && stmt.name !== undefined)
varNames.add(stmt.name.text);
}
for (const [name, decls] of lexical) {
if (varNames.has(name)) {
report(
diags,
LEXICAL_VAR_COLLISION,
decls[0]!,
functionBody
? `the lexical declaration '${name}' collides with a 'var' or function declaration of the same name in this function body`
: `the lexical declaration '${name}' collides with a 'var' declaration of the same name in this block`,
);
continue;
}
if (decls.length < 2) continue;
// Annex B B.3.3.1: duplicate plain function declarations are the web's
// legacy, and only non-strict code keeps them.
if (decls.every((d) => ts.isFunctionDeclaration(d) && !isEnhancedFunction(d))) {
if (!isStrict(sf, decls[0]!)) continue;
}
report(
diags,
NO_DUPLICATE_LEXICAL,
decls[1]!,
`the declaration '${name}' is already declared in this block's statement list`,
);
}
}
/** Whether a block is the body of a function or a class static block, whose
* top-level function declarations are var-scoped. */
function isFunctionBody(node: ts.Node): boolean {
const parent = node.parent;
if (parent === undefined || !ts.isBlock(node)) return false;
if (ts.isClassStaticBlockDeclaration(parent)) return true;
return ts.isFunctionLike(parent) && (parent as { body?: ts.Node }).body === node;
}
/** The names a DIRECT statement of a statement list declares lexically
* (let/const/using, class, and every function declaration form outside a
* function body), paired with the node to point the diagnostic at. */
function lexicalDeclarationsOf(
stmt: ts.Statement,
functionBody: boolean,
): Array<[string, ts.Node]> {
const names: Array<[string, ts.Node]> = [];
if (ts.isVariableStatement(stmt)) {
if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) === 0) return names;
for (const decl of stmt.declarationList.declarations.nodes)
collectBindingNames(decl.name, names);
return names;
}
if (ts.isClassDeclaration(stmt) || (ts.isFunctionDeclaration(stmt) && !functionBody)) {
const name = stmt.name;
if (name !== undefined && ts.isIdentifier(name)) names.push([name.text, stmt]);
}
return names;
}
function collectBindingNames(name: ts.BindingName, into: Array<[string, ts.Node]>): void {
if (ts.isIdentifier(name)) {
into.push([name.text, name]);
return;
}
for (const element of name.elements.nodes) {
if (ts.isOmittedExpression(element)) continue;
if (ts.isBindingElement(element) && element.name !== undefined)
collectBindingNames(element.name, into);
}
}
/** VarDeclaredNames of a statement: every `var`-declared name below it,
* stopping at function and class boundaries (their declarations are not
* var-scoped here). */
function collectVarDeclaredNames(root: ts.Node, into: Set<string>): void {
const stack: ts.Node[] = [root];
while (stack.length > 0) {
const node = stack.pop()!;
if (ts.isFunctionLike(node) || ts.isClassDeclaration(node) || ts.isClassExpression(node))
continue;
if (ts.isVariableDeclarationList(node) && (node.flags & ts.NodeFlags.BlockScoped) === 0) {
const names: Array<[string, ts.Node]> = [];
for (const decl of node.declarations.nodes) collectBindingNames(decl.name, names);
for (const [name] of names) into.add(name);
}
ts.forEachChild(node, (child) => {
stack.push(child);
return undefined;
});
}
}
/** `async function` and `function*` are Annex B's allowance never — only the
* plain form is. */
function isEnhancedFunction(decl: ts.FunctionDeclaration): boolean {
if (decl.asteriskToken !== undefined) return true;
return (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Async) !== 0;
}
/* ── SC0007: a declaration where the grammar admits only a Statement ─────
*
* Every `Statement` slot below admits Statement productions only, and only
* Annex B widens three of them to a plain FunctionDeclaration in non-strict
* code: the body of an `if`/`else` and a labelled statement. Async functions,
* generators, classes and lexical declarations are never admitted, and a
* LABELLED function is never admitted as an if/else body (`IsLabelledFunction`
* is an explicit early error there, so `if (x) l: function f(){}` fails even
* in sloppy code). */
type StatementSlot = "if" | "loop" | "with" | "label";
function checkStatementSlot(node: ts.Node, diags: ScrDiagnostic[], isStrict: IsStrictCode): void {
if (ts.isIfStatement(node)) {
checkStatementPosition(node.thenStatement, "if", diags, isStrict);
if (node.elseStatement !== undefined)
checkStatementPosition(node.elseStatement, "if", diags, isStrict);
return;
}
if (
ts.isWhileStatement(node) ||
ts.isDoStatement(node) ||
ts.isForStatement(node) ||
ts.isForInStatement(node) ||
ts.isForOfStatement(node)
) {
checkStatementPosition(node.statement, "loop", diags, isStrict);
return;
}
if (ts.isWithStatement(node)) {
checkStatementPosition(node.statement, "with", diags, isStrict);
return;
}
if (ts.isLabeledStatement(node)) checkStatementPosition(node.statement, "label", diags, isStrict);
}
function checkStatementPosition(
stmt: ts.Statement,
slot: StatementSlot,
diags: ScrDiagnostic[],
isStrict: IsStrictCode,
): void {
const sf = ts.getSourceFileOfNode(stmt);
// A labelled function is an early error as an if-clause, loop, or 'with'
// body in BOTH modes (Node agrees); only a plain labelled STATEMENT is
// Annex B's, and that is the `slot === "label"` case, whose terminal
// function the function branches below handle.
const labelTerminal =
slot === "label" || !ts.isLabeledStatement(stmt) ? null : labelledFunctionOf(stmt);
if (labelTerminal !== null) {
report(
diags,
NOT_A_STATEMENT,
labelTerminal,
"a labelled function declaration cannot be the body of a loop, a 'with' statement, or an 'if' clause",
);
return;
}
if (ts.isVariableStatement(stmt)) {
if ((stmt.declarationList.flags & ts.NodeFlags.BlockScoped) !== 0)
report(
diags,
NOT_A_STATEMENT,
stmt,
"a lexical declaration cannot appear in a single-statement position",
);
return;
}
if (ts.isClassDeclaration(stmt)) {
report(
diags,
NOT_A_STATEMENT,
stmt,
"a class declaration cannot appear in a single-statement position",
);
return;
}
if (!ts.isFunctionDeclaration(stmt)) return;
if (isEnhancedFunction(stmt)) {
report(
diags,
NOT_A_STATEMENT,
stmt,
"an async function or generator declaration can only appear at the top level or inside a block",
);
return;
}
// A plain function declaration: Annex B admits it as an if/else body and as
// a labelled statement, in non-strict code only, and never as a loop's or a
// 'with' statement's body.
if (slot === "loop" || slot === "with") {
report(
diags,
NOT_A_STATEMENT,
stmt,
"a function declaration can only appear at the top level, inside a block, or as the body of an if statement",
);
return;
}
if (isStrict(sf, stmt))
report(
diags,
NOT_A_STATEMENT,
stmt,
"strict mode code may not declare a function in a single-statement position",
);
}
/** The function declaration at the end of a `label:` chain, when `stmt` is a
* labelled statement around one. */
function labelledFunctionOf(stmt: ts.Statement): ts.FunctionDeclaration | null {
let cur: ts.Statement = stmt;
while (ts.isLabeledStatement(cur)) cur = cur.statement;
return ts.isFunctionDeclaration(cur) ? cur : null;
}
/* ── strictness of the code around a node ───────────────────────────────
*
* A directive prologue's "use strict" applies to the function (or script) it
* opens, an arrow function's block body included, and every part of a class is strict by
* construction. Object literal methods and accessors are ordinary functions:
* strict only by their own prologue or their surroundings. Module sources are
* strict whether they say so or not, and Node's classification — not the
* checker's forced external-module scope — decides which sources are modules.
*
* The walk starts at the node's parent: a function declaration's own "use
* strict" makes its body strict, not the code that declares it. */
function isStrictCode(
sf: ts.SourceFile,
node: ts.Node,
isModuleCode: (sf: ts.SourceFile) => boolean,
): boolean {
if (isModuleCode(sf)) return true;
for (let n: ts.Node | undefined = node.parent; n !== undefined; n = n.parent) {
if (ts.isSourceFile(n)) return hasUseStrictPrologue(n.statements.nodes);
if (ts.isClassDeclaration(n) || ts.isClassExpression(n)) return true;
if (ts.isFunctionLike(n)) {
const body = n.body;
if (body !== undefined && ts.isBlock(body) && hasUseStrictPrologue(body.statements.nodes))
return true;
}
}
return false;
}
function hasUseStrictPrologue(statements: readonly ts.Statement[]): boolean {
for (const stmt of statements) {
if (!ts.isExpressionStatement(stmt)) return false;
const expr = stmt.expression;
if (!ts.isStringLiteral(expr)) return false;
if (expr.text === "use strict") return true;
}
return false;
}
@@ -94,3 +94,23 @@ test.for([
invalid,
);
});
test("JavaScript async generators may delegate over a runtime async-iterator shape", () => {
expect(
diagnostics(
`export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }
export async function* looped() { for await (const value of { [Symbol.asyncIterator]() { return "text"; } }) console.dir(value); }`,
'import { delegated, looped } from "./library.js"; delegated(); looped();',
),
).toEqual([]);
});
test("TypeScript keeps the async-iterator demand", () => {
expect(
diagnostics(
"export async function* delegated() { yield* { [Symbol.asyncIterator]: 42 }; }",
'import { delegated } from "./library.js"; delegated();',
"ts",
).some((diagnostic) => diagnostic.message.includes("Symbol.asyncIterator")),
).toBe(true);
});
@@ -0,0 +1,18 @@
/* Shape inference for the program's own JavaScript (SCRIPTC_INFER_JS=1).
*
* Shape inference (shape-inference/) proves value sets with a closed-world
* points-to analysis and writes them as JSDoc into overlay copies of the
* files, which the checker then types. By default it runs only for builds
* with --npm-static packages, behind SCRIPTC_SHAPE_OVERLAY=1. This option
* runs it for the program's own JavaScript files, with or without
* npm-static packages, and leaves the packages' files as they are unless
* SCRIPTC_SHAPE_OVERLAY=1 is set too (SCRIPTC_SHAPE_OVERLAY=0 turns it
* off). The overlay's fallback applies unchanged: a refusal, or a deferred
* refusal the build without the overlay lacks, drops the annotations it
* depends on, and the program compiles as it does without the overlay where
* it must. */
/** Whether the option is on. */
export function jsInferenceEnabled(): boolean {
return process.env["SCRIPTC_INFER_JS"] === "1";
}
@@ -6,7 +6,8 @@ import { isGenericCallableMemberType } from "../type-mapper.js";
/** True when nothing in `sym`'s DECLARING FILE ever writes it after the
* initializer: assignments (plain and compound, destructuring targets
* included), ++/--, and for-of/for-in expression targets all count.
* included), ++/--, for-of/for-in expression targets, a `var` in a
* for-of/for-in head, and redeclarations that initialize all count.
* Sound file-locally for module-scope bindings because ESM import
* bindings are read-only — no other file can write one. Cached per
* symbol (the scan walks the whole file once). */
@@ -31,6 +32,7 @@ export function fileWritesBinding(
file: ts.SourceFile,
resolve: (ident: ts.Identifier) => ts.Symbol | null | undefined,
): boolean {
if (declarationsWriteBinding(sym)) return true;
let written = false;
// Text pre-check keeps the file walk cheap: only same-named
// identifiers pay a symbol resolution.
@@ -78,6 +80,77 @@ export function fileWritesBinding(
return written;
}
/** True when `sym`'s own declarations write it after it holds a value:
* a `var` in a for-in or for-of head is written on every iteration, a
* `var` with an initializer inside a loop body is written each time the
* body runs, and a binding declared more than once (`var x = 1; ... var x
* = 2;`, a parameter or function declaration that a `var` redeclares) is
* written by each initializing declaration that runs. Only value
* declarations count, and of functions only implementations: overload
* signatures have no body. A redeclaration without an initializer is
* counted too, which only over-approximates. */
function declarationsWriteBinding(sym: ts.Symbol): boolean {
let values = 0;
let initializing = 0;
for (const d of sym.declarations) {
let root: ts.Node = d;
while (
root.parent !== undefined &&
(ts.isBindingElement(root) ||
ts.isObjectBindingPattern(root) ||
ts.isArrayBindingPattern(root))
)
root = root.parent;
if (ts.isVariableDeclaration(root)) {
const list = root.parent;
if (
ts.isVariableDeclarationList(list) &&
(list.flags & ts.NodeFlags.BlockScoped) === 0 &&
(ts.isForInStatement(list.parent) || ts.isForOfStatement(list.parent))
)
return true;
values++;
if (root.initializer !== undefined) {
if (insideLoopBody(root)) return true;
initializing++;
}
} else if (
ts.isParameter(root) ||
(ts.isFunctionDeclaration(d) && d.body !== undefined) ||
ts.isClassDeclaration(d)
) {
values++;
initializing++;
}
}
return values > 1 && initializing > 0;
}
/** Whether a declaration sits in the body of a loop of its own function,
* where it runs once per iteration. */
function insideLoopBody(node: ts.Node): boolean {
let child = node;
for (let parent = node.parent; parent !== undefined; parent = parent.parent) {
if (
ts.isFunctionLike(parent) ||
ts.isClassStaticBlockDeclaration(parent) ||
ts.isSourceFile(parent)
)
return false;
if (
(ts.isForStatement(parent) ||
ts.isForInStatement(parent) ||
ts.isForOfStatement(parent) ||
ts.isWhileStatement(parent) ||
ts.isDoStatement(parent)) &&
parent.statement === child
)
return true;
child = parent;
}
return false;
}
/** Strips the value-preserving wrappers off an expression: parens,
* non-null assertions, `as`/`satisfies`/angle-bracket casts. What
* remains is the expression that actually evaluates. */
@@ -1,4 +1,5 @@
import { lowerReadlineCursorCall } from "./builtins/readline.js";
import { lowerFsWatchFileCall } from "./builtins/filesystem.js";
import { lowerMonitorEventLoopDelayCall } from "./builtins/performance.js";
import {
lowerEnvironmentData,
@@ -134,6 +135,13 @@ export function lowerBuiltinCall(
if (streamServed) return streamServed;
const fsTs = lowerer.lowerFsToUnixTimestampCall(expr, bi, loc);
if (fsTs) return fsTs;
// fs.watchFile/fs.unwatchFile have no table entry (their checked-dynamic
// form is the fs.watchFileChk ladder below), so the typed polling forms
// dispatch here; anything else falls through to the ladder and the fence.
if (bi.module === "fs" && (bi.member === "watchFile" || bi.member === "unwatchFile")) {
const polling = lowerFsWatchFileCall(lowerer, expr, loc, bi.member === "unwatchFile");
if (polling) return polling;
}
const fsLadder = lowerer.lowerFsLadderCall(expr, bi, loc);
if (fsLadder) return fsLadder;
const cryptoServed = lowerer.lowerCryptoModuleCall(expr, bi, loc);
@@ -31,7 +31,9 @@ export function lowerChildMethodCall(
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (call.questionDotToken || access.questionDotToken) return null;
// A `?.` in the chain re-dispatches here with the receiver bound to the
// chain (optional-chains.ts), so only an UNHANDLED token declines.
if (lowerer.chainBlocked(call, access)) return null;
if (lowerer.mapTypeOf(lowerer.typeOf(access.expression))?.kind !== "child") return null;
if (!isChildSurfaceMember(lowerer, access)) return null;
const name = access.name.text;
@@ -477,11 +477,11 @@ export function lowerSpawnCall(lowerer: Lowerer, expr: ts.CallExpression, loc: S
} else if (m.value.kind === ts.SyntaxKind.FalseKeyword) {
detached = boolLit(false, loc);
} else {
lowerer.noLowering(
"spawn with a non-literal detached option",
m.value,
"detached must be a boolean literal",
);
// A runtime boolean — the portable daemon idiom `detached:
// process.platform !== "win32"` — reaches the same
// POSIX_SPAWN_SETSID flag through scr_spawn_opts's bool slot.
detached = lowerer.lowerExprExpecting(m.value, BOOL);
plain = false;
}
break;
}
@@ -72,7 +72,7 @@ const DATE_TEXT_FNS: ReadonlyMap<string, IrLibFn> = new Map([
]);
export const DATE_METHOD_HINT =
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale formatters have no lowering";
"getTime(), valueOf(), toISOString(), toUTCString(), toJSON(), toString(), toDateString(), toTimeString(), toLocaleString(), toLocaleTimeString(), the local/UTC calendar getters and setters, setTime(), and getTimezoneOffset() are supported; locale and options arguments, toLocaleDateString() and the Intl.DateTimeFormat surface have no lowering";
/** Date statics and methods on Date objects (a TimeClip'd millisecond
* time value with identity): getters, text forms and setters.
@@ -208,6 +208,19 @@ export function dateValueMethod(name: string, value: IrExpr, loc: SrcLoc): IrExp
if (textFn !== undefined) {
return { kind: "libCall", fn: textFn, args: [value], type: STRING, loc };
}
// The default locale's forms. The spec makes them DateTimeFormat(locale,
// options).format(this); with neither argument that is ICU's default
// locale over the host time zone, which scr_intl.c formats over the
// ICU release exactly as V8 does.
if (name === "toLocaleString" || name === "toLocaleTimeString") {
return {
kind: "libCall",
fn: name === "toLocaleString" ? "icu.dateLocaleString" : "icu.dateLocaleTimeString",
args: [value],
type: STRING,
loc,
};
}
return null;
}
@@ -1049,8 +1049,14 @@ export function lowerFsWriteOptionsCall(
const operation = promiseWriteOptions ? "fs.promises.writeFile" : "writeFileSync";
const plainFn: IrLibFn = promiseWriteOptions ? "fsp.writeFile" : "fs.writeFileSync";
const modeFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileMode" : "fs.writeFileModeSync";
const flagsFn: IrLibFn = promiseWriteOptions ? "fsp.writeFileFlags" : "fs.writeFileFlagsSync";
const resultType: IrType = promiseWriteOptions ? { kind: "promise", inner: VOID } : VOID;
type WriteOptionValue = { kind: "effect" | "mode"; value: IrExpr };
/** `flag: "w"` is the plain truncate-or-create write; `flag: "wx"` is
* open(2)'s O_EXCL (EEXIST when the path exists). Both are literals, so
* the bit is known here and carries no evaluation of its own. */
let exclusive = false;
let sawFlag = false;
// The runtime needs only `mode`, but every source option value is an
// ordinary JS expression. Stage path/data and then evaluate the option
// values in object-literal order before issuing the write; otherwise a
@@ -1079,19 +1085,36 @@ export function lowerFsWriteOptionsCall(
});
mode = varRef(modeLocal.id, modeLocal.type, loc);
}
const result: IrExpr = {
kind: "libCall",
fn: mode ? modeFn : plainFn,
args: mode
? [
const result: IrExpr = sawFlag
? {
kind: "libCall",
fn: flagsFn,
args: [
varRef(pathLocal.id, pathLocal.type, loc),
varRef(dataLocal.id, dataLocal.type, loc),
mode,
]
: [varRef(pathLocal.id, pathLocal.type, loc), varRef(dataLocal.id, dataLocal.type, loc)],
type: resultType,
loc,
};
// Node's default creation mode when only the flag is given.
mode ?? { kind: "numLit", value: 0o666, type: F64, loc },
{ kind: "numLit", value: exclusive ? 1 : 0, type: F64, loc },
],
type: resultType,
loc,
}
: {
kind: "libCall",
fn: mode ? modeFn : plainFn,
args: mode
? [
varRef(pathLocal.id, pathLocal.type, loc),
varRef(dataLocal.id, dataLocal.type, loc),
mode,
]
: [
varRef(pathLocal.id, pathLocal.type, loc),
varRef(dataLocal.id, dataLocal.type, loc),
],
type: resultType,
loc,
};
return { kind: "seqExpr", stmts, result, type: resultType, loc };
};
// The bare-encoding spelling — writeFileSync(p, data, "utf-8") — is
@@ -1134,13 +1157,21 @@ export function lowerFsWriteOptionsCall(
}
optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) });
} else if (m.name === "flag") {
// Documented, behavior-changing (open(2)'s disposition — 'a'
// IS appendFileSync), no lowering: fence by name.
lowerer.noLowering(
`${operation} with the flag option`,
p,
"the write truncates-or-creates (Node's default 'w'); other flags have no lowering",
);
// Node's disposition flag changes whether an existing file is
// truncated: "w" is the default write, "wx" the exclusive create.
// Every other flag (append "a"/"as", synchronous "ws", ...) writes
// different bytes and keeps the fence by name.
const t = lowerer.typeOf(m.value);
if (!t.isStringLiteralType() || (t.value !== "w" && t.value !== "wx")) {
lowerer.noLowering(
`${operation} with the flag option`,
p,
'the lowered flags are "w" (the default truncate-or-create) and "wx" (O_EXCL: EEXIST when the path exists); other flags write different bytes',
);
}
exclusive = t.value === "wx";
sawFlag = true;
optionValues.push({ kind: "effect", value: lowerer.lowerExprExpecting(m.value, STRING) });
} else {
// The options-record stance: documented keys with no lowering
// fence by name; undocumented keys drop like Node.
@@ -1150,7 +1181,7 @@ export function lowerFsWriteOptionsCall(
m.name,
operation,
FS_WRITE_FILE_DOCUMENTED_OPTIONS,
'the supported options are { mode: <number>, encoding: "utf8" }',
'the supported options are { mode: <number>, encoding: "utf8", flag: "w" | "wx" }',
);
}
}
@@ -1159,7 +1190,7 @@ export function lowerFsWriteOptionsCall(
lowerer.noLowering(
`${operation} with 3 arguments`,
optsNode,
'the supported options are { mode: <number>, encoding: "utf8" } over string data',
'the supported options are { mode: <number>, encoding: "utf8", flag: "w" | "wx" } over string data',
);
}
return finishWrite(optionValues);
@@ -8,6 +8,7 @@ import {
builtinModuleFnOf,
FS_READDIR_DOCUMENTED_OPTIONS,
FS_WATCH_DOCUMENTED_OPTIONS,
FS_WATCH_FILE_DOCUMENTED_OPTIONS,
fenceOrDropOptionKey,
} from "../surfaces.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
@@ -1127,13 +1128,37 @@ export function lowerStatsMethodCall(
);
}
/** The watch-listener shapes: () , (eventType: string), and
* (eventType: string, filename: string | null) — the filename parameter
* must admit null (Node passes null when the platform names nothing), or
* be the checked dynamic. Returns the parameter count, or null when the
* shape has no lowering. */
function watchListenerArity(lowerer: Lowerer, cb: IrExpr): 0 | 1 | 2 | null {
if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 2)
return null;
const [event, name] = cb.type.params;
if (event !== undefined && event.kind !== "string" && event.kind !== "dyn") return null;
if (name !== undefined && name.kind !== "dyn") {
const def = name.kind === "union" ? lowerer.unions.get(name.unionId) : undefined;
if (
!def ||
!def.arms.some((a) => a.kind === "nullT") ||
!def.arms.some((a) => a.kind === "string") ||
!def.arms.every((a) => a.kind === "nullT" || a.kind === "undefinedT" || a.kind === "string")
)
return null;
}
return cb.type.params.length as 0 | 1 | 2;
}
/** `fs.watch(path[, options][, listener])` → the fs.watch libCall
* (scr_watch.c — kqueue EVFILT_VNODE on the opened path; an unopenable
* path THROWS Node's fs error synchronously, the polling-fallback catch
* shape). The listener fires with "rename"/"change" and takes zero
* parameters or the eventType string — the filename parameter has no
* lowering (kqueue watches the inode, not the directory entry; you
* watched one path). The options record follows the options-record
* parameters, the eventType string, or (eventType, filename: string |
* null) — the filename is the entry name on a directory watch and the
* watched file's basename otherwise (Linux inotify names both; a macOS
* directory watch has no name source until F3). The options record follows the options-record
* stance: persistent: true, recursive: false, and encoding: "utf8"
* state the lowered behavior and are accepted; persistent: false
* (a watcher that does NOT hold the loop), recursive: true (kqueue
@@ -1204,7 +1229,7 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
lowerer.noLowering(
"fs.watch with a non-utf8 encoding",
prop,
"the encoding applies to the filename argument, which has no lowering — utf8 (the default) is accepted",
"the filename argument is a string — utf8 (the default) is the accepted encoding",
);
}
if (key === "signal") {
@@ -1236,19 +1261,11 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
: null;
if (listenerArg !== null) {
const cb = lowerer.lowerExpr(listenerArg);
if (cb.type.kind !== "func" || cb.type.ret.kind !== "void" || cb.type.params.length > 1) {
if (watchListenerArity(lowerer, cb) === null) {
lowerer.noLowering(
"fs.watch with this listener shape",
listenerArg,
"the listener takes () or (eventType: string) — the filename parameter has no lowering (you watched one path)",
);
}
const param = cb.type.params[0];
if (param !== undefined && param.kind !== "string") {
lowerer.unsupported(
"SC1090",
listenerArg,
`watch listeners whose parameter is not the eventType string (got '${lowerer.fmt(param)}')`,
"the listener takes (), (eventType: string), or (eventType: string, filename: string | null)",
);
}
args.push(cb);
@@ -1262,10 +1279,134 @@ export function lowerFsWatchCall(lowerer: Lowerer, expr: ts.CallExpression, loc:
};
}
/** Method calls on FSWatcher receivers: close() — idempotent, statement
* position (Node returns void there too). Everything else @types/node
* declares (ref/unref, the EventEmitter surface) fences member-
* qualified. Null for non-watcher receivers. */
/** `fs.watchFile(path[, options], listener)` and
* `fs.unwatchFile(path[, listener])` → the polling libCalls
* (scr_watch_file.c: libuv's uv_fs_poll shape — one poller per RESOLVED
* path, shared by every listener on it, re-armed every `interval` ms,
* 5007 by default, persistent by default). The listener takes
* (current: Stats, previous: Stats) — zero, one or both — and runs when a
* poll sees a difference; the first poll only records the baseline, and a
* file that cannot be stat'd polls as all-zero Stats. The options record
* follows the options-record stance: interval, persistent: true/false and
* bigint: false are accepted; bigint: true fences (BigIntStats); other
* documented keys fence by name; undocumented keys drop like Node.
* Statement position only — Node returns a StatWatcher, which has no
* lowering. Returns null for any untyped shape so the checked-dynamic
* ladder (fs.watchFileChk) keeps its current IR. */
export function lowerFsWatchFileCall(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
unwatch: boolean,
): IrExpr | null {
const nodes = expr.arguments.nodes;
if (nodes.length < 1 || nodes.length > 3 || nodes.some(ts.isSpreadElement)) return null;
if (lowerer.mapTypeOf(lowerer.typeOf(nodes[0]!))?.kind !== "string") return null;
const path = lowerer.lowerExprExpecting(nodes[0]!, STRING);
if (unwatch) {
if (nodes.length > 2) return null;
const args: IrExpr[] = [path];
if (nodes.length === 2) {
if (lowerer.mapTypeOf(lowerer.typeOf(nodes[1]!))?.kind !== "func") return null;
args.push(lowerer.lowerExpr(nodes[1]!));
}
return { kind: "libCall", fn: "fs.unwatchFile", args, type: VOID, loc };
}
const hasOptions = nodes.length >= 2 && ts.isObjectLiteralExpression(nodes[1]!);
if (nodes.length === 3 && !hasOptions) return null;
if (nodes.length === 2 && hasOptions) return null; /* no listener */
const listenerArg = nodes[nodes.length - 1]!;
if (lowerer.mapTypeOf(lowerer.typeOf(listenerArg))?.kind !== "func") return null;
const parent = expr.parent;
if (!ts.isExpressionStatement(parent) && !(ts.isArrowFunction(parent) && parent.body === expr)) {
lowerer.unsupported(
"SC1090",
expr,
"using the returned StatWatcher (register the fs.watchFile call as its own statement)",
);
}
let interval: IrExpr | null = null;
let persistent: IrExpr | null = null;
if (hasOptions) {
for (const prop of (nodes[1] as ts.ObjectLiteralExpression).properties.nodes) {
if (!ts.isPropertyAssignment(prop) && !ts.isShorthandPropertyAssignment(prop)) {
lowerer.noLowering(
"fs.watchFile options with computed keys or spreads",
prop,
"each option must be a plain `name: value` entry with a literal key",
);
}
if (!ts.isIdentifier(prop.name) && !ts.isStringLiteral(prop.name)) {
lowerer.noLowering(
"fs.watchFile options with computed keys",
prop,
"each option must be a plain `name: value` entry with a literal key",
);
}
const key = prop.name.text;
const value = ts.isPropertyAssignment(prop)
? prop.initializer
: ts.isShorthandPropertyAssignment(prop)
? prop.name
: null;
if (key === "interval" && value !== null) {
interval = lowerer.lowerExprExpecting(value, F64);
continue;
}
if (key === "persistent" && value !== null) {
persistent = lowerer.lowerExprExpecting(value, BOOL);
continue;
}
if (key === "bigint") {
// The fallback's option record declares `bigint: false`, so the
// false literal (or `false` as a value) states the lowered Stats
// shape; BigIntStats has no lowering.
if (value !== null && value.kind === ts.SyntaxKind.FalseKeyword) continue;
lowerer.noLowering(
"fs.watchFile with bigint: true",
prop,
"the listener receives Stats (double integers); BigIntStats has no lowering",
);
}
fenceOrDropOptionKey(
lowerer,
prop,
key,
"fs.watchFile",
FS_WATCH_FILE_DOCUMENTED_OPTIONS,
"interval, persistent: true/false, and bigint: false are the accepted options",
);
// An undocumented key, dropped like Node drops it.
}
}
const cb = lowerer.lowerExpr(listenerArg);
if (
cb.type.kind !== "func" ||
cb.type.ret.kind !== "void" ||
cb.type.params.length > 2 ||
!cb.type.params.every((p) => p.kind === "stats")
) {
lowerer.noLowering(
"fs.watchFile with this listener shape",
listenerArg,
"the listener takes (current: Stats, previous: Stats)",
);
}
return {
kind: "libCall",
fn: "fs.watchFile",
args: [path, interval ?? numLit(5007, loc), persistent ?? boolLit(true, loc), cb],
type: VOID,
loc,
};
}
/** Method calls on FSWatcher receivers: close() (idempotent, statement
* position — Node returns void there too) and the EventEmitter surface for
* 'change', 'error' and 'close' (on/once/addListener/prependListener/
* prependOnceListener/off/removeListener, registered as their own
* statement). Everything else @types/node declares (ref/unref, other
* events) fences member-qualified. Null for non-watcher receivers. */
export function lowerWatcherMethodCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -1280,10 +1421,82 @@ export function lowerWatcherMethodCall(
const receiver = lowerer.lowerExpr(access.expression);
return { kind: "libCall", fn: "watcher.close", args: [receiver], type: VOID, loc };
}
// The EventEmitter surface for the three events the watcher emits:
// "change" (the listener shapes fs.watch takes), "error", and "close".
const ON = ["on", "once", "addListener", "prependListener", "prependOnceListener"];
if (
(ON.includes(name) || name === "off" || name === "removeListener") &&
call.arguments.nodes.length === 2
) {
const evT = lowerer.typeOf(call.arguments.nodes[0]!);
const event = evT.isStringLiteralType() ? evT.value : null;
const code = event === "change" ? 0 : event === "error" ? 1 : event === "close" ? 2 : -1;
if (code < 0) {
lowerer.noLowering(
`FSWatcher.${name}(${event === null ? "non-literal event" : `"${event}"`}, ...)`,
call.arguments.nodes[0]!,
'"change", "error", and "close" are the supported FSWatcher events (as literals)',
);
}
const parent = call.parent;
if (
!ts.isExpressionStatement(parent) &&
!(ts.isArrowFunction(parent) && parent.body === call)
) {
lowerer.unsupported(
"SC1090",
call,
`using the result of FSWatcher.${name} (register it as its own statement)`,
);
}
const receiver = lowerer.lowerExpr(access.expression);
const cb = lowerer.lowerExpr(call.arguments.nodes[1]!);
if (name === "off" || name === "removeListener") {
return {
kind: "libCall",
fn: "watcher.off",
args: [receiver, numLit(code, loc), cb],
type: VOID,
loc,
};
}
const ok =
code === 0
? watchListenerArity(lowerer, cb) !== null
: code === 1
? cb.type.kind === "func" &&
cb.type.ret.kind === "void" &&
cb.type.params.length <= 1 &&
(cb.type.params[0] === undefined ||
cb.type.params[0].kind === "dyn" ||
(cb.type.params[0].kind === "object" && cb.type.params[0].className === "%Error"))
: cb.type.kind === "func" && cb.type.ret.kind === "void" && cb.type.params.length === 0;
if (!ok) {
lowerer.noLowering(
`FSWatcher.${name}("${event}", ...) with this listener shape`,
call.arguments.nodes[1]!,
code === 0
? "the listener takes (), (eventType: string), or (eventType: string, filename: string | null)"
: code === 1
? "the listener takes () or (error: Error)"
: "the listener takes ()",
);
}
const flags =
(name === "once" || name === "prependOnceListener" ? 1 : 0) |
(name.startsWith("prepend") ? 2 : 0);
return {
kind: "libCall",
fn: "watcher.on",
args: [receiver, numLit(code, loc), numLit(flags, loc), cb],
type: VOID,
loc,
};
}
lowerer.noLowering(
`FSWatcher.${name}`,
call,
"close() is the supported FSWatcher member",
'close(), and on/once/addListener/prependListener/prependOnceListener/off/removeListener for "change", "error", and "close" are the supported FSWatcher members',
lowerer.checker.getSymbolAtLocation(access.name),
);
}
@@ -32,10 +32,20 @@ import { lowerUtilTypeCall } from "./util-types.js";
import { InternalCompilerError } from "../../../errors.js";
import { dynUndefinedExpr, varRef } from "../../../ir/build.js";
import * as ts from "../../ts7/adapter.js";
import { jsCheckedBuiltinCall } from "../lower-builtin-values.js";
import { type Lowerer } from "../lowerer.js";
import { type BuiltinModuleFn } from "../surfaces.js";
import { type ParamShape } from "../call-signatures.js";
import { BYTES_U8, DYN, F64, type IrExpr, STRING, type SrcLoc, arrayOf } from "../../../ir/ir.js";
import {
BYTES_U8,
DYN,
F64,
type IrExpr,
type IrLibFn,
STRING,
type SrcLoc,
arrayOf,
} from "../../../ir/ir.js";
import { typeKey } from "../../type-mapper.js";
import { lowerProcessLoadEnvFile } from "./environment.js";
import {
@@ -72,6 +82,10 @@ export function lowerBuiltinModuleCall(
);
if (fallible) return fallible;
checkFsUrlPaths(lowerer, expr, bi);
{
const checked = jsCheckedBuiltinCall(lowerer, expr, fn, `${bi.module}.${bi.member}`, loc);
if (checked) return checked;
}
if (bi.module === "util/types") {
return lowerUtilTypeCall(lowerer, expr, bi, loc, name);
}
@@ -331,6 +345,31 @@ export function lowerBuiltinModuleCall(
: { kind: "numLit", value: 0, type: F64, loc };
return { kind: "libCall", fn: fn.fn, args: [path, second], type: fn.result, loc };
}
// copyFileSync(src, dest, mode?) and copyFile(src, dest, mode?): the
// mode is libuv's copyfile flag bits (COPYFILE_EXCL = 1, COPYFILE_FICLONE
// = 2, COPYFILE_FICLONE_FORCE = 4 — fs.constants.* reads bake to
// literals). The omitted form is the plain copy and keeps its libCall.
if (
(bi.module === "fs" && bi.member === "copyFileSync") ||
(bi.module === "fs/promises" && bi.member === "copyFile")
) {
const count = expr.arguments.nodes.length;
if (count < 2 || count > 3 || expr.arguments.nodes.some(ts.isSpreadElement)) {
lowerer.noLowering(`${bi.member} with ${count} arguments`, expr);
}
const src = lowerFsPath(lowerer, expr.arguments.nodes[0]!);
const dest = lowerFsPath(lowerer, expr.arguments.nodes[1]!);
const modeNode = expr.arguments.nodes[2];
if (!modeNode) return { kind: "libCall", fn: fn.fn, args: [src, dest], type: fn.result, loc };
const flags: IrLibFn = bi.module === "fs" ? "fs.copyFileFlagsSync" : "fsp.copyFileFlags";
return {
kind: "libCall",
fn: flags,
args: [src, dest, lowerer.lowerExprExpecting(modeNode, F64)],
type: fn.result,
loc,
};
}
if (
bi.module === "fs/promises" &&
bi.member === "appendFile" &&
@@ -467,10 +467,40 @@ export function lowerCreateRequireCall(
}
}
function staticString(node: ts.Expression | undefined): string | null {
if (node === undefined) return null;
/** The statically-known text of a module specifier expression. Module
* resolution is a build-time artifact, so only a value the compiler already
* knows can name a module: a string literal (or substitution-free
* template), a parenthesized or cast form of one, a `+` concatenation of
* those, or a `const` binding whose initializer folds to one
* (`const FS = "node:" + "fs"; import(FS)`). Null when the text is only
* known at run time. */
export function staticString(
lowerer: Lowerer,
node: ts.Expression | undefined,
depth = 0,
): string | null {
if (node === undefined || depth > 8) return null;
const value = stripTypeCasts(node);
return ts.isStringLiteralLike(value) ? value.text : null;
if (ts.isStringLiteralLike(value)) return value.text;
if (ts.isBinaryExpression(value) && value.operatorToken.kind === ts.SyntaxKind.PlusToken) {
const left = staticString(lowerer, value.left, depth + 1);
if (left === null) return null;
const right = staticString(lowerer, value.right, depth + 1);
return right === null ? null : left + right;
}
if (ts.isIdentifier(value)) {
const decl = lowerer.checker.getSymbolAtLocation(value)?.valueDeclaration;
if (
decl !== undefined &&
ts.isVariableDeclaration(decl) &&
decl.initializer !== undefined &&
decl.initializer !== value &&
ts.isVariableDeclarationList(decl.parent) &&
(decl.parent.flags & ts.NodeFlags.Const) !== 0
)
return staticString(lowerer, decl.initializer, depth + 1);
}
return null;
}
function runtimeResolveThrow(error: RuntimeResolveError, type: IrType, loc: SrcLoc): IrExpr {
@@ -503,7 +533,7 @@ export function lowerImportMetaResolveCall(
'the lowered form is import.meta.resolve("<static specifier>") using the containing module as its parent',
);
}
const specifier = staticString(call.arguments.nodes[0]);
const specifier = staticString(lowerer, call.arguments.nodes[0]);
if (specifier === null) {
lowerer.noLowering(
"import.meta.resolve with a runtime-computed specifier",
@@ -618,7 +648,7 @@ export function lowerRequireResolveCall(lowerer: Lowerer, call: ts.CallExpressio
call,
);
}
const specifier = staticString(call.arguments.nodes[0]);
const specifier = staticString(lowerer, call.arguments.nodes[0]);
// A computed request (or options) through a createRequire loader in
// JavaScript source resolves at run time.
if (
@@ -17,8 +17,9 @@ import {
} from "../../../ir/ir.js";
/** `os.userInfo()` — the passwd-entry snapshot, Node's uv_os_get_passwd
* behind the call site's own mapped record shape: username = pw_name,
* uid/gid = getuid/getgid, shell = pw_shell (as the `string | null`
* for the effective uid behind the call site's own mapped record shape:
* username = pw_name, uid/gid = pw_uid/pw_gid (the user's primary group,
* not the process gid; -1 on Windows), shell = pw_shell (as the `string | null`
* union @types/node declares — POSIX always answers the string arm;
* null is Node's Windows answer), homedir = pw_dir (the PASSWD home,
* NOT os.homedir's $HOME-first cascade — Node's own split). The record
@@ -56,12 +57,12 @@ export function lowerOsUserInfoCall(
} else if (f.name === "uid" && f.type.kind === "f64") {
fields.push({
name: f.name,
value: { kind: "libCall", fn: "process.getuid", args: [], type: F64, loc },
value: { kind: "libCall", fn: "os.userUid", args: [], type: F64, loc },
});
} else if (f.name === "gid" && f.type.kind === "f64") {
fields.push({
name: f.name,
value: { kind: "libCall", fn: "process.getgid", args: [], type: F64, loc },
value: { kind: "libCall", fn: "os.userGid", args: [], type: F64, loc },
});
} else if (f.name === "homedir" && f.type.kind === "string") {
fields.push({
@@ -34,6 +34,7 @@ import { builtinFunctionValue } from "../lower-exprs.js";
import { canonicalBuiltinModule } from "../../builtin-modules.js";
import { lowerProcessIpcSend, ipcMessageListener, ipcDisconnectListener } from "./ipc.js";
import { lowerDiagnosticsSubscriber, lowerTracingArguments } from "./async-context.js";
import { isCheckerPanic } from "../../../diagnostics/diagnostic.js";
/** Whether the process has an IPC channel, Node's test for defining
* process.send and process.disconnect: a channel this fork child opened
@@ -173,6 +174,70 @@ export function programReadsExitCode(lowerer: Lowerer): boolean {
return found;
}
/** A write-method replacement on a process output stream, its source site.
* `stream` is null when the receiver is a first-class alias, whose type does
* not say which of the two output streams it holds. */
export interface StdioPatchSite {
stream: "stdout" | "stderr" | null;
loc: SrcLoc;
}
/** Whether any program file replaces a process output stream's write
* method: `process.stdout.write = …` / `process.stderr.write = …`, or the
* same assignment through a first-class alias of the stream (a value whose
* type maps to the process stream). Such a program routes its typed
* stdout/stderr writes through the stream object, so the replacement sees
* them with their arguments, as in Node. Cached per program; the first
* site found is recorded for the executable planner's SC8020 check. */
export function programPatchesStdio(lowerer: Lowerer): boolean {
if (lowerer.patchesStdio !== null) return lowerer.patchesStdio;
let found = false;
// undefined: not an output stream; null: a first-class alias of one.
const streamOf = (recv: ts.Expression): "stdout" | "stderr" | null | undefined => {
if (ts.isPropertyAccessExpression(recv)) {
const direct = lowerer.stdlibGlobalMember(recv, "process");
if (direct === "stdout" || direct === "stderr") return direct;
}
// An arbitrary receiver's mapping is a query, never a commit: a checker
// panic (the type has no readable form) is not a patch site. The type
// cannot say WHICH output stream an alias holds.
try {
return lowerer.mapTypeOf(lowerer.typeOf(recv))?.kind === "procStream" ? null : undefined;
} catch (e) {
if (!isCheckerPanic(e)) throw e;
return undefined;
}
};
const walk = (node: ts.Node): void => {
if (found) return;
if (
ts.isBinaryExpression(node) &&
node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
ts.isPropertyAccessExpression(node.left) &&
node.left.name.text === "write"
) {
const recv = node.left.expression;
const direct = ts.isPropertyAccessExpression(recv)
? lowerer.stdlibGlobalMember(recv, "process")
: null;
const stream = direct === "stdout" || direct === "stderr" ? direct : streamOf(recv);
if (stream !== undefined) {
found = true;
lowerer.stdioPatchSites.push({ stream, loc: locOf(node) });
return;
}
}
ts.forEachChild(node, walk);
};
for (const sf of lowerer.program.getImplementationSourceFiles()) {
if (sf.isDeclarationFile) continue;
walk(sf);
if (found) break;
}
lowerer.patchesStdio = found;
return found;
}
/** The pinned Node release the runtime reports as process.versions.node
* (packages/runtime/src/scr_lib.c SCR_NODE_COMPAT_VERSION, kept equal to
* internal/compatibility/node-v24.json by test/node-pin.test.ts). */
@@ -247,12 +312,13 @@ export function lowerProcStreamMethodCall(
}
/** Standard streams expose optional terminal properties: pipes have no
* isTTY, columns, or rows. The declarations do not prove their presence. */
* isTTY, columns, or rows, and only a TTY stdin has a raw-mode flag. The
* declarations do not prove their presence. */
export function isOptionalProcessStreamProperty(
lowerer: Lowerer,
expr: ts.PropertyAccessExpression,
): boolean {
if (!["isTTY", "columns", "rows"].includes(expr.name.text)) return false;
if (!["isTTY", "columns", "rows", "isRaw"].includes(expr.name.text)) return false;
let recv = expr.expression;
while (ts.isParenthesizedExpression(recv) || ts.isAsExpression(recv) || ts.isTypeAssertion(recv))
recv = recv.expression;
@@ -261,7 +327,7 @@ export function isOptionalProcessStreamProperty(
if (
stream === "stdout" ||
stream === "stderr" ||
(stream === "stdin" && expr.name.text === "isTTY")
(stream === "stdin" && (expr.name.text === "isTTY" || expr.name.text === "isRaw"))
)
return true;
}
@@ -341,6 +407,26 @@ export function lowerProcessStreamProperty(
loc: locOf(expr),
};
}
// The write method as a VALUE in TypeScript (`const original =
// process.stdout.write`, `process.stdout.write.bind(process.stdout)`):
// the stream object's current write slot, a checked-dynamic function
// (the native method until a program replaces it).
if (member === "write" && (direct === "stdout" || direct === "stderr")) {
const loc = locOf(expr);
return {
kind: "dynKeyGet",
value: {
kind: "libCall",
fn: "process.stdioSlot",
args: [numLit(direct === "stdout" ? 1 : 2, loc)],
type: DYN,
loc,
},
key: strLit("write", locOf(expr.name)),
type: DYN,
loc,
};
}
// `if (process.stdin.setRawMode)`: Node's stdin has the method only
// when it is a TTY. The runtime's stream object answers the same
// presence (the method or undefined); calls keep their direct lowering.
@@ -357,7 +443,11 @@ export function lowerProcessStreamProperty(
member !== "isTTY" &&
member !== "columns" &&
member !== "rows" &&
member !== "writableLength"
member !== "writableLength" &&
member !== "fd" &&
member !== "isRaw" &&
member !== "writableHighWaterMark" &&
member !== "writableNeedDrain"
)
return null;
let recv: ts.Expression = expr.expression;
@@ -391,19 +481,31 @@ export function lowerProcessStreamProperty(
// nothing is ever queued and Node's number is always 0. The other
// members stay keyed reads — the runtime stream answers their live
// values (isatty columns/rows, stdin readableLength).
if (member === "writableLength") {
if (
member === "writableLength" ||
member === "writableHighWaterMark" ||
member === "writableNeedDrain"
) {
// The receiver still EVALUATES: a call or property read that hands
// back the stream runs its own side effects — and throws where Node
// throws — before the constant answers, exactly as the read of the
// member would. A pure receiver (the alias above) folds away.
const answer: IrExpr = { kind: "numLit", value: 0, type: F64, loc: locOf(expr) };
const answer: IrExpr =
member === "writableNeedDrain"
? { kind: "boolLit", value: false, type: BOOL, loc: locOf(expr) }
: {
kind: "numLit",
value: member === "writableLength" ? 0 : 65536,
type: F64,
loc: locOf(expr),
};
const effect = lowerer.lowerExpr(expr.expression);
if (isSafeToDiscard(effect)) return answer;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: effect, loc: locOf(expr.expression) }],
result: answer,
type: F64,
type: answer.type,
loc: locOf(expr),
};
}
@@ -435,6 +537,16 @@ export function lowerProcessStreamProperty(
// true for the same reason). Backpressure probes read 0 and stay
// correct; the write itself never defers.
if (member === "writableLength") return { kind: "numLit", value: 0, type: F64, loc };
// Node's default high-water mark for the process streams, and the
// drain flag a synchronous write never sets.
if (member === "writableHighWaterMark") {
if (stream === "stdin") return null;
return { kind: "numLit", value: 65536, type: F64, loc };
}
if (member === "writableNeedDrain") {
if (stream === "stdin") return null;
return { kind: "boolLit", value: false, type: BOOL, loc };
}
if (member === "isTTY") {
const type = lowerer.withUndefinedArm(BOOL);
return {
@@ -449,7 +561,29 @@ export function lowerProcessStreamProperty(
loc,
};
}
if (stream === "stdin") return null;
if (member === "fd") {
// The process streams are built over fd 0/1/2 (Node answers those on
// a TTY, a pipe, and a redirected file alike).
return {
kind: "numLit",
value: stream === "stdin" ? 0 : stream === "stdout" ? 1 : 2,
type: F64,
loc,
};
}
if (stream === "stdin") {
if (member !== "isRaw") return null;
// Node answers the raw-mode flag of a TTY stdin and undefined on
// anything else (a pipe-backed stdin); the runtime sentinel carries
// both (scr_process_stdin_is_raw).
return {
kind: "libCall",
fn: "process.stdinIsRaw",
args: [],
type: lowerer.withUndefinedArm(BOOL),
loc,
};
}
const want = lowerer.withUndefinedArm(F64);
return {
kind: "libCall",
@@ -569,9 +703,10 @@ export function lowerProcessProperty(
// The capability-probe members that honestly DON'T EXIST in a
// compiled binary — each reads undefined (their declared types carry
// the undefined arm), so feature probes take their documented
// fallbacks: no gyp build config (process.config.variables.* — no ICU,
// no QUIC — and process.config.target_defaults), no feature flags
// (process.features.* — no inspector, not a debug build).
// fallbacks: no gyp build config (process.config.variables.* — no QUIC
// — and process.config.target_defaults; v8_enable_i18n_support is the
// exception below, answered from the binary's linked ICU), no feature
// flags (process.features.* — no inspector, not a debug build).
if (!expr.questionDotToken && ts.isPropertyAccessExpression(expr.expression)) {
const container = lowerer.stdlibGlobalMember(expr.expression, "process");
if (container === "features") {
@@ -594,6 +729,13 @@ export function lowerProcessProperty(
expr.expression.name.text === "variables" &&
lowerer.stdlibGlobalMember(expr.expression.expression, "process") === "config"
) {
// The one variable a compiled binary can answer truthfully:
// v8_enable_i18n_support. Node bundles ICU and answers the number 1;
// this read selects the `icu` runtime feature (scr_intl.c), so a
// binary that reports ICU is a binary that carries it.
if (expr.name.text === "v8_enable_i18n_support") {
return { kind: "libCall", fn: "icu.i18nSupport", args: [], type: F64, loc: locOf(expr) };
}
return { kind: "unitLit", unit: "undefined", type: UNDEFINED_T, loc: locOf(expr) };
}
}
@@ -920,6 +1062,58 @@ export function lowerProcessMethodCall(
}
return { kind: "libCall", fn: "process.stdinSetRawMode", args: [mode], type: VOID, loc };
}
// process.stdout/stderr.on("drain", fn) and its spellings: Node emits
// 'drain' only after a write() returned false, and these writes are
// synchronous (they never return false), so the listener can never run.
// Registering it is therefore observably a no-op: the receiver and the
// listener are evaluated for their effects and nothing is retained.
// listenerCount/listeners/eventNames on the process streams stay
// refused, so the missing registration cannot be observed.
if (
[
"on",
"once",
"off",
"addListener",
"removeListener",
"prependListener",
"prependOnceListener",
].includes(access.name.text) &&
ts.isPropertyAccessExpression(access.expression) &&
call.arguments.nodes.length === 2 &&
!call.arguments.nodes.some(ts.isSpreadElement)
) {
const stream = lowerer.stdlibGlobalMember(access.expression, "process");
const evT = lowerer.typeOf(call.arguments.nodes[0]!);
if (
(stream === "stdout" || stream === "stderr") &&
evT.isStringLiteralType() &&
evT.value === "drain"
) {
const loc = locOf(call);
const parent = call.parent;
const discarded =
ts.isExpressionStatement(parent) || (ts.isArrowFunction(parent) && parent.body === call);
if (!discarded) {
lowerer.unsupported(
"SC1090",
call,
`using the result of process.${stream}.${access.name.text}("drain", ...) (register it as its own statement)`,
);
}
const stmts: IrStmt[] = [];
for (const arg of call.arguments.nodes) {
const value = lowerer.lowerExprExpecting(arg, DYN);
if (!isSafeToDiscard(value)) stmts.push({ kind: "exprStmt", expr: value, loc: value.loc });
}
// The call's value is discarded (statement position or a void arrow
// body), so the lowering is the evaluated arguments alone; a bare
// unitLit has no standalone IR value, so the no-op result is the
// dynamic undefined a discarded call leaves.
const result: IrExpr = dynUndefinedExpr(loc);
return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: DYN, loc };
}
}
// process.stdout.on("resize", cb) (and once/off/removeListener/
// addListener, stderr too): the terminal resize event, registered on the
// runtime's process stream object (a SIGWINCH listener that emits when a
@@ -1078,6 +1272,33 @@ export function lowerProcessMethodCall(
if (stream === "stdout" || stream === "stderr") {
const loc = locOf(call);
const args = call.arguments.nodes;
// In a program that replaces a process stream's write, a write call is
// what Node makes it: a call of the stream's CURRENT write property,
// with the arguments as passed (the replacement sees byte chunks,
// encodings and callbacks). The native method behind the slot is the
// same synchronous write, so an unpatched stream behaves as before.
if (programPatchesStdio(lowerer) && !args.some(ts.isSpreadElement)) {
const handle: IrExpr = {
kind: "libCall",
fn: "process.stdioSlot",
args: [numLit(stream === "stdout" ? 1 : 2, loc)],
type: DYN,
loc,
};
const invoke: IrExpr = {
kind: "dynInvoke",
recv: handle,
method: "write",
calleeName: ts.getTextOfNode(access),
args: args.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
type: DYN,
loc,
};
// The declared boolean: a replacement returning something else
// throws at a use that needs the boolean (types do not lie); a
// discarded result is not converted.
return resultIsDiscarded(call) ? invoke : lowerer.coerceToExpected(invoke, BOOL);
}
if (args.length < 1 || args.length > 3 || args.some(ts.isSpreadElement)) {
lowerer.noLowering(
`process.${stream}.write with ${args.length} arguments`,
@@ -1174,6 +1395,24 @@ export function lowerProcessMethodCall(
if (data.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(call))) {
data = { kind: "dynCheck", value: data, type: STRING, loc };
}
// A chunk known only at run time (`any`, or a string | Uint8Array
// union): the runtime dispatches on the value — a string or a
// Uint8Array/Buffer writes, null throws ERR_STREAM_NULL_VALUES, any
// other value throws ERR_INVALID_ARG_TYPE, as Node's Writable.write
// does before writing anything. Only the one-argument form.
if (
args.length === 1 &&
(data.type.kind === "dyn" || data.type.kind === "union") &&
(data.type.kind === "dyn" || lowerer.dynConvertible(data.type))
) {
return {
kind: "libCall",
fn: "process.stdioWriteDyn",
args: [numLit(stream === "stdout" ? 1 : 2, loc), lowerer.coerceToExpected(data, DYN)],
type: BOOL,
loc,
};
}
const isBytes = data.type.kind === "bytes" && data.type.elem === "u8";
if (!isBytes && data.type.kind !== "string") {
lowerer.noLowering(
@@ -1312,13 +1551,33 @@ export function lowerProcessMethodCall(
// the listener to a const so both sites see the same value), and
// `removeListener` IS `off` — Node aliases them.
const isOff = member === "off" || member === "removeListener";
if (member === "on" || member === "once" || isOff) {
// process.prependListener(event, cb): Node's front-of-list registration.
// The signal, 'exit', and 'uncaughtException'/'uncaughtExceptionMonitor'
// registries have a prepend entry; every other family registers by
// appending only, so it refuses rather than reordering silently.
// prependOnceListener has no runtime entry at all.
const isPrepend = member === "prependListener";
if (member === "prependOnceListener") {
lowerer.noLowering(
"process.prependOnceListener",
access,
"use prependListener or once (prependOnceListener has no lowering)",
);
}
if (member === "on" || member === "once" || isOff || isPrepend) {
if (call.arguments.nodes.length !== 2) {
lowerer.noLowering(`process.${member} with ${call.arguments.nodes.length} arguments`, call);
}
const evT = lowerer.typeOf(call.arguments.nodes[0]!);
const event = evT.isStringLiteralType() ? evT.value : null;
if ((event === "message" || event === "disconnect") && !isOff) {
if (isPrepend) {
lowerer.noLowering(
`process.prependListener("${event}", ...)`,
call,
"prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)",
);
}
if (!resultIsDiscarded(call)) {
lowerer.unsupported(
"SC1090",
@@ -1346,8 +1605,16 @@ export function lowerProcessMethodCall(
const monitor = boolLit(event === "uncaughtExceptionMonitor", loc);
return {
kind: "libCall",
fn: isOff ? "process.offUncaughtException" : "process.onUncaughtException",
args: isOff ? [cb, monitor] : [cb, boolLit(member === "once", loc), monitor],
fn: isOff
? "process.offUncaughtException"
: isPrepend
? "process.prependUncaughtException"
: "process.onUncaughtException",
args: isOff
? [cb, monitor]
: isPrepend
? [cb, monitor]
: [cb, boolLit(member === "once", loc), monitor],
type: VOID,
loc,
};
@@ -1361,6 +1628,13 @@ export function lowerProcessMethodCall(
// a handler attached after delivery fires it once, synchronously
// with the promise.
if (event === "unhandledRejection" || event === "rejectionHandled") {
if (isPrepend) {
lowerer.noLowering(
`process.prependListener("${event}", ...)`,
call,
"prependListener is supported for signals, 'exit', and 'uncaughtException' (register these families with on/once)",
);
}
if (!resultIsDiscarded(call)) {
lowerer.unsupported(
"SC1090",
@@ -1384,6 +1658,13 @@ export function lowerProcessMethodCall(
// util.deprecate dispatch on the next tick. off/removeListener remove
// by closure identity.
if (event === "warning" && (member === "on" || isOff)) {
if (isPrepend) {
lowerer.noLowering(
'process.prependListener("warning", ...)',
call,
"prependListener is supported for signals, 'exit', and 'uncaughtException' (register this family with on)",
);
}
if (!resultIsDiscarded(call)) {
lowerer.unsupported(
"SC1090",
@@ -1418,8 +1699,12 @@ export function lowerProcessMethodCall(
const cb = lowerDiagnosticsSubscriber(lowerer, call.arguments.nodes[1]!);
return {
kind: "libCall",
fn: isOff ? "process.offSignal" : "process.onSignal",
args: isOff ? [signal, cb] : [signal, cb, boolLit(member === "once", loc)],
fn: isOff ? "process.offSignal" : isPrepend ? "process.prependSignal" : "process.onSignal",
args: isOff
? [signal, cb]
: isPrepend
? [signal, cb]
: [signal, cb, boolLit(member === "once", loc)],
type: VOID,
loc,
};
@@ -1489,6 +1774,9 @@ export function lowerProcessMethodCall(
if (isOff) {
return { kind: "libCall", fn: "process.offExit", args: [cb], type: VOID, loc };
}
if (isPrepend) {
return { kind: "libCall", fn: "process.prependExit", args: [cb], type: VOID, loc };
}
return { kind: "libCall", fn: "process.onExit", args: [cb, onceArg], type: VOID, loc };
}
// process.emitWarning(...): the argument vector crosses as ONE dyn
@@ -10,6 +10,7 @@ import {
type IrExpr,
STRING,
type SrcLoc,
VOID,
arrayOf,
isUnitType,
} from "../../../ir/ir.js";
@@ -62,6 +63,72 @@ export function lowerReflectCall(
}
if (call.questionDotToken) return null;
const reflectMember = lowerer.stdlibGlobalMember(access, "Reflect");
// Reflect.has(target, key) — the `in` operator's presence query over
// the target's checked-dynamic view (own and inherited names, proxy
// `has` traps included). A primitive target is Node's TypeError
// ("Reflect.has called on non-object"): a statically primitive target
// is refused, and a target held as any, unknown or a union is checked
// at run time (dyn.reflectHasTarget).
if (
reflectMember === "has" &&
call.arguments.nodes.length === 2 &&
!call.arguments.nodes.some(ts.isSpreadElement)
) {
const loc = locOf(call);
const target = lowerer.lowerExpr(call.arguments.nodes[0]!);
const primitive =
isUnitType(target.type) ||
["bool", "f64", "string", "bigint", "symbol"].includes(target.type.kind);
if (primitive)
lowerer.noLowering(
"Reflect.has on a primitive target",
call.arguments.nodes[0]!,
"Node throws a TypeError for a primitive target; narrow to an object first",
);
if (!lowerer.dynConvertible(target.type))
lowerer.noLowering("Reflect.has on this target", call.arguments.nodes[0]!);
const key = lowerer.lowerExprExpecting(call.arguments.nodes[1]!, DYN);
if (target.type.kind !== "dyn" && target.type.kind !== "union") {
return {
kind: "libCall",
fn: "dyn.hasKeyComputed",
args: [lowerer.coerceToExpected(target, DYN), key],
type: BOOL,
loc,
};
}
// A target held as any, unknown or a union may be a primitive at run
// time: both arguments are evaluated, then the target is checked.
const targetLocal = lowerer.declareHiddenLocal("%reflectHasTarget", DYN);
const keyLocal = lowerer.declareHiddenLocal("%reflectHasKey", DYN);
const targetRef = varRef(targetLocal.id, DYN, loc);
return {
kind: "seqExpr",
stmts: [
{
kind: "varDecl",
localId: targetLocal.id,
init: lowerer.coerceToExpected(target, DYN),
loc,
},
{ kind: "varDecl", localId: keyLocal.id, init: key, loc },
{
kind: "exprStmt",
expr: { kind: "libCall", fn: "dyn.reflectHasTarget", args: [targetRef], type: VOID, loc },
loc,
},
],
result: {
kind: "libCall",
fn: "dyn.hasKeyComputed",
args: [targetRef, varRef(keyLocal.id, DYN, loc)],
type: BOOL,
loc,
},
type: BOOL,
loc,
};
}
// Reflect.getOwnPropertyDescriptor and Reflect.defineProperty over
// checked objects (execa's Promise mixin): the Object operations, with
// defineProperty answering true. A non-object target throws the Object
@@ -5,6 +5,7 @@ import { staticTextDecoderEncoding } from "../text-decoder-encoding.js";
import * as ts from "../../ts7/adapter.js";
import { type Lowerer } from "../lowerer.js";
import { locOf } from "../../program.js";
import { InternalCompilerError } from "../../../errors.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import {
BOOL,
@@ -121,6 +122,24 @@ export function lowerStringDecoderMethodCall(
loc,
};
}
// A chunk that crossed the checked-dynamic boundary (a stream "data"
// payload): the runtime answers Node's write — strings pass through
// unchanged, ArrayBufferViews decode, anything else throws
// ERR_INVALID_ARG_TYPE.
if (chunk.type.kind === "dyn") {
return {
kind: "call",
callee: stringDecoderHelper(
lowerer,
name === "end" ? "endDyn" : "writeDyn",
receiver.type.shapeId,
loc,
),
args: [receiver, chunk],
type: STRING,
loc,
};
}
chunk = checkedOptionalBuiltinArm(lowerer, chunk, BYTES_U8) ?? chunk;
if (chunk.type.kind === "bytes" && chunk.type.elem !== "u8") {
chunk = {
@@ -153,7 +172,7 @@ export function lowerStringDecoderMethodCall(
* the pure strdec.* libCalls. */
function stringDecoderHelper(
lowerer: Lowerer,
op: "write" | "end" | "endChunk" | "endString" | "writeString",
op: "write" | "end" | "endChunk" | "endString" | "writeString" | "writeDyn" | "endDyn",
shapeId: string,
loc: SrcLoc,
): string {
@@ -245,6 +264,100 @@ function stringDecoderHelper(
{ kind: "return", value: varRef("s.0", STRING, loc), loc },
];
}
if (op === "writeDyn" || op === "endDyn") {
const dynChunk = { localId: "chunk.0", name: "chunk", type: DYN };
params.push(dynChunk);
locals.splice(1, 0, { id: "chunk.0", name: "chunk", type: DYN, mutable: false });
const writeDyn: IrExpr = {
kind: "libCall",
fn: "strdec.writeDyn",
args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)],
type: STRING,
loc,
};
body = [
{ kind: "varDecl", localId: "s.0", init: writeDyn, loc },
{
kind: "recordSet",
obj: varRef("d.0", recT, loc),
shapeId,
field: "%pending",
value: {
kind: "libCall",
fn: "strdec.nextDyn",
args: [encRead(), pendingRead(), varRef("chunk.0", DYN, loc)],
type: F64,
loc,
},
loc,
},
{ kind: "return", value: varRef("s.0", STRING, loc), loc },
];
if (op === "endDyn") {
// end(buf) is write(buf) followed by the flush of whatever the write
// left buffered (Node's end).
locals.push({ id: "tail.0", name: "tail", type: STRING, mutable: false });
body.splice(
body.length - 1,
0,
{
kind: "varDecl",
localId: "tail.0",
init: {
kind: "libCall",
fn: "strdec.end",
args: [encRead(), pendingRead()],
type: STRING,
loc,
},
loc,
},
{
kind: "recordSet",
obj: varRef("d.0", recT, loc),
shapeId,
field: "%pending",
value: { kind: "numLit", value: 0, type: F64, loc },
loc,
},
);
body[body.length - 1] = {
kind: "return",
value: {
kind: "strConcat",
left: varRef("s.0", STRING, loc),
right: varRef("tail.0", STRING, loc),
type: STRING,
loc,
},
loc,
};
// end(undefined) only flushes, as Node's end skips the write when
// its chunk is undefined.
const [write, next] = body.splice(0, 2, {
kind: "varDecl",
localId: "s.0",
init: { kind: "strLit", value: "", type: STRING, loc },
loc,
});
if (write?.kind !== "varDecl" || write.init === null || next === undefined)
throw new InternalCompilerError("StringDecoder.end helper without its write");
locals.find((local) => local.id === "s.0")!.mutable = true;
body.splice(1, 0, {
kind: "if",
cond: {
kind: "dynTest",
test: "undefined",
value: varRef("chunk.0", DYN, loc),
type: BOOL,
loc,
},
then: [],
else_: [{ kind: "assign", localId: "s.0", value: write.init, loc }, next],
loc,
});
}
}
if (op === "endString" || op === "writeString") {
const inputT = STRING;
params.push({ localId: "chunk.0", name: "chunk", type: inputT });
@@ -228,11 +228,30 @@ function lowerSearchParamsInit(lowerer: Lowerer, arg: ts.Expression, loc: SrcLoc
if (ts.isObjectLiteralExpression(arg)) {
let acc: IrExpr = { kind: "libCall", fn: "sp.new", args: [], type: SEARCH_PARAMS_T, loc };
for (const prop of arg.properties.nodes) {
// `{ model }` appends the binding's value under its own name, like
// the `{ model: model }` spelling.
if (ts.isShorthandPropertyAssignment(prop)) {
const keyExpr: IrExpr = {
kind: "strLit",
value: (prop.name as ts.Identifier | ts.StringLiteral).text,
type: STRING,
loc: locOf(prop.name),
};
const value = lowerer.coerceInto(prop.name, lowerer.lowerShorthandValue(prop), STRING);
acc = {
kind: "libCall",
fn: "sp.with",
args: [acc, keyExpr, value],
type: SEARCH_PARAMS_T,
loc,
};
continue;
}
if (!ts.isPropertyAssignment(prop) || prop.name === undefined) {
lowerer.unsupported(
"SC1090",
prop,
"URLSearchParams record inits with spreads, accessors, or shorthand entries",
"URLSearchParams record inits with spreads or accessors",
);
}
let key: string;
@@ -30,11 +30,11 @@ const HEAP_STATISTICS_FIELDS = [
];
/** `v8.getHeapStatistics()`: one memoryUsage sample (the runtime heap's
* total and used bytes), with the machine's memory as the heap limit (the
* runtime heap has no fixed ceiling) and the available size the limit
* minus used bytes. V8-only figures (executable code space, malloced
* memory, global handles, external memory, zapping) read 0, and one
* native context exists. The record is the call site's interned HeapInfo
* total and used bytes, and external memory, memoryUsage's `external`),
* with the machine's memory as the heap limit (the runtime heap has no
* fixed ceiling) and the available size the limit minus used bytes.
* V8-only figures (executable code space, malloced memory, global handles,
* zapping) read 0, and one native context exists. The record is the call site's interned HeapInfo
* shape, built by a shared helper per shape. */
export function lowerV8HeapStatisticsCall(
lowerer: Lowerer,
@@ -73,6 +73,8 @@ export function lowerV8HeapStatisticsCall(
return sample(1);
case "used_heap_size":
return sample(2);
case "external_memory":
return sample(3);
case "heap_size_limit":
return limit();
case "total_available_size":
@@ -1,4 +1,7 @@
import { staticFetchMembers } from "../../../compat/fetch-profile.js";
import {
READABLE_STREAM_WEB_VIEW_CALLS,
staticFetchMembers,
} from "../../../compat/fetch-profile.js";
import { numLit } from "../../../ir/build.js";
import { everyStmt } from "../../../ir/traverse.js";
import {
@@ -16,6 +19,7 @@ import { constituentTypes } from "../../ts7/checker.js";
import { ENTRY_NAME, type Lowerer } from "../lowerer.js";
import { stdlibGlobalNameOf } from "../surfaces.js";
import { streamWebImportOf } from "./module-bindings.js";
import { lowerDynObjectLiteral } from "../expressions/object-literals.js";
/* The web stream classes in JavaScript sources: the globals and the
* node:stream/web exports, which are the same constructors. Each reference
@@ -92,6 +96,60 @@ export function programNamesWebStreamClass(lowerer: Lowerer): boolean {
return found;
}
/** The classes a TypeScript source constructs through the web stream
* classes once it uses an operation the native streams lack. */
const TYPED_CLASSES: ReadonlySet<string> = new Set([
"ReadableStream",
"WritableStream",
"TransformStream",
]);
const WEB_VIEW_CALLS: ReadonlySet<string> = new Set(READABLE_STREAM_WEB_VIEW_CALLS);
/** Whether a TypeScript source of the program uses a web stream operation
* the native streams lack: a ReadableStream tee(), pipeTo() or
* pipeThrough() call, a WritableStream constructor, or a TransformStream
* constructor with arguments (a transformer or queuing strategies). Such a
* program builds its ReadableStream, WritableStream and TransformStream
* values with the web stream classes, as JavaScript does; a program that
* uses only getReader(), cancel() and the identity transform keeps the
* native streams. */
export function programUsesTypedWebStreams(lowerer: Lowerer): boolean {
if (lowerer.typedWebStreams !== undefined) return lowerer.typedWebStreams;
let found = false;
for (const file of lowerer.program.getSourceFiles()) {
if (file.isDeclarationFile || isJsSourceFile(file)) continue;
ts.walkPreorder(file, (node) => {
const member = staticMemberName(node);
if (member !== null && WEB_VIEW_CALLS.has(member) && isReadableStreamMember(lowerer, node))
found = true;
else if (ts.isNewExpression(node)) {
const name = stdlibGlobalNameOf(lowerer, unparenthesized(node.expression));
if (
name === "WritableStream" ||
(name === "TransformStream" && (node.arguments?.nodes.length ?? 0) > 0)
)
found = true;
}
return found ? "stop" : undefined;
});
if (found) break;
}
lowerer.typedWebStreams = found;
return found;
}
/** The class a TypeScript `new` expression constructs through the web
* stream classes (programUsesTypedWebStreams), as its index in
* WEB_STREAM_CLASSES; null otherwise. */
function typedWebStreamClassKindOf(lowerer: Lowerer, node: ts.Expression): number | null {
const expr = unparenthesized(node);
if (!ts.isIdentifier(expr) || isJsSourceFile(ts.getSourceFileOfNode(expr))) return null;
const name = streamWebImportOf(lowerer, expr) ?? stdlibGlobalNameOf(lowerer, expr);
if (name === null || !TYPED_CLASSES.has(name) || !programUsesTypedWebStreams(lowerer))
return null;
return CLASS_NAMES.indexOf(name);
}
export function webStreamClassValue(kind: number, loc: SrcLoc): IrExpr {
return { kind: "libCall", fn: "webstream.class", args: [numLit(kind, loc)], type: DYN, loc };
}
@@ -102,13 +160,24 @@ export function lowerWebStreamClassRead(lowerer: Lowerer, node: ts.Expression):
return kind === null ? null : webStreamClassValue(kind, locOf(node));
}
/** `new X(...args)` on one of the classes in JavaScript. */
/** `new X(...args)` on one of the classes in JavaScript, and on
* ReadableStream, WritableStream and TransformStream in a TypeScript
* program that uses the web stream classes (programUsesTypedWebStreams).
* A TypeScript source's object literal argument (a source, sink,
* transformer or strategy) becomes a plain object with its methods. */
export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr | null {
const kind = webStreamClassKindOf(lowerer, expr.expression);
const typed = webStreamClassKindOf(lowerer, expr.expression) === null;
const kind = typed
? typedWebStreamClassKindOf(lowerer, expr.expression)
: webStreamClassKindOf(lowerer, expr.expression);
if (kind === null) return null;
const nodes = expr.arguments?.nodes ?? [];
if (nodes.some(ts.isSpreadElement)) return null;
const loc = locOf(expr);
const argument = (arg: ts.Expression): IrExpr =>
typed && ts.isObjectLiteralExpression(arg)
? lowerDynObjectLiteral(lowerer, arg)
: lowerer.lowerExprExpecting(arg, DYN);
return {
kind: "libCall",
fn: "dyn.construct",
@@ -116,7 +185,7 @@ export function lowerWebStreamNew(lowerer: Lowerer, expr: ts.NewExpression): IrE
webStreamClassValue(kind, loc),
{
kind: "dynArrLit",
elems: nodes.map((arg) => lowerer.lowerExprExpecting(arg, DYN)),
elems: nodes.map(argument),
type: DYN,
loc,
},
@@ -244,10 +313,29 @@ function isReadableStreamType(lowerer: Lowerer, type: ts.Type): boolean {
);
}
/** Whether a member access resolves to a member of the stdlib
* ReadableStream interface. */
function isReadableStreamMember(lowerer: Lowerer, access: ts.PropertyAccessExpression): boolean {
const member = lowerer.checker.getSymbolAtLocation(access.name);
type MemberAccess = ts.PropertyAccessExpression | ts.ElementAccessExpression;
/** The member a property access names, or an element access with a string
* literal key (`body.tee` and `body["tee"]`); null for any other node. */
function staticMemberName(node: ts.Node): string | null {
if (ts.isPropertyAccessExpression(node)) return node.name.text;
if (ts.isElementAccessExpression(node)) {
const key = node.argumentExpression;
if (ts.isStringLiteral(key) || ts.isNoSubstitutionTemplateLiteral(key)) return key.text;
}
return null;
}
/** Whether a member access (staticMemberName) resolves to a member of the
* stdlib ReadableStream interface. */
function isReadableStreamMember(lowerer: Lowerer, access: ts.Node): boolean {
const key = ts.isPropertyAccessExpression(access)
? access.name
: ts.isElementAccessExpression(access)
? access.argumentExpression
: null;
if (key === null) return false;
const member = lowerer.checker.getSymbolAtLocation(key);
if (member === undefined || !lowerer.isStdlibSymbol(member)) return false;
let owner: ts.Node | undefined = member.declarations[0]?.parent;
while (owner !== undefined && !ts.isInterfaceDeclaration(owner)) owner = owner.parent;
@@ -285,6 +373,7 @@ function programLinksFetch(functions: IrFunction[]): boolean {
* count in a program that links the fetch unit, since the value can be a
* fetch body. */
function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction[]): boolean {
if (programUsesTypedWebStreams(lowerer)) return true;
const members = staticFetchMembers().readableStreamUnsupported;
const iterates = members.has("[Symbol.asyncIterator]");
let untyped = false;
@@ -293,9 +382,10 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction
let found = false;
ts.walkPreorder(file, (node) => {
let receiver: ts.Type | null = null;
if (ts.isPropertyAccessExpression(node) && members.has(node.name.text)) {
const member = staticMemberName(node);
if (member !== null && members.has(member)) {
if (isReadableStreamMember(lowerer, node)) found = true;
else receiver = lowerer.typeOf(node.expression);
else receiver = lowerer.typeOf((node as MemberAccess).expression);
} else if (iterates && ts.isForOfStatement(node) && node.awaitModifier !== undefined) {
receiver = lowerer.typeOf(node.expression);
if (isReadableStreamType(lowerer, receiver)) found = true;
@@ -310,7 +400,9 @@ function programUsesWebStreamOnlyMembers(lowerer: Lowerer, functions: IrFunction
}
/** Links the web stream classes into a program whose JavaScript uses a
* ReadableStream member the fetch unit's own stream lacks: a fetch body's
* ReadableStream member the fetch unit's own stream lacks, or whose
* TypeScript uses the web stream classes (programUsesTypedWebStreams): a
* fetch body's or a native stream's
* handle stands for a web ReadableStream once such a member is used
* (scr_fetch.c, sf_stream_web_view). %main installs the classes first. A
* program that names one of the classes links them already. */
@@ -630,6 +630,41 @@ export function checkDefaultParamBodyType(
}
}
/** Whether a lambda's OWN signature maps to its completed ABI position for
* position — the contract its reads carry (mapType spells `x?: T` as the
* `T | undefined` slot). Two positions can escape the mapped-TYPE
* comparison in requireExactArityValue: a parameter the checker spells
* `any` holds the checked-dynamic value that mapType leaves unmapped when
* its spelling is context-free, and a *contextually* typed lambda's own
* signature need not be the slot's (a comparator with a surplus optional
* parameter under a two-parameter slot). */
function lambdaSpellsCompletedAbi(
lowerer: Lowerer,
lambda: ts.ArrowFunction | ts.FunctionExpression,
funcType: IrType,
): boolean {
if (funcType.kind !== "func" || funcType.rest === true) return false;
const sigs = lowerer.checker.getCallSignatures(lowerer.typeOf(lambda));
if (sigs.length !== 1) return false;
const sig = sigs[0]!;
const params = sig.getParameters();
if (params.length !== funcType.params.length) return false;
for (let i = 0; i < params.length; i++) {
const pTs = lowerer.checker.getTypeOfSymbol(params[i]!);
const slot = funcType.params[i]!;
if ((pTs.flags & ts.TypeFlags.Any) !== 0) {
if (slot.kind !== "dyn") return false;
continue;
}
const mappedParam = lowerer.mapTypeOf(pTs);
if (!mappedParam || !typeEquals(mappedParam, slot)) return false;
}
const retTs = lowerer.checker.getReturnTypeOfSignature(sig);
if ((retTs.flags & ts.TypeFlags.Any) !== 0) return funcType.ret.kind === "dyn";
const mappedRet = lowerer.mapTypeOf(retTs);
return mappedRet !== null && typeEquals(mappedRet, funcType.ret);
}
/** DECISION (docs/ir.md): function VALUES carry one completed native ABI.
* Optional/default parameters appear as literal `T | undefined` slots;
* typed rest parameters appear as one trailing typed-array slot plus the
@@ -675,6 +710,20 @@ export function requireExactArityValue(
? lowerer.mapTypeOf(lowerer.typeOf(contextual))
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// A lambda whose OWN signature already spells the completed ABI is a
// value: every read of it types it that way, and its slot converts it
// (the surplus-parameter and undefined-arm adapters). The mapped-type
// comparison above misses two such lambdas — one whose `any` parameter
// mapType leaves unmapped (`(a: any, b: any, loose?: boolean) => number`
// beside a two-parameter slot), and one whose signature is wider than
// the contextual slot it is written under.
if (
contextual !== null &&
(ts.isArrowFunction(contextual) || ts.isFunctionExpression(contextual)) &&
lambdaSpellsCompletedAbi(lowerer, contextual, funcType)
) {
return;
}
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver), or a slot whose type maps to a checked-dynamic
// value (a member of a literal lowered with a generic function's type
@@ -1062,6 +1111,9 @@ export function lambdaSignature(
type: DYN,
mode: "required",
}));
// `arguments` holds the call's own tuple: a console line passes one
// or two arguments whatever the replacement declares.
appendArgumentsParameter(lowerer, node, shapes, "arguments");
return { shapes, funcType: funcTypeFromParamShapes(shapes, DYN) };
}
}
@@ -38,6 +38,7 @@ import { isCjsRequireGlobal } from "./lower-node-module.js";
import { DYN, type IrExpr } from "../../ir/ir.js";
import { nodeThrowExpr } from "../../ir/build.js";
import { probeNodeRequireRefusal } from "../npm.js";
import { lowerIslandRequireCall } from "./lower-node-island.js";
export interface InlineRequire {
call: ts.CallExpression;
@@ -537,6 +538,9 @@ export function lowerUncompiledRequireCall(
);
}
if (canonicalBuiltinModule(spec) !== null) return null;
// A package that runs in a Node island loads at the call (scope B).
const island = lowerIslandRequireCall(lowerer, call);
if (island !== null) return island;
if (!spec.startsWith(".") && !spec.startsWith("/") && !spec.startsWith("#")) {
const refusal = probeNodeRequireRefusal(sf.fileName, spec);
if (refusal !== null) {
@@ -93,6 +93,9 @@ import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js";
import { STREAM_OPERATORS, operatorSignalOptions, reduceSignalSetup } from "./lower-stream.js";
import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js";
/** The receiver local of a call rewritten for a getter-only name. */
const GETTER_RECEIVER = "%dispatch.getter.receiver.";
const STREAM_METHODS = new Set([
"on",
"addListener",
@@ -1211,6 +1214,11 @@ export class ClassDynamicDispatch {
* classes included: the value's capsule can be any of its subclasses. */
private readonly boxedStatic = new Set<string>();
private readonly dispatches = new Map<string, Dispatch>();
/** Per receiver local of a call rewritten for a getter-only name, the
* classes whose instances it already reads the getter on. A later wave
* that reaches another class with the getter rewrites the fallback call
* for that class. */
private readonly getterReceivers = new Map<string, ReadonlySet<string>>();
private readonly properties = new Map<string, PropertyDispatch>();
private readonly computed = new Map<
string,
@@ -1897,6 +1905,108 @@ export class ClassDynamicDispatch {
byMethod.set(method, matching);
return matching;
};
// A name some class declares as a getter, and no class as a method: on
// an instance of such a class the call reads the member (running the
// getter) before it evaluates the arguments, as JavaScript reads the
// callee first, and then calls what it read. Any other receiver keeps
// the runtime's method dispatch, which answers builtin methods of the
// same name (`n.toFixed(2)` while a class declares `get toFixed()`).
// The runtime reads fields and other own properties of class instances
// that early itself (scr_dyn_prepare_method).
const getterCapsules = (method: string): string[] => {
const keys = new Set<string>();
for (const { info, capsule } of this.propertyReceivers(lowerer, true))
if (findMethodOn(lowerer, info, `get:${method}`))
keys.add(typeKey({ kind: "object", className: capsule.def.name }));
return [...keys];
};
const getterCall = (
fn: IrFunction,
invoke: IrExpr & { kind: "dynInvoke" },
capsules: readonly string[],
covered: ReadonlySet<string> | undefined,
): IrExpr => {
const loc = invoke.loc;
const receiverLocal = {
id: `${GETTER_RECEIVER}${this.getterReceivers.size}`,
name: "receiver",
type: DYN,
mutable: false,
};
fn.locals.push(receiverLocal);
const keys = new Set<string>(capsules);
if (covered) for (const key of covered) keys.add(key);
this.getterReceivers.set(receiverLocal.id, keys);
const calleeLocal = {
id: `%dispatch.callee.${fn.locals.length}`,
name: "callee",
type: DYN,
mutable: false,
};
fn.locals.push(calleeLocal);
const receiver = varRef(receiverLocal.id, DYN, loc);
const isCapsule = (key: string): IrExpr => ({
kind: "libCall",
fn: "dyn.typedRefIs",
args: [receiver, { kind: "strLit", value: key, type: STRING, loc }],
type: BOOL,
loc,
});
const cond = capsules.slice(1).reduce<IrExpr>(
(rest, key) => ({
kind: "ternary",
cond: isCapsule(key),
then: { kind: "boolLit", value: true, type: BOOL, loc },
else_: rest,
type: BOOL,
loc,
}),
isCapsule(capsules[0]!),
);
const read: IrExpr = {
kind: "seqExpr",
stmts: [
{
kind: "varDecl",
localId: calleeLocal.id,
init: {
kind: "dynKeyGet",
key: { kind: "strLit", value: invoke.method, type: STRING, loc },
value: receiver,
type: DYN,
loc,
},
loc,
},
],
result: {
kind: "dynCall",
callee: varRef(calleeLocal.id, DYN, loc),
receiver,
calleeName: invoke.calleeName,
...(invoke.calleeNameValue ? { calleeNameValue: invoke.calleeNameValue } : {}),
args: invoke.args,
type: DYN,
loc,
},
type: DYN,
loc,
};
return {
kind: "seqExpr",
stmts: [{ kind: "varDecl", localId: receiverLocal.id, init: invoke.recv, loc }],
result: {
kind: "ternary",
cond,
then: read,
else_: { ...invoke, recv: receiver },
type: DYN,
loc,
},
type: DYN,
loc,
};
};
const candidateSets = new Map<string, ReadonlySet<ClassInfo>>();
const candidateSet = (method: string): ReadonlySet<ClassInfo> => {
let set = candidateSets.get(method);
@@ -2964,6 +3074,19 @@ export class ClassDynamicDispatch {
const info = lowerer.classes.get(name);
return info && findStaticOn(lowerer, info, method)?.method;
});
if (
invoke &&
expr.kind === "dynInvoke" &&
candidates(invoke.method).length === 0 &&
!staticCandidates(invoke.method)
) {
// The other receivers' call a rewrite left in place tests only
// the classes that rewrite did not.
const covered =
expr.recv.kind === "varRef" ? this.getterReceivers.get(expr.recv.localId) : undefined;
const capsules = getterCapsules(invoke.method).filter((key) => !covered?.has(key));
if (capsules.length > 0) return getterCall(fn, expr, capsules, covered);
}
if (
!invoke ||
(candidates(invoke.method).length === 0 &&
@@ -100,6 +100,21 @@ export function initializeRuntimeStatics(
}
if (!member.name) continue;
const memberLoc = locOf(member);
// A private static field: its native global, assigned in order.
if (ts.isPrivateIdentifier(member.name)) {
const name = member.name.text;
const field = info.staticFields.find((candidate) => candidate.name === name);
if (!ts.isPropertyDeclaration(member) || !field) {
lowerer.unsupported("SC1090", member, "private static fields without an initializer");
}
statements.push({
kind: "assign",
localId: field.globalId,
value: lowerer.lowerExprExpecting(member.initializer!, field.type),
loc: memberLoc,
});
continue;
}
const key: IrExpr = ts.isComputedPropertyName(member.name)
? computedKeys.get(member)!
: lowerer.coerceToExpected(
@@ -0,0 +1,51 @@
import * as ts from "../ts7/adapter.js";
/** A `Pick<C, K>` / `Omit<C, K>` view: the lib alias it instantiates and
* its source type argument `C`. */
export interface ClassView {
name: "Pick" | "Omit";
source: ts.Type;
}
/** The lib's Pick or Omit alias (declared only in declaration files). */
function libViewName(symbol: ts.Symbol): "Pick" | "Omit" | null {
const name = ts.symbolName(symbol);
if (name !== "Pick" && name !== "Omit") return null;
return symbol.declarations.every((d) => ts.getSourceFileOfNode(d).isDeclarationFile)
? name
: null;
}
/** `type` as a Pick/Omit view, spelled inline (`Pick<C, K>`) or through
* non-generic user aliases (`type View = Pick<C, K>`, `type V2 = View`).
* An aliased instantiation carries the USER's alias symbol and no alias
* type arguments, so the alias declaration's type node names the view.
* Generic user aliases (`type V<T> = Pick<T, K>`) are not resolved. */
export function classViewOf(checker: ts.TypeChecker, type: ts.Type, depth = 0): ClassView | null {
const alias = type.getAliasSymbol();
if (alias === undefined) return null;
const direct = libViewName(alias);
if (direct !== null) {
const source = type.getAliasTypeArguments()[0];
return source === undefined ? null : { name: direct, source };
}
if (depth >= 8 || type.getAliasTypeArguments().length > 0) return null;
const decl = alias.declarations.find(ts.isTypeAliasDeclaration);
if (decl === undefined || (decl.typeParameters?.nodes.length ?? 0) > 0) return null;
let node: ts.TypeNode = decl.type;
while (ts.isParenthesizedTypeNode(node)) node = node.type;
if (!ts.isTypeReferenceNode(node)) return null;
let target = checker.getSymbolAtLocation(node.typeName);
if (target !== undefined && (target.flags & ts.SymbolFlags.Alias) !== 0)
target = checker.getAliasedSymbol(target);
if (target === undefined) return null;
const viewName = libViewName(target);
if (viewName !== null) {
const argument = node.typeArguments?.nodes[0];
return argument === undefined
? null
: { name: viewName, source: checker.getTypeFromTypeNode(argument) };
}
if ((target.flags & ts.SymbolFlags.TypeAlias) === 0) return null;
return classViewOf(checker, checker.getDeclaredTypeOfSymbol(target), depth + 1);
}
@@ -114,10 +114,28 @@ export function coercibleValue(lowerer: Lowerer, src: IrType, dst: IrType): bool
return false;
}
/** Whether an omitted parameter position completes with the native
* undefined value: a checked-dynamic slot takes undefined as-is, and an
* undefined-armed union (`x?: T`, `x: T = e`) is the completed ABI
* spelling. */
export function omittableTrailingParam(lowerer: Lowerer, type: IrType): boolean {
if (type.kind === "dyn" || typeEquals(type, UNDEFINED_T)) return true;
return type.kind === "union" && lowerer.armTag(type.unionId, UNDEFINED_T) >= 0;
}
/** Whether all parameters and the result adapt without a trap-only path.
* Rest signatures are excluded, except a typed-rest source in a slot with
* fixed parameters (the rest packing adapter); extra destination
* parameters are ignored. */
* parameters are ignored. A source with MORE parameters than the slot is
* clean when every extra trailing one is omittable — the adapter passes
* that absent value explicitly (funcCoerceAdapter's omittedParams), the
* same way a call through the slot would. A parameter the slot coercion
* converts through a STRUCTURAL width counts (cleanParamPair), which
* recurses through a nested function type: a callback parameter of the
* value taking a narrower record where the slot's takes its `unknown` view
* converts the argument the same way a top-level call does. The result side
* likewise accepts what funcCoerceAdapter converts (cleanResult), including
* a promise whose payload widens. */
export function cleanFuncAdaptable(
lowerer: Lowerer,
src: IrType & { kind: "func" },
@@ -125,13 +143,35 @@ export function cleanFuncAdaptable(
): boolean {
if (restPackingAdapterFits(lowerer, src, dst)) return true;
if (src.rest === true || dst.rest === true) return false;
if (src.params.length > dst.params.length) return false;
for (let i = 0; i < src.params.length; i++) {
if (!lowerer.coercibleValue(dst.params[i]!, src.params[i]!)) return false;
// Recursive function types (`type Handler = (h: Handler) => void`) reach
// the same pair again one parameter down: assume it converts, the same
// coinductive answer recordWidthPlan gives its own back-edges.
const key = `funcClean:${typeKey(src)}:${typeKey(dst)}`;
if (lowerer.coercions.planning.has(key)) return true;
lowerer.coercions.planning.add(key);
try {
for (let i = dst.params.length; i < src.params.length; i++) {
if (!omittableTrailingParam(lowerer, src.params[i]!)) return false;
}
for (let i = 0; i < src.params.length && i < dst.params.length; i++) {
if (!cleanParamPair(lowerer, dst.params[i]!, src.params[i]!)) return false;
}
return cleanResult(lowerer, src.ret, dst.ret);
} finally {
lowerer.coercions.planning.delete(key);
}
if (dst.ret.kind === "void") return true;
if (lowerer.coercibleValue(src.ret, dst.ret)) return true;
return src.ret.kind === "void" && dst.ret.kind === "dyn";
}
/** Whether the slot's coercion converts the value's RESULT without a
* trap-only path: the dispositions funcCoerceAdapter's result side accepts
* (a void slot drops the value; coercibleValue; a structural width, which
* includes the promise payload widening and a nested function adaptation;
* the void-into-unknown drop). */
function cleanResult(lowerer: Lowerer, actual: IrType, expected: IrType): boolean {
if (expected.kind === "void") return true;
if (lowerer.coercibleValue(actual, expected)) return true;
if (actual.kind === "void" && expected.kind === "dyn") return true;
return lowerer.widthLiftPlan(actual, expected) !== null;
}
/** Whether the slot's argument type `actual` converts into the function's
@@ -442,7 +482,7 @@ export function funcCoerceAdapter(
strandRet = false;
// A record result the slot's result is a structural width of.
if (strandRet && lowerer.widthLiftPlan(fromT.ret, toT.ret)) strandRet = false;
const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}`;
const key = `fnadapt:${typeKey(fromT)}:${typeKey(toT)}${lowerer.coercions.checkedRetags > 0 ? ":checked" : ""}`;
const existing = lowerer.coercions.retags.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%fn.adapt", key);
@@ -9,6 +9,13 @@ export class CoercionState {
// plan checks its other fields. Remove every assumption on unwind;
// generated helpers must intern their names before building their bodies.
readonly planning = new Set<string>();
/** > 0 while a record width plan plans or builds its fields: method
* fields may take checked function adapters (WidthLift funcAdaptChecked). */
methodFieldDepth = 0;
/** > 0 while a checked function adapter plans or builds: contravariant
* parameters may width-lift, and union fields may retag with trapping
* arms (WidthLift checkedRetag). */
checkedRetags = 0;
// Registry revisions invalidate only fully validated copy-only routes.
readonly copyRetags = new Map<string, { name: string; shapes: number; unions: number }>();
// Provenance for checked extraction; consumers must not infer it from names.
@@ -1,4 +1,4 @@
import { planRecordUnionLift, planRecordUnionWrap } from "../union-retag.js";
import { planRecordUnionLift, planRecordUnionWrap, planUnionRetag } from "../union-retag.js";
import type { WidthLift } from "../width-lift.js";
import type { IrRecordShape, IrType } from "../../../ir/ir.js";
import { canDynCheckTo, isUnitType, typeEquals, UNDEFINED_T } from "../../../ir/ir.js";
@@ -57,6 +57,33 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
lowerer.armTag(dst.unionId, UNDEFINED_T) >= 0
)
return { how: "unitRetag" };
// Inside a checked method adapter (contravariant positions): source
// arms with no destination home trap instead of refusing the pair.
if (lowerer.coercions.checkedRetags > 0) {
const from = lowerer.unions.get(src.unionId);
const to = lowerer.unions.get(dst.unionId);
if (from && to) {
const trappable: number[] = [];
from.arms.forEach((arm, tag) => {
if (isUnitType(arm) || lowerer.armTag(dst.unionId, arm) >= 0) return;
if (to.arms.some((d) => !isUnitType(d) && lowerer.widthLiftPlan(arm, d) !== null))
return;
trappable.push(tag);
});
if (
trappable.length > 0 &&
trappable.length < from.arms.length &&
planUnionRetag(
from,
to,
(id) => lowerer.shapes.get(id),
(s, d) => lowerer.widthLiftPlan(s, d),
new Set(trappable),
) !== null
)
return { how: "checkedRetag", trappable };
}
}
return null;
}
// A unit-typed source can't wrap here (unionWrap requires the
@@ -71,7 +98,7 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
return { how: "discriminantWrap" };
return { how: "wrap", tag };
}
const candidates: { tag: number; arm: IrType }[] = [];
const candidates: { tag: number; arm: IrType; checked: boolean }[] = [];
def.arms.forEach((arm, i) => {
if (isUnitType(arm)) return;
const sameFamily =
@@ -87,9 +114,15 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
arm.kind === "array") ||
(src.kind === "object" && arm.kind === "record") ||
(src.kind === "record" && arm.kind === "object");
if (sameFamily && lowerer.widthLiftPlan(src, arm) !== null) candidates.push({ tag: i, arm });
const armLift = sameFamily ? lowerer.widthLiftPlan(src, arm) : null;
if (armLift !== null)
candidates.push({ tag: i, arm, checked: armLift.how === "funcAdaptChecked" });
});
let selected = candidates;
// A clean function candidate always wins over a checked one, so every
// selection that resolves today resolves the same way.
if (selected.some((c) => c.checked) && selected.some((c) => !c.checked))
selected = selected.filter((c) => !c.checked);
if (selected.length > 1 && src.kind === "record") {
const source = lowerer.shapes.get(src.shapeId);
// An inferred JS union may widen its tag while retaining variants
@@ -160,6 +193,26 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
if (exact.length === 1) selected = exact;
else if (exact.length === 0 && lossless.length === 1) selected = lossless;
}
// An 'unknown' (dyn) source whose union destination has no dyn arm of
// its own (`details: any` into `Details | undefined`, a tool result's
// `structuredContent: unknown` into the JSON-value union): the checked
// extraction, the same dynCheck coerceToExpected performs for a dyn
// value in a union slot. A dyn source has no arm family, so every arm
// rule above has passed it by, and every plan that exists for a dyn
// source is the one above (its own arm) — this is exactly the case
// that returned null (SC2002) before, so no convertible value changes
// its lowering.
if (
selected.length !== 1 &&
src.kind === "dyn" &&
canDynCheckTo(
dst,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
)
) {
return { how: "dynOut" };
}
if (selected.length !== 1) return null;
return { how: "liftWrap", tag: selected[0]!.tag, arm: selected[0]!.arm };
}
@@ -216,6 +269,22 @@ export function widthLiftPlan(lowerer: Lowerer, src: IrType, dst: IrType): Width
if (dst.kind === "func" && src.kind === "func" && lowerer.cleanFuncAdaptable(src, dst)) {
return { how: "funcAdapt" };
}
// A method FIELD of a record width copy whose parameters convert only
// through a checked narrowing (method bivariance across instantiations):
// a checked adapter. Top-level function conversions never see it.
if (
dst.kind === "func" &&
src.kind === "func" &&
lowerer.coercions.methodFieldDepth > 0 &&
lowerer.coercions.checkedRetags === 0
) {
lowerer.coercions.checkedRetags++;
try {
if (lowerer.cleanFuncAdaptable(src, dst)) return { how: "funcAdaptChecked" };
} finally {
lowerer.coercions.checkedRetags--;
}
}
if (dst.kind === "record" && src.kind === "record") {
// An INDEX-SIGNATURE destination takes the overflow CAPTURE reshape:
// declared fields copy off the source, every other source field keys
@@ -370,6 +439,7 @@ export function recordWidthPlan(
// fields answers "assume coercible" — see CoercionState.planning.
if (lowerer.coercions.planning.has(key)) return new Map();
lowerer.coercions.planning.add(key);
lowerer.coercions.methodFieldDepth++;
try {
const plan = new Map<string, FieldLift>();
for (const tf of to.fields) {
@@ -405,6 +475,7 @@ export function recordWidthPlan(
}
return plan;
} finally {
lowerer.coercions.methodFieldDepth--;
lowerer.coercions.planning.delete(key);
}
}
@@ -131,6 +131,34 @@ export function applyWidthLift(
throw new InternalCompilerError("lowerer bug: planned retag lift failed to intern");
return { kind: "call", callee: retag, args: [value], type: dst, loc };
}
case "checkedRetag": {
if (dst.kind !== "union" || value.type.kind !== "union")
throw new InternalCompilerError("lowerer bug: checked retag lift shape");
const retag = lowerer.unionRetagHelper(
value.type.unionId,
dst.unionId,
loc,
new Set(lift.trappable),
);
if (!retag)
throw new InternalCompilerError("lowerer bug: planned checked retag lift failed to intern");
return { kind: "call", callee: retag, args: [value], type: dst, loc };
}
case "funcAdaptChecked": {
if (dst.kind !== "func" || value.type.kind !== "func")
throw new InternalCompilerError("lowerer bug: checked funcAdapt lift shape");
lowerer.coercions.checkedRetags++;
try {
const adapter = lowerer.funcCoerceAdapter(value.type, dst, loc);
if (!adapter)
throw new InternalCompilerError(
"lowerer bug: planned checked funcAdapt lift failed to intern",
);
return { kind: "call", callee: adapter, args: [value], type: dst, loc };
} finally {
lowerer.coercions.checkedRetags--;
}
}
case "unitRetag": {
if (dst.kind !== "union" || value.type.kind !== "union")
throw new InternalCompilerError("lowerer bug: unit retag lift shape");
@@ -527,11 +555,16 @@ export function recordWidthHelper(
lowerer.coercionOriginNode = null;
lowerer.coercionFieldOrigin = null;
try {
lowerer.liftedFns.push(
buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) =>
declared === null ? null : fieldOrigin(lowerer.checker, declared, field),
),
);
lowerer.coercions.methodFieldDepth++;
try {
lowerer.liftedFns.push(
buildRecordProjection(lowerer, name, fromT, to, plan, loc, (field) =>
declared === null ? null : fieldOrigin(lowerer.checker, declared, field),
),
);
} finally {
lowerer.coercions.methodFieldDepth--;
}
} finally {
lowerer.coercionOriginNode = outerNode;
lowerer.coercionFieldOrigin = outerField;
@@ -56,6 +56,7 @@ import { typeKey } from "../../type-mapper.js";
import {
lowerArrayCallback,
requireProducedArrayElement,
unitOnlyArrayElement,
callbackArrayElement,
truthyFilterCallback,
} from "./callback-arguments.js";
@@ -69,6 +70,7 @@ import {
import { dynamicReceiverThrows } from "./dynamic-receivers.js";
import { defaultAfterUndefined, lowerStaticallyUndefinedArgument } from "../optional-arguments.js";
import { checkedFilterNarrowing } from "./filter-predicates.js";
import { omittableTrailingParam } from "../coercions/function-adapters.js";
/** `a.map(fn)` / `a.filter(fn)` / `a.forEach(fn)` desugar to a direct
* call of a synthetic module function — one per method + element/result
@@ -108,7 +110,14 @@ export function lowerArrayHofCall(
}
const callback = booleanFilter
? { fnArg: truthyFilterCallback(lowerer, valueT, loc), arity: 1, unread: false }
: lowerArrayCallback(lowerer, argNode, [valueT], arrayOf(elem));
: lowerArrayCallback(
lowerer,
argNode,
[valueT],
arrayOf(elem),
false,
method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
);
const { fnArg, arity } = callback;
// Only an array that can hold elements outside its element type needs to
// leave an element its callback ignores unread.
@@ -623,7 +632,14 @@ function arrayHofHelper(
* original concrete array arm — never a copied `(T | U)[]` — and an
* arm-specific function adapter wraps the element and receiver arguments
* into the union-wide callback ABI. That preserves sparse-array reads,
* callback mutation, and the callback's third-argument identity. */
* callback mutation, and the callback's third-argument identity.
*
* `filter` joins them: the kept elements are the checker's result element
* (`(b): b is TextContent` over `(TextContent | ImageContent)[] |
* (ThinkingContent | TextContent)[]` answers `TextContent[]`), so each arm
* filters its own array and converts its survivors through the checked
* narrowing — a predicate that kept an element it does not describe throws
* the TypeError. An arm with no such conversion leaves the fence in place. */
export function lowerArrayUnionHofCall(
lowerer: Lowerer,
call: ts.CallExpression,
@@ -631,7 +647,7 @@ export function lowerArrayUnionHofCall(
receiverT: IrType & { kind: "union" },
): IrExpr | null {
const method = access.name.text;
if (method !== "map" && method !== "forEach") return null;
if (method !== "map" && method !== "forEach" && method !== "filter") return null;
if (!lowerer.isStdlibMember(access)) return null;
const def = lowerer.unions.get(receiverT.unionId);
if (!def || def.arms.length < 2 || !def.arms.every((arm) => arm.kind === "array")) return null;
@@ -659,15 +675,59 @@ export function lowerArrayUnionHofCall(
? valueArms[0]!
: { kind: "union", unionId: lowerer.unions.intern(valueArms) };
const argNode = call.arguments.nodes[0]!;
const { fnArg, arity } = lowerArrayCallback(lowerer, argNode, [valueT], receiverT);
const { fnArg, arity } = lowerArrayCallback(
lowerer,
argNode,
[valueT],
receiverT,
false,
method === "map" ? (unitOnlyArrayElement(lowerer, call) ?? undefined) : undefined,
);
const fnRet = fnArg.type.ret;
if (method === "map" && fnRet.kind === "void") {
// `void[]` has no element (see the plain-receiver map path).
lowerer.badType(call, lowerer.typeOf(call));
}
if (method === "map") requireProducedArrayElement(lowerer, call, "'.map()'", fnRet);
const outElem = method === "map" ? callbackArrayElement(lowerer, call, fnRet) : VOID;
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
if (method === "filter" && !filterPredicateOk(lowerer, fnRet)) {
if (fnRet.kind === "void") {
lowerer.unsupported(
"SC1090",
call,
"'.filter()' with a void-returning predicate (the callback return value is erased before its truthiness can be tested)",
);
}
lowerer.badType(call, lowerer.typeOf(call));
}
// `map` stores the callback's answer; `filter` keeps the elements its
// predicate named. The kept element is the CHECKER's result element, which
// for a written type predicate is narrower than every arm's own:
// `(b): b is TextContent` over `(TextContent | ImageContent)[] |
// (ThinkingContent | TextContent)[]` answers `TextContent[]`. Each arm
// filters its own concrete array and converts its survivors to that
// element through the checked narrowing — a predicate that kept an element
// it does not describe throws instead of misreading it. No conversion the
// arms cannot make honestly means the fence stays.
let outElem: IrType = VOID;
const narrowing: (string | null)[] = [];
if (method === "map") {
outElem = callbackArrayElement(lowerer, call, fnRet);
} else if (method === "filter") {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
if (!result || result.kind !== "array") return null;
outElem = result.elem;
const typeText = filterTypeText(lowerer, call);
for (const array of arrays) {
if (typeEquals(array.elem, outElem)) {
narrowing.push(null);
continue;
}
const helper = checkedFilterNarrowing(lowerer, array.elem, outElem, typeText, locOf(call));
if (helper === null) return null;
narrowing.push(helper);
}
}
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
for (const array of arrays) {
const armFnT = funcOf(
[arrayValueType(lowerer, array.elem), F64, array].slice(0, arity),
@@ -684,6 +744,7 @@ export function lowerArrayUnionHofCall(
arity,
fnRet,
outElem,
narrowing,
locOf(call),
);
return {
@@ -700,24 +761,31 @@ export function lowerArrayUnionHofCall(
function arrayUnionHofHelper(
lowerer: Lowerer,
method: "map" | "forEach",
method: "map" | "forEach" | "filter",
receiverT: IrType & { kind: "union" },
arrays: (IrType & { kind: "array" })[],
callbackT: IrType & { kind: "func" },
arity: number,
fnRet: IrType,
outElem: IrType,
narrowing: (string | null)[],
loc: SrcLoc,
): string {
const resultT: IrType = method === "map" ? arrayOf(outElem) : VOID;
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}`;
const resultT: IrType = method === "forEach" ? VOID : arrayOf(outElem);
// A filter's per-arm conversions are part of what the helper does, so two
// sites that share receiver, callback and result but convert differently
// keep their own helper. `map`/`forEach` never narrow, and leaving their
// key untouched keeps the helpers a program already emitted byte-identical.
const narrowingKey =
method === "filter" ? `:${narrowing.map((helper) => helper ?? "").join(",")}` : "";
const key = `union:${method}:${receiverT.unionId}:${typeKey(callbackT)}:${typeKey(resultT)}${narrowingKey}`;
const existing = lowerer.arrHofHelpers.get(key);
if (existing) return existing;
const name = keyedHelperName(lowerer, "%arr.union", key);
lowerer.arrHofHelpers.set(key, name);
const receiver = varRef("a.0", receiverT, loc);
const callback = varRef("f.0", callbackT, loc);
const branch = (array: IrType & { kind: "array" }): IrStmt[] => {
const branch = (array: IrType & { kind: "array" }, index: number): IrStmt[] => {
const tag = lowerer.armTag(receiverT.unionId, array);
const concrete: IrExpr = {
kind: "unionNarrow",
@@ -735,16 +803,38 @@ function arrayUnionHofHelper(
if (!typeEquals(adapted.type, armFnT)) {
throw new InternalCompilerError("lowerer bug: union-array callback stopped adapting");
}
const callee = arrayHofHelper(lowerer, method, array.elem, fnRet, arity, loc, outElem);
const invoke: IrExpr = { kind: "call", callee, args: [concrete, adapted], type: resultT, loc };
const callee = arrayHofHelper(
lowerer,
method,
array.elem,
fnRet,
arity,
loc,
method === "filter" ? array.elem : outElem,
);
const invoke: IrExpr = {
kind: "call",
callee,
args: [concrete, adapted],
type: method === "filter" ? arrayOf(array.elem) : resultT,
loc,
};
// Survivors of this arm's own array, converted to the checker's result
// element. The helper throws for a kept element the predicate does not
// describe, so a lying predicate never reinterprets a value.
const armNarrow = narrowing[index] ?? null;
const answer: IrExpr =
armNarrow === null
? invoke
: { kind: "call", callee: armNarrow, args: [invoke], type: resultT, loc };
return resultT.kind === "void"
? [
{ kind: "exprStmt", expr: invoke, loc },
{ kind: "return", value: null, loc },
]
: [{ kind: "return", value: invoke, loc }];
: [{ kind: "return", value: answer, loc }];
};
let body = branch(arrays[arrays.length - 1]!);
let body = branch(arrays[arrays.length - 1]!, arrays.length - 1);
for (let i = arrays.length - 2; i >= 0; i--) {
const array = arrays[i]!;
body = [
@@ -759,7 +849,7 @@ function arrayUnionHofHelper(
type: BOOL,
loc,
},
then: branch(array),
then: branch(array, i),
else_: body,
loc,
},
@@ -1806,10 +1896,10 @@ export function lowerArrayFlatMapCall(
/** How each arm of a flatMap callback's union result contributes to the
* output: an array arm appends its elements, an array arm the checker
* types `never[]` (an empty literal such as `s ? s : []`) appends nothing,
* and any other arm appends itself. */
* a tuple arm appends its fields, and any other arm appends itself. */
interface FlatMapUnionPlan {
outElem: IrType;
arms: ("spread" | "empty" | "value")[];
arms: ("spread" | "empty" | "tuple" | "value")[];
}
/** The plan for a callback returning `T | U[]`: every non-array arm must
@@ -1827,15 +1917,18 @@ function flatMapUnionPlan(
const outElem = out.elem;
const arms = lowerer.unions.get(fnRet.unionId)?.arms;
if (!arms) return null;
// The `never[]` constituents of the callback's checked result.
// The constituents of the callback's checked result that can hold no
// element: a `never[]` arm, and the empty tuple a bare `[]` arm infers
// when the enclosing call has a contextual array type (`return x ? [a]
// : []` in a `f(): T[]`).
const emptyArms: IrType[] = [];
const signature = lowerer.checker.getCallSignatures(lowerer.typeOf(argNode))[0];
if (signature) {
const ret = lowerer.checker.getReturnTypeOfSignature(signature);
for (const part of ret.isUnionType() ? ts.constituentTypes(ret) : [ret]) {
if (!lowerer.checker.isArrayType(part)) continue;
const [element] = lowerer.checker.getTypeArguments(part as ts.TypeReference);
if (element === undefined || (element.flags & ts.TypeFlags.Never) === 0) continue;
if (!lowerer.checker.isArrayType(part) && !lowerer.checker.isTupleType(part)) continue;
const element = lowerer.checker.getTypeArguments(part as ts.TypeReference)[0];
if (element !== undefined && (element.flags & ts.TypeFlags.Never) === 0) continue;
const mapped = lowerer.mapTypeOf(part);
if (mapped) emptyArms.push(mapped);
}
@@ -1847,12 +1940,19 @@ function flatMapUnionPlan(
if (outElem.kind !== "union") return false;
return lowerer.armTag(outElem.unionId, t) >= 0;
};
const tupleConverts = (arm: IrType & { kind: "record" }): boolean =>
(lowerer.shapes.get(arm.shapeId)?.fields ?? []).every((f) => converts(f.type));
const plan: FlatMapUnionPlan["arms"] = [];
for (const arm of arms) {
if (arm.kind === "array" && emptyArms.some((e) => typeEquals(e, arm))) plan.push("empty");
// An array arm copies its elements as they are stored, a present
// undefined included, so its element type is the result's.
else if (arm.kind === "array" && typeEquals(arm.elem, outElem)) plan.push("spread");
// A contextual array type makes a multi-statement body's `[pair]`
// infer as a one-element TUPLE (`return x ? [pair] : []`), whose
// fields contribute in order.
else if (arm.kind === "record" && lowerer.shapes.get(arm.shapeId)?.tuple && tupleConverts(arm))
plan.push("tuple");
else if (arm.kind !== "array" && converts(arm)) plan.push("value");
else return null;
}
@@ -1908,6 +2008,32 @@ function flatMapUnionHelper(
loc,
};
if (plan.arms[tag] === "empty") return [];
if (plan.arms[tag] === "tuple") {
const tuple = arm as IrType & { kind: "record" };
// The fields are stored by name ("0", "1", "10", "2", …): the
// elements append in index order.
const elements = [...(lowerer.shapes.get(tuple.shapeId)?.fields ?? [])].sort(
(a, b) => Number(a.name) - Number(b.name),
);
return elements.map((field) => {
const read: IrExpr = {
kind: "recordGet",
obj: narrowed,
shapeId: tuple.shapeId,
field: field.name,
type: field.type,
loc,
};
return arrayValueStore(
lowerer,
outRef(),
outLength(),
lowerer.coerceToExpected(read, plan.outElem),
plan.outElem,
loc,
);
});
}
if (plan.arms[tag] === "value") {
// A unit arm's value is the unit itself, which has no narrowed
// representation.
@@ -2642,11 +2768,15 @@ export function lowerArraySortCall(
let fnArg =
undefinedArg ?? lowerOptionalElementComparator(lowerer, argNode, unwrappedArg, elem, arrT);
// Reusable JS comparators often accept checked values. Adapt the array's
// element ABI just as other array callbacks do before sorting.
// element ABI just as other array callbacks do before sorting. A
// comparator declaring MORE parameters than sort passes (`(a, b, loose?)`,
// the imported semver shape) receives the surplus ones as undefined — the
// completion an ordinary two-argument call performs — so it adapts to the
// (a, b) slot like any other wider signature.
if (
fnArg.type.kind === "func" &&
fnArg.type.params.length <= 2 &&
fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param))
fnArg.type.params.slice(2).every((param) => omittableTrailingParam(lowerer, param)) &&
fnArg.type.params.slice(0, 2).every((param) => lowerer.coercibleValue(elem, param))
) {
// Arithmetic on inferred JS values retains a checked result because
// it can produce BigInt. The sort callback still requires a number.
@@ -2655,7 +2785,7 @@ export function lowerArraySortCall(
? F64
: fnArg.type.ret;
const expected = funcOf(
fnArg.type.params.map(() => elem),
fnArg.type.params.slice(0, 2).map(() => elem),
result,
);
if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected);
@@ -56,6 +56,8 @@ import {
import {
requireProducedArrayElement,
lowerArrayCallback,
lowerArrayCallbackAnswering,
unitOnlyArrayElement,
callbackArrayElement,
} from "./callback-arguments.js";
import { classGeneratorIterable } from "../class-iterables.js";
@@ -1002,6 +1004,8 @@ export function lowerArrayFromCall(
args[1]!,
[input.element],
arrayOf(input.element),
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
@@ -1085,6 +1089,19 @@ export function lowerArrayFromCall(
// and materializes holes while keeping element reference identity.
if ((args.length === 1 || args.length === 2) && !ts.isObjectLiteralExpression(args[0]!)) {
const source = lowerer.lowerExpr(args[0]!);
// Array.from(s): the string iterator's code points, into a fresh
// array (scr_str_code_points already builds one, like Array.from).
// The checker can narrow the argument to a string while the binding
// keeps its nullable storage (`value.includes(x) && Array.from(value)`),
// so the site type decides and the read is coerced to that string.
if (
args.length === 1 &&
(source.type.kind === "string" ||
lowerer.mapTypeOf(lowerer.typeOf(args[0]!))?.kind === "string")
) {
const str = source.type.kind === "string" ? source : lowerer.coerceToExpected(source, STRING);
return strCharsCall(str, loc);
}
let src =
source.type.kind === "string" && args.length === 2 ? strCharsCall(source, loc) : source;
if (src.type.kind === "array") {
@@ -1123,7 +1140,14 @@ export function lowerArrayFromCall(
if (paramSymbol) lowerer.runtimeOptionalBindingTypes.set(paramSymbol, valueT);
let callback: ReturnType<typeof lowerArrayCallback>;
try {
callback = lowerArrayCallback(lowerer, mapper, [valueT], arrT);
callback = lowerArrayCallback(
lowerer,
mapper,
[valueT],
arrT,
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
} finally {
if (paramSymbol) {
if (previous === undefined) lowerer.runtimeOptionalBindingTypes.delete(paramSymbol);
@@ -1162,7 +1186,14 @@ export function lowerArrayFromCall(
// mapper that writes the source changes later reads, as in Node),
// so this is a loop over the typed array, not a toArray snapshot.
if (src.type.kind === "bytes" && args.length === 2) {
const callback = lowerArrayCallback(lowerer, args[1]!, [F64], arrayOf(F64));
const callback = lowerArrayCallback(
lowerer,
args[1]!,
[F64],
arrayOf(F64),
false,
unitOnlyArrayElement(lowerer, call) ?? undefined,
);
if (callback.arity > 2)
lowerer.noLowering("Array.from mapper with an array parameter", args[1]!);
const fnRet = callback.fnArg.type.ret;
@@ -1245,7 +1276,7 @@ export function lowerArrayFromCall(
);
}
if (n.type.kind !== "f64") lowerer.badType(args[0]!, lowerer.typeOf(args[0]!));
const fnArg = lowerer.lowerExpr(args[1]!);
const fnArg = lowerArrayCallbackAnswering(lowerer, args[1]!, unitOnlyArrayElement(lowerer, call));
// The mapper may declare any prefix of (v, i): v is the checker's own
// `unknown` (Node passes undefined there — the dyn undefined singleton
// here), i the index. The result type must be a legal array element.
@@ -306,6 +306,42 @@ function readsParameter(argNode: ts.Expression, index: number): boolean {
);
}
/** The result array's element when it is the unit-only union (`undefined[]`,
* the sparse-literal/`Array.from(..., () => undefined)` shape): a callback
* that answers `undefined` stores a PRESENT unit element, so the producer
* gives the callback that union as its contextual return. The standalone
* `undefined` in return position otherwise maps to the void convention,
* whose answer is erased before it could be stored. Null for other elements. */
export function unitOnlyArrayElement(lowerer: Lowerer, call: ts.CallExpression): IrType | null {
const result = lowerer.mapTypeOf(lowerer.typeOf(call));
if (result?.kind !== "array") return null;
const elem = result.elem;
if (isUnitType(elem)) return elem;
return elem.kind === "union" &&
lowerer.unions.get(elem.unionId)?.arms.every((arm) => isUnitType(arm)) === true
? elem
: null;
}
/** Lower a callback whose answer becomes an array element, with the result
* element as its contextual return when that element is the unit-only union
* (see unitOnlyArrayElement). Other producers lower the callback directly. */
export function lowerArrayCallbackAnswering(
lowerer: Lowerer,
fnNode: ts.Expression,
expected: IrType | null,
): IrExpr {
if (expected === null) return lowerer.lowerExpr(fnNode);
const previous = lowerer.contextualFunctionReturns.get(fnNode);
lowerer.contextualFunctionReturns.set(fnNode, expected);
try {
return lowerer.lowerExpr(fnNode);
} finally {
if (previous === undefined) lowerer.contextualFunctionReturns.delete(fnNode);
else lowerer.contextualFunctionReturns.set(fnNode, previous);
}
}
/** Preserve the checker-selected payload ABI when a callback's runtime
* return gained only undefined. The array state byte carries that value. */
export function callbackArrayElement(
@@ -163,7 +163,13 @@ function conversionBody(
target.indexValue
)
return null;
if (source.fields.length !== target.fields.length) return null;
// A written predicate usually names FEWER members than the receiver's
// element has (`(c): c is { text: string } => ...` over a block with a
// `textSignature` and a `type`): the target's members are a subset of
// the source's, each read and converted, and the copy keeps the
// source's keys in the source's order. A member the source lacks has
// no slot to read, so it stays refused.
if (target.fields.length > source.fields.length) return null;
const fields: { name: string; value: IrExpr }[] = [];
for (const field of target.fields) {
const own = source.fields.find((f) => f.name === field.name);
@@ -216,7 +222,13 @@ function keepAbsence(
}
/** A union value to one of its arms, or to a union of some of its arms:
* each arm the target holds returns, any other arm throws. */
* each arm the target holds returns, any other arm throws.
*
* A written predicate may name a structural type that is no arm at all
* (`(c): c is { text: string; type: string } => ...` over the block union
* above, whose text arm also carries `textSignature`): when no arm matches
* exactly, each arm the target can be read from converts by its own shape,
* tag by tag, and an arm with no such read throws. */
function unionConversion(
lowerer: Lowerer,
input: IrExpr,
@@ -279,7 +291,10 @@ function unionConversion(
// 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.
// 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 ? recordArmConversions === 0 : kept !== targetArms.length) return null;
body.push(
typeError(
@@ -289,3 +304,42 @@ function unionConversion(
);
return body;
}
/** A union value to a structural type that is no arm: each arm the target
* can be read from returns its converted value, any other arm throws. */
function structuralUnionConversion(
lowerer: Lowerer,
input: IrExpr,
unionId: string,
to: IrType,
typeText: string,
loc: SrcLoc,
): IrStmt[] | null {
const source = lowerer.unions.get(unionId);
if (!source) return null;
const body: IrStmt[] = [];
let kept = 0;
for (let tag = 0; tag < source.arms.length; tag++) {
const arm = source.arms[tag]!;
if (isUnitType(arm)) continue;
const payload: IrExpr = { kind: "unionNarrow", unionId, tag, value: input, type: arm, loc };
const value = converted(lowerer, payload, arm, to, loc);
if (value === null) continue;
kept++;
body.push({
kind: "if",
cond: { kind: "unionIsTag", unionId, tag, negated: false, value: input, type: BOOL, loc },
then: [{ kind: "return", value, loc }],
else_: null,
loc,
});
}
if (kept === 0) return null;
body.push(
typeError(
`a kept element is not a '${typeText}' (a type predicate returned true for a value of another type)`,
loc,
),
);
return body;
}
@@ -1,3 +1,4 @@
import { lowerWrapperToObject, programCreatesWrappers } from "../primitive-wrappers.js";
import { keyedHelperName } from "../stable-names.js";
import { nullProtoRecordShape, recordNullProtoTest } from "../null-prototype.js";
import { countedFor, numLit, varRef } from "../../../ir/build.js";
@@ -21,12 +22,14 @@ import {
type SrcLoc,
UNDEFINED_T,
arrayOf,
canDynCheckTo,
isRefCounted,
isSupportedIndexValue,
isUnitType,
typeEquals,
} from "../../../ir/ir.js";
import { tryLowerExpression } from "../expressions/try-lower-expression.js";
import { recordMayKeepSource, recordSourceTest } from "../record-sources.js";
import { locOf } from "../../program.js";
import { typeKey } from "../../type-mapper.js";
import { type WidthLift } from "../lowerer.js";
@@ -50,6 +53,13 @@ export function lowerObjectConstructor(
}
if (args.length !== 1 || ts.isSpreadElement(args[0]!)) return null;
const value = lowerer.lowerExpr(args[0]!);
// ToObject of a value that may be a primitive, once the program creates
// wrapper objects (programCreatesWrappers counts this very call).
if (
["dyn", "string", "f64", "bool"].includes(value.type.kind) &&
(lowerer.createsWrappers ??= programCreatesWrappers(lowerer))
)
return lowerWrapperToObject(lowerer, lowerer.coerceToExpected(value, DYN), loc);
if (value.kind === "dynObjLit" || value.kind === "dynArrLit") return value;
if (
[
@@ -1213,8 +1223,47 @@ export function lowerRecordOvfCaptureHelper(
),
);
}
// A CLOSED source can still hold own keys its shape does not declare: a
// record built from the checked-dynamic tree keeps them beside it (the
// record-sources.ts view), and every JS copy — spread, Object.assign,
// JSON — carries them. The field-by-field rebuild above cannot see
// them, so when the source kept such a view, rebuild the target from
// that live view instead: the validated keyed conversion the checked
// record builder performs, which copies exactly the keys Node copies.
// The declared-field copies before this branch are pure writes into a
// fresh local, so the redone work is only wasted slot stores on the
// rare path where the view exists.
// The conversion is only honest when the checked-dynamic tree can check it,
// and the view can only exist if the program converts something into a
// closed record at all. Both facts are settled before the shape helpers
// finalize their bodies, so the tail is chosen there.
const sourcedFromView =
fIv === null &&
recordMayKeepSource(lowerer, fromT) &&
canDynCheckTo(
toT,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
);
body.push({ kind: "return", value: outRef, loc });
const suffix = body.slice(1 + fieldStmts.size);
const keptSourceTail = (): IrStmt[] => {
const fromView: IrExpr = {
kind: "dynCheck",
value: { kind: "dynFrom", value: sRef, type: DYN, loc },
type: toT,
loc,
};
const fromViewReturn: IrStmt = { kind: "return", value: fromView, loc };
const fromViewBranch: IrStmt = {
kind: "if",
cond: recordSourceTest(sRef, loc),
then: [fromViewReturn],
else_: null,
loc,
};
return [fromViewBranch, ...suffix];
};
const fn: IrFunction = {
name,
params: [{ localId: "s.0", name: "s", type: fromT }],
@@ -1236,13 +1285,15 @@ export function lowerRecordOvfCaptureHelper(
lowerer.shapeOrderHelperFinalizers.push(() => {
const current = lowerer.shapes.get(fromId) ?? from;
const currentOrder = current.declaredOrder ?? current.fields.map((f) => f.name);
let tail: IrStmt[] = suffix;
if (sourcedFromView && lowerer.recordSourcesPossible) tail = keptSourceTail();
fn.body = [
body[0]!,
...currentOrder.flatMap((field) => {
const stmt = fieldStmts.get(field);
return stmt ? [stmt] : [];
}),
...suffix,
...tail,
];
});
lowerer.liftedFns.push(fn);

Some files were not shown because too many files have changed in this diff Show More