Merge main

This commit is contained in:
Chris Tate
2026-10-21 12:43:37 -05:00
169 changed files with 6505 additions and 635 deletions
@@ -1,5 +1,10 @@
- **EventEmitter:** Literal event names use fixed argument tuples.
Computed string names require a constant prefix or suffix that excludes `error`, meta events, and stream-internal events; their arguments must match the exact arity and support checked conversion.
Unrestricted names, symbols, names that may reach internal events, and typed `listeners()`/`rawListeners()` results for names with possible computed registrations are unsupported.
An emitter reached through an untyped value shares one listener list with its typed uses, so untyped registrations and emits reach typed listeners and typed emits reach untyped listeners.
An untyped `emit` of an event that has a listener with typed parameters converts its arguments to the event's typed tuple before any listener runs, and throws a `TypeError` when they do not convert.
Through an untyped value, `listeners()` and `rawListeners()` of an event with a listener that has typed parameters are unsupported, and `emit('error')` of an object that is not an Error with no `error` listener is unsupported.
Untyped `newListener` and `removeListener` listeners receive `undefined` in place of a listener registered with typed parameters.
Reading EventEmitter methods as properties through an untyped value (`typeof emitter.listeners`) answers `undefined`.
`events.once` accepts EventEmitter and stream instances with a literal event name and an optional object-literal `{ signal }`; a stream's `data` event, other options values, and EventTarget emitters are unsupported.
Reading `defaultMaxListeners` through a CommonJS destructured or member binding (`const { defaultMaxListeners } = require("events")`) is unsupported.
@@ -5,4 +5,7 @@
A write to a read-only field throws Node's `TypeError` in strict mode code and leaves the field unchanged in sloppy mode code.
`Object.defineProperty` with a string-literal key changes the value, `writable`, `enumerable`, and `configurable` attributes of a declared field; an object-literal accessor descriptor (`get` or `set`) for a declared field is refused at compile time with `SC2020`, and another accessor descriptor for a declared field throws `SC2020` when the call runs.
Freezing, sealing, or preventing extensions of a class instance held in an untyped value throws `SC2020`.
Functions support `Object.freeze`, `Object.seal`, `Object.preventExtensions`, `Object.isFrozen`, `Object.isSealed`, and `Object.isExtensible`; the operations apply to the function's own properties (`name`, `length`, `prototype`, and the ones a program assigns).
Class constructors are not supported.
A write to a read-only property of a checked dictionary or a function throws a `TypeError` whose message omits Node's `'x' of object '#<Object>'` (or `of function '…'`) suffix.
A JavaScript class field that is first created after its instance became non-extensible is added instead of throwing.
@@ -5,7 +5,8 @@ Bytes the build did not see, including bytes produced at run time, reject with a
`scriptc coverage` lists the modules a build finds, with their size, whether they validate, and the WebAssembly proposals they use.
Translated modules can use the WebAssembly 2.0 feature set: the MVP plus sign-extension operators, non-trapping float-to-int conversions, bulk memory, multi-value, mutable globals, reference types, and extended constant expressions.
Modules that use SIMD, threads, exception handling, tail calls, typed function references or GC, memory64, or multiple memories are not translated, so loading them rejects with the `CompileError` above; packages that fall back to a scalar build when that happens, such as undici and es-module-lexer, take their fallback.
Modules that use SIMD, threads, exception handling, tail calls, typed function references or GC, memory64, or multiple memories are not translated, so loading them rejects with a `CompileError` that names the proposal the module uses, and `WebAssembly.validate` answers `false` for them; packages that fall back to a scalar build when that happens, such as undici and es-module-lexer, take their fallback.
Invalid modules the build finds reject with a `CompileError` that includes the validation error.
Supported: `WebAssembly.compile`, `instantiate` (with bytes or a `Module`), `validate`, `new WebAssembly.Module`, `new WebAssembly.Instance`, `new WebAssembly.Memory`, `new WebAssembly.Table`, `new WebAssembly.Global`, `WebAssembly.Module.imports`, `exports` and `customSections`, and `instanceof` for every class; the error classes `CompileError`, `LinkError` and `RuntimeError`, constructed with or without `new`; `globalThis.WebAssembly` and its `compile`, `instantiate` and `validate` functions as values.
Instances expose exported functions, memories (`buffer`, `grow`), tables (`get`, `set`, `grow`, `length`) and globals (`value`, `valueOf`).
+260
View File
@@ -0,0 +1,260 @@
import { execFile } from "node:child_process";
import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import { expect, test } from "vitest";
import { precompiledRuntimePackTarget } from "../../compiler/src/startup-cache.js";
// The first optimized executable build of an invocation prepares its input in a frontend process
// (prepareExecutableInputInChild in the compiler's index.ts) when the compiler runs from its built
// files, as the CLI's bootstrap does. Later builds of the invocation, and builds with
// SCRIPTC_FRONTEND_PROCESS=0, prepare it in the compiler's own process.
const execFileAsync = promisify(execFile);
const repoRoot = join(import.meta.dirname, "../../..");
const bootstrap = join(repoRoot, "packages/cli/dist/bootstrap.js");
const compilerApi = pathToFileURL(join(repoRoot, "packages/compiler/dist/index.js")).href;
const helperTarget = precompiledRuntimePackTarget();
interface Build {
code: number;
stdout: string;
stderr: string;
}
async function build(
entry: string,
outPath: string,
cacheRoot: string,
inProcess: boolean,
extra: { args?: string[]; env?: NodeJS.ProcessEnv } = {},
): Promise<Build> {
const env: NodeJS.ProcessEnv = {
...process.env,
SCRIPTC_CACHE_DIR: cacheRoot,
SCRIPTC_TIMING: "1",
...extra.env,
};
delete env["SCRIPTC_NO_CACHE"];
if (inProcess) env["SCRIPTC_FRONTEND_PROCESS"] = "0";
else delete env["SCRIPTC_FRONTEND_PROCESS"];
try {
const { stdout, stderr } = await execFileAsync(
process.execPath,
[bootstrap, "build", entry, "-o", outPath, ...(extra.args ?? [])],
{ env, maxBuffer: 8 * 1024 * 1024 },
);
return { code: 0, stdout, stderr };
} catch (error) {
const failed = error as { code?: number; stdout?: string; stderr?: string };
return { code: failed.code ?? 1, stdout: failed.stdout ?? "", stderr: failed.stderr ?? "" };
}
}
/** The build's output without its timing lines, whose times differ between runs. */
const withoutTiming = (text: string): string =>
text
.split("\n")
.filter((line) => !line.startsWith("scriptc timing") && !line.startsWith("scriptc lowering"))
.join("\n");
test.skipIf(helperTarget === null)(
"the frontend process builds the executable, diagnostics and cache entries the in-process build does",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-"));
try {
const src = join(dir, "src");
await mkdir(src);
await writeFile(
join(src, "shapes.ts"),
"export const area = (w: number, h: number): number => w * h;\n",
);
await writeFile(
join(src, "main.ts"),
'import { area } from "./shapes.js";\nconsole.log(area(3, 4), [1, 2, 3].map((x) => x * 2).join(","));\n',
);
const entry = join(src, "main.ts");
const out = (name: string): string => join(dir, name, "program");
await mkdir(join(dir, "child"));
await mkdir(join(dir, "inline"));
const child = await build(entry, out("child"), join(dir, "cache-child"), false);
const inline = await build(entry, out("inline"), join(dir, "cache-inline"), true);
expect(child.code).toBe(0);
expect(inline.code).toBe(0);
expect(child.stderr).toContain('"phase":"frontend-process"');
expect(inline.stderr).not.toContain('"phase":"frontend-process"');
expect(await readFile(out("child"))).toEqual(await readFile(out("inline")));
expect(await readFile(join(dir, "child", "main.ll"), "utf8")).toBe(
await readFile(join(dir, "inline", "main.ll"), "utf8"),
);
expect((await execFileAsync(out("child"))).stdout).toBe("12 2,4,6\n");
// The request and result files are removed.
expect(
(await readdir(join(dir, "child"))).filter((name) => name.includes("frontend")),
).toEqual([]);
// A repeat finds the executable the frontend process's build published. An edit rebuilds in
// the compiler's own process.
const repeat = await build(entry, out("child"), join(dir, "cache-child"), false);
expect(repeat.code).toBe(0);
expect(repeat.stderr).not.toContain("scriptc lowering");
expect(repeat.stderr).not.toContain('"phase":"frontend-process"');
await writeFile(
join(src, "shapes.ts"),
"export const area = (w: number, h: number): number => w * h + 1;\n",
);
const edited = await build(entry, out("child"), join(dir, "cache-child"), false);
expect(edited.code).toBe(0);
expect(edited.stderr).toContain("scriptc lowering");
expect(edited.stderr).not.toContain('"phase":"frontend-process"');
expect((await execFileAsync(out("child"))).stdout).toBe("13 2,4,6\n");
// Without the executable cache entries (as for a program whose frontend inputs cannot be
// cached), the rebuild still runs in the compiler's own process: the first build recorded
// its native program object.
await rm(join(dir, "cache-child", "early-exe"), { recursive: true, force: true });
await rm(join(dir, "cache-child", "early-exe-route"), { recursive: true, force: true });
const uncached = await build(entry, out("child"), join(dir, "cache-child"), false);
expect(uncached.code).toBe(0);
expect(uncached.stderr).toContain("scriptc lowering");
expect(uncached.stderr).not.toContain('"phase":"frontend-process"');
expect((await execFileAsync(out("child"))).stdout).toBe("13 2,4,6\n");
// A program the frontend rejects reports the same diagnostics either way. The first build of
// the invocation in an empty cache runs the frontend process.
await writeFile(join(src, "main.ts"), 'const n: number = "not a number";\nconsole.log(n);\n');
const childError = await build(entry, out("child"), join(dir, "cache-child-error"), false);
const inlineError = await build(entry, out("inline"), join(dir, "cache-inline"), true);
expect(childError.stderr).toContain('"phase":"frontend-process"');
expect(childError.code).not.toBe(0);
expect(childError.code).toBe(inlineError.code);
expect(withoutTiming(childError.stderr)).toBe(withoutTiming(inlineError.stderr));
expect(withoutTiming(childError.stderr)).toContain("not a number");
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
test.skipIf(helperTarget === null)(
"a compile called from evaluated code runs its frontend process once",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-eval-"));
try {
const entry = join(dir, "main.ts");
await writeFile(entry, 'console.log("from eval");\n');
const { stdout, stderr } = await execFileAsync(
process.execPath,
[
"--input-type=module",
"--eval",
`import { compile } from ${JSON.stringify(compilerApi)};
const result = await compile(process.argv[1], { outDir: process.argv[2], outPath: process.argv[3] });
console.log("compiled", result.ok);`,
entry,
dir,
join(dir, "program"),
],
{
env: { ...process.env, SCRIPTC_CACHE_DIR: join(dir, "cache"), SCRIPTC_TIMING: "1" },
maxBuffer: 8 * 1024 * 1024,
},
);
expect(stdout).toBe("compiled true\n");
expect(stderr).toContain('"phase":"frontend-process"');
expect((await execFileAsync(join(dir, "program"))).stdout).toBe("from eval\n");
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
test.skipIf(helperTarget === null)(
"a build with --provenance-sources compiles the attested sources in the frontend process",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-provenance-"));
try {
const fixture = join(repoRoot, "tests/fixtures/provenance");
const entry = join(fixture, "cases/greet/main.ts");
const extra = {
args: ["--provenance-sources"],
env: { SCRIPTC_PROVENANCE_MANIFEST: join(fixture, "manifest.json") },
};
await mkdir(join(dir, "child"));
await mkdir(join(dir, "inline"));
const child = await build(
entry,
join(dir, "child", "greet"),
join(dir, "cache"),
false,
extra,
);
const inline = await build(
entry,
join(dir, "inline", "greet"),
join(dir, "cache"),
true,
extra,
);
expect(child.code).toBe(0);
expect(inline.code).toBe(0);
expect(child.stderr).toContain('"phase":"frontend-process"');
const childLlvm = await readFile(join(dir, "child", "main.ll"), "utf8");
expect(childLlvm).toBe(await readFile(join(dir, "inline", "main.ll"), "utf8"));
// Without the flag, the program compiles the published package instead.
await mkdir(join(dir, "published"));
const published = await build(
entry,
join(dir, "published", "greet"),
join(dir, "cache"),
true,
);
expect(published.code).toBe(0);
expect(await readFile(join(dir, "published", "main.ll"), "utf8")).not.toBe(childLlvm);
expect((await execFileAsync(join(dir, "child", "greet"))).stdout).toBe(
"hello, world\nHELLO, COMPILER!\nhello, chain!\n",
);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
test.skipIf(helperTarget === null)(
"concurrent first builds of entries with the same name in one output directory keep their own LLVM",
async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-frontend-process-stem-"));
try {
const out = join(dir, "out");
await mkdir(out);
const entries = ["first", "second"].map((name) => join(dir, name, "main.ts"));
for (const [index, entry] of entries.entries()) {
await mkdir(join(entry, ".."));
// Different programs with the same stem write the same public main.ll.
const lines = Array.from({ length: 200 }, (_, line) => `console.log("${index}-${line}");`);
await writeFile(entry, `${lines.join("\n")}\n`);
}
const builds = await Promise.all(
entries.map((entry, index) =>
build(entry, join(out, `program-${index}`), join(dir, `cache-${index}`), false),
),
);
for (const [index, result] of builds.entries()) {
expect(result.code).toBe(0);
expect(result.stderr).toContain('"phase":"frontend-process"');
const { stdout } = await execFileAsync(join(out, `program-${index}`));
expect(stdout.split("\n")[0]).toBe(`${index}-0`);
}
// The frontend process's private copies of the LLVM text are removed.
expect((await readdir(out)).filter((name) => name.includes("frontend"))).toEqual([]);
} finally {
await rm(dir, { recursive: true, force: true });
}
},
240_000,
);
+4
View File
@@ -2128,6 +2128,10 @@ declare module "fs/promises" {
options: import("node:fs").GlobOptionsWithFileTypes,
): AsyncIterableIterator<import("node:fs").Dirent>;
export function rm(path: string): Promise<void>;
export function rm(
path: string,
options: { recursive?: boolean; force?: boolean; maxRetries?: number; retryDelay?: number },
): Promise<void>;
export function stat(path: string): Promise<import("node:fs").Stats>;
export function statfs(
path: string | Uint8Array | URL,
+12
View File
@@ -44,3 +44,15 @@ uint8_t scriptc_native_private_directory(const uint8_t *bytes, size_t size, uint
return ok;
#endif
}
/* The runtime's full sweep after a phase's data died (scr_rc.h). */
void scr_collect_cycles_phase(void);
/* Reclaims the cycles a finished compiler phase leaves behind. The
* frontend's syntax trees and program state refer to each other, so
* reference counting frees almost none of them when the frontend is
* disposed, and the collector's schedule alone may not run a full pass
* before the build ends. */
void scriptc_native_collect_cycles(void) {
scr_collect_cycles_phase();
}
+2 -1
View File
@@ -1,6 +1,7 @@
{
"ffi_format": 6,
"functions": [
{ "name": "compilerNativePrivateDirectory", "symbol": "scriptc_native_private_directory", "params": ["string", "bool"], "returns": "bool" }
{ "name": "compilerNativePrivateDirectory", "symbol": "scriptc_native_private_directory", "params": ["string", "bool"], "returns": "bool" },
{ "name": "compilerNativeCollectCycles", "symbol": "scriptc_native_collect_cycles", "params": [], "returns": "void" }
]
}
@@ -1,3 +1,4 @@
import type { DynamicFieldStores } from "../ir/int32-slots.js";
import type { IrModule, IrType } from "../ir/ir.js";
import {
DYN,
@@ -34,8 +35,11 @@ import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
* subclass and retain touches the cycle header, so header presence must be
* uniform across an extends tree): a unit is cycle-capable iff ANY member
* is — every backend uses the same grouping. */
export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set<string> } {
const { shapes, unions } = analyzeCycles(mod, false);
export function computeTraced(
mod: IrModule,
stores?: DynamicFieldStores,
): { shapes: Set<string>; unions: Set<string> } {
const { shapes, unions } = analyzeCycles(mod, false, stores);
return { shapes, unions };
}
@@ -88,6 +92,7 @@ export function explainCycle(mod: IrModule, key: string): CycleExplanation | nul
function analyzeCycles(
mod: IrModule,
labels: boolean,
stores?: DynamicFieldStores,
): {
shapes: Set<string>;
unions: Set<string>;
@@ -120,7 +125,7 @@ function analyzeCycles(
const lifetimeRoots = new Set(mod.contracts?.programLifetime ?? []);
const programLifetime = (className: string): boolean =>
lifetimeRoots.size > 0 && lifetimeRoots.has(rootOf(className));
const mutability = computeCycleMutability(mod, unitOfClass);
const mutability = computeCycleMutability(mod, unitOfClass, stores);
const shapeDefs = [
...classes.map((c) => ({
key: `object:${c.name}`,
@@ -1,6 +1,6 @@
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES, typeKey } from "../ir/ir.js";
import { dynamicFieldStores } from "../ir/int32-slots.js";
import { dynamicFieldStores, type DynamicFieldStores } from "../ir/int32-slots.js";
import { everyExprChild, everyStmtChild, everyTypeChild } from "../ir/traverse.js";
/** Which shape fields can be written after their object escapes.
@@ -72,6 +72,7 @@ export function fieldKey(unit: string, field: string): string {
export function computeCycleMutability(
mod: IrModule,
unitOfClass: (className: string) => string,
stores?: DynamicFieldStores,
): CycleMutability {
const result: CycleMutability = {
all: false,
@@ -100,7 +101,7 @@ export function computeCycleMutability(
// intrinsics other than publish (which only marks objects immortal), the
// intern-table operations (which store published values) and ThreadSlots
// reads stay conservative.
const dynStores = dynamicFieldStores(mod);
const dynStores = stores ?? dynamicFieldStores(mod);
const dynamicStores = dynStores.all || dynStores.names.size > 0;
result.emptyPropertyBags = !dynamicStores;
const boundaryWrites = (e: IrExpr): boolean =>
@@ -31,7 +31,7 @@ export function executableLinkFeatures(mod: IrModule): NativeLinkFeatures {
...(features.handleProps ? { handleProps: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBufferNum ? { dynBufferNum: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
...(features.builtinValues ? { builtinValues: true } : {}),
events: features.processEvents,
emitter: features.emitter,
@@ -1,5 +1,6 @@
/* Static immutability facts the backend uses to drop run-time immutability
* checks (see computeConstructionFacts). Pure functions of the IR module. */
import type { DynamicFieldStores } from "../ir/int32-slots.js";
import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
import {
@@ -44,7 +45,10 @@ export class ImmutableFields {
}
}
export function computeImmutableFields(mod: IrModule): ImmutableFields | null {
export function computeImmutableFields(
mod: IrModule,
stores?: DynamicFieldStores,
): ImmutableFields | null {
if (mod.lib !== undefined) return null;
const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
const roots = new Map<string, string>();
@@ -58,7 +62,7 @@ export function computeImmutableFields(mod: IrModule): ImmutableFields | null {
return cur;
};
const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
const mutability = computeCycleMutability(mod, unitOfClass);
const mutability = computeCycleMutability(mod, unitOfClass, stores);
return mutability.all ? null : new ImmutableFields(mutability, unitOfClass);
}
@@ -33,6 +33,7 @@ export async function compilePackedLibrary(
assert: options.assert ?? false,
inspect: options.inspect ?? false,
dynInvoke: options.dynInvoke ?? false,
...(options.dynBuffer === true ? { dynBuffer: true } : {}),
...(options.dynTier === true ? { dynTier: true } : {}),
dc: false,
dynAsync: false,
+4 -3
View File
@@ -269,8 +269,9 @@ export function cycTypeDescriptor(name: string, trace: string, free: string, kin
* expression) in `%o`, aborting on OOM, with the RC-audit allocation note
* on the out-of-line path. With `cyc`, the object carries a collector
* header (scr_cyc_alloc's contract). `stored` is the number of leading
* object bytes the caller stores unconditionally right after (at least the
* refcount word), which a recycled block need not clear. Labels use the
* object bytes the caller stores unconditionally right after (the refcount
* word, or the 4-byte header of a compact class instance), which a recycled
* block need not clear; it is a multiple of 4 and at most `size`. Labels use the
* `sa.` prefix; the lines end in an open block.
*/
export function emitObjectAlloc(
@@ -350,7 +351,7 @@ function allocLines(
` store ptr %sa.next, ptr %sa.fp`,
` %sa.z = getelementptr inbounds i8, ptr %sa.b, i64 ${keep}`,
` %sa.zn = sub i64 %sa.n, ${keep}`,
` call void @llvm.memset.p0.i64(ptr align 8 %sa.z, i8 0, i64 %sa.zn, i1 false)`,
` call void @llvm.memset.p0.i64(ptr align ${keep % 8 === 0 ? 8 : 4} %sa.z, i8 0, i64 %sa.zn, i1 false)`,
` br label %sa.fast`,
`sa.bump: ; fresh reservation memory is already zero`,
` %sa.c1 = add i64 %sa.c, 1`,
@@ -1,12 +1,19 @@
import { expect, test } from "vitest";
import { DYN, F64, STRING, VOID, type IrModule } from "../../ir/ir.js";
import { buildClassGraph, classMembershipIntervals, emitClassMembershipHelper } from "./classes.js";
import {
buildClassGraph,
classIdLoadLines,
classMembershipIntervals,
emitClassMembershipHelper,
type LlClassMeta,
} from "./classes.js";
import { emitLlvmModule } from "./emitter.js";
import {
mangleClassNew,
mangleClassReleaseDirect,
mangleClassRelease,
mangleClassRetain,
mangleClassStruct,
mangleClassTrace,
mangleRecordRelease,
} from "../mangle.js";
@@ -129,18 +136,19 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts",
expect(body(mangleClassRetain(cls)), cls).toMatch(/entry:\n {2}ret ptr %o\n}/);
expect(body(mangleClassRelease(cls)), cls).toMatch(/entry:\n {2}ret void\n}/);
// new: no collector header (no descriptor, no scr_cyc_alloc) and the
// program-lifetime count 2^31 under the class id (Node's preorder number
// 0 + 1, Leaf's 1 + 1), not SIZE_MAX (which means published).
// 32-bit header: the tag 0x70000000 and the class id (Node's preorder
// number 0 + 1, Leaf's 1 + 1), without a count.
const nw = body(mangleClassNew(cls));
const id = cls === "Node" ? 1 : 2;
expect(nw, cls).toContain(`store i64 ${id * 2 ** 32 + 2 ** 31}, ptr %o.vt`);
expect(nw, cls).not.toContain("store i64 -1, ptr %o");
expect(nw, cls).toContain(`store i32 ${0x70000000 + id}, ptr %o.vt`);
expect(nw, cls).not.toContain("store i64");
expect(nw, cls).not.toContain("scr_cyc_alloc");
expect(llvm, cls).not.toContain(`@${mangleClassTrace(cls)}.cyctype`);
// The RC audit (sanitize builds) learns that the program keeps
// program-lifetime objects; other builds emit nothing for it.
expect(nw, cls).toContain("call void @scr_obj_lifetime_note(ptr %o)");
}
expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, ptr }`);
const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false });
expect(plain).not.toContain("scr_obj_lifetime_note");
// The mortal Owner keeps its header, counts and possible-root step.
@@ -149,6 +157,84 @@ test("ProgramLifetime hierarchies are never freed and take no reference counts",
expect(body(mangleClassRelease("Owner"))).toContain("load i32, ptr %bufp");
});
test("ProgramLifetime classes pack their fields after a 32-bit header", () => {
const node = { kind: "object" as const, className: "Node" };
const leaf = { kind: "object" as const, className: "Leaf" };
const BOOL = { kind: "bool" as const };
const nodeFields = [
{ name: "parent", type: node },
{ name: "flag", type: BOOL },
{ name: "weight", type: F64 },
];
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
classes: [
{ name: "Node", fields: nodeFields, methods: [], loc },
{
name: "Leaf",
base: "Node",
fields: [...nodeFields, { name: "next", type: leaf }, { name: "mark", type: BOOL }],
methods: [],
loc,
},
],
contracts: { programLifetime: ["Node"] },
};
const llvm = emitLlvmModule(mod, { pointerBits: 64 });
// Fields go widest first, each at the first offset that fits it. Node's
// weight (an int32 slot: nothing stores a fraction) takes the word the
// header shares (24 bytes, not 32); Leaf keeps Node's offsets and puts its
// bool next to Node's (32 bytes, not 48).
expect(llvm).toContain(`%${mangleClassStruct("Node")} = type { i32, i32, ptr, i8 }`);
expect(llvm).toContain(`%${mangleClassStruct("Leaf")} = type { i32, i32, ptr, i8, i8, ptr }`);
// Without the contract, the layout is the declared one.
const mortal = emitLlvmModule({ ...mod, contracts: {} }, { pointerBits: 64 });
expect(mortal).toContain(`%${mangleClassStruct("Leaf")} = type { i64, ptr, i8, i32, ptr, i8 }`);
});
test("a recycled block is cleared from the end of a ProgramLifetime instance's 32-bit header", () => {
const BOOL = { kind: "bool" as const };
const mod: IrModule = {
irVersion: 17,
sourceFile: loc.file,
entry: "main",
functions: [{ name: "main", params: [], returnType: VOID, locals: [], body: [], loc }],
classes: [
{ name: "Marker", fields: [], methods: [], loc },
{ name: "Toggle", fields: [{ name: "on", type: BOOL }], methods: [], loc },
],
contracts: { programLifetime: ["Marker", "Toggle"] },
};
const llvm = emitLlvmModule(mod, { pointerBits: 64 });
for (const name of ["Marker", "Toggle"]) {
const start = llvm.indexOf(`define internal ptr @${mangleClassNew(name)}(`);
const body = llvm.slice(start, llvm.indexOf("\n}\n", start));
// The constructor stores the 4-byte header itself; a 4-byte instance
// (Marker) clears nothing, and Toggle's field at offset 4 is cleared.
expect(body).toContain("%sa.z = getelementptr inbounds i8, ptr %sa.b, i64 4");
expect(body).toContain("%sa.zn = sub i64 %sa.n, 4");
expect(body).toContain("call void @llvm.memset.p0.i64(ptr align 4 %sa.z,");
}
});
test("invariant class id reads of compact instances leave out the published bit", () => {
const group = ", !invariant.group !{}";
const compact = (post: number) => ({ compact: true, root: { post } }) as unknown as LlClassMeta;
// Publication sets the header word's top bit; ids below 2^16 are read
// from the low 16 bits, which never change after construction.
expect(classIdLoadLines(compact(40), "%o", "%id", group)).toEqual([
" %id.h = load i16, ptr %o, !invariant.group !{}",
" %id = zext i16 %id.h to i32",
]);
// Wider ids, and reads without the metadata, mask the whole word.
const masked = [" %id.w = load i32, ptr %o", " %id = and i32 %id.w, 268435455"];
expect(classIdLoadLines(compact(0xffff), "%o", "%id", group)).toEqual(masked);
expect(classIdLoadLines(compact(40), "%o", "%id", "")).toEqual(masked);
});
test("published ProgramLifetime instances use the published mark every other object uses", () => {
const node = { kind: "object" as const, className: "Node" };
const leaf = { kind: "object" as const, className: "Leaf" };
@@ -212,19 +298,25 @@ test("published ProgramLifetime instances use the published mark every other obj
contracts: { programLifetime: ["Node"] },
};
const llvm = emitLlvmModule(mod, { pointerBits: 64 });
// The walker marks each instance like a mortal object (SIZE_MAX, logged so
// a refusal restores the program-lifetime count); untraced, so it never
// leaves a collector buffer.
// The walker marks each instance by the top bit of its 32-bit header,
// logging the word so a refusal restores it; untraced, so it never leaves
// a collector buffer.
expect(llvm).toContain("call void @scr_publish(ptr");
// Kind 4 (an object) | 0x100 (the rc word keeps a class id: the mark
// writes only the count).
expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 260\)/);
// The frozen-object guard of the store tests the count against UINT32_MAX:
// an unpublished instance (2^31) stays writable.
// Kind 4 (an object) | 0x200 (the 32-bit header: the mark sets its top
// bit).
expect(llvm).toMatch(/@scr_pub_mark\(ptr %ctx, ptr %o, i1 false, i32 516\)/);
const plain = emitLlvmModule(mod, { pointerBits: 64, objectAudit: false });
expect(plain).toContain("%mpub = or i32 %mrc, -2147483648");
// Pushing a reference skips one published before: a set top bit.
expect(llvm).toMatch(/%immortal0 = icmp slt i32 %rc0, 0/);
// The walker dispatches on the class id in the header's low 28 bits.
expect(llvm).toContain("%id = and i32 %idw, 268435455");
// The frozen-object guard of the store tests the top bit: an unpublished
// instance stays writable.
const store =
llvm.split(/^define /m).find((fn) => fn.includes("call void @scr_throw_published_field(")) ??
"";
expect(store).toMatch(/icmp eq i32 %\S+, -1/);
expect(store).toMatch(/icmp slt i32 %\S+, 0/);
expect(store).toContain("call void @scr_publish(ptr");
// 32-bit targets use 2^31.
const llvm32 = emitLlvmModule(mod, { pointerBits: 32 });
+237 -38
View File
@@ -32,6 +32,7 @@ import {
} from "../mangle.js";
import { cycTypeDescriptor, emitObjectAlloc, emitObjectFree } from "./alloc.js";
import { llvmCommentText } from "./common.js";
import { INLINE_UNION_FIELD } from "./inline-unions.js";
import { NullableRefFields } from "./nullable-fields.js";
import type { Int32Slots } from "../../ir/int32-slots.js";
import {
@@ -88,6 +89,15 @@ export class LlClassMeta {
* bits, and the hierarchy's vtables are one array indexed by the id
* (mangleVtArray). */
idInRc = false;
/** A ProgramLifetime class with the 32-bit header (assignCompactLayouts):
* the i32 at offset 0 is compactHeaderWord, with the class id in place of
* the rc word's high half, and the fields are packed after it. */
compact = false;
/** Compact classes: the struct's elements by offset (the header, field
* -1, then the fields by their index in def.fields). */
compactElems: { offset: number; size: number; align: number; type: string; field: number }[] = [];
/** Compact classes: each field's GEP index, by its index in def.fields. */
compactSlots: number[] = [];
constructor(readonly def: IrClassDef) {
this.root = this;
@@ -371,6 +381,10 @@ export function classSlotWordIndex(meta: LlClassMeta): number {
/** The GEP index where a class's own fields start: after the header words
* and the slot word. */
export function classFieldBase(meta: LlClassMeta): number {
if (meta.compact)
throw new InternalCompilerError(
`llvm emitter bug: compact class ${meta.def.name} packs its fields`,
);
return headerEnd(meta) + (meta.threadSlot ? 1 : 0);
}
@@ -387,7 +401,12 @@ export function classFieldIndex(meta: LlClassMeta, field: string): { index: numb
throw new InternalCompilerError(
`llvm emitter bug: unknown field ${field} on class ${meta.def.name}`,
);
return { index: classFieldBase(meta) + idx, type: meta.def.fields[idx]!.type };
return { index: classFieldSlot(meta, idx), type: meta.def.fields[idx]!.type };
}
/** The GEP index of the field at `idx` in `meta.def.fields`. */
export function classFieldSlot(meta: LlClassMeta, idx: number): number {
return meta.compact ? meta.compactSlots[idx]! : classFieldBase(meta) + idx;
}
/** Lines that release the ThreadSlots values of the object `%o` before it
@@ -472,8 +491,7 @@ export function classPreLines(
): string[] {
if (meta.idInRc)
return [
` ${pre}.idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`,
` ${pre}.id = load i32, ptr ${pre}.idp`,
...classIdLoadLines(meta, obj, `${pre}.id`, ""),
` ${pre}.n = add i32 ${pre}.id, -1 ; the class id is the preorder number + 1`,
` ${pre} = zext i32 ${pre}.n to ${sizeType}`,
];
@@ -499,14 +517,39 @@ export function classIdVtLines(
const vtt = `%${mangleVtStruct(root.def.name)}`;
const n = root.post - root.pre + 1;
return [
` ${t}idp = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`,
` ${t}id = load i32, ptr ${t}idp${group}`,
...classIdLoadLines(meta, `${obj}`, `${t}id`, group),
` ${t}ix = sub i32 ${t}id, ${root.pre + 1}`,
` ${t}ix64 = zext i32 ${t}ix to i64`,
` ${vt} = getelementptr inbounds [${n} x ${vtt}], ptr @${mangleVtArray(root.def.name)}, i64 0, i64 ${t}ix64`,
];
}
/** Lines that load the class id of the LlClassMeta.idInRc instance `obj`
* into the i32 `id`: the high half of the rc word, or the low 28 bits of a
* compact instance's header word (compactHeaderWord). `group` is the
* metadata of the load (`, !invariant.group !{}` for a load LLVM may treat
* as invariant), and it only goes on bytes that never change: publication
* sets the top bit of a compact header word, so a compact instance's id is
* then read as the word's low 16 bits, which hold the whole id when the
* hierarchy's ids are below 2^16, and otherwise as the masked word without
* the metadata. */
export function classIdLoadLines(
meta: LlClassMeta,
obj: string,
id: string,
group: string,
): string[] {
if (meta.compact) {
if (group !== "" && meta.root.post + 1 <= 0xffff)
return [` ${id}.h = load i16, ptr ${obj}${group}`, ` ${id} = zext i16 ${id}.h to i32`];
return [` ${id}.w = load i32, ptr ${obj}`, ` ${id} = and i32 ${id}.w, ${COMPACT_ID_MASK}`];
}
return [
` ${id}p = getelementptr inbounds i8, ptr ${obj}, i64 ${CLASS_ID_OFFSET}`,
` ${id} = load i32, ptr ${id}p${group}`,
];
}
/** The byte offset of the class id in the rc word of an instance that
* keeps it there (LlClassMeta.idInRc): the high half of the little-endian
* 64-bit word. The count is the i32 at offset 0. */
@@ -522,6 +565,134 @@ export function classIdRcWord(meta: LlClassMeta, lifetime: boolean): string {
return (BigInt(meta.pre + 1) * 2n ** 32n + (lifetime ? 2n ** 31n : 1n)).toString();
}
/** The 32-bit header of a compact class instance (LlClassMeta.compact):
* COMPACT_HEADER_TAG, the class id (classIdRcWord's preorder number + 1, 0
* outside a hierarchy) in the low 28 bits, and COMPACT_PUBLISHED once
* publication marks the object. No other object's count reaches
* 0x70000000, so the runtime reads a word of 0xf0000000 and up as published
* for every object (scr_rc.h SCR_RC_PUBLISHED). */
export const COMPACT_HEADER_TAG = 0x70000000;
export const COMPACT_PUBLISHED = 0x80000000;
export const COMPACT_ID_MASK = 0x0fffffff;
/** The leading bytes of a new instance that its allocation function stores
* right after allocating it (emitObjectAlloc's `stored`), which a recycled
* block therefore need not clear: a compact instance's 4-byte header word
* (its fields start at offset 4), the 8-byte rc word, or the rc word and
* the vtable word of a hierarchy member that keeps its class in a vtable. */
export function storedHeaderBytes(meta: LlClassMeta): number {
if (meta.compact) return 4;
return meta.hierarchy && !meta.idInRc ? 16 : 8;
}
/** The header word a compact instance is created with. */
export function compactHeaderWord(meta: LlClassMeta): number {
return COMPACT_HEADER_TAG | (meta.hierarchy ? meta.pre + 1 : 0);
}
/** Byte size and alignment of a class field's LLVM storage type. */
function fieldStorage(type: string): { size: number; align: number } {
switch (type) {
case "i8":
return { size: 1, align: 1 };
case "i32":
return { size: 4, align: 4 };
case "ptr":
case "double":
return { size: 8, align: 8 };
case INLINE_UNION_FIELD:
return { size: 16, align: 8 };
default:
throw new InternalCompilerError(`llvm emitter bug: class field storage ${type}`);
}
}
/** The LLVM storage type of each of a class's flattened fields. */
export function classFieldTypes(host: ShapeHost, cls: IrClassDef, int32?: Int32Slots): string[] {
// Whole-program int32 fields (int32-slots.ts) store the exact i32.
return cls.fields.map((f) =>
int32?.isField(cls.name, f.name)
? "i32"
: llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)),
);
}
/** Gives the classes `compact` admits (whole hierarchies: the test is the
* root's) the 32-bit header and packed fields. A class keeps its base's
* field offsets, and places each of its own fields, widest first, at the
* first offset after the header that fits it: in a hole another field's
* alignment left, in the base's tail padding, or at the end. So 32-bit and
* smaller fields share 8-byte words. The struct lists the header and the
* fields by offset, which LLVM's layout of the type reproduces (checked). */
export function assignCompactLayouts(
metaMap: ReadonlyMap<string, LlClassMeta>,
compact: (root: LlClassMeta) => boolean,
fieldTypes: (cls: IrClassDef) => string[],
): void {
const byPre = [...metaMap.values()].sort((a, b) => a.pre - b.pre);
for (const meta of byPre) {
if (meta.def.runtime === true || !compact(meta.root)) continue;
const base = meta.base;
const inherited = base?.compactElems ?? [
{ offset: 0, size: 4, align: 4, type: "i32", field: -1 },
];
const baseCount = base?.def.fields.length ?? 0;
if (base !== null && (!base.compact || !sameFieldPrefix(base.def, meta.def)))
throw new InternalCompilerError(
`llvm emitter bug: compact class ${meta.def.name} does not extend its base's layout`,
);
const elems = inherited.map((e) => ({ ...e }));
const types = fieldTypes(meta.def);
const own = types
.map((type, field) => ({ type, field, ...fieldStorage(type) }))
.slice(baseCount)
.sort((a, b) => b.size - a.size || a.field - b.field);
for (const f of own) {
const sorted = [...elems].sort((a, b) => a.offset - b.offset);
let at = -1;
let cursor = 0;
for (const e of sorted) {
const start = alignTo(cursor, f.align);
if (start + f.size <= e.offset) {
at = start;
break;
}
cursor = Math.max(cursor, e.offset + e.size);
}
if (at < 0) at = alignTo(cursor, f.align);
elems.push({ offset: at, size: f.size, align: f.align, type: f.type, field: f.field });
}
elems.sort((a, b) => a.offset - b.offset);
// LLVM places each element at the next offset its alignment allows.
let end = 0;
for (const e of elems) {
if (alignTo(end, e.align) !== e.offset)
throw new InternalCompilerError(
`llvm emitter bug: compact layout of ${meta.def.name} is not LLVM's`,
);
end = e.offset + e.size;
}
const slots = new Array<number>(types.length).fill(-1);
elems.forEach((e, index) => {
if (e.field >= 0) slots[e.field] = index;
});
meta.compact = true;
meta.compactElems = elems;
meta.compactSlots = slots;
}
}
function alignTo(n: number, align: number): number {
return Math.ceil(n / align) * align;
}
function sameFieldPrefix(base: IrClassDef, cls: IrClassDef): boolean {
return (
base.fields.length <= cls.fields.length &&
base.fields.every((f, i) => cls.fields[i]!.name === f.name)
);
}
function undefFieldInits(
host: ClassHost,
meta: LlClassMeta,
@@ -589,12 +760,17 @@ export function emitClassShapes(
for (const cls of emitted) {
const meta = metaMap.get(cls.name)!;
// Whole-program int32 fields (int32-slots.ts) store the exact i32.
const fieldTys = cls.fields.map((f) =>
int32?.isField(cls.name, f.name)
? "i32"
: llFieldType(f.type, host.inlineUnions.classField(cls.name, f.name)),
);
if (meta.compact) {
typeDefs.push(
`%${mangleClassStruct(cls.name)} = type { ${meta.compactElems.map((e) => e.type).join(", ")} } ` +
`; class ${cls.name} (32-bit header) { ${meta.compactElems
.slice(1)
.map((e) => llvmCommentText(cls.fields[e.field]!.name))
.join("; ")} }`,
);
continue;
}
const fieldTys = classFieldTypes(host, cls, int32);
// Subclasses of a runtime base embed its remaining prefix (the state
// and display-name slots) between the vtable word and the field list;
// stream subclasses add the stream-state pointer — upcasts to
@@ -718,7 +894,7 @@ export function emitClassShapes(
const traced = host.tracedShapes.has(`object:${cls.name}`);
const runtimePrefix = runtimePrefixOf(meta);
const isStreamRooted = streamRooted(meta);
const fieldIndex = (i: number): number => classFieldBase(meta) + i;
const fieldIndex = (i: number): number => classFieldSlot(meta, i);
// Teardown, trace and gcFree see each slot's storage type: a nullable
// class field holds the instance pointer (NULL for the unit arm), and
// every class retain/release/visit entry point is NULL-tolerant.
@@ -851,30 +1027,41 @@ export function emitClassShapes(
``,
);
// Direct release (the vtable entry): the class's own teardown. The
// caller already handled NULL/immortal.
const reld: string[] = [
`define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`,
`entry:`,
` %rc = load ${count}, ptr %o`,
` %n = sub ${count} %rc, 1`,
` store ${count} %n, ptr %o`,
` %dead = icmp eq ${count} %n, 0`,
` br i1 %dead, label %free, label %${traced ? "root" : "done"}`,
`free:`,
];
if (traced) reld.push(...cycleDeadLines(host, "teardown"));
if (bounded) {
const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`;
const destroyBody: string[] = [];
teardown(destroyBody);
defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``);
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`);
} else teardown(reld);
reld.push(` br label %done`);
if (traced) reld.push(`root:`, ...cycleRootLines(host, "done"));
reld.push(`done:`, ` ret void`, `}`, ``);
defs.push(...reld);
// caller already handled NULL/immortal. A compact instance has no
// count: nothing releases it.
if (meta.compact) {
defs.push(
`define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name} (immortal)`,
`entry:`,
` ret void`,
`}`,
``,
);
} else {
const reld: string[] = [
`define internal void @${mangleClassReleaseDirect(cls.name)}(ptr %o) ${FN_ATTRS} { ; direct release ${cls.name}`,
`entry:`,
` %rc = load ${count}, ptr %o`,
` %n = sub ${count} %rc, 1`,
` store ${count} %n, ptr %o`,
` %dead = icmp eq ${count} %n, 0`,
` br i1 %dead, label %free, label %${traced ? "root" : "done"}`,
`free:`,
];
if (traced) reld.push(...cycleDeadLines(host, "teardown"));
if (bounded) {
const destroy = `${mangleClassReleaseDirect(cls.name)}_destroy`;
const destroyBody: string[] = [];
teardown(destroyBody);
defs.push(...destroyDefinitions(destroy, destroyBody, chained), ``);
host.declare(`declare void @scr_rc_destroy(ptr, ptr)`);
reld.push(` call void @scr_rc_destroy(ptr %o, ptr @${destroy})`);
} else teardown(reld);
reld.push(` br label %done`);
if (traced) reld.push(`root:`, ...cycleRootLines(host, "done"));
reld.push(`done:`, ` ret void`, `}`, ``);
defs.push(...reld);
}
} else {
const freeBody: string[] = [];
teardown(freeBody);
@@ -927,10 +1114,22 @@ export function emitClassShapes(
host,
sizeOf,
traced && !lifetime ? { type: cycType } : null,
meta.hierarchy && !meta.idInRc ? 16 : 8,
storedHeaderBytes(meta),
),
);
if (meta.idInRc) {
if (meta.compact && !meta.hierarchy)
nw.push(` store i32 ${compactHeaderWord(meta)}, ptr %o ; no count (compactHeaderWord)`);
else if (meta.compact) {
// The header word: the class id and no count (compactHeaderWord).
// Only publication writes it again, setting COMPACT_PUBLISHED in the
// top byte, so the class id reads that carry !invariant.group read
// the low 16 bits only (classIdLoadLines).
host.declare(`declare ptr @llvm.launder.invariant.group.p0(ptr)`);
nw.push(
` %o.vt = call ptr @llvm.launder.invariant.group.p0(ptr %o)`,
` store i32 ${compactHeaderWord(meta)}, ptr %o.vt ; class ${llvmCommentText(cls.name)} is ${meta.pre}`,
);
} else if (meta.idInRc) {
// The class id in the high half of the rc word, the count in the low
// half (classIdRcWord). It never changes while the object lives:
// retains, releases and the runtime write only the low half.
+47 -8
View File
@@ -3245,7 +3245,8 @@ export class LlDyn {
/* ── the dyn ToString pair (dynToStrHelper, ported) ────────────────── */
/** Node's String() over a dyn value — sc_ds (+1 result) over the
* recursive sc_ds_buf walker. Borrowed operand; never throws. */
* recursive sc_ds_buf walker. Borrowed operand; an exception that an
* object's toString or valueOf throws stays pending. */
dynToStrHelper(): string {
if (this.dynToStrFn) return this.dynToStrFn;
const name = "sc_ds";
@@ -3348,9 +3349,9 @@ export class LlDyn {
B.startBlock(labels.get(DYN_KIND.ARR)!);
{
// Array.prototype.toString: join(",") — null/undefined ELEMENTS
// print empty (unlike top level), nested arrays flatten.
// print empty (unlike top level), nested arrays flatten. The length
// is read once, as join reads it.
const n = this.lenOf(B, "%d");
const items = this.itemsOf(B, "%d");
this.i64Loop(B, "ds.ar", n, (i, brNext) => {
const nz = B.tmp();
B.line(`${nz} = icmp ugt ${this.S} ${i}, 0`);
@@ -3361,7 +3362,22 @@ export class LlDyn {
B.line(`call void @scr_jb_putc(ptr %b, i8 44)`);
B.br(lel);
B.startBlock(lel);
const e = this.itemAt(B, items, i);
// Each index reads the element as join does (scr_dyn_arr_at, +1):
// from the current storage, undefined past the current length,
// and through the prototype for a hole. An element's toString
// can append to, shrink or reallocate the array; a getter that
// throws answers NULL with the exception pending.
host.declare(`declare ptr @scr_dyn_arr_at(ptr, double)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
const index = B.tmp();
B.line(`${index} = uitofp ${this.S} ${i} to double`);
const e = B.tmp();
B.line(`${e} = call ptr @scr_dyn_arr_at(ptr %d, double ${index})`);
const missing = B.tmp();
B.line(`${missing} = icmp eq ptr ${e}, null`);
const lGot = B.newLabel("ds.gt");
B.condBr(missing, done, lGot);
B.startBlock(lGot);
const ek = this.kindOf(B, e);
const isU = B.tmp();
const isN = B.tmp();
@@ -3369,20 +3385,30 @@ export class LlDyn {
B.line(`${isU} = icmp eq i32 ${ek}, ${DYN_KIND.UNDEF}`);
B.line(`${isN} = icmp eq i32 ${ek}, ${DYN_KIND.NULL}`);
B.line(`${unit} = or i1 ${isU}, ${isN}`);
const lSkip = B.newLabel("ds.sk");
const lRec = B.newLabel("ds.rc");
const lSkip = B.newLabel("ds.sk");
B.condBr(unit, lSkip, lRec);
B.startBlock(lSkip);
B.line(`call void @scr_dyn_release_v(ptr ${e})`);
brNext();
B.startBlock(lRec);
B.line(`call void @sc_ds_buf(ptr %b, ptr ${e})`);
B.line(`call void @scr_dyn_release_v(ptr ${e})`);
// The first exception an element's conversion throws ends the
// conversion; later elements are not read.
const thrown = B.tmp();
for (const line of host.pendingTestLines(thrown)) B.line(line.trimStart());
const lMore = B.newLabel("ds.mo");
B.condBr(thrown, done, lMore);
B.startBlock(lMore);
});
B.br(done);
}
B.startBlock(labels.get(DYN_KIND.OBJ)!);
{
// The checked-dynamic tree's error encoding renders Error.prototype.toString;
// plain objects are "[object Object]".
// other objects convert with ToPrimitive's string hint: a toString or
// valueOf of their own or of their prototypes, else "[object Object]".
const marker = this.objGetLit(B, "%d", "%error");
const isErr = B.tmp();
B.line(`${isErr} = icmp ne ptr ${marker}, null`);
@@ -3390,8 +3416,21 @@ export class LlDyn {
const lPlain = B.newLabel("ds.pl");
B.condBr(isErr, lErr, lPlain);
B.startBlock(lPlain);
this.puts(B, "%b", "[object Object]");
B.br(done);
{
// Null leaves the exception the conversion threw pending.
host.declare(`declare ptr @scr_dyn_string_coerce_js(ptr)`);
host.declare(`declare void @scr_str_release(ptr)`);
const text = B.tmp();
B.line(`${text} = call ptr @scr_dyn_string_coerce_js(ptr %d)`);
const converted = B.tmp();
B.line(`${converted} = icmp ne ptr ${text}, null`);
const lPut = B.newLabel("ds.pp");
B.condBr(converted, lPut, done);
B.startBlock(lPut);
this.putScrStr(B, "%b", text);
B.line(`call void @scr_str_release(ptr ${text})`);
B.br(done);
}
B.startBlock(lErr);
// Error.prototype.toString, reading name and message through the
// prototype. Null leaves the exception its conversion threw pending.
@@ -29,7 +29,6 @@ import {
vAdapters,
} from "./shapes.js";
import { emitLlvmLayouts } from "./layouts.js";
import { moduleRuntimeFeatures } from "../../ir/runtime-features.js";
import { emitArraySearchHelpers } from "./array-search.js";
import { emitClassMembershipHelper } from "./classes.js";
import { type RegexMatcherCode, compileRegexMatcher } from "./regex-matchers.js";
@@ -138,7 +137,7 @@ export function emitModuleParts(emitter: LlEmitter): string[] {
// outlive the RC audit — release it with the globals (the runtime ABI’s
// sc_release_globals tail). Only when the dispatch unit is even
// linked (defineProps is the only writer).
const runtimeFeatures = moduleRuntimeFeatures(emitter.mod);
const runtimeFeatures = emitter.runtimeFeatures;
const fnValueProps = [...emitter.fnValues];
if (fnValueProps.length > 0) emitter.declare(`declare void @scr_box_release(ptr)`);
if (emitter.classObjs.size > 0)
+45 -22
View File
@@ -54,11 +54,7 @@ import {
VOID,
classJsName,
} from "../../ir/ir.js";
import {
moduleRuntimeFeatures,
moduleUsesDynBuiltinStream,
moduleUsesMockTimers,
} from "../../ir/runtime-features.js";
import { moduleRuntimeFeatures, type RuntimeFeatures } from "../../ir/runtime-features.js";
import { captureFreeFunctions, observesStacks } from "../stack-observations.js";
import { type DenseArrayLoop } from "./dense-array-loops.js";
import { findInitializedByteLoopBindings } from "./byte-windows.js";
@@ -264,7 +260,9 @@ import {
emitLibCall,
} from "./lib-dispatch.js";
import {
assignCompactLayouts,
buildClassGraph,
classFieldTypes,
classMembershipIntervals,
classFieldIndex,
classPreLines,
@@ -879,13 +877,16 @@ export class LlEmitter {
/** The program calls mock.timers members (moduleUsesMockTimers): timer
* calls emit scr_test.c's mock-aware entry points. */
readonly mockTimers: boolean;
private schedulesInContextValue: boolean | undefined = undefined;
private runtimeFeaturesValue: RuntimeFeatures | undefined;
/** The module's runtime features (moduleRuntimeFeatures), found with one
* walk of the module the first time any of them is read. */
get runtimeFeatures(): RuntimeFeatures {
return (this.runtimeFeaturesValue ??= moduleRuntimeFeatures(this.mod));
}
/** Whether timer and tick scheduling goes through the *_als entry
* points (RuntimeFeatures asyncContext). */
get schedulesInContext(): boolean {
if (this.schedulesInContextValue === undefined)
this.schedulesInContextValue = moduleRuntimeFeatures(this.mod).asyncContext;
return this.schedulesInContextValue;
return this.runtimeFeatures.asyncContext;
}
/** Emitted ref-kind resolve thunks for new Promise, interned per inner
* typeKey → thunk symbol. */
@@ -901,10 +902,9 @@ export class LlEmitter {
/** Identity-preserving static→dyn capsules for program classes and Web
* APIs whose values remain directly observable. */
readonly liveDynRefAdapters = new Map<string, LlStreamTypedRefAdapter>();
private dynBuiltinStreamUse: boolean | undefined;
/** See moduleUsesDynBuiltinStream. */
get usesDynBuiltinStream(): boolean {
return (this.dynBuiltinStreamUse ??= moduleUsesDynBuiltinStream(this.mod));
return this.runtimeFeatures.dynBuiltinStream;
}
/** Runtime-arm dispatchers for live union values. */
readonly liveDynUnionRefAdapters = new Map<string, string>();
@@ -1141,7 +1141,7 @@ export class LlEmitter {
) {
this.stackTraces = observesStacks(mod);
this.nullableUnions = new NullableUnions(mod, options.debugSources === undefined);
this.mockTimers = moduleUsesMockTimers(mod);
this.mockTimers = this.runtimeFeatures.mockTimers;
this.debug =
options.debugSources === undefined
? null
@@ -1154,14 +1154,18 @@ export class LlEmitter {
);
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.int32Slots = analyzeInt32Slots(mod);
// One walk finds the dynamic field stores every analysis below consults.
const dynamicStores = dynamicFieldStores(mod);
this.int32Slots = analyzeInt32Slots(mod, {
dynamicStores,
initializerBindings: this.initializerBindings,
});
this.liveViewsReadOnly =
mod.lib === undefined && !dynamicStores.all && dynamicStores.names.size === 0;
this.int32Abis = this.debug === null ? findInt32Abis(mod, this.int32Slots) : new Map();
this.immortalGlobals = new Set(mod.contracts?.immortalGlobals ?? []);
this.programLifetime = new Set(mod.contracts?.programLifetime ?? []);
this.finalFields = analyzeFinalFields(mod);
this.finalFields = analyzeFinalFields(mod, false, dynamicStores);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
@@ -1231,7 +1235,7 @@ export class LlEmitter {
this.unframedFunctions = this.stackTraces
? captureFreeFunctions(mod, this.stackChecks, this.publishedTypes)
: new Set();
this.immutableFields = computeImmutableFields(mod);
this.immutableFields = computeImmutableFields(mod, dynamicStores);
this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`));
this.indirectMayThrow = mt.indirect || mod.workers === true;
for (const cls of mod.classes ?? []) {
@@ -1356,7 +1360,7 @@ export class LlEmitter {
this.receiverReaders = new AmbientReceiverReaders(this.fnByName);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
this.lazyCaptures = new LazyCaptures(this.fnByName);
const traced = computeTraced(mod);
const traced = computeTraced(mod, dynamicStores);
this.tracedShapes = traced.shapes;
this.tracedUnions = traced.unions;
// A shape union's value is whichever record it holds, and the collector
@@ -1492,6 +1496,15 @@ export class LlEmitter {
classDefinitions: (cls, sizeOf) => regions.classDefs(cls, sizeOf),
};
}
// ProgramLifetime hierarchies count nothing: their instances get the
// 32-bit header and packed fields (classes.ts LlClassMeta.compact),
// except where regions count them or a slot word follows the header.
if (this.sizeType === "i64" && mod.lifetimeRegions !== true)
assignCompactLayouts(
this.classMeta,
(root) => programLifetimeClass(this.shapeHost, root) && !root.threadSlot,
(cls) => classFieldTypes(this.shapeHost, cls, this.int32Slots),
);
for (const [name, rec] of RUNTIME_ERROR_CLASSES) {
const meta = this.classMeta.get(name);
if (!meta) break; // hand-written IR without the builtin defs: no stamps
@@ -5114,6 +5127,7 @@ export class LlEmitter {
post: meta.post,
runtimeRooted: meta.root.def.runtime === true,
idInRc: meta.idInRc,
compact: meta.compact,
// Inline scalar-union fields are plain bits: nothing to push.
fields: meta.def.fields.flatMap((f) =>
this.inlineUnions.classField(className, f.name)
@@ -5154,15 +5168,21 @@ export class LlEmitter {
/** Branches on "`obj` is published" (rc == SIZE_MAX, or a count of
* UINT32_MAX in the low half for `count` i32: classes.ts
* LlClassMeta.idInRc); leaves the builder in the cold block and answers
* the continuation label. */
private emitPublishedTest(obj: string, count: "i32" | "i64" = this.sizeType): string {
* LlClassMeta.idInRc, or the top bit of a compact header word for
* "compact": classes.ts COMPACT_PUBLISHED); leaves the builder in the cold
* block and answers the continuation label. */
private emitPublishedTest(obj: string, count: "i32" | "i64" | "compact" = this.sizeType): string {
const B = this.B;
const rc = B.tmp(),
frozen = B.tmp(),
expected = B.tmp();
B.line(`${rc} = load ${count}, ptr ${obj}`);
B.line(`${frozen} = icmp eq ${count} ${rc}, -1`);
if (count === "compact") {
B.line(`${rc} = load i32, ptr ${obj}`);
B.line(`${frozen} = icmp slt i32 ${rc}, 0`);
} else {
B.line(`${rc} = load ${count}, ptr ${obj}`);
B.line(`${frozen} = icmp eq ${count} ${rc}, -1`);
}
this.declare(`declare i1 @llvm.expect.i1(i1, i1)`);
B.line(`${expected} = call i1 @llvm.expect.i1(i1 ${frozen}, i1 false)`);
const cold = B.newLabel("pub.frozen"),
@@ -5203,7 +5223,10 @@ export class LlEmitter {
if (className !== null && !published.classGuarded(className)) return;
const B = this.B;
const meta = className === null ? undefined : this.classMeta.get(className);
const ok = this.emitPublishedTest(obj, meta?.idInRc === true ? "i32" : this.sizeType);
const ok = this.emitPublishedTest(
obj,
meta?.compact === true ? "compact" : meta?.idInRc === true ? "i32" : this.sizeType,
);
let owner: string;
if (meta === undefined) owner = this.cstr("Object");
else if (!meta.hierarchy) owner = this.cstr(meta.def.jsName ?? meta.def.name);
@@ -29,7 +29,7 @@ import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { emitUnionWiden } from "./expr-records.js";
import { emitJsIcMethod, jsIcMethods } from "./js-ic.js";
import { DYN_BUFFER_NUM_METHODS } from "../../ir/runtime-features.js";
import { DYN_BUFFER_METHODS } from "../../ir/runtime-features.js";
export function emitDynamicExpr(
host: LlvmEmitterContext,
@@ -234,11 +234,9 @@ export function emitDynamicExpr(
});
argsPtr = arr;
}
// Fixed-width Buffer method names take scr_bytes_dyn.c's entry, which
// Buffer.prototype method names take their scr_bytes_dyn.c entry, which
// runs them on Buffer receivers and dispatches any other receiver.
const invoke = DYN_BUFFER_NUM_METHODS.has(e.method)
? "scr_dyn_invoke_buffer_num"
: "scr_dyn_invoke_prepared";
const invoke = DYN_BUFFER_METHODS.get(e.method) ?? "scr_dyn_invoke_prepared";
host.declare(`declare ptr @${invoke}(ptr, ptr, ptr, ptr, ${host.sizeType}, ptr)`);
let name = host.cstr(e.calleeName);
if (e.calleeNameValue !== undefined) {
@@ -1038,6 +1036,29 @@ export function emitDynamicExpr(
B.line(`${n} = xor i1 ${t}, true`);
return { name: n, type: e.type };
}
if (e.test === "dynamic") {
// A REF payload whose retain adapter is the dyn adapter (sc_cd's
// by-reference arm).
host.declare(`declare ptr @scr_dyn_retain_v(ptr)`);
const kp = B.tmp(),
k = B.tmp(),
isRef = B.tmp(),
rp = B.tmp(),
r = B.tmp(),
isDyn = B.tmp(),
t = B.tmp();
B.line(`${kp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 1`);
B.line(`${k} = load i32, ptr ${kp}`);
B.line(`${isRef} = icmp eq i32 ${k}, 4`);
B.line(`${rp} = getelementptr inbounds %ScrCaught, ptr ${c.name}, i64 0, i32 5`);
B.line(`${r} = load ptr, ptr ${rp}`);
B.line(`${isDyn} = icmp eq ptr ${r}, @scr_dyn_retain_v`);
B.line(`${t} = and i1 ${isRef}, ${isDyn}`);
if (e.negated !== true) return { name: t, type: e.type };
const n = B.tmp();
B.line(`${n} = xor i1 ${t}, true`);
return { name: n, type: e.type };
}
if (e.test === "object") {
host.declare(`declare zeroext i1 @scr_caught_is_object(ptr)`);
const t = B.tmp();
@@ -853,7 +853,7 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitFlex") {
if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") {
const args = e.args.map((a) => host.emitExpr(a));
const n = args.length - 2;
let payload = "null";
@@ -867,10 +867,57 @@ export function emitErrorsEventsLibCall(host: LlvmEmitterContext, e: LibCallExpr
}
payload = arr;
}
host.declare(`declare zeroext i1 @scr_emitter_emit_flex(ptr, ptr, ptr, ${host.sizeType})`);
const sym = e.fn === "emitter.emitAny" ? "scr_emitter_emit_any" : "scr_emitter_emit_flex";
host.declare(`declare zeroext i1 @${sym}(ptr, ptr, ptr, ${host.sizeType})`);
const t = B.tmp();
B.line(
`${t} = call i1 @scr_emitter_emit_flex(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`,
`${t} = call i1 @${sym}(ptr ${args[0]!.name}, ptr ${args[1]!.name}, ptr ${payload}, ${host.sizeType} ${n})`,
);
host.emitPendingCheck();
return { name: t, type: e.type };
}
if (e.fn === "emitter.emitMixed") {
// (recv, name, k, ...k dyn arguments, ...converted tuple): the dyn
// arguments ride an exact-length array like emitFlex, the tuple the
// pointer-classed variadic tail like emitter.emit. Borrowed; may throw.
const count = e.args[2]!;
const k = count.kind === "numLit" ? count.value : 0;
const args = [e.args[0]!, e.args[1]!, ...e.args.slice(3)].map((a) => host.emitExpr(a));
let payload = "null";
if (k > 0) {
const arr = B.slot();
B.entryAllocas.push(`${arr} = alloca [${k} x ptr]`);
for (let i = 0; i < k; i++) {
const slot = B.tmp();
B.line(`${slot} = getelementptr inbounds [${k} x ptr], ptr ${arr}, i64 0, i64 ${i}`);
B.line(`store ptr ${args[i + 2]!.name}, ptr ${slot}`);
}
payload = arr;
}
const tuple = args.slice(2 + k).map((a) => {
const ty = host.llType(a.type);
if (ty === "double" || ty === "i1") {
const slot = B.slot();
B.entryAllocas.push(`${slot} = alloca ${ty} ; EventEmitter scalar argument`);
B.line(`store ${ty} ${a.name}, ptr ${slot}`);
return `ptr ${slot}`;
}
return `ptr ${a.name}`;
});
host.declare(
`declare zeroext i1 @scr_emitter_emit_mixed(ptr, ptr, ptr, ${host.sizeType}, ...)`,
);
const t = B.tmp();
B.line(
`${t} = call zeroext i1 (ptr, ptr, ptr, ${host.sizeType}, ...) @scr_emitter_emit_mixed(` +
[
`ptr ${args[0]!.name}`,
`ptr ${args[1]!.name}`,
`ptr ${payload}`,
`${host.sizeType} ${k}`,
...tuple,
].join(", ") +
`)`,
);
host.emitPendingCheck();
return { name: t, type: e.type };
@@ -793,6 +793,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"fsp.rename": "scr_fsp_rename",
"fsp.readdir": "scr_fsp_readdir",
"fsp.rm": "scr_fsp_rm",
"fsp.rmOpts": "scr_fsp_rm_opts",
"fsp.rmRetry": "scr_fsp_rm_opts_retry",
"fsp.stat": "scr_fsp_stat",
"fsp.realpath": "scr_fsp_realpath",
"fsp.lstat": "scr_fsp_lstat",
@@ -1070,6 +1072,7 @@ export const LIB_FN_SYMS: Record<string, string> = {
"emitter.off": "scr_emitter_off",
"emitter.offDyn": "scr_emitter_off_dyn",
"emitter.onFlex": "scr_emitter_on_flex",
"emitter.onAny": "scr_emitter_on_any",
"emitter.checkListener": "scr_emitter_check_listener",
"emitter.removeAll": "scr_emitter_remove_all",
"emitter.emitError": "scr_emitter_emit_error",
@@ -1078,6 +1081,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"emitter.countDyn": "scr_emitter_listener_count_dyn",
"emitter.names": "scr_emitter_event_names",
"emitter.listeners": "scr_emitter_listeners",
"emitter.listenersDyn": "scr_emitter_listeners_dyn",
"emitter.hasTyped": "scr_emitter_has_typed",
"emitter.setMax": "scr_emitter_set_max",
"emitter.setMaxChk": "scr_emitter_set_max_chk",
"emitter.setDefaultMaxChk": "scr_emitter_set_default_max_chk",
+61 -21
View File
@@ -25,6 +25,7 @@ import type { IrClassDef, IrRecordShape, IrType, IrUnionDef, SrcLoc } from "../.
import { mapOf, STRING, typeKey } from "../../ir/ir.js";
import { mangleClassStruct, mangleRecordStruct } from "../mangle.js";
import { FN_ATTRS } from "./shapes.js";
import { COMPACT_ID_MASK } from "./classes.js";
import { shapeTagFn } from "./nullable-unions.js";
import type { InlineUnionFields } from "./inline-unions.js";
import { LlvmUnsupportedError } from "./unsupported.js";
@@ -66,6 +67,8 @@ export interface PublishClassInfo {
/** Instances keep their class id in the rc word (classes.ts
* LlClassMeta.idInRc). */
idInRc: boolean;
/** Instances have the 32-bit header (classes.ts LlClassMeta.compact). */
compact: boolean;
/** The class's own and inherited fields: GEP index and storage type. */
fields: { name: string; index: number; type: IrType }[];
}
@@ -76,6 +79,11 @@ const PUB_KIND_UNION = 5;
/** SCR_PUB_CLASS_ID, or'ed into a kind: the object keeps its class id
* in the high half of its rc word (classes.ts LlClassMeta.idInRc). */
const PUB_CLASS_ID = 0x100;
/** SCR_PUB_COMPACT, or'ed into a kind: the object has the 32-bit header
* (classes.ts compactHeaderWord), whose top bit is the mark. */
const PUB_COMPACT = 0x200;
/** COMPACT_PUBLISHED as an i32 constant. */
const COMPACT_MARK = "-2147483648";
/** SCR_PUB_LOG_COUNT (scr_publish.c), in a log entry's `buf`: `rc` is the
* count of the low half. */
const PUB_LOG_COUNT = "-2147483648";
@@ -223,7 +231,11 @@ export class PublishWalkers {
// hold time-value bits that no walker may push.
if (def.arms.some((arm) => scalarRefusal(arm)))
throw new LlvmUnsupportedError(`publish of ${typeKey(t)}`, loc);
const live = arms.flatMap((a) => (a.fn === null ? [] : [{ tag: a.tag, fn: a.fn }]));
const live = arms.flatMap((a) =>
a.fn === null
? []
: [{ tag: a.tag, fn: a.fn, compact: this.compactRef(def.arms[a.tag]!) }],
);
if (this.host.shapeUnion(t)) {
// The value is the record itself (or a unit encoding): no box to
// mark; the arm's walker marks and walks the record.
@@ -256,7 +268,7 @@ export class PublishWalkers {
for (const a of live)
lines.push(
`arm${a.tag}:`,
...this.push("%slot", a.fn, `u${a.tag}`),
...this.push("%slot", a.fn, `u${a.tag}`, a.compact),
` br label %done`,
);
}
@@ -316,18 +328,25 @@ export class PublishWalkers {
const lines = [
head,
`entry:`,
...(info.idInRc
...(info.compact
? [
` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`,
` %id = load i32, ptr %idp`,
` %idw = load i32, ptr %o ; the class id (classes.ts compactHeaderWord)`,
` %id = and i32 %idw, ${COMPACT_ID_MASK}`,
` %idpre = add i32 %id, -1`,
` %pre = zext i32 %idpre to ${this.host.sizeType}`,
]
: [
` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`,
` %vt = load ptr, ptr %vtp`,
` %pre = load ${this.host.sizeType}, ptr %vt`,
]),
: info.idInRc
? [
` %idp = getelementptr inbounds i8, ptr %o, i64 4 ; the class id (classes.ts classIdRcWord)`,
` %id = load i32, ptr %idp`,
` %idpre = add i32 %id, -1`,
` %pre = zext i32 %idpre to ${this.host.sizeType}`,
]
: [
` %vtp = getelementptr inbounds ptr, ptr %o, i64 1`,
` %vt = load ptr, ptr %vtp`,
` %pre = load ${this.host.sizeType}, ptr %vt`,
]),
` switch ${this.host.sizeType} %pre, label %other [ ${cases.map((c) => `${this.host.sizeType} ${c.pre}, label %c${c.pre}`).join(" ")} ]`,
];
for (const c of cases)
@@ -363,6 +382,7 @@ export class PublishWalkers {
path,
loc,
info.idInRc,
info.compact,
);
}
@@ -374,23 +394,26 @@ export class PublishWalkers {
* targets), and an object with a collector header leaves its candidate
* buffer through scr_cyc_pub_take. Anything else calls scr_pub_mark, as
* does every mark of an RC-audit build. */
private markLines(cyc: boolean, kind: number, classId = false): string[] {
private markLines(cyc: boolean, kind: number, classId = false, compact = false): string[] {
const flags = compact ? PUB_COMPACT : classId ? PUB_CLASS_ID : 0;
const call = (name: string): string[] => [
` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${classId ? kind | PUB_CLASS_ID : kind})`,
` %${name} = call zeroext i1 @scr_pub_mark(ptr %ctx, ptr %o, i1 ${cyc ? "true" : "false"}, i32 ${kind | flags})`,
` br i1 %${name}, label %walk, label %done`,
];
if (this.host.rcAudit === true) return call("m");
const S = this.host.sizeType;
// An object that keeps its class id in the rc word is marked and
// logged through its count, the low half.
const C = classId ? "i32" : S;
// A compact object's header word is logged whole (the restore writes
// the low 32 bits back) and marked by its top bit.
const C = classId || compact ? "i32" : S;
const field = (name: string, slot: number): string[] => [
` %${name}p = getelementptr inbounds ptr, ptr %ctx, i32 ${slot}`,
` %${name} = load ${S === "i64" ? "i64" : "i32"}, ptr %${name}p`,
];
const lines = [
` %mrc = load ${C}, ptr %o`,
` %mimm = icmp eq ${C} %mrc, -1`,
compact ? ` %mimm = icmp slt i32 %mrc, 0` : ` %mimm = icmp eq ${C} %mrc, -1`,
` br i1 %mimm, label %done, label %mtest`,
`mtest:`,
` %mrefp = getelementptr inbounds ptr, ptr %ctx, i32 6`,
@@ -408,7 +431,7 @@ export class PublishWalkers {
ok = "%mok2";
}
lines.push(` br i1 ${ok}, label %mfast, label %mslow`, `mslow:`, ...call("ms"), `mfast:`);
let buf = classId ? String(PUB_LOG_COUNT) : "0";
let buf = classId || compact ? String(PUB_LOG_COUNT) : "0";
if (cyc) {
// ScrCycHdr.buf, the word before the object: its top bit while the
// object is a buffered candidate.
@@ -443,7 +466,9 @@ export class PublishWalkers {
` store i32 ${buf}, ptr %mebuf`,
` %mlogged1 = add ${S} %mlogged, 1`,
` store ${S} %mlogged1, ptr %mloggedp`,
` store ${C} -1, ptr %o`,
...(compact
? [` %mpub = or i32 %mrc, ${COMPACT_MARK}`, ` store i32 %mpub, ptr %o`]
: [` store ${C} -1, ptr %o`]),
` br label %walk`,
);
return lines;
@@ -453,15 +478,18 @@ export class PublishWalkers {
* immortal references (the parent links of a tree, values published
* before) are the ones scr_pub_push skips: they are tested here, without
* the call. The lines end in a fresh block labelled `pushed${id}`. */
private push(v: string, fn: string, id: string): string[] {
private push(v: string, fn: string, id: string, compact = false): string[] {
return [
` %null${id} = icmp eq ptr ${v}, null`,
` br i1 %null${id}, label %pushed${id}, label %live${id}`,
`live${id}:`,
// The count (the low half of a 64-bit rc word) is UINT32_MAX for an
// immortal object of every kind (scr_rc.h SCR_RC_COUNT).
// immortal object of every kind (scr_rc.h SCR_RC_COUNT), and a
// compact object's header word has its top bit set once published.
` %rc${id} = load i32, ptr ${v}`,
` %immortal${id} = icmp eq i32 %rc${id}, -1`,
compact
? ` %immortal${id} = icmp slt i32 %rc${id}, 0`
: ` %immortal${id} = icmp eq i32 %rc${id}, -1`,
` br i1 %immortal${id}, label %pushed${id}, label %push${id}`,
`push${id}:`,
...this.pushLines(v, fn, id),
@@ -497,6 +525,12 @@ export class PublishWalkers {
];
}
/** Whether references of type `t` are compact class instances. */
private compactRef(t: IrType): boolean {
const arm = this.host.nullableArm(t) ?? t;
return arm.kind === "object" && this.host.classInfo(arm.className).compact;
}
private objectBody(
head: string,
struct: string,
@@ -505,8 +539,14 @@ export class PublishWalkers {
path: string,
loc: SrcLoc,
classId = false,
compact = false,
): string[] {
const lines = [head, `entry:`, ...this.markLines(traced, PUB_KIND_OBJECT, classId), `walk:`];
const lines = [
head,
`entry:`,
...this.markLines(traced, PUB_KIND_OBJECT, classId, compact),
`walk:`,
];
let n = 0;
for (const member of members) {
const fn = this.walker(member.type, `${path}.${member.name}`, loc);
@@ -519,7 +559,7 @@ export class PublishWalkers {
lines.push(
` %f${n} = getelementptr inbounds %${struct}, ptr %o, i64 0, i32 ${member.index} ; ${llvmComment(member.name)}`,
` %v${n} = load ptr, ptr %f${n}`,
...this.push(`%v${n}`, fn, `${n}`),
...this.push(`%v${n}`, fn, `${n}`, this.compactRef(member.type)),
);
n++;
}
@@ -1,12 +1,18 @@
/* The WebAssembly modules translated into a program (IrModule.wasmModules):
* each module's translation (wasm/translate.ts) and the registry the
* runtime looks modules up in (scr_wasm_registry, scr_wasm_abi.h). The
* each module's translation (wasm/translate.ts), the registry the runtime
* looks modules up in (scr_wasm_registry, scr_wasm_abi.h), and the table of
* modules the build saw but does not translate (scr_wasm_untranslated). The
* translations join the program module, so the native helper optimizes,
* partitions and caches them with it. Programs that do not use the
* WebAssembly API carry no wasmModules and emit nothing here. */
import type { IrModule } from "../../ir/ir.js";
import type { IrModule, IrWasmUntranslated } from "../../ir/ir.js";
import { decodeBase64 } from "../../wasm/base64.js";
import { translateWasmModule, WasmTranslateError } from "../../wasm/translate.js";
import {
cBytes,
translateWasmModule,
utf8Bytes,
WasmTranslateError,
} from "../../wasm/translate.js";
function hexBytes(hex: string): Uint8Array {
const out = new Uint8Array(hex.length / 2);
@@ -28,11 +34,38 @@ export function wasmBoundsChecks(options: {
return /windows|mingw|cygwin|win32|msvc/.test(target);
}
/** The top-level lines of every translated module plus the registry.
* `declared` holds the declarations the module already carries; runtime
* entry points it lacks are declared here. A module the translator
* cannot translate is left out of the registry, so loading it at run
* time rejects with the CompileError for untranslated bytes. */
/** scr_wasm_untranslated: hash, byte length and reason per module. */
function emitUntranslated(untranslated: readonly IrWasmUntranslated[]): string[] {
if (untranslated.length === 0)
return [`@scr_wasm_untranslated = constant { i64, ptr } { i64 0, ptr null }`];
const out: string[] = [];
const entries: string[] = [];
for (let index = 0; index < untranslated.length; index++) {
const module = untranslated[index]!;
const reason = new Uint8Array(utf8Bytes(module.reason));
out.push(
`@sc_wasm_untranslated_reason${index} = private unnamed_addr constant [${reason.length} x i8] c"${cBytes(reason, 0, reason.length)}"`,
);
const digest = hexBytes(module.sha256);
const sha: string[] = [];
for (let i = 0; i < 32; i++) sha.push(`i8 ${digest[i]!}`);
entries.push(
`{ [32 x i8], i64, ptr, i64 } { [32 x i8] [${sha.join(", ")}], i64 ${module.size}, ptr @sc_wasm_untranslated_reason${index}, i64 ${reason.length} }`,
);
}
out.push(
`@sc_wasm_untranslated_modules = private constant [${entries.length} x { [32 x i8], i64, ptr, i64 }] [${entries.join(", ")}]`,
`@scr_wasm_untranslated = constant { i64, ptr } { i64 ${entries.length}, ptr @sc_wasm_untranslated_modules }`,
);
return out;
}
/** The top-level lines of every translated module plus the registry and
* the untranslated table. `declared` holds the declarations the module
* already carries; runtime entry points it lacks are declared here. A
* module the translator cannot translate joins the untranslated table, so
* loading it at run time rejects with a CompileError that names the
* translator's reason. */
export function emitWasmModules(
mod: IrModule,
declared: ReadonlySet<string>,
@@ -43,6 +76,7 @@ export function emitWasmModules(
const out: string[] = [];
const decls = new Set(declared);
const descriptors: string[] = [];
const untranslated = [...(mod.wasmUntranslated ?? [])];
for (const module of modules) {
const bytes = decodeBase64(module.bytes);
if (bytes === null) continue;
@@ -53,8 +87,9 @@ export function emitWasmModules(
customSections: mod.wasmCustomSections === true,
});
} catch (error) {
if (error instanceof WasmTranslateError) continue;
throw error;
if (!(error instanceof WasmTranslateError)) throw error;
untranslated.push({ sha256: module.sha256, size: bytes.length, reason: error.message });
continue;
}
for (const decl of translation.decls) {
if (decls.has(decl)) continue;
@@ -72,6 +107,7 @@ export function emitWasmModules(
`@scr_wasm_registry = constant { i64, ptr } { i64 ${descriptors.length}, ptr @sc_wasm_registry_modules }`,
);
}
out.push(...emitUntranslated(untranslated));
out.push("");
return out;
}
@@ -1,8 +1,8 @@
import { execFile } from "node:child_process";
import { createHash } from "node:crypto";
import { constants } from "node:fs";
import { createHash, type Hash } from "node:crypto";
import { closeSync, constants, openSync, readSync } from "node:fs";
import { createRequire } from "node:module";
import { access, chmod, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import { access, chmod, copyFile, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
import { dirname, join } from "node:path";
import { promisify } from "node:util";
import {
@@ -73,9 +73,31 @@ export interface NativeCodegenArtifact {
const resolvedHelperCache = new Map<string, Promise<ResolvedHelper>>();
/** LLVM text already written to `file`, of `bytes` bytes, which a build passes on without reading it. */
export interface LlvmFile {
readonly file: string;
readonly bytes: number;
}
/** LLVM text as a string, as parts to concatenate, or as a file. */
export type LlvmInput = string | readonly string[] | LlvmFile;
export function isLlvmFile(llvm: LlvmInput): llvm is LlvmFile {
return typeof llvm === "object" && !Array.isArray(llvm);
}
/** The size of `llvm` in bytes. */
export function llvmInputBytes(llvm: LlvmInput): number {
if (isLlvmFile(llvm)) return llvm.bytes;
return (typeof llvm === "string" ? [llvm] : llvm).reduce(
(bytes, part) => bytes + Buffer.byteLength(part),
0,
);
}
export interface NativeCodegenOptions {
outputPath: string;
llvm: string | readonly string[];
llvm: LlvmInput;
outputKind: NativeCodegenOutputKind;
sourcePath: string;
optimization?: "0" | "1" | "2" | "3" | "s" | "z";
@@ -278,14 +300,31 @@ async function resolveHelper(
return pending;
}
/** Adds the bytes of `file` to `hash`, as `hash.update` of the text written there would. */
function updateHashFromFile(hash: Hash, file: string): void {
const fd = openSync(file, "r");
try {
const buffer = Buffer.allocUnsafe(4 * 1024 * 1024);
for (;;) {
const read = readSync(fd, buffer, 0, buffer.length, null);
if (read === 0) break;
hash.update(buffer.subarray(0, read));
}
} finally {
closeSync(fd);
}
}
function cacheKey(
options: NativeCodegenOptions,
target: NativeTargetSpec,
helper: HelperIdentity,
): string {
const hash = createHash("sha256").update("scriptc-native-codegen-v1\0");
for (const part of typeof options.llvm === "string" ? [options.llvm] : options.llvm)
hash.update(part);
if (isLlvmFile(options.llvm)) updateHashFromFile(hash, options.llvm.file);
else
for (const part of typeof options.llvm === "string" ? [options.llvm] : options.llvm)
hash.update(part);
return hash
.update("\0")
.update(JSON.stringify(target))
@@ -424,7 +463,8 @@ export async function emitNativeArtifact(
// concurrent build of another entry that has the same output stem.
const input = privateSiblingPath(options.outputPath, "native-input");
try {
await writeLlvmSource(input, options.llvm, 0o600);
if (isLlvmFile(options.llvm)) await copyFile(options.llvm.file, input);
else await writeLlvmSource(input, options.llvm, 0o600);
await invoke(helper.binaryPath, [
"emit",
"--input",
@@ -58,9 +58,9 @@ export interface NativeLinkFeatures {
/** events.once with options: scr_events_once_signal.c. Absent (not
* false) for programs without it. */
eventsOnceSignal?: boolean;
/** Fixed-width Buffer methods called on checked-dynamic values link
/** Buffer.prototype methods called on checked-dynamic values link
* scr_bytes_dyn.c; absent (false) for programs without them. */
dynBufferNum?: boolean;
dynBuffer?: boolean;
/** Builtin functions read as values (Buffer.byteLength, Buffer.alloc,
* fs.realpathSync) link scr_builtin_values.c; absent (false) otherwise. */
builtinValues?: boolean;
@@ -123,9 +123,9 @@ export interface CcOptions {
/** events.once with options (events.onceSignal): compiles
* scr_events_once_signal.c. */
eventsOnceSignal?: boolean;
/** Fixed-width Buffer methods called on checked-dynamic values:
/** Buffer.prototype methods called on checked-dynamic values:
* compiles scr_bytes_dyn.c. */
dynBufferNum?: boolean;
dynBuffer?: boolean;
/** Builtin functions read as values: compiles scr_builtin_values.c. */
builtinValues?: boolean;
/** The program constructs EventTarget/Event/CustomEvent values or
@@ -324,6 +324,9 @@ export interface LibArchiveOptions {
threadInstances?: boolean;
/** IR-detected link gates (the compileC precedent, refusal-narrowed). */
dynInvoke?: boolean;
/** Buffer.prototype methods on checked-dynamic values: scr_bytes_dyn.c,
* with dynInvoke. */
dynBuffer?: boolean;
/** See CompileOptions.dynTier: the scr_js_* units. */
dynTier?: boolean;
regex?: boolean;
@@ -180,8 +180,8 @@ const RUNTIME_UNITS: readonly RuntimeUnit[] = [
unit((u) => u.opts.assert || u.regex || u.opts.symbol, "scr_assert.c"),
unit((u) => u.opts.inspect, "scr_inspect.c", "scr_console_native.c"),
unit((u) => u.opts.dynInvoke || u.opts.workers || u.nativeFetch, "scr_dyn_invoke.c"),
// Fixed-width Buffer methods called on checked-dynamic values.
unit((u) => u.opts.dynBufferNum === true && u.opts.dynInvoke, "scr_bytes_dyn.c"),
// Buffer.prototype methods called on checked-dynamic values.
unit((u) => u.opts.dynBuffer === true && u.opts.dynInvoke, "scr_bytes_dyn.c"),
// Builtin functions read as values in JavaScript dependencies.
unit((u) => u.opts.builtinValues === true, "scr_builtin_values.c"),
unit((u) => u.opts.dc, "scr_dc.c"),
@@ -112,6 +112,7 @@ export async function compileLibArchive(opts: LibArchiveOptions): Promise<void>
...(opts.assert && opts.bigint ? ["scr_bigint_assert.c"] : []),
...(opts.emitter ? ["scr_events_emitter.c", "scr_dyn_handle.c"] : []),
...(opts.dynInvoke ? ["scr_dyn_invoke.c"] : []),
...(opts.dynInvoke && opts.dynBuffer ? ["scr_bytes_dyn.c"] : []),
...(opts.dynTier === true ? ["scr_js_value.c", "scr_js_object.c"] : []),
...(opts.zlib ? ["scr_zlib.c"] : []),
...(opts.copying ? ["scr_copying.c"] : []),
@@ -88,7 +88,7 @@ export interface RuntimeFeatureSet extends NativeLinkFeatures {
handleProps: boolean;
dynForAwait: boolean;
eventsOnceSignal: boolean;
dynBufferNum: boolean;
dynBuffer: boolean;
builtinValues: boolean;
warningReplace: boolean;
stdioExtended: boolean;
@@ -286,7 +286,7 @@ export function effectiveRuntimeFeatures(
handleProps: features.handleProps === true,
dynForAwait: features.dynForAwait === true,
eventsOnceSignal: features.eventsOnceSignal === true,
dynBufferNum: features.dynBufferNum === true,
dynBuffer: features.dynBuffer === true,
builtinValues: features.builtinValues === true,
warningReplace: features.warningReplace === true,
stdioExtended: features.stdioExtended === true,
@@ -13,9 +13,11 @@ import {
} from "../backend/native-toolchain.js";
import { installDarwinDebugSymbols, readDarwinDebugSymbols } from "../backend/debug-symbols.js";
import {
hasEarlyExecutableNativeBuild,
publishEarlyExecutableCache,
readEarlyExecutableCache,
readRoutedExecutableCache,
recordEarlyExecutableNativeBuild,
type EarlyExecutableCacheOptions,
type EarlyExecutableNativeFeatures,
} from "./executable-cache.js";
@@ -596,3 +598,38 @@ test("the route index still isolates eager and deferred native environments", as
)?.executableRestored,
).toBe(true);
});
test("a recorded native build is found by its invocation", async () => {
const f = await fixture();
expect(await hasEarlyExecutableNativeBuild(null, f.options)).toBe(false);
expect(await hasEarlyExecutableNativeBuild(f.root, f.options)).toBe(false);
await recordEarlyExecutableNativeBuild(null, f.options);
await recordEarlyExecutableNativeBuild(f.root, f.options);
expect(await hasEarlyExecutableNativeBuild(f.root, f.options)).toBe(true);
// The record does not depend on the compiler implementation or the native environment.
const rebuilt: EarlyExecutableCacheOptions = {
...f.options,
implementation: "b".repeat(64),
nativeEnvironment: "another driver",
};
expect(await hasEarlyExecutableNativeBuild(f.root, rebuilt)).toBe(true);
expect(
await hasEarlyExecutableNativeBuild(f.root, {
...f.options,
outPath: join(f.options.outDir, "other"),
}),
).toBe(false);
expect(await hasEarlyExecutableNativeBuild(f.root, { ...f.options, sanitize: true })).toBe(false);
// Publishing an executable does not record a native build.
const other = { ...f.options, outPath: join(f.options.outDir, "published") };
const tracker = new FrontendInputTracker();
tracker.run(() => trackedReadFile(f.source));
await publishEarlyExecutableCache(f.root, other, {
llvmPath: f.llvmPath,
irPath: f.irPath,
native,
executableRestored: false,
frontend: tracker.snapshot(),
});
expect(await hasEarlyExecutableNativeBuild(f.root, other)).toBe(false);
});
@@ -71,7 +71,7 @@ export interface EarlyExecutableNativeFeatures {
handleProps?: boolean;
dynForAwait?: boolean;
eventsOnceSignal?: boolean;
dynBufferNum?: boolean;
dynBuffer?: boolean;
builtinValues?: boolean;
events: boolean;
emitter: boolean;
@@ -338,6 +338,39 @@ function routePath(
return join(root, "early-exe-route", routeKey(options));
}
function nativeBuildMarkerPath(
root: string,
options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment">,
): string {
return join(root, "early-exe-native-build", routeKey(options));
}
/** Records that a build of the invocation `options` emitted its native program object, so the
* native helper's partition cache holds its partitions. Unlike the route, the record does not
* depend on the build publishing its executable. */
export async function recordEarlyExecutableNativeBuild(
root: string | null,
options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment">,
): Promise<void> {
if (root === null) return;
const marker = nativeBuildMarkerPath(root, options);
await mkdir(dirname(marker), { recursive: true, mode: 0o700 });
await writeFile(marker, "", { mode: 0o600 });
}
/** Whether an earlier build of the invocation `options` recorded its native program object
* (`recordEarlyExecutableNativeBuild`). False when there is no cache. */
export async function hasEarlyExecutableNativeBuild(
root: string | null,
options: Omit<EarlyExecutableRouteOptions, "nativeEnvironment">,
): Promise<boolean> {
if (root === null) return false;
return stat(nativeBuildMarkerPath(root, options)).then(
(info) => info.isFile(),
() => false,
);
}
function routeIntegrity(stamp: Omit<EarlyExecutableRouteStamp, "integrity">): string {
return createHash("sha256")
.update("early-executable-route-stamp-v2\0")
@@ -1,5 +1,6 @@
import type { CompileFailure, CompileRequestOptions } from "../compile-types.js";
import type { FfiProfile } from "../ffi/ffi-manifest.js";
import { frontendDisposed } from "../frontend/dispose-hook.js";
import {
checkerPanicDiag,
npmStaticRefusalDiag,
@@ -112,6 +113,7 @@ export function prepareExecutableModule(
}
} finally {
fe.dispose();
frontendDisposed();
timing("frontend-dispose");
}
@@ -0,0 +1,56 @@
import { expect, test } from "vitest";
import { frontendProcessExecArgv } from "./frontend-process-args.js";
test("the frontend process keeps heap limits and loaders", () => {
expect(
frontendProcessExecArgv([
"--max-old-space-size=8192",
"--stack-size=4000",
"--import",
"tsx",
"-r",
"./setup.cjs",
"--conditions=development",
"--enable-source-maps",
]),
).toEqual([
"--max-old-space-size=8192",
"--stack-size=4000",
"--import",
"tsx",
"-r",
"./setup.cjs",
"--conditions=development",
"--enable-source-maps",
]);
});
test("the frontend process drops evaluated code, inspectors, watch and test runner options", () => {
expect(
frontendProcessExecArgv([
"--max-old-space-size=8192",
"--input-type=module",
"--eval",
"await compile(process.argv[1], {})",
]),
).toEqual(["--max-old-space-size=8192"]);
expect(frontendProcessExecArgv(["-e", "code", "-p", "code", "-pe", "code"])).toEqual([]);
expect(
frontendProcessExecArgv(["--eval=code", "--print=code", "--input-type", "module"]),
).toEqual([]);
expect(
frontendProcessExecArgv([
"--inspect",
"--inspect-brk=9229",
"--inspect-port",
"9230",
"--watch",
"--watch-path=src",
"--test",
"--test-name-pattern",
"build",
"--test-reporter=spec",
"--expose-gc",
]),
).toEqual(["--expose-gc"]);
});
@@ -0,0 +1,54 @@
/** Options of the compiler's own process that the frontend process (frontend-process.ts) must not
* receive, with the options among them that take their value as the next argument: the code of
* --eval and --print would run in place of the frontend process's entry, and the inspector, watch
* and test runner options belong to the process that was started with them. */
const OPTIONS_WITH_VALUES = new Set([
"-e",
"--eval",
"-p",
"--print",
"--input-type",
"--inspect-port",
"--debug-port",
"--watch-path",
"--test-concurrency",
"--test-coverage-branches",
"--test-coverage-exclude",
"--test-coverage-functions",
"--test-coverage-include",
"--test-coverage-lines",
"--test-global-setup",
"--test-isolation",
"--test-name-pattern",
"--test-reporter",
"--test-reporter-destination",
"--test-shard",
"--test-skip-pattern",
"--test-timeout",
]);
const OPTIONS = new Set(["-i", "--interactive", "-c", "--check", "--test"]);
const OPTION_PREFIXES = ["--inspect", "--debug-port", "--watch", "--test-", "--input-type="];
/** The Node.js options of the frontend process: `execArgv` without the options that run code
* (--eval, --print), open an inspector, or watch or test, so that its own entry runs with the
* compiler process's remaining options, such as heap limits and loaders. */
export function frontendProcessExecArgv(execArgv: readonly string[]): string[] {
const kept: string[] = [];
for (let index = 0; index < execArgv.length; index++) {
const option = execArgv[index]!;
// A short option group such as -pe takes the code that follows it.
if (OPTIONS_WITH_VALUES.has(option) || /^-[a-z]*[ep][a-z]*$/i.test(option)) {
index++;
continue;
}
if (
OPTIONS.has(option) ||
option.startsWith("--eval=") ||
option.startsWith("--print=") ||
OPTION_PREFIXES.some((prefix) => option.startsWith(prefix))
)
continue;
kept.push(option);
}
return kept;
}
+18
View File
@@ -0,0 +1,18 @@
// The frontend process of an executable build (FrontendProcess and runFrontendProcess in index.ts):
// loads the compiler, then reads the paths of its request and result from stdin. Without them it
// exits: the build found its executable without it.
import { enableCompileCache } from "node:module";
try {
enableCompileCache();
} catch {
// Bytecode caching only speeds up loading.
}
const { runFrontendProcess } = await import("./index.js");
let input = "";
for await (const chunk of process.stdin) input += String(chunk);
const [requestPath, resultPath] = input.split("\n");
if (requestPath !== undefined && requestPath !== "" && resultPath !== undefined)
await runFrontendProcess(requestPath, resultPath);
// The frontend's own helpers (the checker process, timers) must not keep this process alive.
process.exit(0);
@@ -0,0 +1,14 @@
/* What runs right after a build disposes its frontend. The native compiler
* sets it to a full cycle collection (native/cli.ts): the frontend's syntax
* trees and program state refer to each other, so disposing them leaves
* cyclic garbage that reference counting cannot free. Under Node it is
* unset. */
let afterDispose: (() => void) | null = null;
export function setFrontendDisposedHook(hook: () => void): void {
afterDispose = hook;
}
export function frontendDisposed(): void {
if (afterDispose !== null) afterDispose();
}
@@ -863,15 +863,22 @@ export function lowerFsPromisesMkdirCall(
: { kind: "libCall", fn: "fsp.mkdirRecursive", args: [path], type, loc };
}
// rmSync(p, options): literal { recursive?, force? } booleans — the
// cleanup shape rmSync(dir, { recursive: true, force: true }) —
// plus the maxRetries/retryDelay numbers (the tmpdir-harness shape
// rmSync(p, options) and fs.promises.rm(p, options): literal
// { recursive?, force? } booleans — the cleanup shape
// rmSync(dir, { recursive: true, force: true }) — plus the
// maxRetries/retryDelay numbers (the tmpdir-harness shape
// rmSync(p, { maxRetries: 3, recursive: true, force: true })): those
// lower as ordinary number expressions into the retry libCall, whose
// runtime implements Node's linear-backoff retry on
// EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM. The retry-free shape keeps the
// historical rmOptsSync lowering unchanged.
export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc: SrcLoc): IrExpr {
// historical rmOptsSync lowering unchanged. The promise form runs the same
// removal and rejects with its error.
export function lowerFsRemoveCall(
lowerer: Lowerer,
expr: ts.CallExpression,
loc: SrcLoc,
promise: boolean,
): IrExpr {
const opts = literalBoolOptions(
expr.arguments.nodes[1]!,
["recursive", "force"],
@@ -879,7 +886,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc
);
if (opts === null) {
lowerer.noLowering(
"rmSync with an options argument beyond { recursive, force, maxRetries, retryDelay }",
`${promise ? "fs.promises.rm" : "rmSync"} with an options argument beyond { recursive, force, maxRetries, retryDelay }`,
expr.arguments.nodes[1]!,
"the recursive/force flags must be boolean literals; maxRetries/retryDelay are numbers; other options have no lowering",
);
@@ -888,12 +895,13 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc
const recursive = boolLit(opts.bools["recursive"] === true, loc);
const force = boolLit(opts.bools["force"] === true, loc);
const type: IrType = promise ? { kind: "promise", inner: VOID } : VOID;
if (opts.exprs["maxRetries"] === undefined && opts.exprs["retryDelay"] === undefined) {
return {
kind: "libCall",
fn: "fs.rmOptsSync",
fn: promise ? "fsp.rmOpts" : "fs.rmOptsSync",
args: [path, recursive, force],
type: VOID,
type,
loc,
};
}
@@ -904,7 +912,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc
node ? lowerer.lowerExprExpecting(node, F64) : { kind: "numLit", value: dflt, type: F64, loc };
return {
kind: "libCall",
fn: "fs.rmRetrySync",
fn: promise ? "fsp.rmRetry" : "fs.rmRetrySync",
args: [
path,
recursive,
@@ -912,7 +920,7 @@ export function lowerFsRemoveCall(lowerer: Lowerer, expr: ts.CallExpression, loc
num(opts.exprs["maxRetries"], 0),
num(opts.exprs["retryDelay"], 100),
],
type: VOID,
type,
loc,
};
}
@@ -292,7 +292,10 @@ export function lowerBuiltinModuleCall(
return lowerFsPromisesMkdirCall(lowerer, expr, loc);
}
if (bi.module === "fs" && bi.member === "rmSync" && expr.arguments.nodes.length === 2) {
return lowerFsRemoveCall(lowerer, expr, loc);
return lowerFsRemoveCall(lowerer, expr, loc, false);
}
if (bi.module === "fs/promises" && bi.member === "rm" && expr.arguments.nodes.length === 2) {
return lowerFsRemoveCall(lowerer, expr, loc, true);
}
// accessSync(p, mode?) and truncateSync(p, len?), with their promise
// spellings: an omitted mode is Node's F_OK (0) and an omitted len is 0.
@@ -676,12 +676,14 @@ export function requireExactArityValue(
: null;
if (mapped && typeEquals(mapped, funcType)) return;
// An 'any' slot (`C.prototype.m = function (a, b) {…}` through an
// untyped receiver) boxes the value into a checked-dynamic function:
// 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
// parameters erased), boxes the value into a checked-dynamic function:
// the box completes omitted arguments with undefined and checks every
// argument into the completed ABI, so any boxable signature passes.
if (
target !== undefined &&
(target.flags & ts.TypeFlags.Any) !== 0 &&
((target.flags & ts.TypeFlags.Any) !== 0 || mapped?.kind === "dyn") &&
canBoxFuncIntoDyn(
funcType,
(id) => lowerer.shapes.get(id),
@@ -1,6 +1,16 @@
import { varRef } from "../../ir/build.js";
import { BOOL, CAUGHT, DYN, type IrFunction, type IrType } from "../../ir/ir.js";
import {
BOOL,
CAUGHT,
DYN,
type IrExpr,
type IrFunction,
type IrType,
type SrcLoc,
} from "../../ir/ir.js";
import { everyStmtList, transformStmtList } from "../../ir/traverse.js";
import { classInstanceOf } from "./class-dynamic-dispatch.js";
import type { ClassInfo } from "./lower-classes.js";
import type { Lowerer } from "./lowerer.js";
/** Exception snapshots retain native objects. Recover their known layouts
@@ -10,6 +20,10 @@ export class CaughtValueDispatch {
private readonly thrownClasses = new Set<string>();
private readonly convertedClasses = new Set<string>();
private helper: IrFunction | null = null;
/** The dynamic-payload helpers of class tests and extractions, by kind
* and class (dynamicPayloads). */
private readonly payloadHelpers = new Map<string, IrFunction>();
private readonly payloadHelperFns = new Set<IrFunction>();
process(lowerer: Lowerer, functions: readonly IrFunction[]): boolean {
const consumers: IrFunction[] = [];
@@ -81,4 +95,164 @@ export class CaughtValueDispatch {
}
return changed;
}
/** A thrown unknown or union value, and the runtime's own dynamic throws,
* reach a catch binding as a dynamic payload. When an instance of class C
* can be such a payload (C or a subclass of C crosses into unknown, the
* `boxed` classes), `e instanceof C`, the reads it narrows and `e as C` in
* `sites` also answer through the dynamic class test, as standalone classes
* already do. Programs that never box C keep the native payload test. */
dynamicPayloads(
lowerer: Lowerer,
sites: ReadonlySet<IrFunction>,
boxed: ReadonlySet<string>,
): boolean {
if (boxed.size === 0 || sites.size === 0) return false;
const reached = new Map<string, ClassInfo | null>();
const reachable = (className: string | undefined): ClassInfo | null => {
if (className === undefined) return null;
let info = reached.get(className);
if (info === undefined) {
const found = lowerer.classes.get(className);
info =
found &&
lowerer.inHierarchy(found) &&
[...boxed].some((name) => name === className || lowerer.isSubclassOf(name, className))
? found
: null;
reached.set(className, info);
}
return info;
};
let changed = false;
const qualifies = (expr: IrExpr): boolean =>
expr.kind === "caughtTest"
? expr.test === "instanceof" && reachable(expr.className) !== null
: (expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") &&
expr.type.kind === "object" &&
reachable(expr.type.className) !== null;
for (const fn of sites) {
// The conversions themselves test the native payload.
if (fn === this.helper || this.payloadHelperFns.has(fn)) continue;
const found = !everyStmtList(fn.body, { stmt: () => true, expr: (expr) => !qualifies(expr) });
if (!found) continue;
fn.body = transformStmtList(fn.body, {
stmt: (stmt) => stmt,
expr: (expr) => {
if (expr.kind === "caughtTest" && expr.test === "instanceof") {
const info = reachable(expr.className);
if (!info) return expr;
changed = true;
const call: IrExpr = {
kind: "call",
callee: this.payloadHelper(lowerer, "instanceof", info, expr.loc).name,
args: [expr.value],
type: BOOL,
loc: expr.loc,
};
return expr.negated
? { kind: "unary", op: "!", operand: call, type: BOOL, loc: expr.loc }
: call;
}
if (
(expr.kind === "caughtNarrow" || expr.kind === "caughtCheck") &&
expr.type.kind === "object"
) {
const info = reachable(expr.type.className);
if (!info) return expr;
changed = true;
const kind = expr.kind === "caughtNarrow" ? "narrow" : "check";
return {
kind: "call",
callee: this.payloadHelper(lowerer, kind, info, expr.loc).name,
args: [expr.value],
type: expr.type,
loc: expr.loc,
};
}
return expr;
},
});
}
return changed;
}
/** `%caught.instanceof:C`, `%caught.narrow:C` and `%caught.check:C`: the
* native payload test or extraction, and otherwise a value thrown as
* unknown tested against C and checked into C. A check of a payload that
* is not a C keeps caughtCheck's TypeError. */
private payloadHelper(
lowerer: Lowerer,
kind: "instanceof" | "narrow" | "check",
info: ClassInfo,
loc: SrcLoc,
): IrFunction {
const className = info.def.name;
const name = `%caught.${kind}:${className}`;
const existing = this.payloadHelpers.get(name);
if (existing) return existing;
const caught = (): IrExpr => varRef("caught", CAUGHT, loc);
const dynamic = (): IrExpr => ({ kind: "caughtToDyn", value: caught(), type: DYN, loc });
const native: IrExpr = {
kind: "caughtTest",
value: caught(),
test: "instanceof",
className,
type: BOOL,
loc,
};
// Only a value thrown as unknown can hold an instance besides a native
// payload, which the native test answers exactly.
const dynamicInstance = (): IrExpr => ({
kind: "logical",
op: "&&",
left: { kind: "caughtTest", value: caught(), test: "dynamic", type: BOOL, loc },
right: classInstanceOf(lowerer, dynamic(), info, loc),
type: BOOL,
loc,
});
const type: IrType = { kind: "object", className };
const returnType = kind === "instanceof" ? BOOL : type;
let value: IrExpr;
if (kind === "instanceof") {
value = {
kind: "ternary",
cond: native,
then: { kind: "boolLit", value: true, type: BOOL, loc },
else_: dynamicInstance(),
type: BOOL,
loc,
};
} else if (kind === "narrow") {
value = {
kind: "ternary",
cond: native,
then: { kind: "caughtNarrow", value: caught(), type, loc },
else_: lowerer.coerceToExpected(dynamic(), type),
type,
loc,
};
} else {
value = {
kind: "ternary",
cond: dynamicInstance(),
then: lowerer.coerceToExpected(dynamic(), type),
else_: { kind: "caughtCheck", value: caught(), className, type, loc },
type,
loc,
};
}
const helper: IrFunction = {
name,
params: [{ localId: "caught", name: "caught", type: CAUGHT }],
locals: [{ id: "caught", name: "caught", type: CAUGHT, mutable: false }],
returnType,
body: [{ kind: "return", value, loc }],
loc,
};
this.payloadHelpers.set(name, helper);
this.payloadHelperFns.add(helper);
lowerer.liftedFns.push(helper);
return helper;
}
}
@@ -177,7 +177,7 @@ export function lowerClassDataDescriptor(
function descriptorGuard(lowerer: Lowerer, info: ClassInfo, loc: SrcLoc): string {
const name = `%descriptorGuard:${info.def.name}`;
const existing = lowerer.liftedFns.find((fn) => fn.name === name);
const existing = lowerer.liftedFnNamed(name);
const key = varRef("key", DYN, loc);
const fields = new Set<string>();
const collect = (owner: ClassInfo): void => {
@@ -32,6 +32,9 @@ function context() {
unions: new Map(),
classes: new Map(),
liftedFns,
liftedFnNamed: (name: string | undefined) => liftedFns.find((fn) => fn.name === name),
subclassLinks: 0,
constructedClassCount: () => -1,
diags: [],
prototypeMethodAccesses: new Map(),
protocolDispatches: new Map(),
@@ -68,7 +68,11 @@ import { isClassCallback } from "./class-callbacks.js";
import { CaughtValueDispatch } from "./caught-value-dispatch.js";
import { refreshDescriptorGuards } from "./class-descriptors.js";
import { ClassConstructionDispatch } from "./class-construction.js";
import { emitterRooted } from "./lower-event-emitter.js";
import {
emitterRooted,
lowerDynamicEmitterEmit,
lowerDynamicEmitterOn,
} from "./lower-event-emitter.js";
import { protocolArm, protocolFieldArm } from "./class-protocol-dispatch.js";
import { STREAM_OPERATORS } from "./lower-stream.js";
import { deletedPropertyNames, fixedFieldFamily } from "./js-class-fields.js";
@@ -97,6 +101,24 @@ const EMITTER_METHODS = new Set([
"off",
"removeListener",
]);
/** The rest of EventEmitter.prototype, answered through an untyped value
* on emitters that are not streams (streams keep their own surface). */
const EMITTER_INTROSPECTION_METHODS = new Set([
"listenerCount",
"eventNames",
"removeAllListeners",
"listeners",
"rawListeners",
"setMaxListeners",
"getMaxListeners",
]);
/** True when the class is or extends one of the runtime stream classes. */
function streamRooted(info: ClassInfo): boolean {
for (let c: ClassInfo | null = info; c; c = c.base) {
if (c.builtinStream !== undefined) return true;
}
return false;
}
const STREAM_BOOL_PROPERTIES = new Set([
"readable",
"readableEnded",
@@ -366,7 +388,7 @@ function readOnlyBagBody(body: IrStmt[], orEmpty = false): IrStmt[] {
export function classPropertiesHelper(lowerer: Lowerer, loc: SrcLoc, readOnly = false): IrFunction {
const name = readOnly ? READ_PROPERTIES : "%dyn.class.properties";
const existing = lowerer.liftedFns.find((fn) => fn.name === name);
const existing = lowerer.liftedFnNamed(name);
if (existing) return existing;
const helper: IrFunction = {
name,
@@ -762,6 +784,7 @@ export class ClassDynamicDispatch {
const rewrite = new Set<IrFunction>();
const toPrimitiveSites = new Set<IrFunction>();
const hasKeySites = new Set<IrFunction>();
const caughtSites = new Set<IrFunction>();
const discover = (type: IrType): void => {
const key = typeKey(type);
if (seenTypes.has(key)) return;
@@ -827,6 +850,13 @@ export class ClassDynamicDispatch {
case "dynHasKey":
hasKeySites.add(fn);
break;
case "caughtTest":
if (expr.test === "instanceof") caughtSites.add(fn);
break;
case "caughtNarrow":
case "caughtCheck":
if (expr.type.kind === "object") caughtSites.add(fn);
break;
case "dynKeyGet":
case "dynInvoke":
rewrite.add(fn);
@@ -865,6 +895,7 @@ export class ClassDynamicDispatch {
return true;
},
});
changed = this.caughtDispatch.dynamicPayloads(lowerer, caughtSites, this.boxed) || changed;
if (this.boxed.size === 0 && rewrite.size === 0) return changed;
const converting = this.convertingHints(lowerer);
const calling = this.calledConversions(lowerer);
@@ -1003,7 +1034,7 @@ export class ClassDynamicDispatch {
const loc = this.propertyBag.loc;
const method = classMethodValue(lowerer, info.decl, info, "toJSON", loc);
const prototype = method ? classPrototypeData(lowerer, info, loc) : null;
const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper);
const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper);
const init = helper?.body[0];
if (
!info.prototypeReflectionReady &&
@@ -1223,6 +1254,13 @@ export class ClassDynamicDispatch {
const matching = this.propertyReceivers(lowerer, true).flatMap(({ info }) => {
if (!info || info.fields.has(method) || info.builtinError) return [];
if (emitterRooted(lowerer, info) && EMITTER_METHODS.has(method)) return [info];
if (
emitterRooted(lowerer, info) &&
!streamRooted(info) &&
EMITTER_INTROSPECTION_METHODS.has(method) &&
!findMethodOn(lowerer, info, method)
)
return [info];
if (info.builtinStream) {
return STREAM_METHODS.has(method) ||
(STREAM_OPERATORS.has(method) && info.builtinStream !== "w")
@@ -1236,6 +1274,15 @@ export class ClassDynamicDispatch {
byMethod.set(method, matching);
return matching;
};
const candidateSets = new Map<string, ReadonlySet<ClassInfo>>();
const candidateSet = (method: string): ReadonlySet<ClassInfo> => {
let set = candidateSets.get(method);
if (!set) {
set = new Set(candidates(method));
candidateSets.set(method, set);
}
return set;
};
for (const fn of functions) {
// Discovery already visits every expression. Preserve bodies without
// dispatch sites instead of rebuilding their entire typed IR tree on
@@ -2220,16 +2267,16 @@ export class ClassDynamicDispatch {
: expr.kind === "dynInvoke"
? expr
: null;
const staticCandidates =
invoke &&
// Static methods matter only for methods no instance answers.
const staticCandidates = (method: string): boolean =>
[...this.boxedConstructors].some((name) => {
const info = lowerer.classes.get(name);
return info && findStaticOn(lowerer, info, invoke.method)?.method;
return info && findStaticOn(lowerer, info, method)?.method;
});
if (
!invoke ||
(candidates(invoke.method).length === 0 &&
!staticCandidates &&
!staticCandidates(invoke.method) &&
!(invoke.method === "toString" && lowerer.classes.has("%Error")))
)
return expr;
@@ -2501,10 +2548,13 @@ export class ClassDynamicDispatch {
});
changed = true;
}
for (const plan of this.propertyReceivers(lowerer, true)) {
// Compute the candidates where the first plan would have, so the
// memoized set is the same whichever plans are already answered.
const plans = this.propertyReceivers(lowerer, true);
const matching = plans.length ? candidateSet(dispatch.source.method) : null;
for (const plan of plans) {
const { info } = plan;
if (!candidates(dispatch.source.method).includes(info) || dispatch.classes.has(plan.key))
continue;
if (dispatch.classes.has(plan.key) || !matching!.has(info)) continue;
dispatch.classes.add(plan.key);
const loc = dispatch.source.loc;
const value = varRef("p.0", DYN, loc);
@@ -2648,7 +2698,7 @@ export class ClassDynamicDispatch {
if (
plan.info !== plan.capsule ||
dispatch.classes.has(plan.key) ||
(!dispatch.field && !candidates(dispatch.method).includes(plan.info))
(!dispatch.field && !candidateSet(dispatch.method).has(plan.info))
)
continue;
dispatch.classes.add(plan.key);
@@ -2738,6 +2788,8 @@ export class ClassDynamicDispatch {
}
for (const plan of this.propertyReceivers(lowerer, true)) {
const { info } = plan;
// Classes answered in an earlier pass skip the member lookups.
if (dispatch.classes.has(plan.key)) continue;
if (
!isClassOwnEnumerableFieldName(dispatch.name) ||
(!(info.builtinStream !== undefined && streamProperty(dispatch.name)) &&
@@ -2751,7 +2803,6 @@ export class ClassDynamicDispatch {
!findMethodOn(lowerer, info, `set:${dispatch.name}`))
)
continue;
if (dispatch.classes.has(plan.key)) continue;
dispatch.classes.add(plan.key);
const loc = dispatch.fn.loc;
const { receiver, condition } = this.propertyReceiver(plan, loc);
@@ -3310,7 +3361,7 @@ export class ClassDynamicDispatch {
loc: SrcLoc,
): IrExpr {
const name = fn === "dyn.hasKey" ? "%dyn.class.bagHas" : "%dyn.class.bagHasComputed";
let helper = lowerer.liftedFns.find((item) => item.name === name);
let helper = lowerer.liftedFnNamed(name);
if (!helper) {
const keyType = fn === "dyn.hasKey" ? STRING : DYN;
const value = varRef("p.0", DYN, loc);
@@ -3907,7 +3958,7 @@ export class ClassDynamicDispatch {
private readPropertyBagOrEmpty(lowerer: Lowerer, receiver: IrExpr, loc: SrcLoc): IrExpr {
// One call per read site: large JS programs have a branch per class and
// property, so the absent-bag handling lives in the shared helper.
let helper = lowerer.liftedFns.find((fn) => fn.name === READ_PROPERTIES_OR_EMPTY);
let helper = lowerer.liftedFnNamed(READ_PROPERTIES_OR_EMPTY);
if (!helper) {
// Settled by finalize; until then, the read-only bag's own dispatch.
helper = {
@@ -4256,17 +4307,38 @@ export class ClassDynamicDispatch {
add(root);
}
/** propertyReceivers results by mode, with the class graph they were
* computed from: the boxed classes, the registered classes, the subclass
* links and the constructed classes only grow, so equal counts and the
* same capsules give the same plans. */
private readonly receiverPlans = new Map<
string,
{ revision: string; capsules: (ClassInfo | undefined)[]; plans: readonly PropertyReceiver[] }
>();
/** A capsule retains its static type, while its object may be a subclass.
* Preorder insertion lets later branches test the most derived layout first. */
private propertyReceivers(
lowerer: Lowerer,
streams = false,
normalize = true,
): PropertyReceiver[] {
): readonly PropertyReceiver[] {
const mode = `${streams}:${normalize}`;
const revision = `${this.boxed.size}:${lowerer.classes.size}:${lowerer.subclassLinks}:${lowerer.constructedClassCount()}`;
const cached = this.receiverPlans.get(mode);
if (cached?.revision === revision) {
let i = 0;
let same = true;
for (const name of this.boxed)
if (lowerer.classes.get(name) !== cached.capsules[i++]) same = false;
if (same) return cached.plans;
}
const capsules: (ClassInfo | undefined)[] = [];
const plans: PropertyReceiver[] = [];
const seen = new Set<string>();
for (const name of this.boxed) {
const capsule = lowerer.classes.get(name);
capsules.push(capsule);
if (
!capsule ||
((capsule.builtinEmitter || capsule.builtinStream) && !streams) ||
@@ -4275,10 +4347,10 @@ export class ClassDynamicDispatch {
continue;
const canonical = normalize && !capsule.builtinStream && !capsule.builtinEmitter;
const visit = (info: ClassInfo): void => {
if (
(!canonical || !seen.has(info.def.name)) &&
(info === capsule || lowerer.classCanBeConstructed(info))
) {
// A seen class was visited with its whole subtree, and a canonical
// visit adds no plan for a seen class.
if (canonical && seen.has(info.def.name)) return;
if (info === capsule || lowerer.classCanBeConstructed(info)) {
seen.add(info.def.name);
plans.push({
info,
@@ -4290,6 +4362,7 @@ export class ClassDynamicDispatch {
};
visit(capsule);
}
this.receiverPlans.set(mode, { revision, capsules, plans });
return plans;
}
@@ -5055,6 +5128,66 @@ export class ClassDynamicDispatch {
return [store, { kind: "return", value: null, loc }];
}
/** EventEmitter.prototype's introspection and bookkeeping members on an
* emitter seen through an untyped value: Node's results over the native
* listener registry. */
private emitterIntrospection(lowerer: Lowerer, dispatch: Dispatch, receiver: IrExpr): IrStmt[] {
const { method, loc } = dispatch.source;
const incoming = dispatch.source.args.map((_, i) => varRef(`p.${i + 1}`, DYN, loc));
const ret = (value: IrExpr): IrStmt[] => [
{ kind: "return", value: lowerer.coerceToExpected(value, DYN), loc },
];
const call = (fn: IrLibFn, args: IrExpr[], type: IrType): IrExpr => ({
kind: "libCall",
fn,
args: [receiver, ...args],
type,
loc,
});
// Node indexes _events with the argument itself: an omitted name is
// the key "undefined".
const eventName = (): IrExpr =>
incoming.length
? lowerer.coerceToExpected(incoming[0]!, STRING)
: { kind: "strLit", value: "undefined", type: STRING, loc };
switch (method) {
case "listenerCount":
return ret(
incoming.length >= 2
? call("emitter.countDyn", [eventName(), incoming[1]!], F64)
: call("emitter.count", [eventName()], F64),
);
case "eventNames":
return ret(call("emitter.names", [], { kind: "array", elem: STRING }));
case "removeAllListeners":
return ret(
incoming.length
? call(
"emitter.removeAll",
[eventName(), { kind: "boolLit", value: false, type: BOOL, loc }],
receiver.type,
)
: call(
"emitter.removeAll",
[
{ kind: "strLit", value: "", type: STRING, loc },
{ kind: "boolLit", value: true, type: BOOL, loc },
],
receiver.type,
),
);
case "listeners":
case "rawListeners":
return ret(call("emitter.listenersDyn", [eventName()], DYN));
case "setMaxListeners":
return ret(
call("emitter.setMaxChk", [incoming[0] ?? dynUndefinedExpr(loc)], receiver.type),
);
default:
return ret(call("emitter.getMax", [], F64));
}
}
private methodBody(
lowerer: Lowerer,
dispatch: Dispatch,
@@ -5070,6 +5203,14 @@ export class ClassDynamicDispatch {
loc,
},
];
if (
emitterRooted(lowerer, info) &&
!streamRooted(info) &&
EMITTER_INTROSPECTION_METHODS.has(method) &&
!findMethodOn(lowerer, info, method)
) {
return this.emitterIntrospection(lowerer, dispatch, receiver);
}
if (
info.builtinStream ||
(emitterRooted(lowerer, info) &&
@@ -5090,18 +5231,37 @@ export class ClassDynamicDispatch {
];
if (method === "emit") {
if (!incoming.length) return fence();
return ret({
kind: "libCall",
fn: "emitter.emitFlex",
args: [receiver, lowerer.coerceToExpected(incoming[0]!, STRING), ...incoming.slice(1)],
type: BOOL,
loc,
});
const event = lowerer.coerceToExpected(incoming[0]!, STRING);
const bridged = streamRooted(info)
? null
: lowerDynamicEmitterEmit(lowerer, receiver, event, incoming.slice(1), loc);
return ret(
bridged ?? {
kind: "libCall",
fn: "emitter.emitAny",
args: [receiver, event, ...incoming.slice(1)],
type: BOOL,
loc,
},
);
}
if (registering || removing) {
if (incoming.length < 2) return fence();
const event = lowerer.coerceToExpected(incoming[0]!, STRING);
const cb = incoming[1]!;
const bridged =
registering && !streamRooted(info)
? lowerDynamicEmitterOn(
lowerer,
receiver,
event,
cb,
method === "once" || method === "prependOnceListener",
method.startsWith("prepend"),
loc,
)
: null;
if (bridged) return ret(bridged);
return [
{
kind: "exprStmt",
@@ -5113,7 +5273,7 @@ export class ClassDynamicDispatch {
fn: registering
? info.builtinStream
? "stream.onDyn"
: "emitter.onFlex"
: "emitter.onAny"
: "emitter.offDyn",
args: [
receiver,
@@ -426,7 +426,7 @@ function reflectedMethodValue(
);
}
if (value.kind === "closure" && value.captures.length === 0) {
const body = lowerer.liftedFns.find((fn) => fn.name === value.fnName);
const body = lowerer.liftedFnNamed(value.fnName);
if (body?.captures?.length === 0) delete body.captures;
}
const cache = staticMethod
@@ -172,7 +172,7 @@ export function classPrototypeData(
}
if (!metadataOnly && !info.prototypeObservedMethodsReady) {
info.prototypeObservedMethodsReady = true;
const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper)!;
const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper)!;
const value = varRef(`%g.${info.def.prototypeDataHelper}`, DYN, loc);
// Materialize only observed method slots. Own declarations stop lookup
// at the correct prototype even if an ancestor is replaced later. An
@@ -307,7 +307,7 @@ export function reflectClassPrototype(
if (info.base.prototypeReflectionRefusal)
return refuse("prototype reflection with this base class is not supported yet");
}
const helper = lowerer.liftedFns.find((fn) => fn.name === info.def.prototypeDataHelper);
const helper = lowerer.liftedFnNamed(info.def.prototypeDataHelper);
const initialization = helper?.body[0];
if (!helper || initialization?.kind !== "if" || info.prototypeReflectionReady) return prototype;
info.prototypeReflectionReady = true;
@@ -1,6 +1,7 @@
import { generatorDrain } from "../iterator-adapters.js";
import { identityPreservingWidening } from "../coercions/identity.js";
import { liftedFreshSpread } from "../coercions/fresh-spread.js";
import { checkedIterableSpread } from "../checked-iterable-spread.js";
import { InternalCompilerError } from "../../../errors.js";
import {
dynUndefinedExpr,
@@ -25,6 +26,7 @@ import {
STRING,
type SrcLoc,
arrayOf,
canConvertToDyn,
funcOf,
isRefCounted,
isSupportedArrayElem,
@@ -153,6 +155,32 @@ export function lowerArraySpreadItems(
// Fresh elements (an array literal) convert into the element layout.
source = liftedFreshSpread(lowerer, node.expression, source, arrType, loc) ?? source;
}
// A checked-dynamic source, or any other iterable spread into
// checked-dynamic items, drains once through the iterator protocol
// (with the spread call's TypeErrors), and its elements convert to
// the element type.
const drains =
source.type.kind === "dyn"
? elem.kind === "dyn" ||
elem.kind === "f64" ||
elem.kind === "string" ||
elem.kind === "bool"
: elem.kind === "dyn" &&
!typeEquals(source.type, arrType) &&
!(
source.type.kind === "array" &&
identityPreservingWidening(lowerer, source.type.elem, elem)
) &&
canConvertToDyn(
source.type,
(id) => lowerer.shapes.get(id),
(id) => lowerer.unions.get(id),
);
if (drains) {
const spelling = ts.getTextOfNode(node.expression);
const items = checkedIterableSpread(lowerer, source, spelling, loc);
source = lowerer.coerceInto(node.expression, items, arrType);
}
if (!typeEquals(source.type, arrType)) {
if (
source.type.kind !== "array" ||
@@ -38,7 +38,7 @@ import {
lowerOptionalArgument,
lowerStaticallyUndefinedArgument,
} from "../optional-arguments.js";
import { lowerCheckedArrayFrom } from "./array-construction.js";
import { lowerArraySpreadItems, lowerCheckedArrayFrom } from "./array-construction.js";
import { lowerToNumberArgument } from "../lower-exprs.js";
import { lowerBytesIteratorValue } from "../iterator-adapters.js";
import { lowerArrayCallback } from "./callback-arguments.js";
@@ -642,7 +642,7 @@ export function lowerBytesNew(
);
}
/** TypedArray.from without a mapping callback. Preserve its iterable /
/** TypedArray.from and TypedArray.of. Preserve from's iterable /
* array-like semantics separately from constructor length coercion. */
export function lowerBytesStaticCall(
lowerer: Lowerer,
@@ -662,10 +662,15 @@ export function lowerBytesStaticCall(
loc: locOf(call),
};
}
if (access.name.text !== "from" || !ts.isIdentifier(access.expression)) return null;
if (
(access.name.text !== "from" && access.name.text !== "of") ||
!ts.isIdentifier(access.expression)
)
return null;
const symbol = lowerer.resolveValueSymbol(access.expression);
const elem = symbol ? own(BYTES_CTORS, ts.symbolName(symbol)) : undefined;
if (!elem || !lowerer.isStdlibSymbol(symbol ?? undefined)) return null;
if (access.name.text === "of") return lowerBytesOf(lowerer, call, elem);
if (
call.arguments.nodes.length < 1 ||
call.arguments.nodes.length > 3 ||
@@ -754,6 +759,59 @@ export function lowerBytesStaticCall(
};
}
/** TypedArray.of: a new array of the arguments, each converted with ToNumber
* in argument order after all of them are evaluated. Numbers copy directly;
* other values (and spread items) collect into an array that goes through
* TypedArray.from's conversion, which calls valueOf/toString the same way. */
function lowerBytesOf(lowerer: Lowerer, call: ts.CallExpression, elem: IrBytesElem): IrExpr {
const loc = locOf(call);
const nodes = call.arguments.nodes;
const type = bytesOf(elem);
if (nodes.some(ts.isSpreadElement)) {
const numeric = nodes.every((node) => {
const mapped = lowerer.mapTypeOf(
lowerer.typeOf(ts.isSpreadElement(node) ? node.expression : node),
);
return ts.isSpreadElement(node)
? mapped?.kind === "array" && mapped.elem.kind === "f64"
: mapped?.kind === "f64";
});
const elemType = numeric ? F64 : DYN;
const items = lowerArraySpreadItems(
lowerer,
nodes,
elemType,
{ kind: "array", elem: elemType },
loc,
);
if (numeric) return { kind: "bytesNew", source: items, type, loc };
return {
kind: "bytesNew",
source: lowerer.coerceToExpected(items, DYN),
from: true,
type,
loc,
};
}
const values = nodes.map((node) => lowerer.lowerExpr(node));
if (values.every((value) => value.type.kind === "f64")) {
return {
kind: "bytesNew",
source: { kind: "arrayLit", elems: values, type: arrayOf(F64), loc },
type,
loc,
};
}
const elems = values.map((value, i) => lowerer.coerceInto(nodes[i]!, value, DYN));
return {
kind: "bytesNew",
source: lowerer.coerceToExpected({ kind: "arrayLit", elems, type: arrayOf(DYN), loc }, DYN),
from: true,
type,
loc,
};
}
/** Construct a view over the complete branded backing allocation. */
function lowerArrayBufferView(
lowerer: Lowerer,
@@ -1479,6 +1537,8 @@ function lowerBufferInstanceMethod(
if (encName === "utf16le") align = 2;
} else if (vT?.kind === "bytes" && vT.elem === "u8") {
needle = lowerer.lowerExpr(args[0]!);
// A Buffer needle under utf16le also searches whole 2-byte units.
if (encName === "utf16le") align = 2;
} else {
lowerer.noLowering(
`.${name} of '${lowerer.checker.typeToString(lowerer.typeOf(args[0]!))}' values`,
@@ -1,5 +1,6 @@
import { expect, test } from "vitest";
import { F64, type IrExpr, type IrFunction, type IrStmt, type IrType } from "../../ir/ir.js";
import { observeRecordLiteralRebuilds, transformExpr } from "../../ir/traverse.js";
import { ShapeRegistry } from "../type-mapper.js";
import {
applyKeyOrderWitnesses,
@@ -156,7 +157,24 @@ test("a synthesized construction pins today's table", () => {
expect(f.order(ab)).toEqual(["a", "b"]);
});
test("a checked conversion pins today's table", () => {
test("a checked conversion alone pins today's table", () => {
const f = fixture();
const ab = f.record(
[
["a", OPT],
["b", F64],
],
["a", "b"],
);
const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc };
// A late write to `a` would move it after `b`; values only a checked
// conversion builds keep today's table, so parsed values in that order
// need no source view.
f.run([decl("p", parsed), set(ref("p", ab), "a"), observe(ref("p", ab))]);
expect(f.order(ab)).toEqual(["a", "b"]);
});
test("a shape literals and a checked conversion both build takes the literals' order", () => {
const f = fixture();
const ab = f.record(
[
@@ -166,8 +184,33 @@ test("a checked conversion pins today's table", () => {
["a", "b"],
);
const parsed: IrExpr = { kind: "dynCheck", value: ref("j", { kind: "dyn" }), type: ab, loc };
// The literal's values can enumerate in no other order; parsed values in
// another order keep a record-source view.
f.run([observe(f.literal(ab, ["b", "a"])), observe(parsed)]);
expect(f.order(ab)).toEqual(["a", "b"]);
expect(f.order(ab)).toEqual(["b", "a"]);
});
test("a literal an IR rewrite rebuilds keeps its witness", () => {
const f = fixture();
const ab = f.record(
[
["a", F64],
["b", F64],
],
["a", "b"],
);
const identity = { expr: (e: IrExpr): IrExpr => e, stmt: (st: IrStmt): IrStmt => st };
const original = f.literal(ab, ["b", "a"]);
observeRecordLiteralRebuilds((from, to) => f.notes.follow(from, to));
let rebuilt: IrExpr;
try {
rebuilt = transformExpr(original, identity);
} finally {
observeRecordLiteralRebuilds(null);
}
expect(rebuilt).not.toBe(original);
f.run([observe(rebuilt)]);
expect(f.order(ab)).toEqual(["b", "a"]);
});
test("a spread contributes its source's order, and an override keeps its position", () => {
@@ -23,10 +23,12 @@
* - any other construction (synthesized builtin results, opaque runtime
* producers) pins today's table;
* - checked conversions from dynamic values (typed JSON.parse, values
* from JavaScript) pin today's table: the record-source table keeps
* their own key order relative to it, and another table would make
* values parsed in today's order keep a source view. Their order is
* unknown, so a copy out of such a shape cannot be ordered statically;
* from JavaScript) contribute nothing: the record-source table keeps
* their own key order relative to the chosen table. A shape only they
* build keeps today's table, since another table would make values
* parsed in today's order keep a source view; a shape object literals
* also build takes the literals' order. Their order is unknown, so a
* copy out of such a shape cannot be ordered statically;
* - a write to an optional key that may be absent appends it in Node. A
* key no construction creates ("late-only") goes after every key a
* construction can create; any other moving write goes after every key.
@@ -56,8 +58,10 @@ export type KeyOrderSegment =
| { kind: "opaque" };
/** Witness notes the lowering attaches to the IR nodes it builds. Keyed by
* node identity, so nodes a speculative lowering discards are never walked,
* and a node some later pass rebuilds falls back to pinning. */
* node identity, so nodes a speculative lowering discards are never walked.
* Literals the IR rewriters rebuild (traverse.ts mapExprChildren) carry
* their notes over (follow); a node rebuilt any other way falls back to
* pinning. */
export class KeyOrderNotes {
private readonly literals = new Map<IrExpr, KeyOrderSegment[]>();
private readonly copies = new Map<IrExpr, string>();
@@ -77,6 +81,15 @@ export class KeyOrderNotes {
return node;
}
/** A pass rebuilt `from` as `to` (traverse.ts mapExprChildren): the
* rebuilt literal is the same construction, so it keeps the witness. */
follow(from: IrExpr, to: IrExpr): void {
const segments = this.literals.get(from);
if (segments !== undefined) this.literals.set(to, segments);
const source = this.copies.get(from);
if (source !== undefined) this.copies.set(to, source);
}
observe(type: IrType): void {
if (type.kind === "record") this.observed.add(type.shapeId);
}
@@ -388,8 +401,13 @@ export function applyKeyOrderWitnesses(lowerer: Lowerer, fns: readonly IrFunctio
// A checked conversion builds values in their source's own order, which
// the record-source table reproduces relative to the shape's table. A new
// table would make every value parsed in today's order keep a source
// view, so such shapes keep today's table.
for (const f of facts.values()) if (f.runtime) f.pinned = true;
// view, so a shape only checked conversions build keeps today's table.
// A shape the program also builds with object literals takes their order:
// the literal's values have no other way to enumerate in it, and values
// parsed from JSON those literals wrote (a message sent to a child or
// another service of the program) arrive in it too. Parsed values in any
// other order keep a source view, exactly as before.
for (const f of facts.values()) if (f.runtime && f.literals.length === 0) f.pinned = true;
// Nothing to choose: every witness agrees with today's table and no
// optional key is written late.
@@ -4098,6 +4098,12 @@ const DYN_DISPATCH_METHODS = new Set([
"prependOnceListener",
"listeners",
"listenerCount",
// The rest of EventEmitter.prototype, which compiled emitters answer
// through the class dispatch (class-dynamic-dispatch.ts).
"rawListeners",
"eventNames",
"setMaxListeners",
"getMaxListeners",
"write",
"end",
"destroy",
@@ -8079,6 +8085,14 @@ function lowerObjectStaticCall(
loc,
};
}
const fnIntegrity = functionIntegrity(
lowerer,
member,
value,
call.arguments.nodes[0]!,
locOf(call),
);
if (fnIntegrity) return fnIntegrity;
if (member === "preventExtensions" || member === "seal") {
const integrity = lowerClassIntegrity(lowerer, call, member, value);
if (integrity) return integrity;
@@ -8162,6 +8176,8 @@ function lowerObjectStaticCall(
)
)
return value;
const fnIntegrity = functionIntegrity(lowerer, "freeze", value, argNode, locOf(call));
if (fnIntegrity) return fnIntegrity;
const integrity = lowerClassIntegrity(lowerer, call, "freeze", value);
if (integrity) return integrity;
lowerer.noLowering(
@@ -10647,3 +10663,55 @@ function lowerObjectAssignOntoError(lowerer: Lowerer, call: ts.CallExpression):
loc,
};
}
/** `Object.freeze(fn)` and the other integrity operations on a function
* value: the function's checked-dynamic face holds its own properties (the
* ones a program assigns, `name`, `length`, `prototype`) and their
* integrity state, which the runtime keeps on its property table. The
* mutators answer the function itself; the queries answer from the table.
* Null for other values. */
function functionIntegrity(
lowerer: Lowerer,
member: string,
value: IrExpr,
node: ts.Expression,
loc: SrcLoc,
): IrExpr | null {
if (value.type.kind !== "func" || !lowerer.dynConvertible(value.type)) return null;
const operations = {
freeze: "dyn.freeze",
seal: "dyn.seal",
preventExtensions: "dyn.preventExtensions",
isSealed: "dyn.isSealed",
isExtensible: "dyn.isExtensible",
} as const;
if (!(member in operations)) return null;
const fn = operations[member as keyof typeof operations];
// The first operation on a function creates its property table, whose
// `name` comes from the box: give the box the function's JS name, as a
// closure flowing into any checked-dynamic slot gets.
const name = jsFuncNameOf(node, lowerer);
const boxed = (e: IrExpr): IrExpr => {
const box = lowerer.coerceToExpected(e, DYN);
return box.kind === "dynFrom" && name !== null ? { ...box, fnName: name } : box;
};
if (fn === "dyn.isSealed" || fn === "dyn.isExtensible") {
return { kind: "libCall", fn, args: [boxed(value)], type: BOOL, loc };
}
const held = lowerer.declareHiddenLocal("%integrityFn", value.type);
const ref = (): IrExpr => ({ kind: "varRef", localId: held.id, type: value.type, loc });
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: held.id, init: value, loc },
{
kind: "exprStmt",
expr: { kind: "libCall", fn, args: [boxed(ref())], type: DYN, loc },
loc,
},
],
result: ref(),
type: value.type,
loc,
};
}
@@ -946,7 +946,10 @@ export function registerBuiltinErrorClasses(lowerer: Lowerer): void {
throwingSetters: [],
staticFields: [],
};
if (base) base.subclasses.push(info);
if (base) {
base.subclasses.push(info);
lowerer.subclassLinks++;
}
lowerer.classes.set(irName, info);
}
}
@@ -1026,7 +1029,10 @@ export function registerBuiltinStreamClasses(lowerer: Lowerer): void {
throwingSetters: [],
staticFields: [],
};
if (base) base.subclasses.push(info);
if (base) {
base.subclasses.push(info);
lowerer.subclassLinks++;
}
lowerer.classes.set(irName, info);
}
}
@@ -4471,7 +4477,10 @@ export function collectClassShapeInner(
};
lowerer.genericClassByDecl.set(decl, info.generic);
}
if (base) base.subclasses.push(info);
if (base) {
base.subclasses.push(info);
lowerer.subclassLinks++;
}
lowerer.classes.set(className, info);
if (computedBase && !functionLocalClass(decl)) {
info.def.baseValueGlobal = `%g.${className}.computedBase`;
@@ -53,6 +53,8 @@ import {
BOOL,
canBoxFuncIntoDyn,
canConvertToDyn,
canDynCheckTo,
isDynTypedRefType,
DYN,
F64,
type IrExpr,
@@ -69,7 +71,7 @@ import {
VOID,
} from "../../ir/ir.js";
import { STREAM_FORCED_EVENT_NAMES, streamForcedTuple, streamSidesOf } from "./lower-stream.js";
import { boolLit, strLit, varRef } from "../../ir/build.js";
import { boolLit, dynUndefinedExpr, strLit, varRef } from "../../ir/build.js";
import { tryLowerExpression } from "./expressions/try-lower-expression.js";
import { lowerAddAbortListener } from "./builtin-calls.js";
import { requestGeneralStreamSurface } from "./general-streams.js";
@@ -336,9 +338,15 @@ function emitterEvents(lowerer: Lowerer): Map<string, EventSig> {
const cbCt = lowerer.typeOf(node.arguments.nodes[1]);
const cbT =
lowerer.mapTypeOf(cbCt) ?? dynFallbackType(lowerer, node.arguments.nodes[1], cbCt);
// A TypeScript listener with no parameters is typed too: it
// registers without the adapter, so an untyped emit reaches it
// only through the event's fixed ABI. A JavaScript function
// without parameters can still read `arguments`.
if (
cbT?.kind !== "dyn" &&
(cbT?.kind !== "func" || cbT.params.some((p) => p.kind !== "dyn"))
(cbT?.kind !== "func" ||
(cbT.params.length === 0 && !isJsSourceFile(sf)) ||
cbT.params.some((p) => p.kind !== "dyn"))
) {
sigOf(name).typedListener = true;
}
@@ -931,6 +939,239 @@ function lowerDynListenerCall(
};
}
/** The literal events typed code dispatches with a fixed tuple — it emits
* them or listens with typed parameters — whose names an untyped receiver
* may also use. 'error' and the meta events are excluded: the runtime fixes
* their tuples itself (scr_emitter_on_flex / scr_emitter_emit_flex). */
function bridgedEvents(lowerer: Lowerer): { name: string; tuple: IrType[] }[] {
const table = emitterEvents(lowerer);
const events: { name: string; tuple: IrType[] }[] = [];
for (const [key, sig] of table) {
if (key === "error" || key === ERROR_MONITOR_EVENT || META_EVENTS.has(key)) continue;
if (sig.conflict !== null || (!sig.fromEmit && sig.typedListener !== true)) continue;
if (flexibleJsEvent(key, sig)) continue;
if (computedPatterns(lowerer).some((pattern) => patternMatchesName(pattern, key))) continue;
events.push({ name: eventKeyName(key), tuple: sig.tuple });
}
return events;
}
/** `emit(name, ...args)` through an untyped value on a (non-stream)
* emitter-rooted receiver, when typed code fixes the tuple of some event.
* Listeners registered as checked-dynamic functions receive the arguments
* as given; when the name is such an event and a listener with typed
* parameters is registered, the arguments also convert to the event's tuple
* (a mismatch throws the checked conversion's TypeError). Null when no event
* needs this, so the caller keeps the plain untyped emit. */
export function lowerDynamicEmitterEmit(
lowerer: Lowerer,
receiver: IrExpr,
name: IrExpr,
payload: IrExpr[],
loc: SrcLoc,
): IrExpr | null {
const recvT = receiver.type;
if (recvT.kind !== "object") return null;
const k = payload.length;
const getRecord = (id: string) => lowerer.shapes.get(id);
const getUnion = (id: string) => lowerer.unions.get(id);
const checkable = (t: IrType): boolean =>
t.kind === "dyn" ||
isDynTypedRefType(t) ||
isUnitType(t) ||
(t.kind === "union"
? (getUnion(t.unionId)?.arms.every(checkable) ?? false)
: canDynCheckTo(t, getRecord, getUnion));
const converted = (tuple: IrType[]): IrExpr[] | null => {
if (!tuple.every(checkable)) return null;
const out: IrExpr[] = [];
for (let i = 0; i < tuple.length; i++) {
const arg = i < k ? varRef(`a.${i}`, DYN, loc) : dynUndefinedExpr(loc);
const value = lowerer.coerceToExpected(arg, tuple[i]!);
if (!typeEquals(value.type, tuple[i]!)) return null;
out.push(value);
}
return out;
};
const events = bridgedEvents(lowerer)
.map((event) => ({ ...event, args: converted(event.tuple) }))
.filter((event) => event.args !== null);
if (events.length === 0) return null;
const key = `emitter.dynEmit:${typeKey(recvT)}:${k}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = `%emitter.dynEmit.${lowerer.arrHofHelpers.size}`;
lowerer.arrHofHelpers.set(key, helper);
const params: IrParam[] = [
{ localId: "r.0", name: "r", type: recvT },
{ localId: "n.0", name: "n", type: STRING },
...payload.map((_, i) => ({ localId: `a.${i}`, name: `a${i}`, type: DYN })),
];
const r = varRef("r.0", recvT, loc);
const n = varRef("n.0", STRING, loc);
const dynArgs = payload.map((_, i) => varRef(`a.${i}`, DYN, loc));
const body: IrStmt[] = events.map((event): IrStmt => ({
kind: "if",
cond: {
kind: "strEq",
negated: false,
left: n,
right: strLit(event.name, loc),
type: BOOL,
loc,
},
then: [
{
kind: "if",
cond: { kind: "libCall", fn: "emitter.hasTyped", args: [r, n], type: BOOL, loc },
then: [
{
kind: "return",
value: {
kind: "libCall",
fn: "emitter.emitMixed",
args: [
r,
n,
{ kind: "numLit", value: k, type: F64, loc },
...dynArgs,
...event.args!,
],
type: BOOL,
loc,
},
loc,
},
],
else_: null,
loc,
},
],
else_: null,
loc,
}));
body.push({
kind: "return",
value: { kind: "libCall", fn: "emitter.emitAny", args: [r, n, ...dynArgs], type: BOOL, loc },
loc,
});
lowerer.liftedFns.push({
name: helper,
params,
returnType: BOOL,
locals: params.map((p) => ({ id: p.localId, name: p.name, type: p.type, mutable: false })),
body,
loc,
});
}
return { kind: "call", callee: helper, args: [receiver, name, ...payload], type: BOOL, loc };
}
/** on/once/prepend*(name, fn) through an untyped value on a (non-stream)
* emitter-rooted receiver, when typed code fixes the tuple of some event.
* Under such an event's name the function registers with a typed adapter
* for typed emits as well as its checked-dynamic thunk for untyped ones;
* other names register the thunk alone. Null when no event needs this. */
export function lowerDynamicEmitterOn(
lowerer: Lowerer,
receiver: IrExpr,
name: IrExpr,
cb: IrExpr,
once: boolean,
prepend: boolean,
loc: SrcLoc,
): IrExpr | null {
const recvT = receiver.type;
if (recvT.kind !== "object") return null;
const getRecord = (id: string) => lowerer.shapes.get(id);
const getUnion = (id: string) => lowerer.unions.get(id);
const events = bridgedEvents(lowerer).filter((event) =>
event.tuple.every((t) => t.kind === "dyn" || canConvertToDyn(t, getRecord, getUnion)),
);
if (events.length === 0) return null;
const key = `emitter.dynOn:${typeKey(recvT)}`;
let helper = lowerer.arrHofHelpers.get(key);
if (!helper) {
helper = `%emitter.dynOn.${lowerer.arrHofHelpers.size}`;
lowerer.arrHofHelpers.set(key, helper);
const params: IrParam[] = [
{ localId: "r.0", name: "r", type: recvT },
{ localId: "n.0", name: "n", type: STRING },
{ localId: "cb.0", name: "cb", type: DYN },
{ localId: "o.0", name: "o", type: BOOL },
{ localId: "p.0", name: "p", type: BOOL },
];
const locals: IrLocal[] = params.map((p) => ({
id: p.localId,
name: p.name,
type: p.type,
mutable: false,
}));
const r = varRef("r.0", recvT, loc);
const n = varRef("n.0", STRING, loc);
const fn = varRef("cb.0", DYN, loc);
const o = varRef("o.0", BOOL, loc);
const p = varRef("p.0", BOOL, loc);
const body: IrStmt[] = [
{
kind: "exprStmt",
expr: { kind: "libCall", fn: "emitter.checkListener", args: [fn], type: VOID, loc },
loc,
},
];
events.forEach((event, i) => {
const adapterT: IrType = { kind: "func", params: event.tuple, ret: VOID };
const adapter = `a.${i}`;
locals.push({ id: adapter, name: "a", type: adapterT, mutable: false });
body.push({
kind: "if",
cond: {
kind: "strEq",
negated: false,
left: n,
right: strLit(event.name, loc),
type: BOOL,
loc,
},
then: [
{
kind: "varDecl",
localId: adapter,
init: { kind: "dynCheck", value: fn, type: adapterT, loc },
loc,
},
{
kind: "return",
value: {
kind: "libCall",
fn: "emitter.onDyn",
args: [r, n, fn, varRef(adapter, adapterT, loc), o, p],
type: recvT,
loc,
},
loc,
},
],
else_: null,
loc,
});
});
body.push({
kind: "return",
value: { kind: "libCall", fn: "emitter.onAny", args: [r, n, fn, o, p], type: recvT, loc },
loc,
});
lowerer.liftedFns.push({ name: helper, params, returnType: recvT, locals, body, loc });
}
return {
kind: "call",
callee: helper,
args: [receiver, name, cb, boolLit(once, loc), boolLit(prepend, loc)],
type: recvT,
loc,
};
}
/** A listener argument: lowered, checked void-returning, and its
* parameters checked as a typeEquals PREFIX of the event tuple. */
function lowerListenerArg(
@@ -35,6 +35,7 @@ import {
import { identityPreservingWidening } from "./coercions/identity.js";
import { scanDivergences } from "./divergences.js";
import { applyKeyOrderWitnesses, KeyOrderNotes } from "./key-order-witness.js";
import { observeRecordLiteralRebuilds } from "../../ir/traverse.js";
import { checkedClassAssertion } from "./class-assertions.js";
import { narrowStoredClassValue, narrowGenericClassValue } from "./class-unions.js";
import {
@@ -210,6 +211,7 @@ import type {
IrLocal,
IrModule,
IrWasmModule,
IrWasmUntranslated,
IrParam,
IrRecordShape,
IrStmt,
@@ -1210,17 +1212,28 @@ export function lowerToIr(
});
// A program that uses the WebAssembly API carries every module the build
// can see and translate (wasm/translate.ts); the rest fail at run time
// with the CompileError for untranslated bytes.
// with a CompileError, which names the reason for the modules the build
// saw but does not translate.
if (result.module !== null && resultLowerer.usesWebAssembly) {
const bytesByHash = new Map<string, Uint8Array>();
const reports = discoverModules((sha256, bytes) => bytesByHash.set(sha256, bytes));
const wasmModules: IrWasmModule[] = [];
const wasmUntranslated: IrWasmUntranslated[] = [];
for (const report of reports) {
const bytes = bytesByHash.get(report.sha256);
if (report.status !== "valid" || bytes === undefined) continue;
wasmModules.push({ sha256: report.sha256, bytes: encodeBase64(bytes) });
if (bytes === undefined) continue;
if (report.status === "valid") {
wasmModules.push({ sha256: report.sha256, bytes: encodeBase64(bytes) });
continue;
}
const reason =
report.status === "unsupported"
? `it uses ${report.unsupported.join(", ")}, outside the translated WebAssembly feature set`
: `it is not a valid module: ${report.error ?? report.status}`;
wasmUntranslated.push({ sha256: report.sha256, size: bytes.length, reason });
}
result.module.wasmModules = wasmModules;
result.module.wasmUntranslated = wasmUntranslated;
if (resultLowerer.usesWasmCustomSections) result.module.wasmCustomSections = true;
timing("wasm-discovery", { modules: wasmModules.length });
}
@@ -3045,6 +3058,10 @@ export class Lowerer {
// top level, top = the innermost nested function currently lowering).
fnStack: FnCtx[] = [];
readonly liftedFns: IrFunction[] = [];
/** The first lifted function of each name (liftedFnNamed), for the
* liftedFns indexed so far. liftedFns only grows. */
private readonly liftedFnsByName = new Map<string, IrFunction>();
private liftedFnsIndexed = 0;
/** Runtime-provided classes used as values (defineClassValueConstruct),
* by IR class name. */
readonly runtimeClassValues = new Map<string, ClassInfo>();
@@ -3116,6 +3133,10 @@ export class Lowerer {
* can contain any retained descendant, but importing its declaration
* alone does not make every descendant constructible. */
private constructedClasses: Set<string> | null = null;
/** Classes added to some ClassInfo's subclasses (lower-classes.ts). With
* the class and constructed-class counts, it tells caches over the class
* graph when the graph changed. */
subclassLinks = 0;
/** Set while a subclass constructor's super() call notes its base
* constructor edge: that edge does not instantiate the base exactly. */
private superConstructorEdge = false;
@@ -3132,6 +3153,20 @@ export class Lowerer {
info.mixinInstance !== undefined
);
}
/** How many classes classCanBeConstructed admits by name, or -1 while
* it admits every class. The count only grows. */
constructedClassCount(): number {
return this.constructedClasses?.size ?? -1;
}
/** The first lifted function named `name`, the one
* `liftedFns.find((fn) => fn.name === name)` returns. */
liftedFnNamed(name: string | undefined): IrFunction | undefined {
for (; this.liftedFnsIndexed < this.liftedFns.length; this.liftedFnsIndexed++) {
const fn = this.liftedFns[this.liftedFnsIndexed]!;
if (!this.liftedFnsByName.has(fn.name)) this.liftedFnsByName.set(fn.name, fn);
}
return name === undefined ? undefined : this.liftedFnsByName.get(name);
}
/** Coverage remainder mode: the reachability gate inverts (see wantBody)
* and no module is built. */
readonly remainder: boolean;
@@ -7411,6 +7446,10 @@ export class Lowerer {
* body; both fire edges through the same hooks and are not units
* themselves. */
emitReachable(extraRoots?: readonly string[]): { reachable: Set<string>; result: LowerResult } {
// Frontend passes below rewrite function bodies through the IR mapper
// (caught-value and class dispatch); key-order notes follow the record
// literals they rebuild until the witness pass has read them.
observeRecordLiteralRebuilds((from, to) => this.keyOrder.follow(from, to));
this.deferredModuleInitializers = new DeferredModuleInitializers();
const parts = this.splitFiles();
this.narrowing.setFiles(parts.map((fp) => fp.sf));
@@ -7841,6 +7880,7 @@ export class Lowerer {
...this.liftedFns,
...this.implicitFns,
]);
observeRecordLiteralRebuilds(null);
for (const finalize of this.shapeOrderHelperFinalizers) finalize();
const functions = [
...orderedUnits,
@@ -81,7 +81,9 @@ export class RecordFlowClasses {
/** Identifiers by name; symbols resolve only for the names of slots and
* functions, so the scan asks the checker about few nodes. */
private readonly names = new Map<string, ts.Identifier[]>();
private readonly references = new Map<ts.Symbol, ts.Identifier[]>();
/** The identifiers of each name grouped by the symbol they resolve to,
* built on the first reference query for that name. */
private readonly references = new Map<string, Map<ts.Symbol, ts.Identifier[]>>();
/** Names written as shorthand properties (`{ p }`): the checker resolves
* those to the property, so a binding of that name stays open. */
private readonly shorthandNames = new Set<string>();
@@ -349,14 +351,20 @@ export class RecordFlowClasses {
/** The identifiers that name `symbol`, its declaration included. */
private referencesOf(symbol: ts.Symbol): readonly ts.Identifier[] {
let list = this.references.get(symbol);
if (!list) {
list = (this.names.get(ts.symbolName(symbol)) ?? []).filter(
(node) => this.host.symbolOf(node) === symbol,
);
this.references.set(symbol, list);
const name = ts.symbolName(symbol);
let bySymbol = this.references.get(name);
if (!bySymbol) {
bySymbol = new Map();
for (const node of this.names.get(name) ?? []) {
const resolved = this.host.symbolOf(node);
if (!resolved) continue;
const list = bySymbol.get(resolved);
if (list) list.push(node);
else bySymbol.set(resolved, [node]);
}
this.references.set(name, bySymbol);
}
return list;
return bySymbol.get(symbol) ?? [];
}
private collectFunctions(): void {
@@ -2104,6 +2104,8 @@ export const BUILTIN_MODULE_FN_ALIASES: Record<
glob: ["fsp.globTypes"],
// The Uint8Array/Buffer-data form.
appendFile: ["fsp.appendFileBytes"],
// The options forms.
rm: ["fsp.rmOpts", "fsp.rmRetry"],
},
crypto: {
// The composed randomBytes(n).toString(enc) chain keeps its one-libCall
@@ -0,0 +1,71 @@
import { spawnSync } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { nodeFrontendServices } from "./services-node.js";
const dirs: string[] = [];
afterAll(() => {
for (const dir of dirs) rmSync(dir, { recursive: true, force: true });
});
function rewrite(source: string): string | null {
return nodeFrontendServices().tscBuiltinImports(source, "/pkg/index.js");
}
const helpers = `"use strict";
var __importDefault = (this && this.__importDefault) || function (mod) {
return (mod && mod.__esModule) ? mod : { "default": mod };
};
var __importStar = (this && this.__importStar) || function (mod) {
if (mod && mod.__esModule) return mod;
var result = {};
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) result[k] = mod[k];
result["default"] = mod;
return result;
};
`;
function expectSameBehavior(source: string): string {
const result = rewrite(source);
expect(typeof result).toBe("string");
const dir = mkdtempSync(join(tmpdir(), "scriptc-tsc-builtin-imports-"));
dirs.push(dir);
writeFileSync(join(dir, "original.cjs"), source);
writeFileSync(join(dir, "rewritten.cjs"), result!);
const run = (file: string) =>
spawnSync(process.execPath, [join(dir, file)], { encoding: "utf8" });
const oracle = run("original.cjs");
expect(oracle.status).toBe(0);
expect(run("rewritten.cjs").stdout).toBe(oracle.stdout);
return result!;
}
test("builtin modules behind tsc's interop helpers become plain requires", () => {
const out = expectSameBehavior(`${helpers}const events_1 = __importDefault(require("events"));
const path = __importStar(require("node:path"));
class Bus extends events_1.default.EventEmitter {}
console.log(new Bus() instanceof events_1.default, path.basename("/a/b.txt"), typeof path.join);
`);
expect(out).toContain('const events_1 = require("events");');
expect(out).toContain("class Bus extends events_1.EventEmitter {}");
expect(out).toContain('const path = require("node:path");');
});
test("other references, program modules and default reads of a namespace keep their text", () => {
// The binding itself as a value.
expect(
rewrite(
`${helpers}const events_1 = __importDefault(require("events"));\nconsole.log(events_1);\n`,
),
).toBeNull();
// A program module (cjs-tsc-interop.ts owns it).
expect(
rewrite(`${helpers}const m = __importDefault(require("./m"));\nm.default();\n`),
).toBeNull();
// `ns.default` reads the namespace copy's own default.
expect(
rewrite(`${helpers}const p = __importStar(require("path"));\nconsole.log(p.default === p);\n`),
).toBeNull();
});
@@ -0,0 +1,106 @@
/* tsc wraps every CommonJS import in its interop helpers:
*
* const events_1 = __importDefault(require("events")); // events_1.default.X
* const path = __importStar(require("path")); // path.basename
*
* For program modules cjs-tsc-interop.ts reads such a binding as a plain
* require binding, and the helper declarations (`var __importDefault =
* (this && this.__importDefault) || …`) go dead. A builtin module is not a
* program module, so its binding kept the helpers live, and their probe of
* the module's top-level `this` has no lowering. A builtin carries no
* `__esModule`: `events_1.default` is the module and the namespace copy
* reads the module's members. When every reference has that form
* (`x.default…` for the default helper, `x.<member>` other than `default`
* for the namespace helper), the binding becomes the plain `require()` of
* the builtin and `x.default` becomes `x`. */
import * as ts from "./ts7/syntax.js";
import { applyEdits, unparen, type Edit } from "./npm-static-bundled-chunks.js";
import { canonicalBuiltinModule } from "./builtin-modules.js";
/** True when the text may hold such a binding (a cheap probe). */
export function mayHoldTscBuiltinImport(source: string): boolean {
return /__import(?:Default|Star)\(require\(/.test(source);
}
/** `__importDefault(require("<builtin>"))` / `__importStar(…)`: the helper
* and builtin spec, or null. */
function builtinInterop(init: ts.Expression): { helper: string; spec: string } | null {
const e = unparen(init);
if (!ts.isCallExpression(e) || !ts.isIdentifier(e.expression) || e.arguments.nodes.length !== 1)
return null;
const helper = e.expression.text;
if (helper !== "__importDefault" && helper !== "__importStar") return null;
const req = unparen(e.arguments.nodes[0]!);
if (
!ts.isCallExpression(req) ||
!ts.isIdentifier(req.expression) ||
req.expression.text !== "require" ||
req.arguments.nodes.length !== 1 ||
!ts.isStringLiteral(req.arguments.nodes[0]!)
)
return null;
const spec = (req.arguments.nodes[0] as ts.StringLiteral).text;
return canonicalBuiltinModule(spec) === null ? null : { helper, spec };
}
/** The `.default` accesses to remove when every reference to `name` (other
* than its declaration) has the helper's form, or null. */
function interopReferences(
sf: ts.SourceFile,
decl: ts.VariableDeclaration,
name: string,
helper: string,
): ts.PropertyAccessExpression[] | null {
const defaults: ts.PropertyAccessExpression[] = [];
let ok = true;
ts.walkPreorder(sf, (n) => {
if (!ok) return "stop";
if (!ts.isIdentifier(n) || n.text !== name || n === decl.name) return undefined;
const parent = n.parent;
if (
parent === undefined ||
!ts.isPropertyAccessExpression(parent) ||
parent.expression !== n ||
parent.questionDotToken !== undefined
) {
ok = false;
return "stop";
}
const member = parent.name.text;
if (helper === "__importDefault") {
if (member !== "default") ok = false;
else defaults.push(parent);
} else if (member === "default") {
ok = false;
}
return undefined;
});
return ok ? defaults : null;
}
/** The module text with its builtin interop bindings unwrapped, or null. */
export function unwrapTscBuiltinImports(sf: ts.SourceFile): string | null {
if (ts.isExternalModule(sf) && !sf.fileName.endsWith(".cjs")) return null;
const edits: Edit[] = [];
const stmts = sf.statements.nodes;
// Builtin modules through the interop helpers.
for (const stmt of stmts) {
if (!ts.isVariableStatement(stmt)) continue;
for (const d of stmt.declarationList.declarations.nodes) {
if (!ts.isIdentifier(d.name) || d.initializer === undefined) continue;
const interop = builtinInterop(d.initializer);
if (interop === null) continue;
const defaults = interopReferences(sf, d, d.name.text, interop.helper);
if (defaults === null) continue;
edits.push({
start: d.initializer.getStart(sf),
end: d.initializer.getEnd(),
text: `require(${JSON.stringify(interop.spec)})`,
});
for (const access of defaults)
edits.push({ start: access.getStart(sf), end: access.getEnd(), text: d.name.text });
}
}
if (edits.length === 0) return null;
return applyEdits(sf.text, 0, sf.text.length, edits);
}
@@ -455,6 +455,9 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
// Polyfill packages that ARE a Node builtin compile as the builtin.
const aliased = original === null ? null : services.builtinAliasRequires(original, path);
if (aliased !== null) original = aliased;
// tsc's interop helpers around builtin modules.
const tscHelpers = original === null ? null : services.tscBuiltinImports(original, path);
if (tscHelpers !== null) original = tscHelpers;
// A module exporting one of its functions reaches its members
// through that function.
const fnExports = original === null ? null : services.cjsFunctionExports(original, path);
@@ -469,6 +472,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (exportsVar !== null) original = exportsVar;
const respelled =
split !== null ||
tscHelpers !== null ||
esm !== null ||
aliased !== null ||
fnExports !== null ||
@@ -13,6 +13,7 @@ import {
rewriteBuiltinAliasRequires,
} from "./npm-static-builtin-aliases.js";
import { mayHoldEnumIife, rewriteEnumIifes } from "./npm-static-enum-iife.js";
import { mayHoldTscBuiltinImport, unwrapTscBuiltinImports } from "./npm-static-tsc-helpers.js";
import { builtinPackageAliasFor } from "./package-shims.js";
import * as syntax from "./npm-static-declaration-syntax.js";
import { normalizeCjsExportsVar } from "./npm-static-cjs-exports-var.js";
@@ -106,6 +107,13 @@ export class FrontendServices {
if (!source.includes("module.exports")) return null;
return normalizeCjsFunctionExports(this.parse(path, source, "js"));
}
/** A package module with tsc's interop bindings of builtin modules
* unwrapped to plain requires (null: none). */
tscBuiltinImports(source: string, path: string): string | null {
this.ensureOpen();
if (!mayHoldTscBuiltinImport(source)) return null;
return unwrapTscBuiltinImports(this.parse(path, source, "js"));
}
/** A package module with its literal-valued TypeScript enum IIFEs
* respelled as object literals (null: none). */
enumIifes(source: string, path: string): string | null {
@@ -101,6 +101,8 @@ const cases: Record<string, string> = {
'/** @typedef {Object} Options\n * @property {string} name\n * @property {number} count\n */\n/** @type {Options} */ const options = { name: "x", count: 2 };',
"missing.ts": "const incomplete = ;\nfunction f( {\n",
"empty.ts": "",
"targets.ts":
"let x: number | undefined = 1;\nconst a = [1];\n(x) = 2;\nx! = 3;\na[0]! += 1;\n(a[0])!++;\n--(x!);\nfor ((x) of a) {}\nconst y = (x!) + 1;\n",
"lists.ts":
"const trailing = [1, 2 /* last */, ];\nconst plain = [1, 2];\nf(1, 2,);\nexport abstract class Flags { protected static readonly value = 1; }\n",
};
@@ -167,6 +169,38 @@ function ids(
return nodes?.map((n) => (n as OracleNode).index);
}
test("the writeTargets option answers write targets without their wrappers", () => {
const { bytes, file: plain } = decoded.get("targets.ts")!;
const file = new AstFile(bytes, undefined, { writeTargets: true });
const statements = (root: AstNode) =>
(root.childList("statements") as AstNodeList).nodes.map((statement) =>
statement.kind === SyntaxKind.ExpressionStatement ? statement.expression : statement,
);
const text = (node: AstNode | undefined) => node?.getText();
const writes = statements(file.root);
const raw = statements(plain.root);
// Assignment left sides, update operands and a for-of head.
const targets = [writes[2]!.left, writes[3]!.left, writes[4]!.left, writes[5]!.operand];
expect(targets.map(text)).toEqual(["x", "x", "a[0]", "a[0]"]);
expect(text(writes[6]!.operand)).toBe("x");
expect(text(writes[7]!.initializer)).toBe("x");
for (const [index, target] of targets.entries())
expect(target!.parent, `target ${index}`).toBe(writes[index + 2]);
expect(writes[7]!.initializer!.parent).toBe(writes[7]);
// The reference keeps its own source span.
expect(writes[4]!.left!.getStart()).toBe(raw[4]!.left!.getStart());
expect(writes[4]!.left!.end).toBe(raw[4]!.left!.end - 1);
// A wrapper outside a write target is kept.
const init = (writes[8]!.childNode("declarationList")!.childList("declarations") as AstNodeList)
.nodes[0]!.initializer!;
expect(text(init.left)).toBe("(x!)");
expect(init.left!.kind).toBe(SyntaxKind.ParenthesizedExpression);
// Without the option, the file is TypeScript's exact tree.
expect(raw[3]!.left!.kind).toBe(SyntaxKind.NonNullExpression);
expect(raw[2]!.left!.kind).toBe(SyntaxKind.ParenthesizedExpression);
expect(raw[5]!.operand!.kind).toBe(SyntaxKind.NonNullExpression);
});
test("node lists report a trailing comma and modifier lists their flags", () => {
const { file } = decoded.get("lists.ts")!;
const lists: [SyntaxKind, boolean][] = [];
+114 -10
View File
@@ -60,6 +60,18 @@ export class AstModifierList<T extends AstNode = AstNode> extends AstNodeList<T>
* list.
* Parent access is lazy, so resolving a checker handle near the bottom
* of a deep tree never recursively constructs its ancestors. */
export interface AstFileOptions {
/** Answer the reference an assignment, a `++`/`--` update or a for-in/of
* head writes without the parentheses and non-null assertions around it.
* TypeScript accepts both around a target (`(x) = 1`, `a[i]! += 1`) and
* both are erased at run time. With this option the writer's `left`,
* `operand` or `initializer` is the reference itself and the reference's
* `parent` is the writer, so every reader of a write target sees one
* shape. Positions stay the source's: the reference keeps its own span.
* Files read for other purposes keep TypeScript's exact tree. */
writeTargets?: boolean;
}
export class AstFile {
readonly wire: AstWireFile;
readonly root: AstNode;
@@ -73,10 +85,16 @@ export class AstFile {
private lines: number[] | undefined;
private childIndices: Uint32Array | undefined;
/** Whether nodes answer write targets without their wrappers (see
* AstFileOptions.writeTargets). */
readonly writeTargets: boolean;
constructor(
bytes: Uint8Array,
private readonly materialized?: () => void,
options: AstFileOptions = {},
) {
this.writeTargets = options.writeTargets === true;
this.wire = new AstWireFile(bytes);
this.nodes = new Array<AstNode>(this.wire.nodeCount);
this.lists = new Array<AstNode[]>(this.wire.nodeCount);
@@ -192,6 +210,73 @@ export class AstFile {
/** Concrete native layout shared by every syntax kind. Kind-specific
* properties are views over the wire; they never copy identity-bearing
* nodes into structural records. */
/* Write targets without their wrappers (AstFileOptions.writeTargets). */
function isTargetWrapper(node: AstNode): boolean {
return node.kind === AstKind.ParenthesizedExpression || node.kind === AstKind.NonNullExpression;
}
function unwrapTarget(node: AstNode): AstNode {
let target = node;
while (isTargetWrapper(target)) {
const inner = target.childNode("expression");
if (inner === undefined) break;
target = inner;
}
return target;
}
/** Whether `field` of `node` is a write target: an assignment's left side,
* a `++`/`--` operand, or a for-in/of head that declares nothing. */
function writesTarget(node: AstNode, field: "left" | "operand" | "initializer"): boolean {
switch (node.kind) {
case AstKind.BinaryExpression: {
if (field !== "left") return false;
const operator = node.childNode("operatorToken")?.kind;
return (
operator !== undefined &&
operator >= AstKind.FirstAssignment &&
operator <= AstKind.LastAssignment
);
}
case AstKind.PrefixUnaryExpression:
case AstKind.PostfixUnaryExpression:
return (
field === "operand" &&
(node.operator === AstKind.PlusPlusToken || node.operator === AstKind.MinusMinusToken)
);
case AstKind.ForInStatement:
case AstKind.ForOfStatement:
return (
field === "initializer" &&
node.childNode("initializer")?.kind !== AstKind.VariableDeclarationList
);
default:
return false;
}
}
/** The parent of `node`, whose syntactic parent `wrapper` is a parenthesis
* or a non-null assertion: the writer when the wrappers around `node` are
* the whole target of an assignment, update or for-in/of head, and
* `wrapper` otherwise. */
function writerOf(node: AstNode, wrapper: AstNode): AstNode {
let outer = wrapper;
for (;;) {
const up = outer.parent;
if (up === undefined) return wrapper;
if (isTargetWrapper(up)) {
outer = up;
continue;
}
for (const field of ["left", "operand", "initializer"] as const) {
if (writesTarget(up, field) && up.childNode(field) === outer && unwrapTarget(outer) === node)
return up;
}
return wrapper;
}
}
export class AstNode {
readonly kind: SyntaxKind;
readonly pos: number;
@@ -221,7 +306,11 @@ export class AstNode {
get parent(): AstNode | undefined {
if (!this.parentResolved) {
const index = this.file.wire.semanticParent(this.index);
this.parentCache = index === 0 || index === this.index ? undefined : this.file.node(index);
const parent = index === 0 || index === this.index ? undefined : this.file.node(index);
this.parentCache =
parent !== undefined && this.file.writeTargets && isTargetWrapper(parent)
? writerOf(this, parent)
: parent;
this.parentResolved = true;
}
return this.parentCache;
@@ -483,6 +572,30 @@ export class AstNode {
return token?.kind === SyntaxKind.QuestionToken ? token : undefined;
}
// A write target keeps its parentheses and non-null assertions in the
// tree; with AstFileOptions.writeTargets these views return the target
// without them.
get initializer(): AstNode | undefined {
const initializer = this.childNode("initializer");
return initializer !== undefined && this.file.writeTargets && writesTarget(this, "initializer")
? unwrapTarget(initializer)
: initializer;
}
get left(): AstNode | undefined {
const left = this.childNode("left");
return left !== undefined && this.file.writeTargets && writesTarget(this, "left")
? unwrapTarget(left)
: left;
}
get operand(): AstNode | undefined {
const operand = this.childNode("operand");
return operand !== undefined && this.file.writeTargets && writesTarget(this, "operand")
? unwrapTarget(operand)
: operand;
}
// BEGIN GENERATED CHILD GETTERS
// Generated from typescript@7.0.2; run scripts/generate-ts7-ast-schema.mjs.
get argument(): AstNode | undefined {
@@ -611,18 +724,12 @@ export class AstNode {
get indexType(): AstNode | undefined {
return this.childNode("indexType");
}
get initializer(): AstNode | undefined {
return this.childNode("initializer");
}
get jsdocPropertyTags(): AstNode | undefined {
return this.childNode("jsdocPropertyTags");
}
get label(): AstNode | undefined {
return this.childNode("label");
}
get left(): AstNode | undefined {
return this.childNode("left");
}
get literal(): AstNode | undefined {
return this.childNode("literal");
}
@@ -662,9 +769,6 @@ export class AstNode {
get openingFragment(): AstNode | undefined {
return this.childNode("openingFragment");
}
get operand(): AstNode | undefined {
return this.childNode("operand");
}
get operatorToken(): AstNode | undefined {
return this.childNode("operatorToken");
}
+60 -21
View File
@@ -72,6 +72,33 @@ export function parseAstNodeHandle(handle: string): AstNodeHandle {
};
}
/** Node fields as word offsets into a node's NODE_LEN bytes. */
const NODE_WORDS = NODE_LEN / 4;
const WORD_KIND = NODE_OFFSET_KIND / 4;
const WORD_POS = NODE_OFFSET_POS / 4;
const WORD_END = NODE_OFFSET_END / 4;
const WORD_NEXT = NODE_OFFSET_NEXT / 4;
const WORD_PARENT = NODE_OFFSET_PARENT / 4;
const WORD_DATA = NODE_OFFSET_DATA / 4;
const WORD_FLAGS = NODE_OFFSET_FLAGS / 4;
if (
!Number.isInteger(NODE_WORDS) ||
![WORD_KIND, WORD_POS, WORD_END, WORD_NEXT, WORD_PARENT, WORD_DATA, WORD_FLAGS].every((word) =>
Number.isInteger(word),
)
)
throw new AstDecodeError("node fields are not word-aligned");
/** Kept out of line, so the index check that calls it stays small enough
* to inline into every node accessor. */
function invalidNodeIndex(index: number): never {
throw new AstDecodeError(`invalid node index ${index}`);
}
/** Whether this host stores a Uint32Array's words little-endian, the wire's
* byte order. */
const LITTLE_ENDIAN = new Uint8Array(new Uint32Array([1]).buffer)[0] === 1;
function hexWord(value: number): string {
const digits = "0123456789abcdef";
let result = "";
@@ -93,12 +120,14 @@ export class AstWireFile {
private readonly extended: number;
private readonly structured: number;
private readonly nodes: number;
private readonly view: DataView;
private readonly stringCache = new Map<number, string>();
/** The node table as 32-bit words: a view of the response when it is
* word-aligned on a little-endian host, otherwise a decoded copy. */
private readonly words: Uint32Array;
/** Decoded strings by string word index / 2. */
private readonly stringCache: (string | undefined)[];
constructor(private readonly bytes: Uint8Array) {
astBounds(bytes, 0, HEADER_SIZE);
this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
const metadata = astU32(bytes, HEADER_OFFSET_METADATA);
if (metadata >>> 24 !== PROTOCOL_VERSION)
throw new AstDecodeError(`unsupported protocol version ${metadata >>> 24}`);
@@ -122,6 +151,15 @@ export class AstWireFile {
}
this.nodeCount = (bytes.length - this.nodes) / NODE_LEN;
this.stringCount = (this.strings - this.stringOffsets) / 8;
const wordCount = this.nodeCount * NODE_WORDS;
const start = bytes.byteOffset + this.nodes;
if (LITTLE_ENDIAN && start % 4 === 0) {
this.words = new Uint32Array(bytes.buffer, start, wordCount);
} else {
this.words = new Uint32Array(wordCount);
for (let i = 0; i < wordCount; i++) this.words[i] = astU32(bytes, this.nodes + i * 4);
}
this.stringCache = new Array<string | undefined>(this.stringCount).fill(undefined);
if (this.nodeCount < 2) throw new AstDecodeError("missing root");
if (this.kind(1) === KIND_NODE_LIST || this.parent(1) > 1)
throw new AstDecodeError("invalid root node");
@@ -142,40 +180,41 @@ export class AstWireFile {
}
}
private nodeOffset(index: number): number {
if (!Number.isInteger(index) || index < 0 || index >= this.nodeCount)
throw new AstDecodeError(`invalid node index ${index}`);
return this.nodes + index * NODE_LEN;
/** The first word of node `index`. `index >>> 0 === index` accepts exactly
* the integers in [0, 2^32), so with the bound it accepts the same indices
* as an integer and range check. */
private nodeWord(index: number): number {
if (index >>> 0 !== index || index >= this.nodeCount) invalidNodeIndex(index);
return index * NODE_WORDS;
}
// The constructor validates the complete fixed-width node table, and
// nodeOffset validates its index. Read each word in one operation while
// preserving little-endian decoding even in an unaligned byte view.
// nodeWord validates its index.
kind(index: number): number {
return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_KIND, true);
return this.words[this.nodeWord(index) + WORD_KIND]!;
}
pos(index: number): number {
return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_POS, true);
return this.words[this.nodeWord(index) + WORD_POS]! | 0;
}
end(index: number): number {
return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_END, true);
return this.words[this.nodeWord(index) + WORD_END]! | 0;
}
flags(index: number): number {
return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_FLAGS, true);
return this.words[this.nodeWord(index) + WORD_FLAGS]!;
}
data(index: number): number {
return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_DATA, true);
return this.words[this.nodeWord(index) + WORD_DATA]!;
}
next(index: number): number {
const next = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_NEXT, true);
const next = this.words[this.nodeWord(index) + WORD_NEXT]!;
if (next !== 0 && (next <= index || next >= this.nodeCount))
throw new AstDecodeError("invalid sibling link");
return next;
}
parent(index: number): number {
const parent = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_PARENT, true);
const parent = this.words[this.nodeWord(index) + WORD_PARENT]!;
if (parent >= this.nodeCount || (index <= 1 ? parent > index : parent >= index))
throw new AstDecodeError("invalid parent link");
return parent;
@@ -188,14 +227,14 @@ export class AstWireFile {
}
string(index: number): string {
if (!Number.isInteger(index) || index < 0 || index % 2 !== 0 || index >= this.stringCount * 2)
if (index >>> 0 !== index || index % 2 !== 0 || index >= this.stringCount * 2)
throw new AstDecodeError(`invalid string index ${index}`);
const cached = this.stringCache.get(index);
const cached = this.stringCache[index / 2];
if (cached !== undefined) return cached;
const start = astU32(this.bytes, this.stringOffsets + index * 4);
const end = astU32(this.bytes, this.stringOffsets + (index + 1) * 4);
const text = decodeAstString(this.bytes, this.strings + start, end - start);
this.stringCache.set(index, text);
this.stringCache[index / 2] = text;
return text;
}
@@ -250,7 +289,7 @@ export class AstWireFile {
}
firstChild(index: number): number {
this.nodeOffset(index);
this.nodeWord(index);
return index + 1 < this.nodeCount && this.parent(index + 1) === index ? index + 1 : 0;
}
@@ -360,7 +399,7 @@ export class AstWireFile {
const result: number[] = [];
for (let i = 0; i < count; i++) {
const index = reader.uint();
this.nodeOffset(index);
this.nodeWord(index);
if (index === 0 || this.kind(index) === KIND_NODE_LIST)
throw new AstDecodeError("structured reference is not a node");
result.push(index);
@@ -298,9 +298,13 @@ export class Ts7SessionProgram {
if (source === undefined) {
const bytes = this.session.binary("getSourceFile", JSON.stringify(this.query(file)));
if (bytes.length !== 0) {
const ast = new AstFile(bytes, () => {
this.session.timing.materialized();
});
const ast = new AstFile(
bytes,
() => {
this.session.timing.materialized();
},
{ writeTargets: true },
);
this.session.timing.fetched(Math.max(0, ast.wire.nodeCount - 2));
source = this.session.cache.set(path, ast.sourceFile, this.snapshot.id, this.project.id);
}
+317 -35
View File
@@ -1,5 +1,5 @@
import { optimizationField, type NativeOptimization } from "./backend/optimization.js";
import { compilationTiming, type CompilationTiming } from "./timing.js";
import { compilationTiming, type CompilationTiming, type TimingClock } from "./timing.js";
import { prepareExecutableModule } from "./executable/prepare.js";
import {
prepareLibrary,
@@ -45,9 +45,13 @@ export type {
} from "./compile-types.js";
import { compilePackedLibrary } from "./backend/library-pack.js";
import { InternalCompilerError } from "./errors.js";
import { frontendProcessExecArgv } from "./frontend-process-args.js";
import { type ChildProcess, spawn } from "node:child_process";
import { createHash } from "node:crypto";
import { existsSync } from "node:fs";
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
import { basename, dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import {
clearCcCaches,
configuredTargetPlatform,
@@ -67,6 +71,9 @@ import { emitLlvmModuleSource, LlvmUnsupportedError } from "./backend/llvm/emitt
import { inlineRcOption } from "./backend/llvm/shapes.js";
import {
emitNativeArtifact,
isLlvmFile,
llvmInputBytes,
type LlvmInput,
NativeCodegenError,
writeLlvmSource,
} from "./backend/native-codegen.js";
@@ -114,16 +121,24 @@ import {
import { type IrModule, type SrcLoc } from "./ir/ir.js";
import { moduleRuntimeFeatures } from "./ir/runtime-features.js";
import { serializeModuleParts } from "./ir/serialize.js";
import { writeTextParts } from "./text-parts.js";
import { readTextParts, writeTextParts } from "./text-parts.js";
import { validateModule } from "./ir/validate.js";
import { loadProgram } from "./frontend/program-node.js";
import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js";
import { provenanceSources } from "./frontend/provenance-registry.js";
import {
type ProvenanceSources,
provenanceSources,
setProvenanceSources,
} from "./frontend/provenance-registry.js";
import { clearResolveCaches } from "./frontend/resolve.js";
import { loadFfiProfile, type FfiProfile } from "./ffi/ffi-manifest.js";
import { executableLinkFeatures } from "./backend/executable-features.js";
import { youngNurseryRequested } from "./backend/young-nursery.js";
import { FrontendInputTracker, trackedReadFile } from "./frontend/input-tracker.js";
import {
type FrontendInputSnapshot,
FrontendInputTracker,
trackedReadFile,
} from "./frontend/input-tracker.js";
import {
libraryFrontendImplementationFingerprint,
publishEarlyLibraryCache,
@@ -135,9 +150,11 @@ import {
type SemanticLibraryCacheHit,
} from "./library/library-cache.js";
import {
hasEarlyExecutableNativeBuild,
publishEarlyExecutableCache,
publishEarlyExecutableRoute,
readEarlyExecutableCache,
recordEarlyExecutableNativeBuild,
type EarlyExecutableCacheOptions,
type EarlyExecutableNativeFeatures,
} from "./executable/executable-cache.js";
@@ -419,10 +436,16 @@ export async function compile(
clearCompileSessionCaches();
const frontendInputs = new FrontendInputTracker();
const warnings: CompileWarningSink = { warnings: [], sourceTexts: null };
const result = await frontendInputs.run(() =>
compileTracked(entryPath, opts, frontendInputs, warnings),
);
return withCompileWarnings(result, warnings);
// compileTracked starts it for a build with no earlier build of the same invocation.
const frontendProcess = preparesInChildProcess(opts) ? new FrontendProcess() : undefined;
try {
const result = await frontendInputs.run(() =>
compileTracked(entryPath, opts, frontendInputs, warnings, frontendProcess),
);
return withCompileWarnings(result, warnings);
} finally {
frontendProcess?.release();
}
}
/** Divergence warnings collected while a compile lowers (none when a cache
@@ -561,7 +584,7 @@ async function compileExecutableNative(
...(features.handleProps ? { handleProps: true } : {}),
...(features.dynForAwait ? { dynForAwait: true } : {}),
...(features.eventsOnceSignal ? { eventsOnceSignal: true } : {}),
...(features.dynBufferNum ? { dynBufferNum: true } : {}),
...(features.dynBuffer ? { dynBuffer: true } : {}),
...(features.builtinValues ? { builtinValues: true } : {}),
events: features.events,
emitter: features.emitter,
@@ -613,7 +636,7 @@ interface NativeProgramObject {
async function emitNativeProgramObject(
entryPath: string,
opts: CompileRequestOptions,
llvm: string | readonly string[],
llvm: LlvmInput,
features: EarlyExecutableNativeFeatures,
profile: NativeProfileOptions | undefined,
): Promise<NativeProgramObject> {
@@ -625,10 +648,7 @@ async function emitNativeProgramObject(
const linkPath = `${privateSiblingPath(artifactPath, "native-program-object")}.o`;
// The validated program-object artifact is a single object by contract.
const target = nativeCodegenTarget();
const llvmBytes = (typeof llvm === "string" ? [llvm] : llvm).reduce(
(bytes, part) => bytes + Buffer.byteLength(part),
0,
);
const llvmBytes = llvmInputBytes(llvm);
const partitions =
opts.nativeProgramObject === true || target === null ? 1 : nativeProgramPartitions(llvmBytes);
// Optimized programs import small runtime functions from the bitcode of
@@ -718,7 +738,7 @@ function runtimePackDiagnostic(error: RuntimePackError, entryPath: string): ScrD
}
interface PreparedExecutable {
llvmSource: string | readonly string[];
llvmSource: LlvmInput;
llvmPath: string;
irPath: string | undefined;
nativeFeatures: EarlyExecutableNativeFeatures;
@@ -886,13 +906,247 @@ async function prepareExecutableInput(
return { llvmSource, llvmPath, irPath, nativeFeatures, programSplit, sourceTexts };
}
/**
* The child process that prepares an executable build's input: frontend, lowering and LLVM emission
* (`runFrontendProcess`). Node's heap keeps the pages a build's frontend used for the life of the
* process, so running them in a process of their own leaves the native helper the memory they took.
*/
const frontendProcessEntry = fileURLToPath(new URL("./frontend-process.js", import.meta.url));
/**
* Whether `prepareExecutableInputInChild` may prepare this build: optimized executables, when the
* compiler runs from its built files. Development builds split the LLVM text in this process, and
* SCRIPTC_FRONTEND_PROCESS=0 keeps the frontend in-process.
*
* compileTracked starts the process only when no earlier build of the same invocation emitted its
* native program object (`hasEarlyExecutableNativeBuild`). A rebuild finds most native partitions
* in the helper's cache, so its memory peaks in the frontend, and the parent process would add to
* that peak.
*/
function preparesInChildProcess(opts: CompileRequestOptions): boolean {
return (
process.env["SCRIPTC_FRONTEND_PROCESS"] !== "0" &&
(opts.outputKind ?? "exe") === "exe" &&
(opts.optimization ?? "release") !== "dev" &&
existsSync(frontendProcessEntry)
);
}
/**
* The frontend process (frontend-process.ts) of one compile. `start` launches it; it then waits on
* its stdin for the paths of a request and its result (`run`), and exits once its stdin closes
* without them (`release`).
*/
class FrontendProcess {
private child: ChildProcess | undefined;
private exited: Promise<number | null> | undefined;
private sent = false;
/** Whether `start` launched the process. */
get started(): boolean {
return this.child !== undefined;
}
start(): void {
if (this.child !== undefined) return;
const execArgv = frontendProcessExecArgv(process.execArgv);
const child = spawn(process.execPath, [...execArgv, frontendProcessEntry], {
stdio: ["pipe", "inherit", "inherit"],
});
this.exited = new Promise<number | null>((resolveExit) => {
child.on("error", () => resolveExit(null));
child.on("exit", (code) => resolveExit(code));
});
// A failed start surfaces as a failed run; the build then prepares its input in-process.
child.stdin!.on("error", () => undefined);
this.child = child;
}
/** Sends the request at `requestPath`; resolves with the process's exit code. */
run(requestPath: string, resultPath: string): Promise<number | null> {
if (this.child === undefined || this.exited === undefined || this.sent)
return Promise.resolve(null);
this.sent = true;
this.child.stdin!.end(`${requestPath}\n${resultPath}\n`);
return this.exited;
}
/** Lets the process exit if no request was sent. */
release(): void {
if (this.child === undefined || this.sent) return;
this.sent = true;
this.child.stdin!.end();
// The build is done with it: this process need not wait for it to exit.
this.child.unref();
(this.child.stdin as { unref?: () => void }).unref?.();
}
}
interface FrontendProcessRequest {
entryPath: string;
opts: CompileRequestOptions;
buildPlatform: string;
/** The compile's SCRIPTC_TIMING clock, which the frontend process's phases continue. */
timing: TimingClock;
/** The compile's --provenance-sources mappings, which the frontend process installs. */
provenance: ProvenanceSources | null;
}
/** What the frontend process sends back. A thrown error comes back as `failed`. */
type FrontendProcessResult =
| {
kind: "prepared";
llvmPath: string;
/** A private copy of the LLVM text, which the native helper reads (`nativeInputPath`). */
nativeInputPath: string;
llvmBytes: number;
irPath: string | undefined;
nativeFeatures: EarlyExecutableNativeFeatures;
sourceTexts: [string, string][];
warnings: ScrDiagnostic[];
frontend: FrontendInputSnapshot;
}
| {
kind: "diagnostics";
diagnostics: ScrDiagnostic[];
sourceTexts: [string, string][];
warnings: ScrDiagnostic[];
frontend: FrontendInputSnapshot;
}
| { kind: "failed" };
/**
* `prepareExecutableInput` in the frontend process, with the LLVM text passed on as its file. The
* process's frontend inputs join `frontendInputs` and its warnings `warningSink`. If it fails, the
* input is prepared here, so the build reports the same errors.
*/
async function prepareExecutableInputInChild(
frontendProcess: FrontendProcess,
entryPath: string,
opts: CompileRequestOptions,
ffi: FfiProfile | null,
buildPlatform: string,
timing: CompilationTiming,
timingClock: TimingClock,
frontendInputs: FrontendInputTracker,
warningSink?: CompileWarningSink,
): Promise<PreparedExecutable | CompileRequestResult> {
await mkdir(opts.outDir, { recursive: true });
const requestPath = privateSiblingPath(join(opts.outDir, "frontend-request.json"), "frontend");
const resultPath = privateSiblingPath(join(opts.outDir, "frontend-result.json"), "frontend");
let result: FrontendProcessResult = { kind: "failed" };
try {
const request: FrontendProcessRequest = {
entryPath,
opts,
buildPlatform,
timing: timingClock,
provenance: provenanceSources(),
};
await writeFile(requestPath, JSON.stringify(request), { mode: 0o600 });
const exitCode = await frontendProcess.run(requestPath, resultPath);
if (exitCode === 0)
result = JSON.parse(await readFile(resultPath, "utf8")) as FrontendProcessResult;
} catch {
result = { kind: "failed" };
} finally {
await rm(requestPath, { force: true });
await rm(resultPath, { force: true });
}
if (result.kind === "failed")
return prepareExecutableInput(entryPath, opts, ffi, buildPlatform, timing, warningSink);
timing("frontend-process");
for (const probe of result.frontend.probes) frontendInputs.record(probe);
if (!result.frontend.stable) frontendInputs.markUnstable();
const sourceTexts = new Map(result.sourceTexts);
if (warningSink !== undefined && result.warnings.length > 0) {
warningSink.warnings = result.warnings;
warningSink.sourceTexts = sourceTexts;
}
if (result.kind === "diagnostics")
return { ok: false, diagnostics: result.diagnostics, sourceTexts };
return {
llvmSource: { file: result.nativeInputPath, bytes: result.llvmBytes },
llvmPath: result.llvmPath,
irPath: result.irPath,
nativeFeatures: result.nativeFeatures,
programSplit: null,
sourceTexts,
};
}
/**
* The frontend process (frontend-process.ts): prepares the executable input of the request at
* `requestPath` as `compileTracked` would in-process, and writes what it prepared, without the LLVM
* text, to `resultPath`.
*/
export async function runFrontendProcess(requestPath: string, resultPath: string): Promise<void> {
const request = JSON.parse(await readFile(requestPath, "utf8")) as FrontendProcessRequest;
clearCompileSessionCaches();
setProvenanceSources(request.provenance);
const frontendInputs = new FrontendInputTracker();
const warnings: CompileWarningSink = { warnings: [], sourceTexts: null };
let result: FrontendProcessResult = { kind: "failed" };
try {
let ffi: FfiProfile | null = null;
if (request.opts.ffiProfilePath !== undefined) {
const loaded = loadFfiProfile(resolve(request.opts.ffiProfilePath));
if (!loaded.ok) throw new InternalCompilerError("the FFI profile could not be reloaded");
ffi = loaded.profile;
}
const prepared = await frontendInputs.run(() =>
prepareExecutableInput(
request.entryPath,
request.opts,
ffi,
request.buildPlatform,
compilationTiming(request.timing),
warnings,
),
);
const frontend = frontendInputs.snapshot();
if (!("ok" in prepared)) {
// The public .ll file may be rewritten by a concurrent build of another entry with the
// same output stem before the native helper reads it (see emitNativeArtifact).
if (isLlvmFile(prepared.llvmSource))
throw new InternalCompilerError("in-process preparation returned LLVM text as a file");
const nativeInputPath = privateSiblingPath(prepared.llvmPath, "frontend-llvm");
await writeLlvmSource(nativeInputPath, prepared.llvmSource, 0o600);
result = {
kind: "prepared",
llvmPath: prepared.llvmPath,
nativeInputPath,
llvmBytes: llvmInputBytes(prepared.llvmSource),
irPath: prepared.irPath,
nativeFeatures: prepared.nativeFeatures,
sourceTexts: [...prepared.sourceTexts],
warnings: warnings.warnings,
frontend,
};
} else if (!prepared.ok) {
result = {
kind: "diagnostics",
diagnostics: prepared.diagnostics,
sourceTexts: [...prepared.sourceTexts],
warnings: warnings.warnings,
frontend,
};
}
} catch {
result = { kind: "failed" };
}
await writeFile(resultPath, JSON.stringify(result), { mode: 0o600 });
}
async function compileTracked(
entryPath: string,
opts: CompileRequestOptions,
frontendInputs: FrontendInputTracker,
warningSink?: CompileWarningSink,
frontendProcess?: FrontendProcess,
): Promise<CompileRequestResult> {
const timing = compilationTiming();
const timingStart = Date.now();
entryPath = resolve(entryPath);
const outputKind = opts.outputKind ?? "exe";
if (
@@ -1121,18 +1375,12 @@ async function compileTracked(
if (outputKind === "exe") {
const helperObjectRoute =
opts.nativeProgramObject === true || usesPrecompiledRuntimePack(opts, "llvm");
// Package content and native driver discovery are independent inputs to
// the same cache key. Start both before waiting so edits do not pay their
// filesystem and subprocess latency serially.
const [implementation, nativeEnvironment] = await Promise.all([
compilerImplementationIdentity(),
helperObjectRoute
? executableLinkerEnvironmentFingerprint(process.env, nativeCodegenTarget()?.defaultLinker)
: executableNativeEnvironmentFingerprint(),
]);
// Calls into Node record the libnode pack (node-islands/libnode.ts).
const libnodeKey = opts.libnode ?? process.env["SCRIPTC_LIBNODE"];
earlyCacheOptions = {
const routeOptions: Omit<
EarlyExecutableCacheOptions,
"implementation" | "implementationDependencies" | "nativeEnvironment"
> = {
entryPath,
outDir: opts.outDir,
outPath: opts.outPath,
@@ -1164,8 +1412,27 @@ async function compileTracked(
? resolvePlatformLinker(process.env, nativeCodegenTarget()?.defaultLinker)
: (process.env["SCRIPTC_CC"] ?? "clang"),
],
nativeEnvironment,
nodeVersion: process.version,
};
// The frontend process prepares the first build of an invocation (preparesInChildProcess).
// Started here, it loads the compiler while this process computes the rest of the cache key.
if (
frontendProcess !== undefined &&
!(await hasEarlyExecutableNativeBuild(cacheRoot, routeOptions))
)
frontendProcess.start();
// Package content and native driver discovery are independent inputs to
// the same cache key. Start both before waiting so edits do not pay their
// filesystem and subprocess latency serially.
const [implementation, nativeEnvironment] = await Promise.all([
compilerImplementationIdentity(),
helperObjectRoute
? executableLinkerEnvironmentFingerprint(process.env, nativeCodegenTarget()?.defaultLinker)
: executableNativeEnvironmentFingerprint(),
]);
earlyCacheOptions = {
...routeOptions,
nativeEnvironment,
implementation: implementation.digest,
implementationDependencies: implementation.dependencies,
};
@@ -1216,7 +1483,9 @@ async function compileTracked(
nativeProgramObject = await emitNativeProgramObject(
entryPath,
opts,
await readFile(earlyHit.llvmPath, "utf8"),
// A large program's LLVM exceeds the engine's string limit as one
// string; the emitter takes it in parts, as on a cold build.
await readTextParts(earlyHit.llvmPath),
earlyHit.native,
profile,
);
@@ -1299,14 +1568,20 @@ async function compileTracked(
};
}
timing("executable-cache-miss");
const prepared = await prepareExecutableInput(
entryPath,
opts,
ffi,
buildPlatform,
timing,
warningSink,
);
const prepared =
frontendProcess?.started === true
? await prepareExecutableInputInChild(
frontendProcess,
entryPath,
opts,
ffi,
buildPlatform,
timing,
{ start: timingStart, previous: Date.now() },
frontendInputs,
warningSink,
)
: await prepareExecutableInput(entryPath, opts, ffi, buildPlatform, timing, warningSink);
if ("ok" in prepared) return prepared;
const { llvmSource, llvmPath, irPath, nativeFeatures, programSplit, sourceTexts } = prepared;
const backend = "llvm" as const;
@@ -1335,6 +1610,9 @@ async function compileTracked(
profile,
);
timing("native-object");
await recordEarlyExecutableNativeBuild(cacheRoot, executableCacheOptions).catch(
() => undefined,
);
} catch (err) {
if (!(err instanceof NativeCodegenError)) throw err;
return {
@@ -1395,6 +1673,8 @@ async function compileTracked(
nativeProgramObject.partitionPaths,
);
}
// The frontend process's private copy of the LLVM text.
if (isLlvmFile(llvmSource)) await rm(llvmSource.file, { force: true });
}
if (!publishedExecutable) {
await publishEarlyExecutableCache(cacheRoot, executableCacheOptions, {
@@ -1445,6 +1725,7 @@ function libraryNativeFeatures(mod: IrModule, backend: "llvm"): EarlyLibraryNati
copying: features.copying,
textDecoderLegacy: features.legacyTextDecoder,
dynInvoke: features.dynInvoke,
...(features.dynBuffer ? { dynBuffer: true } : {}),
...(features.dynTier ? { dynTier: true } : {}),
...(mod.lib?.identity !== undefined ? { buildId: mod.lib.identity.buildId } : {}),
};
@@ -1539,6 +1820,7 @@ async function compileLibraryNative(
copying: features.copying,
textDecoderLegacy: features.textDecoderLegacy,
dynInvoke: features.dynInvoke,
...(features.dynBuffer === true ? { dynBuffer: true } : {}),
...(features.dynTier === true ? { dynTier: true } : {}),
};
if (packTarget !== null) await compilePackedLibrary(archiveOptions, packTarget);
@@ -240,12 +240,16 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
// 'error' has no listener, Node's unhandled-'error' contract.
"emitter.emit",
"emitter.emitFlex",
"emitter.emitAny",
"emitter.emitMixed",
"emitter.listenersDyn",
"emitter.emitError",
"emitter.on",
"emitter.off",
"emitter.checkListener",
"emitter.onDyn",
"emitter.onFlex",
"emitter.onAny",
"emitter.offDyn",
"emitter.onData",
"emitter.onDataDyn",
+26 -3
View File
@@ -3148,6 +3148,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* scandir failure becomes its rejection. */
"fsp.readdirTypes": { argTypes: [STRING], result: { kind: "promise", inner: VOID } },
"fsp.rm": { argTypes: [STRING], result: { kind: "promise", inner: VOID } },
/** `fs.promises.rm(path, options)`: fs.rmOptsSync and fs.rmRetrySync
* behind a settled promise; their errors become its rejection. */
"fsp.rmOpts": { argTypes: [STRING, BOOL, BOOL], result: { kind: "promise", inner: VOID } },
"fsp.rmRetry": {
argTypes: [STRING, BOOL, BOOL, F64, F64],
result: { kind: "promise", inner: VOID },
},
"fsp.stat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } },
"fsp.realpath": { argTypes: [STRING], result: { kind: "promise", inner: STRING } },
"fsp.lstat": { argTypes: [STRING], result: { kind: "promise", inner: STATS_T } },
@@ -3765,16 +3772,31 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"emitter.checkListener": { argTypes: [DYN], result: VOID },
"emitter.onDyn": { argTypes: [null, STRING, DYN, null, BOOL, BOOL], result: VOID },
"emitter.onFlex": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID },
/** on/once/prepend* and emit through an untyped value (the class
* dispatch of compiled emitters): onFlex/emitFlex plus runtime typed
* adapters under 'error' and the meta events, and Node's unhandled-'error'
* contract. emitAny is VARIADIC like emitFlex. */
"emitter.onAny": { argTypes: [null, STRING, DYN, BOOL, BOOL], result: VOID },
"emitter.offDyn": { argTypes: [null, STRING, DYN], result: VOID },
"emitter.removeAll": { argTypes: [null, STRING, BOOL], result: VOID },
"emitter.emit": { argTypes: [null, STRING], result: BOOL },
"emitter.emitFlex": { argTypes: [null, STRING], result: BOOL },
"emitter.emitAny": { argTypes: [null, STRING], result: BOOL },
/** An untyped emit of an event with a fixed tuple: (recv, name, k, then
* k checked-dynamic arguments as given, then the event's converted
* tuple) — VARIADIC like emitter.emit. hasTyped answers whether a
* listener of the name needs the converted tuple. */
"emitter.emitMixed": { argTypes: [null, STRING, F64], result: BOOL },
"emitter.hasTyped": { argTypes: [null, STRING], result: BOOL },
"emitter.emitError": { argTypes: [null, STRING, null], result: BOOL },
"emitter.count": { argTypes: [null, STRING], result: F64 },
"emitter.countFn": { argTypes: [null, STRING, null], result: F64 },
"emitter.countDyn": { argTypes: [null, STRING, DYN], result: F64 },
"emitter.names": { argTypes: [null], result: VOID },
"emitter.listeners": { argTypes: [null, STRING], result: VOID },
/** listeners()/rawListeners() through an untyped value: a checked-
* dynamic array (throws when a listener has typed parameters). */
"emitter.listenersDyn": { argTypes: [null, STRING], result: DYN },
/** Stream-'data' registration twins of emitter.on/onDyn (same args):
* chosen at registration sites whose receiver is stream-rooted, so the
* backend emits DATA thunks — the runtime's 'data' emission carries
@@ -4631,8 +4653,7 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
* return value has no lowering (statement position only, frontend-
* fenced). rmOptsSync is rmSync with (recursive, force) bools: force
* swallows ENOENT, recursive removes trees post-order, a directory
* without recursive throws the EISDIR-worded error (divergence 13's
* wording note). mkdtempSync appends the six X's and returns the
* without recursive throws Node's ERR_FS_EISDIR SystemError. mkdtempSync appends the six X's and returns the
* created path (+1). accessSync takes the F_OK/R_OK/W_OK/X_OK bits as
* one f64 (the frontend bakes fs.constants.* as literals and completes
* an omitted mode to 0). readFdSync/readFdSyncBytes are the
@@ -4643,7 +4664,9 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
/** rmOptsSync's maxRetries/retryDelay form (path, recursive, force,
* maxRetries, retryDelay): Node's linear-backoff retry on
* EBUSY/EMFILE/ENFILE/ENOTEMPTY/EPERM — the tmpdir-harness shape
* rmSync(p, { maxRetries: 3, recursive: true, force: true }). */
* rmSync(p, { maxRetries: 3, recursive: true, force: true }). Throws
* ERR_OUT_OF_RANGE for a retryDelay outside int32 >= 0 or a maxRetries
* outside uint32, before checking the path. */
"fs.rmRetrySync": { argTypes: [STRING, BOOL, BOOL, F64, F64], result: VOID },
"fs.mkdtempSync": { argTypes: [STRING], result: STRING },
"fs.accessSync": { argTypes: [STRING, F64], result: VOID },
+7 -3
View File
@@ -1,5 +1,5 @@
import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js";
import { dynamicFieldStores } from "./int32-slots.js";
import { dynamicFieldStores, type DynamicFieldStores } from "./int32-slots.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
/** Whole-program final reference fields.
@@ -78,11 +78,15 @@ export function constructorClass(fnName: string): string | null {
/** `scalars` extends the analysis from reference-counted fields to fields of
* every type, for consumers that read a final field's value once instead of
* borrowing it (field-values.ts). */
export function analyzeFinalFields(mod: IrModule, scalars = false): FinalFields {
export function analyzeFinalFields(
mod: IrModule,
scalars = false,
stores?: DynamicFieldStores,
): FinalFields {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const finals = new FinalFields(classes);
if (mod.lib) return finals;
const dynamic = dynamicFieldStores(mod);
const dynamic = stores ?? dynamicFieldStores(mod);
if (dynamic.all) return finals;
const genericFamilies = new Set<string>();
@@ -214,6 +214,8 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, Role> = {
"contracts.programLifetime[]": "other",
"wasmModules[].sha256": "other",
"wasmModules[].bytes": "other",
"wasmUntranslated[].sha256": "other",
"wasmUntranslated[].reason": "other",
"threadSlotKeys[]": "other",
"nodeIsland.libnode": "other",
"nodeIsland.nodeVersion": "other",
+113 -99
View File
@@ -1,4 +1,4 @@
import type { IrClassDef, IrExpr, IrFunction, IrModule, IrStmt, IrType } from "./ir.js";
import type { IrClassDef, IrExpr, IrFunction, IrGlobal, IrModule, IrStmt, IrType } from "./ir.js";
import {
analyzeIntegerRanges,
INT32_RANGE,
@@ -458,11 +458,18 @@ function walkFunction(
* whose key is not a literal), otherwise the
* literal keys of `dyn.keySet`/`dyn.keyDelete`. A view commits a store back
* into the instance's native field slots, which the IR does not show. */
export interface DynamicFieldStores {
all: boolean;
names: Set<string>;
}
/** Computes DynamicFieldStores with one walk of every function. Analyses of
* the same module accept the result so the emitter walks the module once. */
export function dynamicFieldStores(
mod: IrModule,
records = new Map((mod.records ?? []).map((r) => [r.id, r])),
unions = new Map((mod.unions ?? []).map((u) => [u.id, u])),
): { all: boolean; names: Set<string> } {
): DynamicFieldStores {
const dynTypes = new Map<IrType, boolean>();
const dynNamed = new Map<string, boolean>();
const dynish = (type: IrType): boolean => {
@@ -522,7 +529,11 @@ export function dynamicFieldStores(
* can write: the class reaches checked-dynamic code (directly or through
* another exposed type), and some dynamic store in the program can name the
* field. */
export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set<string> {
export function dynamicallyStoredFamilies(
mod: IrModule,
slots: Int32Slots,
stores?: DynamicFieldStores,
): Set<string> {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const children = new Map<string, string[]>();
for (const cls of classes.values())
@@ -588,7 +599,8 @@ export function dynamicallyStoredFamilies(mod: IrModule, slots: Int32Slots): Set
// writes every field from the view, which holds the exact snapshot value
// of any property no store named. So reaching dynamic code disqualifies
// exactly the field names some dynamic store in the program can name.
const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions);
const { all: dynWritesAll, names: dynWritten } =
stores ?? dynamicFieldStores(mod, records, unions);
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
@@ -655,20 +667,11 @@ export function functionCallFacts(
const runtimePrefixes: string[] = [];
for (const cls of classes.values())
if (runtimeRooted(cls.name)) runtimePrefixes.push(`%${cls.name}.`);
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
const referenced = exprFunctionReference(e);
if (referenced !== undefined) escaped.add(referenced);
if (e.kind !== "virtualCall") return;
for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`);
for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined)
virtualTargets.add(`%${c.name}.${e.method}`);
},
});
// A class value can reach `new` through its construct thunk; one that
// only feeds an instanceof interval check cannot.
// only feeds an instanceof interval check cannot. Class references are
// checked once every function has contributed its tested-only ones.
const testedOnly = new Set<IrExpr>();
const classRefs: (IrExpr & { kind: "classRef" })[] = [];
for (const fn of mod.functions) {
// Direct operands, and unboxed locals bound once to a class reference
// and read only as instanceof operands (the lowering's temporary).
@@ -681,11 +684,18 @@ export function functionCallFacts(
};
walkFunction(fn, {
expr: (e) => {
if (e.kind === "instanceOfValue") {
const referenced = exprFunctionReference(e);
if (referenced !== undefined) escaped.add(referenced);
if (e.kind === "virtualCall") {
for (const name of subtree(e.className)) virtualTargets.add(`%${name}.${e.method}`);
for (let c = classes.get(e.className); c; c = c.base ? classes.get(c.base) : undefined)
virtualTargets.add(`%${c.name}.${e.method}`);
} else if (e.kind === "instanceOfValue") {
if (e.classValue.kind === "classRef") testedOnly.add(e.classValue);
else if (e.classValue.kind === "varRef") count(tests, e.classValue.localId);
} else if (e.kind === "varRef") count(reads, e.localId);
else if (e.kind === "assignExpr" || e.kind === "incDec") count(writes, e.localId);
else if (e.kind === "classRef") classRefs.push(e);
},
stmt: (s) => {
if (s.kind === "varDecl" && s.init?.kind === "classRef") bound.set(s.localId, s.init);
@@ -698,18 +708,19 @@ export function functionCallFacts(
if (!boxed.has(id) && writes.get(id) === 1 && reads.get(id) === tests.get(id))
testedOnly.add(ref);
}
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (e.kind === "classRef" && !testedOnly.has(e)) escaped.add(`%${e.className}.constructor`);
},
});
for (const e of classRefs) if (!testedOnly.has(e)) escaped.add(`%${e.className}.constructor`);
const runtimeVirtual = (name: string): boolean =>
runtimePrefixes.some((prefix) => name.startsWith(prefix));
return { valueEscaped: escaped, virtualTargets, runtimeVirtual };
}
export function analyzeInt32Slots(mod: IrModule): Int32Slots {
/** Facts about the same module that a caller has already computed. */
export interface Int32SlotInputs {
dynamicStores?: DynamicFieldStores;
initializerBindings?: ReadonlyMap<string, IrGlobal[]>;
}
export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}): Int32Slots {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const slots = new Int32Slots(classes);
if (mod.lib) return slots;
@@ -780,7 +791,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
}
/* ── dynamic, reflective and runtime exposure ── */
for (const family of dynamicallyStoredFamilies(mod, slots)) {
for (const family of dynamicallyStoredFamilies(mod, slots, inputs.dynamicStores)) {
if (slots.isPinnedField(family)) continue;
dropFamily(family, "dynamic stores can name the field");
}
@@ -799,56 +810,16 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
const dropAllParams = (fn: IrFunction, why: string): void => {
for (const param of fn.params) dropParam(fn.name, param.localId, why);
};
// Only functions some IR call reaches directly get parameter facts: one
// the backend invokes by convention has no call site to prove against.
// One walk of every function collects what the steps below need: the
// functions IR calls reach directly, calls whose arity differs from their
// callee's, the captured bindings' boxes, and which functions read which
// slot facts. Facts that depend on earlier steps are applied in walk order
// where those steps complete.
const called = slots.called;
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (e.kind === "call") called.add(e.callee);
else if (e.kind === "new") called.add(`%${e.className}.constructor`);
},
});
for (const fn of mod.functions) {
if (fn.returnType.kind === "f64" && !fn.async && !fn.generator) {
slots.addReturn(fn.name);
slots.assumeNonNegative(`ret:${fn.name}`);
}
if (
fn.async ||
fn.generator ||
!called.has(fn.name) ||
escaped.has(fn.name) ||
virtual(fn.name)
)
continue;
for (const param of fn.params)
if (param.type.kind === "f64" && !slots.isPinned(paramKey(fn.name, param.localId))) {
slots.addParam(fn.name, param.localId);
slots.assumeNonNegative(paramKey(fn.name, param.localId));
}
}
const arityMismatches: IrFunction[] = [];
const arityChecked = (fn: IrFunction | undefined, args: number, offset: number): void => {
if (fn && fn.params.length !== args + offset) dropAllParams(fn, "call arity differs");
if (fn && fn.params.length !== args + offset) arityMismatches.push(fn);
};
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (e.kind === "call") arityChecked(functions.get(e.callee), e.args.length, 0);
else if (e.kind === "new")
arityChecked(functions.get(`%${e.className}.constructor`), e.args.length, 1);
},
});
// Candidates the fixpoint may drop; rangedParameters retries some.
const candidates = new Map<string, string[]>();
const retry = new Set<string>();
for (const fn of mod.functions) {
const ids = fn.params.filter((p) => slots.hasParam(fn.name, p.localId)).map((p) => p.localId);
if (ids.length > 0) candidates.set(fn.name, ids);
}
/* ── captured bindings ── */
// A captured local lives in one box shared by its declaring function and
// every closure that captures it (closure.captures[i] is the lifted
// function's captures[i]). The box holds an int32 when every write to it
@@ -864,6 +835,16 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
const boxKey = (fnName: string, localId: string): string => `${fnName}\0${localId}`;
const boxMembers = new Set<string>();
const unstableBoxes = new Set<string>();
// Readers: which functions depend on which slot facts. Reads of a boxed
// local resolve to its box once the boxes are known.
const readers = new Map<string, Set<string>>();
const reads = (slot: string, fnName: string): void => {
const set = readers.get(slot);
if (set) set.add(fnName);
else readers.set(slot, new Set([fnName]));
};
const boxedReads: [IrFunction, Set<string>][] = [];
const incrementedFamilies: string[] = [];
for (const fn of mod.functions) {
for (const local of fn.locals)
if (local.boxed) {
@@ -873,9 +854,27 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
}
for (const capture of fn.classCaptures ?? [])
unstableBoxes.add(boxKey(fn.name, capture.localId));
const read = new Set<string>();
walkFunction(fn, {
expr: (e) => {
if (e.kind === "closure") {
if (e.kind === "call") {
called.add(e.callee);
arityChecked(functions.get(e.callee), e.args.length, 0);
reads(`ret:${e.callee}`, fn.name);
} else if (e.kind === "new") {
const constructor = `%${e.className}.constructor`;
called.add(constructor);
arityChecked(functions.get(constructor), e.args.length, 1);
// Constructors return nothing: this only records their callers.
reads(`ret:${constructor}`, fn.name);
} else if (e.kind === "varRef") read.add(e.localId);
else if (e.kind === "fieldGet") {
const family = slots.family(e.className, e.field);
if (family) reads(`field:${family}`, fn.name);
} else if (e.kind === "fieldIncDec") {
const family = slots.family(e.className, e.field);
if (family) incrementedFamilies.push(family);
} else if (e.kind === "closure") {
const lifted = functions.get(e.fnName);
e.captures.forEach((id, i) => {
const inner = lifted?.captures?.[i];
@@ -897,7 +896,41 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
unstableBoxes.add(boxKey(fn.name, s.catchLocalId));
},
});
if (read.size > 0) boxedReads.push([fn, read]);
}
// Only functions some IR call reaches directly get parameter facts: one
// the backend invokes by convention has no call site to prove against.
for (const fn of mod.functions) {
if (fn.returnType.kind === "f64" && !fn.async && !fn.generator) {
slots.addReturn(fn.name);
slots.assumeNonNegative(`ret:${fn.name}`);
}
if (
fn.async ||
fn.generator ||
!called.has(fn.name) ||
escaped.has(fn.name) ||
virtual(fn.name)
)
continue;
for (const param of fn.params)
if (param.type.kind === "f64" && !slots.isPinned(paramKey(fn.name, param.localId))) {
slots.addParam(fn.name, param.localId);
slots.assumeNonNegative(paramKey(fn.name, param.localId));
}
}
for (const fn of arityMismatches) dropAllParams(fn, "call arity differs");
// Candidates the fixpoint may drop; rangedParameters retries some.
const candidates = new Map<string, string[]>();
const retry = new Set<string>();
for (const fn of mod.functions) {
const ids = fn.params.filter((p) => slots.hasParam(fn.name, p.localId)).map((p) => p.localId);
if (ids.length > 0) candidates.set(fn.name, ids);
}
/* ── captured bindings ── */
const groups = new Map<string, string[]>();
for (const key of boxMembers) {
const root = boxRoot(key);
@@ -960,34 +993,15 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
return slots.dropBox(group);
};
/* ── readers: which functions depend on which slot facts ── */
const readers = new Map<string, Set<string>>();
const reads = (slot: string, fnName: string): void => {
const set = readers.get(slot);
if (set) set.add(fnName);
else readers.set(slot, new Set([fnName]));
};
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
if (e.kind === "fieldGet") {
const family = slots.family(e.className, e.field);
if (family) reads(`field:${family}`, fn.name);
} else if (e.kind === "call") reads(`ret:${e.callee}`, fn.name);
// Constructors return nothing: this only records their callers.
else if (e.kind === "new") reads(`ret:%${e.className}.constructor`, fn.name);
else if (e.kind === "varRef") {
const group = slots.boxGroup(fn.name, e.localId);
if (group !== null) reads(`box:${group}`, fn.name);
} else if (e.kind === "fieldIncDec") {
const family = slots.family(e.className, e.field);
if (family) dropFamily(family, "++/-- on the field");
}
},
});
for (const [fn, read] of boxedReads)
for (const localId of read) {
const group = slots.boxGroup(fn.name, localId);
if (group !== null) reads(`box:${group}`, fn.name);
}
for (const family of incrementedFamilies) dropFamily(family, "++/-- on the field");
/* ── the optimistic fixpoint ── */
const bindings = findInitializerBindings(mod);
const bindings = inputs.initializerBindings ?? findInitializerBindings(mod);
const numeric = (fn: IrFunction): IrFunction =>
withInitializerBindings(fn, bindings.get(fn.name) ?? []);
const queue: string[] = mod.functions.map((fn) => fn.name);
+18 -2
View File
@@ -1058,6 +1058,10 @@ export interface IrModule {
* API (possibly empty). The backend appends each module's translation
* and emits the registry the runtime looks modules up in. */
wasmModules?: IrWasmModule[];
/** The modules the build saw but does not translate, with the reason,
* present with wasmModules. The backend emits them in a second table, so
* loading one rejects with a CompileError that names the reason. */
wasmUntranslated?: IrWasmUntranslated[];
/** The program calls WebAssembly.Module.customSections, so translated
* modules keep their custom sections (otherwise they carry none, and
* debug sections cost nothing). */
@@ -1073,6 +1077,16 @@ export interface IrWasmModule {
bytes: string;
}
/** A WebAssembly module whose bytes the build saw but does not translate. */
export interface IrWasmUntranslated {
/** Hex SHA-256 of the bytes. */
sha256: string;
/** The byte length. */
size: number;
/** Why the module is not translated, as a clause ("it uses simd, ..."). */
reason: string;
}
/** Verified contract facts, keyed by IR names. */
export interface IrContracts {
/** Int32 class field families: `${topmost declaring class}\0${field}`. */
@@ -3550,11 +3564,13 @@ export type IrExpr =
* excluding callable and primitive references;
* "instanceof" requires `className`: hierarchy payloads use the vtable
* interval, while standalone classes use their exact retain adapter.
* Other payloads answer false. `negated` flips the result (the `!==` spelling). */
* Other payloads answer false. "dynamic" is a value thrown as unknown
* (the payload caughtToDyn returns by reference). `negated` flips the
* result (the `!==` spelling). */
| {
kind: "caughtTest";
value: IrExpr;
test: "string" | "number" | "boolean" | "object" | "instanceof";
test: "string" | "number" | "boolean" | "object" | "instanceof" | "dynamic";
className?: string;
negated?: boolean;
type: IrType;
+45 -8
View File
@@ -10,9 +10,7 @@ import {
import { everyModuleNode } from "./traverse.js";
import { JsIcSites, hasJsIcSite } from "./js-ic.js";
/** The fixed-width Buffer read/write method names (both "UInt" and "Uint"
* spellings) a checked-dynamic method call routes to scr_bytes_dyn.c. */
export const DYN_BUFFER_NUM_METHODS: ReadonlySet<string> = new Set(
const DYN_BUFFER_NUM_METHODS: ReadonlySet<string> = new Set(
["read", "write"].flatMap((rw) => [
...["UInt", "Uint"].flatMap((u) =>
["8", "16LE", "16BE", "32LE", "32BE"].map((w) => `${rw}${u}${w}`),
@@ -31,6 +29,45 @@ export const DYN_BUFFER_NUM_METHODS: ReadonlySet<string> = new Set(
]),
);
/** Buffer.prototype members scr_bytes_dyn.c refuses on Buffer receivers. */
const DYN_BUFFER_REFUSED_METHODS: readonly string[] = [
...["read", "write"].flatMap((rw) => [
...["UIntLE", "UIntBE", "UintLE", "UintBE", "IntLE", "IntBE"].map((n) => `${rw}${n}`),
...["BigUInt64", "BigUint64", "BigInt64"].flatMap((n) => [`${rw}${n}LE`, `${rw}${n}BE`]),
]),
...["ascii", "base64", "base64url", "latin1", "hex", "ucs2", "utf8"].flatMap((enc) => [
`${enc}Slice`,
`${enc}Write`,
]),
"inspect",
];
/** The scr_bytes_dyn.c entry, one per method family, that a checked-dynamic
* call of a Buffer.prototype method name routes to: the fixed-width reads and
* writes (both "UInt" and "Uint" spellings), write, fill, toString, the
* searches, and the other Buffer-only members. Each runs the method on a
* Buffer receiver and gives any other receiver the ordinary dispatch. */
export const DYN_BUFFER_METHODS: ReadonlyMap<string, string> = new Map([
...[...DYN_BUFFER_NUM_METHODS].map((m): [string, string] => [m, "scr_dyn_invoke_buffer_num"]),
["write", "scr_dyn_invoke_buffer_write"],
["fill", "scr_dyn_invoke_buffer_fill"],
["toString", "scr_dyn_invoke_buffer_to_string"],
...["indexOf", "lastIndexOf", "includes"].map((m): [string, string] => [
m,
"scr_dyn_invoke_buffer_search",
]),
...[
"toJSON",
"equals",
"compare",
"copy",
"swap16",
"swap32",
"swap64",
...DYN_BUFFER_REFUSED_METHODS,
].map((m): [string, string] => [m, "scr_dyn_invoke_buffer"]),
]);
const DYN_ASYNC_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
// DEP0040 rides the dyn-async unit's warning machinery.
"async.awaitDyn",
@@ -694,10 +731,10 @@ const RUNTIME_FEATURE_RULES = {
handleProps: rule({ fns: ["child.propsInstall"] }),
/** for await over checked-dynamic values (scr_dyn_for_await.c). */
dynForAwait: rule({ fns: ["dyn.asyncIteratorOpen", "dyn.asyncIteratorWasSync"] }),
/** Fixed-width Buffer read/write methods called on checked-dynamic
* values (scr_bytes_dyn.c). */
dynBufferNum: rule({
expr: (node, _tables) => node.kind === "dynInvoke" && DYN_BUFFER_NUM_METHODS.has(node.method),
/** Buffer.prototype methods called on checked-dynamic values
* (scr_bytes_dyn.c). */
dynBuffer: rule({
expr: (node, _tables) => node.kind === "dynInvoke" && DYN_BUFFER_METHODS.has(node.method),
}),
/** Builtin functions read as values (scr_builtin_values.c). */
builtinValues: rule({ fns: ["buffer.byteLenValue", "buffer.allocValue", "fs.realpathValue"] }),
@@ -902,7 +939,7 @@ function noRuntimeFeatures(): RuntimeFeatures {
streamDyn: false,
handleProps: false,
dynForAwait: false,
dynBufferNum: false,
dynBuffer: false,
builtinValues: false,
symbol: false,
tls: false,
+17 -2
View File
@@ -466,6 +466,18 @@ export function everyStmtChild(
throw new Error("unhandled IR node");
}
/** Called with each record literal mapExprChildren rebuilds and its
* replacement, so a side table keyed by node identity (the lowering's
* key-order witness notes) follows the literal into the rewritten tree. */
let recordLiteralRebuilt: ((from: IrExpr, to: IrExpr) => void) | null = null;
/** Installs (or with null removes) the record-literal rebuild observer. */
export function observeRecordLiteralRebuilds(
observer: ((from: IrExpr, to: IrExpr) => void) | null,
): void {
recordLiteralRebuilt = observer;
}
/** Copy a node with transformed immediate children; preserve all metadata. */
export function mapExprChildren(
node: IrExpr,
@@ -653,11 +665,14 @@ export function mapExprChildren(
return { ...node, value: expr(node.value) };
case "virtualCall":
return { ...node, args: mapOf(node.args, expr) };
case "recordLit":
return {
case "recordLit": {
const rebuilt: IrExpr = {
...node,
fields: node.fields.map((child) => ({ ...child!, value: expr(child.value) })),
};
if (recordLiteralRebuilt !== null) recordLiteralRebuilt(node, rebuilt);
return rebuilt;
}
case "recordClone":
return {
...node,
+19 -3
View File
@@ -1117,7 +1117,7 @@ function validateFunction(
// helpers. They are not source-level types and never return as values.
if (fn.returnType.kind === "caught") err("return type is caught", fn.loc);
for (const p of fn.params) {
if (p.type.kind === "caught" && fn.name !== "%caught.dynamicValue")
if (p.type.kind === "caught" && !fn.name.startsWith("%caught."))
err(`param "${p.name}" is caught-typed`, fn.loc);
if (!locals.has(p.localId)) {
err(`param "${p.name}" has no local entry "${p.localId}"`, fn.loc);
@@ -4080,6 +4080,8 @@ function validateFunction(
const variadic =
e.fn === "emitter.emit" ||
e.fn === "emitter.emitFlex" ||
e.fn === "emitter.emitAny" ||
e.fn === "emitter.emitMixed" ||
e.fn === "readable.new" ||
e.fn === "writable.new" ||
e.fn === "duplex.new" ||
@@ -5847,6 +5849,7 @@ function validateFunction(
e.fn === "emitter.off" ||
e.fn === "emitter.onDyn" ||
e.fn === "emitter.onFlex" ||
e.fn === "emitter.onAny" ||
e.fn === "stream.onDyn" ||
e.fn === "emitter.offDyn" ||
e.fn === "emitter.onData" ||
@@ -5901,14 +5904,27 @@ function validateFunction(
if (!ok) err(`libCall emitter.listeners must return a func array`, e.loc);
return true;
}
if (e.fn === "emitter.emitFlex") {
if (e.fn === "emitter.emitFlex" || e.fn === "emitter.emitAny") {
if (e.args.slice(2).some((arg) => arg.type.kind !== "dyn")) {
err("libCall emitter.emitFlex payloads must be checked-dynamic", e.loc);
err(`libCall ${e.fn} payloads must be checked-dynamic`, e.loc);
}
}
if (e.fn === "emitter.emitMixed") {
const count = e.args[2];
const k = count?.kind === "numLit" ? count.value : -1;
if (!Number.isInteger(k) || k < 0 || e.args.length < 3 + k) {
err("libCall emitter.emitMixed needs a literal argument count", e.loc);
} else if (e.args.slice(3, 3 + k).some((arg) => arg.type.kind !== "dyn")) {
err("libCall emitter.emitMixed untyped arguments must be checked-dynamic", e.loc);
}
}
if (
e.fn === "emitter.emit" ||
e.fn === "emitter.emitFlex" ||
e.fn === "emitter.emitAny" ||
e.fn === "emitter.emitMixed" ||
e.fn === "emitter.hasTyped" ||
e.fn === "emitter.listenersDyn" ||
e.fn === "emitter.count" ||
e.fn === "emitter.getMax" ||
e.fn === "emitter.ctor" ||
@@ -63,6 +63,8 @@ interface EarlyLibraryCacheStamp {
export interface EarlyLibraryNativeFeatures {
backend: "llvm";
dynInvoke: boolean;
/** Buffer.prototype methods on checked-dynamic values (scr_bytes_dyn.c). */
dynBuffer?: boolean;
dynTier?: boolean;
regex: boolean;
assert: boolean;
+2
View File
@@ -1,5 +1,6 @@
import { dirname, resolve } from "node:path";
import type { CompileFailure } from "../compile-types.js";
import { frontendDisposed } from "../frontend/dispose-hook.js";
import {
fenceSpeculativeWasiFunctions,
moduleWasiUnavailableSurface,
@@ -585,6 +586,7 @@ export function prepareLibrary(
sourceTexts = fe.sourceTexts();
} finally {
fe.dispose();
frontendDisposed();
}
const mod = lowered.module!;
timing("frontend-dispose");
+3
View File
@@ -10,6 +10,7 @@ import {
} from "../backend/runtime-pack-native.js";
import { mergeNativeProfiles } from "../backend/native-tools.js";
import { setDeclarationRoot } from "../frontend/dts-paths.js";
import { setFrontendDisposedHook } from "../frontend/dispose-hook.js";
import { FrontendServices } from "../frontend/services.js";
import { createNativeTs7Api } from "../frontend/ts7/native-api.js";
import { resolveProvenanceSourcesWithParser } from "../frontend/provenance-core.js";
@@ -21,8 +22,10 @@ import { buildSanitizedRuntime } from "./sanitizer.js";
import { runCompilerTask } from "./task.js";
declare function compilerNativePrivateDirectory(path: string, harden: boolean): boolean;
declare function compilerNativeCollectCycles(): void;
async function main(): Promise<number> {
setFrontendDisposedHook(() => compilerNativeCollectCycles());
const manifestPath = process.env["SCRIPTC_TOOLCHAIN"] ?? process.execPath + ".json";
let compiler: NativeCompiler | null = null;
let cache: ReturnType<typeof openNativeCache> = null;
@@ -81,7 +81,7 @@ test.skipIf(process.platform !== "linux" && process.platform !== "darwin")(
handleProps: false,
dynForAwait: false,
eventsOnceSignal: false,
dynBufferNum: false,
dynBuffer: false,
builtinValues: false,
warningReplace: false,
stdioExtended: false,
+49
View File
@@ -0,0 +1,49 @@
import { constants } from "node:buffer";
import { mkdtempSync, rmSync, truncateSync, writeFileSync } from "node:fs";
import { readFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterAll, expect, test } from "vitest";
import { readTextParts, writeTextParts } from "./text-parts.js";
const scratch = mkdtempSync(join(tmpdir(), "scriptc-text-parts-"));
afterAll(() => rmSync(scratch, { recursive: true, force: true }));
test("parts join to the file's text, each within the part length", async () => {
// Multi-byte sequences and surrogate pairs straddle the 1 MiB reads and
// the 8-unit parts.
const unit = "abéc中\u{1f600}d";
const text = unit.repeat(90_000);
const path = join(scratch, "mixed.ll");
writeFileSync(path, text);
const parts = await readTextParts(path, 8);
expect(parts.join("")).toBe(text);
for (const part of parts) {
expect(part.length).toBeLessThanOrEqual(8);
const last = part.charCodeAt(part.length - 1);
expect(last >= 0xd800 && last <= 0xdbff).toBe(false);
}
// Each part encodes on its own, so writing them back reproduces the file.
const copy = join(scratch, "copy.ll");
await writeTextParts(copy, parts);
expect(await readFile(copy, "utf8")).toBe(text);
});
test("an empty file has no parts", async () => {
const path = join(scratch, "empty.ll");
writeFileSync(path, "");
expect(await readTextParts(path)).toEqual([]);
});
test("a file past the engine's string limit reads in parts", async () => {
// A sparse file: its bytes are NUL characters, valid UTF-8, and cost no
// disk space.
const path = join(scratch, "huge.ll");
const size = constants.MAX_STRING_LENGTH + 1;
writeFileSync(path, "");
truncateSync(path, size);
await expect(readFile(path, "utf8")).rejects.toThrow(RangeError);
const parts = await readTextParts(path);
expect(parts.length).toBeGreaterThan(1);
expect(parts.reduce((length, part) => length + part.length, 0)).toBe(size);
}, 120_000);
+46
View File
@@ -1,4 +1,10 @@
import { createReadStream } from "node:fs";
import { open } from "node:fs/promises";
import { StringDecoder } from "node:string_decoder";
/** The longest part readTextParts returns, far below the engine's string
* limit. */
const TEXT_PART_LENGTH = 64 * 1024 * 1024;
/** Writes compiler text artifacts (LLVM, serialized IR) in large batches.
* Programs past the engine's string limit arrive as many parts, and writing
@@ -35,3 +41,43 @@ export async function writeTextParts(
await file.close();
}
}
/** Reads a text artifact as parts of at most `partLength` UTF-16 units:
* cached LLVM and serialized IR of large programs exceed the engine's
* string limit as one string. The decoder carries a multi-byte sequence
* split across reads into the next part, and no part ends inside a
* surrogate pair, so the parts join to the file's text and each encodes on
* its own. */
export async function readTextParts(
path: string,
partLength = TEXT_PART_LENGTH,
): Promise<string[]> {
const decoder = new StringDecoder("utf8");
const parts: string[] = [];
let pending: string[] = [];
let pendingLength = 0;
const flush = (): void => {
if (pendingLength === 0) return;
parts.push(pending.join(""));
pending = [];
pendingLength = 0;
};
const add = (text: string): void => {
for (let offset = 0; offset < text.length;) {
let take = Math.min(text.length - offset, partLength - pendingLength);
// A part never ends inside a surrogate pair: writeTextParts encodes
// its batches separately.
const last = text.charCodeAt(offset + take - 1);
if (take > 1 && last >= 0xd800 && last <= 0xdbff && offset + take < text.length) take--;
pending.push(text.slice(offset, offset + take));
pendingLength += take;
offset += take;
if (pendingLength >= partLength - 1) flush();
}
};
for await (const chunk of createReadStream(path, { highWaterMark: 1 << 20 }))
add(decoder.write(chunk as Buffer));
add(decoder.end());
flush();
return parts;
}
+13 -4
View File
@@ -1,11 +1,20 @@
export type CompilationTiming = (phase: string, detail?: Record<string, unknown>) => void;
/** The start of a timing and its latest phase, as `Date.now()` values. */
export interface TimingClock {
start: number;
previous: number;
}
/** Opt-in phase durations shared by the Node seed and native compiler.
* The disabled path does not read the clock or write compiler output. */
export function compilationTiming(): CompilationTiming {
* The disabled path does not read the clock or write compiler output.
* With `clock`, the phases continue a timing that started in another
* process, so a frontend process reports them as its parent compile would. */
export function compilationTiming(clock?: TimingClock): CompilationTiming {
if (process.env["SCRIPTC_TIMING"] !== "1") return () => {};
const start = performance.now();
let previous = start;
const opened = performance.now();
const start = clock === undefined ? opened : opened - (Date.now() - clock.start);
let previous = clock === undefined ? start : opened - (Date.now() - clock.previous);
return (phase, detail = {}) => {
const now = performance.now();
process.stderr.write(
+2 -2
View File
@@ -211,7 +211,7 @@ export function signatureHash(sig: string): string {
}
/** UTF-8 bytes of a string. */
function utf8Bytes(text: string): number[] {
export function utf8Bytes(text: string): number[] {
const out: number[] = [];
for (let i = 0; i < text.length; i++) {
const cp = text.codePointAt(i)!;
@@ -233,7 +233,7 @@ function utf8Bytes(text: string): number[] {
}
/** An LLVM c"..." string body for bytes. */
function cBytes(bytes: Uint8Array, start: number, end: number): string {
export function cBytes(bytes: Uint8Array, start: number, end: number): string {
let s = "";
for (let i = start; i < end; i++) {
const b = bytes[i]!;
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/4635-contract-program-lifetime-layouts.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6495-key-order-rewritten-literals.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6496-key-order-built-and-parsed.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6580-emitter-typed-untyped-listeners.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6581-emitter-untyped-error-and-meta.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6582-emitter-class-expression-escapes.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6583-emitter-untyped-surface.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6584-emitter-class-body-untyped-listeners.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6819-caught-instanceof-dynamic-payloads.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6903-typedarray-of.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6904-typedarray-of-loose.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6907-generic-member-defaulted-callback.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6930-dyn-buffer-methods.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6930-fs-promises-rm-options.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/6941-emitter-untyped-zero-arg-listener.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/function-integrity-names.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/function-integrity.js"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/object-literal-tostring-template.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/prototype-tostring-conversions.cjs"
],
"diags": []
}

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