Merge main

This commit is contained in:
Chris Tate
2026-10-22 22:26:04 -05:00
245 changed files with 18869 additions and 554 deletions
+1 -1
View File
@@ -360,7 +360,7 @@ A class can be both `ProgramLifetime` and `Acyclic`. `Acyclic` is still checked
Instances can be published to other threads with `@scriptc/threads` like other class instances: `publish()` makes them immutable and shares them by reference, writes to a published instance throw the same `TypeError` as under Node.js, and instances that were not published stay writable and are cloned by `postMessage` and `workerData`.
Static code has no `WeakRef` or `FinalizationRegistry`, and a `WeakMap` or `WeakSet` entry whose key is never freed is never observable as removed, so nothing else can tell the difference.
Static code has no `WeakRef`, a `FinalizationRegistry` never runs its cleanup callbacks, and a `WeakMap` or `WeakSet` entry whose key is never freed is never observable as removed, so nothing else can tell the difference.
### Lifetime regions
@@ -3,6 +3,15 @@ Copied values, other native handles, and symbol keys are unsupported.
Constructor seeds support arrays; custom iterators and detached methods are unsupported.
Weak collections do not retain their keys, but values that refer back to a key can keep it alive until deletion or collection release.
`FinalizationRegistry` keeps its registrations: `register` and `unregister` check their arguments and answer as in Node.js.
Its cleanup callbacks never run, which the ECMAScript specification allows.
Program output matches Node.js, which runs cleanup callbacks only when its garbage collector decides to.
Memory that a package frees only from a cleanup callback is not reclaimed until the program exits.
For example, a wasm-bindgen package frees the WebAssembly memory behind a wrapper object from its cleanup callback, so a long-running server that drops such wrappers without calling their `free()` method keeps that memory.
Call `free()` on wrappers the program no longer needs.
Symbol targets and unregister tokens are unsupported.
`WeakRef` is unsupported.
Memory uses reference counting.
Acyclic values are freed deterministically, and cycles are collected at defined collection points.
Native checked objects, arrays, Sets, and captured closures participate in cycle collection.
@@ -57,10 +57,11 @@ test("only empty literals stored where the module appends construct small", () =
expect(found.has(idle)).toBe(false);
expect(found.has(returned)).toBe(false);
// Only `fill` builds arrays.
// Only `fill` builds arrays. They may hold int32 slots (element kind 7),
// which does not change the choice of constructor.
const llvm = emitLlvmModule(module(body, ["xs", "ys"]));
expect(llvm.match(/call ptr @scr_arr_new_small\(i32 0\)/g)).toHaveLength(2);
expect(llvm.match(/call ptr @scr_arr_new\(i32 0, i64 0\)/g)).toHaveLength(2);
expect(llvm.match(/call ptr @scr_arr_new_small\(i32 [07]\)/g)).toHaveLength(2);
expect(llvm.match(/call ptr @scr_arr_new\(i32 [07], i64 0\)/g)).toHaveLength(2);
});
test("a binding with the same id in another function does not count", () => {
@@ -33,7 +33,7 @@ import {
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { compactRefLoadLines, compactRefStoreLines } from "./classes.js";
import { elemAccess } from "./shapes.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { indexInteger } from "./integer-values.js";
import { integerOrNaNIndex } from "./wrapping-sums.js";
const SCR_ARR_VALUE = 1;
@@ -99,14 +99,17 @@ function emitDenseGuard(
): DenseGuard {
const B = host.B;
const size = host.sizeType;
const integer = exactInteger(host, index, indexExpr);
const nanIndex = integer ? null : integerOrNaNIndex(host, index, indexExpr);
const integer = indexInteger(host, index, indexExpr);
const nanIndex = integer === null ? integerOrNaNIndex(host, index, indexExpr) : null;
let wide: string;
let integral: string | null = null;
if (integer) {
// A proven integer needs no double round-trip. Negative signed values
// compare above any length or capacity as unsigned 64-bit integers.
wide = widenInteger(host, integer);
if (integer !== null) {
// A proven integer, or a wide view that equals the double at every
// index below a length, needs no double round-trip. Negative signed
// values compare above any length or capacity as unsigned 64-bit
// integers, and every index that fails the comparisons takes the
// double's path.
wide = integer;
} else if (nanIndex) {
// An exact integer or NaN is an index exactly when it is ordered.
wide = nanIndex.wide;
@@ -239,9 +242,10 @@ function statePointer(
return statePtr;
}
/** How an array's dense slots hold its elements: the access kind, or
* `cref` for references to compact class instances (SCR_ELEM_CREF: a 4-byte
* slot holding the address over 8; LlEmitter.compactArrayElem). */
/** How an array's dense slots hold its elements: the access kind (`i32`
* for int32 arrays, ir/int32-arrays.ts), or `cref` for references to compact
* class instances (SCR_ELEM_CREF: a 4-byte slot holding the address over 8;
* LlEmitter.compactArrayElem). */
type SlotKind = ArrayAccess | "cref";
function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType): SlotKind {
@@ -249,14 +253,14 @@ function slotKind(host: LlvmEmitterContext, acc: ArrayAccess, element: IrType):
}
/** The dense slot type: boolean arrays store one byte per element, compact
* references four, every other element kind an 8-byte slot. */
* references and int32 arrays four, every other element kind an 8-byte slot. */
function slotType(kind: SlotKind): "i8" | "i32" | "i64" {
return kind === "bool" ? "i8" : kind === "cref" ? "i32" : "i64";
return kind === "bool" ? "i8" : kind === "cref" || kind === "i32" ? "i32" : "i64";
}
/** Bytes per dense slot (scr_arr_slot_width). */
function slotWidth(kind: SlotKind): number {
return kind === "bool" ? 1 : kind === "cref" ? 4 : 8;
export function slotWidth(kind: SlotKind): number {
return kind === "bool" ? 1 : kind === "cref" || kind === "i32" ? 4 : 8;
}
/** Bytes per dense slot of an array of `element` (scr_arr_slot_width). */
@@ -264,6 +268,17 @@ export function denseSlotWidth(host: LlvmEmitterContext, element: IrType): numbe
return slotWidth(slotKind(host, elemAccess(element), element));
}
/** The runtime accessor family of an access: int32 arrays answer through
* the double-valued entry points, which convert their slots. */
export function runtimeAccess(acc: ArrayAccess): "f64" | "bool" | "ref" {
return acc === "i32" ? "f64" : acc;
}
/** The LLVM type of an access's values at the emitter's interface. */
function valueType(acc: ArrayAccess): "double" | "i1" | "ptr" {
return acc === "f64" || acc === "i32" ? "double" : acc === "bool" ? "i1" : "ptr";
}
function valuePointer(
host: LlvmEmitterContext,
guard: Pick<DenseGuard, "offset" | "data">,
@@ -285,7 +300,13 @@ function loadState(host: LlvmEmitterContext, guard: DenseGuard): string {
return state;
}
export type ArrayAccess = "f64" | "bool" | "ref";
/** The ScrElemKind of int32 arrays (scr_array.h). */
export const SCR_ELEM_I32 = 7;
/** How a dense array's slots are read and written: doubles, booleans,
* references, or the int32 slots of SCR_ELEM_I32 arrays (ir/int32-arrays.ts),
* whose values are doubles at the emitter's interface. */
export type ArrayAccess = "f64" | "bool" | "ref" | "i32";
/** Before a self-append (`a[i] = a[i] + suffix`) replaces a reference
* element: when `index` lies in the hole-free prefix and the slot still
@@ -341,15 +362,33 @@ export function emitDenseArrayGet(
retain: boolean,
proven = false,
): string {
return emitDenseArrayRead(host, array, index, indexExpr, acc, element, getter, retain, proven)
.value;
}
/** emitDenseArrayGet, with the int32 of an int32 array's element: the
* slot itself on the dense path, the runtime getter's double converted
* (exactly: the element is an int32) on the other. */
export function emitDenseArrayRead(
host: LlvmEmitterContext,
array: string,
index: LlValue,
indexExpr: IrExpr | undefined,
acc: ArrayAccess,
element: IrType,
getter: string,
retain: boolean,
proven = false,
): { value: string; int32: string | null } {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const ty = valueType(acc);
const ret = acc === "bool" ? "zeroext i1" : ty;
const kind = slotKind(host, acc, element);
host.declare(`declare ${ret} @${getter}(ptr, double)`);
if (acc === "ref" && retain && !isRefCounted(element)) {
const t = B.tmp();
B.line(`${t} = call ${ty} @${getter}(ptr ${array}, double ${index.name})`);
return t;
return { value: t, int32: null };
}
const slow = B.newLabel("arr.get.slow"),
join = B.newLabel("arr.get.join"),
@@ -380,10 +419,16 @@ export function emitDenseArrayGet(
B.startBlock(valueLabel);
const ptr = valuePointer(host, guard, kind);
let fast: string;
let fastInt: string | null = null;
let fastLabel = valueLabel;
if (acc === "f64") {
fast = B.tmp();
B.line(`${fast} = load double, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
} else if (acc === "i32") {
fastInt = B.tmp();
fast = B.tmp();
B.line(`${fastInt} = load i32, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
B.line(`${fast} = sitofp i32 ${fastInt} to double`);
} else if (acc === "bool") {
const raw = B.tmp();
fast = B.tmp();
@@ -410,11 +455,21 @@ export function emitDenseArrayGet(
const slowValue = B.tmp();
const attrs = acc === "ref" && retain ? " cold" : READ_FALLBACK;
B.line(`${slowValue} = call ${ty} @${getter}(ptr ${array}, double ${index.name})${attrs}`);
let slowInt: string | null = null;
if (fastInt !== null) {
slowInt = B.tmp();
B.line(`${slowInt} = fptosi double ${slowValue} to i32`);
}
B.br(join);
B.startBlock(join);
const result = B.tmp();
B.line(`${result} = phi ${ty} [ ${fast}, %${fastLabel} ], [ ${slowValue}, %${slow} ]`);
return result;
let int32: string | null = null;
if (fastInt !== null && slowInt !== null) {
int32 = B.tmp();
B.line(`${int32} = phi i32 [ ${fastInt}, %${fastLabel} ], [ ${slowInt}, %${slow} ]`);
}
return { value: result, int32 };
}
/** `scr_arr_get_number`: a numeric element, or NaN for every missing,
@@ -425,13 +480,14 @@ export function emitDenseArrayNumber(
index: LlValue,
indexExpr: IrExpr | undefined,
proven = false,
acc: "f64" | "i32" = "f64",
): string {
return emitDenseArrayGet(
host,
array,
index,
indexExpr,
"f64",
acc,
{ kind: "f64" },
"scr_arr_get_number",
false,
@@ -486,7 +542,9 @@ export function emitDenseArrayState(
* value's reference. A dense index stores the slot, marks it present, and
* extends the length when it lands at or past it; a replaced present
* reference is released after the new edge is published (a release can
* collect cycles). Every other index takes the unchanged runtime call. */
* collect cycles). Every other index takes the unchanged runtime call.
* `int32`, for an int32 array, is the value as an i32 when the caller has
* it at hand. */
export function emitDenseArraySet(
host: LlvmEmitterContext,
array: string,
@@ -496,11 +554,12 @@ export function emitDenseArraySet(
element: IrType,
value: string,
proven = false,
int32: string | null = null,
): void {
const B = host.B;
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const ty = valueType(acc);
const kind = slotKind(host, acc, element);
const setter = `scr_arr_set_${acc}`;
const setter = `scr_arr_set_${runtimeAccess(acc)}`;
host.declare(`declare void @${setter}(ptr, double, ${acc === "bool" ? "i1 zeroext" : ty})`);
const call = (attrs: string): void =>
B.line(`call void @${setter}(ptr ${array}, double ${index.name}, ${ty} ${value})${attrs}`);
@@ -508,6 +567,13 @@ export function emitDenseArraySet(
call("");
return;
}
// An int32 array's stores are proven int32s (ir/int32-arrays.ts); both
// dense paths below store the converted value.
let int32Value: string | null = int32;
if (acc === "i32" && int32Value === null) {
int32Value = B.tmp();
B.line(`${int32Value} = fptosi double ${value} to i32`);
}
const slow = B.newLabel("arr.set.slow"),
join = B.newLabel("arr.set.join"),
filledLabel = B.newLabel("arr.set.filled");
@@ -526,6 +592,8 @@ export function emitDenseArraySet(
const storeValue = (valuePtr: string): void => {
if (acc === "f64") {
B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "i32") {
B.line(`store i32 ${int32Value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "bool") {
const bits = B.tmp();
B.line(`${bits} = zext i1 ${value} to i8`);
@@ -713,12 +781,21 @@ export function emitDenseArrayPop(
B.line(`${wide} = zext i8 ${raw} to i64`);
raw = wide;
} else if (slotTy === "i32") {
// The 8-byte slot of a compact reference is its address.
const wide = B.tmp(),
address = B.tmp();
B.line(`${wide} = zext i32 ${raw} to i64`);
B.line(`${address} = shl nuw nsw i64 ${wide}, 3`);
raw = address;
if (kind === "cref") {
// The 8-byte slot of a compact reference is its address.
const wide = B.tmp(),
address = B.tmp();
B.line(`${wide} = zext i32 ${raw} to i64`);
B.line(`${address} = shl nuw nsw i64 ${wide}, 3`);
raw = address;
} else {
// The slot bits of an int32 element's double, as the f64 path returns them.
const number = B.tmp(),
bits = B.tmp();
B.line(`${number} = sitofp i32 ${raw} to double`);
B.line(`${bits} = bitcast double ${number} to i64`);
raw = bits;
}
}
B.line(`store ${slotTy} 0, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
// Only an array with states has a state byte to clear.
@@ -755,8 +832,8 @@ export function emitDenseArrayPush(
const B = host.B;
const size = host.sizeType;
const kind = element === null ? acc : slotKind(host, acc, element);
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const pusher = `scr_arr_push_${acc}`;
const ty = valueType(acc);
const pusher = `scr_arr_push_${runtimeAccess(acc)}`;
host.declare(`declare double @${pusher}(ptr, ${acc === "bool" ? "i1 zeroext" : ty})`);
const header = (field: number): string => {
const ptr = B.tmp();
@@ -794,6 +871,10 @@ export function emitDenseArrayPush(
const valuePtr = valuePointer(host, { offset: len, data }, kind);
if (acc === "f64") {
B.line(`store double ${value}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "i32") {
const int32 = B.tmp();
B.line(`${int32} = fptosi double ${value} to i32`);
B.line(`store i32 ${int32}, ptr ${valuePtr}${host.fieldAliasAttachment(valuePtr)}`);
} else if (acc === "bool") {
const bits = B.tmp();
B.line(`${bits} = zext i1 ${value} to i8`);
@@ -61,6 +61,7 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
const B = new BlockBuilder();
emitter.B = B;
emitter.currentFunctionName = fn.name;
emitter.findInt32Allocations(fn);
emitter.currentTier = fn.tier;
// Fiber switches restore the active exception cell before control
// returns to a synchronous frame (emitPendingCheck).
@@ -138,6 +139,8 @@ export function emitFunction(emitter: LlEmitter, fn: IrFunction): string {
emitter.speculativeFunction = fn.name;
emitter.speculation = null;
emitter.speculationResume = null;
emitter.int32Kinds = null;
emitter.int32KindResume = null;
emitter.finalFieldBorrows =
!fn.async &&
!fn.generator &&
@@ -34,9 +34,16 @@ import { dynHoleRead } from "./loop-array-borrows.js";
import { endsWithJump, matchStringSelfConcat } from "../../ir/analysis.js";
import { charAtUnitRead, emitDiscardedSplice } from "./expr-containers.js";
import { InternalCompilerError } from "../../errors.js";
import { emitDenseArrayGet, emitDenseArrayPop, emitDenseArraySet } from "./dense-array-access.js";
import {
emitDenseArrayGet,
emitDenseArrayPop,
emitDenseArraySet,
SCR_ELEM_I32,
} from "./dense-array-access.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { f64Lit, llvmCommentText, loadArrCount, sizeToDouble } from "./common.js";
import { f64Lit, llvmCommentText, loadArrCount, SCR_ARR_FIELD, sizeToDouble } from "./common.js";
import { planInt32KindLoop, type Int32KindLoopPlan } from "./int32-kind-loops.js";
import { withinInt32 } from "../../ir/integer-ranges.js";
import { resultSlotStore } from "../result-slots.js";
import {
matchIntegerArrayForLoop,
@@ -411,7 +418,21 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
const v = emitter.emitExpr(s.value);
if (s.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arraySet on non-array");
const acc = elemAccess(s.arr.type.elem);
if (emitter.checkedInt32Array(s.arr)) {
// An int32 array with checked stores: the int32 arm stores an int32
// value, and widens the array for any other first.
emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name);
const elem = s.arr.type.elem;
const proven = emitter.provenDenseIndex(s.arr, s.index);
emitter.emitByElementKind(arr.name, null, (acc, widened) => {
if (acc === "i32") emitter.emitCheckedInt32Store(arr.name, v.name, s.value, widened);
emitDenseArraySet(emitter, arr.name, idx, s.index, acc, elem, v.name, proven);
return null;
});
break;
}
const acc = emitter.arrayAccess(s.arr, s.arr.type.elem);
const int32 = emitter.emitInt32KindStoreCheck(s.arr, v, s.value);
emitter.emitPublishedArrayGuard(arr.name, s.arr.type, 0, idx.name);
if (acc === "ref") emitter.toHeap(v);
emitDenseArraySet(
@@ -423,6 +444,7 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
s.arr.type.elem,
v.name,
emitter.provenDenseIndex(s.arr, s.index),
int32,
);
break;
}
@@ -478,12 +500,19 @@ export function emitStmtBody(emitter: LlEmitter, s: IrStmt): void {
B.condBr(dropsLast, pop, call);
B.startBlock(pop);
const elem = s.arr.type.elem;
const acc = elemAccess(elem);
const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call);
if (acc === "ref" && isRefCounted(elem)) {
const old = B.tmp();
B.line(`${old} = inttoptr i64 ${raw} to ptr`);
emitter.releaseHeap(old, elem);
if (emitter.checkedInt32Array(s.arr)) {
emitter.emitByElementKind(arr.name, null, (acc) => {
emitDenseArrayPop(emitter, arr.name, acc, elem, call, call);
return null;
});
} else {
const acc = emitter.arrayAccess(s.arr, elem);
const raw = emitDenseArrayPop(emitter, arr.name, acc, elem, call, call);
if (acc === "ref" && isRefCounted(elem)) {
const old = B.tmp();
B.line(`${old} = inttoptr i64 ${raw} to ptr`);
emitter.releaseHeap(old, elem);
}
}
B.br(done);
B.startBlock(call);
@@ -1826,7 +1855,7 @@ export function emitTryCatch(emitter: LlEmitter, s: IrStmt & { kind: "tryCatch"
* case, refcounted/boxed case-body locals are NULL-reset up front and
* the scope-exit releases rely on NULL tolerance. */
export function emitSwitch(emitter: LlEmitter, s: IrStmt & { kind: "switch" }): void {
const unitSwitch = charAtUnitSwitch(s);
const unitSwitch = charAtUnitSwitch(emitter, s);
if (unitSwitch !== null) return emitSwitch(emitter, unitSwitch);
const B = emitter.B;
const discKind = s.disc.type.kind;
@@ -2098,9 +2127,26 @@ export function emitLoopCopies(
)
: null;
if (!plan) {
const proven = allowed ? planProvenLoop(emitter, loop) : null;
if (proven) emitProvenLoop(emitter, proven, exit, emitCopy);
else emitCopy(exit);
const ordinary = (exit: string): void => {
const proven = allowed ? planProvenLoop(emitter, loop) : null;
if (proven) emitProvenLoop(emitter, proven, exit, emitCopy);
else emitCopy(exit);
};
const kinds =
allowed &&
emitter.speculativeLoopsEnabled &&
emitter.int32Slots.checkedInt32Arrays.size > 0 &&
emitter.int32Kinds === null &&
emitter.speculation === null &&
emitter.speculationResume === null
? planInt32KindLoop(
loop,
(id) => emitter.checkedInt32Local(id),
(e) => withinInt32(emitter.integerRanges.get(e)),
)
: null;
if (kinds) emitInt32KindLoop(emitter, kinds, exit, kinds.writes.size > 0 ? emitCopy : ordinary);
else ordinary(exit);
return;
}
const B = emitter.B;
@@ -2243,6 +2289,124 @@ export function emitLoopCopies(
}
}
/** An int32 kind loop (int32-kind-loops.ts). The entry test reads the kind
* of each of the plan's arrays: when every one holds int32 slots the fast
* copy runs, when every one holds doubles the wide copy, which reads and
* writes them as double arrays, and otherwise (a plan with several arrays)
* the mixed copy, which tests their kinds at each access. A failed store of
* the fast copy re-executes in the third copy, which finishes its
* iteration with kind tests and then enters the mixed copy, or the wide
* copy when the plan has one array (it just widened). Each copy is emitted
* by `ordinary`: the loop's plain copy when a store may fail, since a
* proven integer loop keeps locals in shadows of its own copy that another
* copy cannot resume from. */
function emitInt32KindLoop(
emitter: LlEmitter,
plan: Int32KindLoopPlan,
exit: string,
ordinary: (exit: string) => void,
): void {
const B = emitter.B;
const fn = emitter.currentFunctionName;
const keys = new Set([...plan.arrays.keys()].map((id) => `${fn}\0${id}`));
const several = keys.size > 1;
let allInt32 = "true",
allWide = "true";
const and = (all: string, next: string): string => {
if (all === "true") return next;
const both = B.tmp();
B.line(`${both} = and i1 ${all}, ${next}`);
return both;
};
for (const ref of plan.arrays.values()) {
const arr = emitter.emitStableReceiver(ref, []);
const kindPtr = B.tmp(),
kind = B.tmp(),
isInt32 = B.tmp();
B.line(
`${kindPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 ${SCR_ARR_FIELD.elem}`,
);
emitter.markMemoryPointer(kindPtr, "array:header");
B.line(`${kind} = load i8, ptr ${kindPtr}${emitter.fieldAliasAttachment(kindPtr)}`);
B.line(`${isInt32} = icmp eq i8 ${kind}, ${SCR_ELEM_I32}`);
allInt32 = and(allInt32, isInt32);
if (several) {
const isWide = B.tmp();
B.line(`${isWide} = xor i1 ${isInt32}, true`);
allWide = and(allWide, isWide);
}
}
const fast = B.newLabel("kind.fast"),
wide = B.newLabel("kind.wide"),
mixed = several ? B.newLabel("kind.mixed") : null;
if (mixed === null) B.condBr(allInt32, fast, wide);
else {
const other = B.newLabel("kind.other");
B.condBr(allInt32, fast, other);
B.startBlock(other);
B.condBr(allWide, wide, mixed);
}
const resume = new Map<IrStmt, { label: string; placed: boolean }>();
for (const s of plan.writes) resume.set(s, { label: B.newLabel("kind.resume"), placed: false });
// The header the third copy enters: the mixed copy's, or the wide copy's.
let header: string | null = null;
const capture = (label: string): void => {
header = label;
};
B.startBlock(wide);
if (resume.size > 0 && mixed === null) emitter.speculationHeader = capture;
emitter.int32Kinds = { int32: new Set(), wide: keys, resume: new Map() };
try {
ordinary(exit);
} finally {
emitter.speculationHeader = null;
emitter.int32Kinds = null;
}
if (mixed !== null) {
B.startBlock(mixed);
if (resume.size > 0) emitter.speculationHeader = capture;
try {
ordinary(exit);
} finally {
emitter.speculationHeader = null;
}
}
if (resume.size > 0) {
if (header === null)
throw new InternalCompilerError("llvm emitter bug: int32 kind loop copy without a header");
// A failed store finishes its iteration here, as in a speculative
// integer loop's third copy.
B.startBlock(B.newLabel("kind.tail"));
emitter.speculationResume = resume;
emitter.speculationHeader = () => {
B.br(header!);
B.startBlock(B.newLabel("kind.unused"));
};
try {
ordinary(exit);
} finally {
emitter.speculationResume = null;
emitter.speculationHeader = null;
}
}
B.startBlock(fast);
if (![...resume.values()].every((r) => r.placed)) {
// A write the third copy never reached: this copy tests the kinds.
ordinary(exit);
return;
}
emitter.int32Kinds = {
int32: keys,
wide: new Set(),
resume: new Map([...resume].map(([s, r]) => [s, r.label])),
};
try {
ordinary(exit);
} finally {
emitter.int32Kinds = null;
}
}
/** At the top of a loop copy's header: lets a speculative loop find its
* ordinary copy's header, and keeps its third copy out of the loop. */
function enterLoopHeader(emitter: LlEmitter, header: string): void {
@@ -2518,7 +2682,10 @@ function nullableStringSwitch(
* one-unit string literals, as a switch on `s.charCodeAt(i)` with the units
* as cases: no character string is built. An index out of range answers
* NaN, which matches no case, as "" matches no one-unit literal. */
function charAtUnitSwitch(s: IrStmt & { kind: "switch" }): (IrStmt & { kind: "switch" }) | null {
function charAtUnitSwitch(
emitter: LlEmitter,
s: IrStmt & { kind: "switch" },
): (IrStmt & { kind: "switch" }) | null {
const disc = s.disc;
if (disc.kind !== "strIntrinsic" || disc.method !== "charAt" || disc.args.length !== 1)
return null;
@@ -2531,7 +2698,7 @@ function charAtUnitSwitch(s: IrStmt & { kind: "switch" }): (IrStmt & { kind: "sw
return null;
return {
...s,
disc: charAtUnitRead(disc),
disc: charAtUnitRead(emitter, disc),
cases: s.cases.map((c) =>
c.test?.kind === "strLit"
? {
+299 -48
View File
@@ -79,8 +79,18 @@ import {
} from "./emit-journal.js";
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
import { versionLoopBoundParameters } from "../../ir/parameter-versions.js";
import {
allocatedLiteral,
fillAllocationLength,
selectInt32Arrays,
} from "../../ir/int32-arrays.js";
import { everyExpr, everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { INT32_RANGE, type IntegerRange, type IntegerRanges } from "../../ir/integer-ranges.js";
import {
INT32_RANGE,
withinInt32,
type IntegerRange,
type IntegerRanges,
} from "../../ir/integer-ranges.js";
import { unshareNodes } from "../../ir/unshare.js";
import { copyStableCaptures } from "../../ir/capture-values.js";
import { resolveSelfCaptures } from "../../ir/self-captures.js";
@@ -100,7 +110,7 @@ import {
} from "./int32-abi.js";
import { type Int32Local } from "./int32-locals.js";
import { findIntegerClones, NO_INTEGER_CLONES, type IntegerClones } from "./integer-clones.js";
import { exactInteger, narrowInteger } from "./integer-values.js";
import { exactInteger, narrowInteger, widenInteger } from "./integer-values.js";
import { findInitializerBindings } from "../../ir/initializer-bindings.js";
import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js";
import {
@@ -147,7 +157,12 @@ import {
} from "./inline-unions.js";
import { OptionalArrayReads, type LocalArrayRead } from "./local-array-reads.js";
import { CheckedNarrows } from "./checked-narrows.js";
import { emitDenseArraySet, emitDenseSlotUnlink } from "./dense-array-access.js";
import {
emitDenseArraySet,
emitDenseSlotUnlink,
SCR_ELEM_I32,
type ArrayAccess,
} from "./dense-array-access.js";
import { matchMapRead, type MapReadLifetimes } from "./map-read-lifetimes.js";
import { type MapEntryFusion } from "./map-entry-fusion.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
@@ -168,7 +183,7 @@ import {
} from "./string-slices.js";
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import { emitFieldAbsentTest, f64Lit } from "./common.js";
import { emitFieldAbsentTest, f64Lit, SCR_ARR_FIELD } from "./common.js";
import { ffiExtendsNarrowIntegers } from "../targets.js";
import {
emitLiteralExpr,
@@ -483,39 +498,65 @@ function versionParameters(mod: IrModule): IrModule {
return versionLoopBoundParameters(mod, mangleFunction);
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
/** The int32 arrays the int32 slot analysis keeps in `mod` (proven and
* checked), under the inputs LlEmitter gives it (selectInt32Arrays). */
function provenInt32Arrays(mod: IrModule): ReadonlySet<string> {
const slots = analyzeInt32Slots(mod, {
dynamicStores: dynamicFieldStores(mod),
initializerBindings: findInitializerBindings(mod),
});
return new Set([...slots.int32Arrays, ...slots.checkedInt32Arrays]);
}
/** The emitter for `input` after the module passes every emission runs. */
function moduleEmitter(input: IrModule, options: LlvmTargetOptions): LlEmitter {
// Facts are keyed by node identity: emit the same module whether it was
// lowered in memory or read back from its serialized form.
const mod = unshareNodes(input);
// Keep source storage intact for debugger inspection in dev builds.
return new LlEmitter(
options.debugSources === undefined
? versionParameters(
addRecordResultEntries(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
? selectInt32Arrays(
versionParameters(
addRecordResultEntries(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
),
),
),
),
),
mangleFunction,
),
mangleFunction,
),
mangleFunction,
),
),
),
provenInt32Arrays,
)
: mod,
options,
).emit();
);
}
export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
return moduleEmitter(input, options).emit();
}
/** emitLlvmModule's text as parts that concatenate to it, without the copy
* that joining them makes: a large program's text is 150 MB. */
export function emitLlvmModuleParts(input: IrModule, options: LlvmTargetOptions = {}): string[] {
return moduleEmitter(input, options)
.emitParts()
.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
}
/** Keep large translation units below the host's single-string limit. */
@@ -525,35 +566,7 @@ export function emitLlvmModuleSource(
): string | readonly string[] {
// IR validation, contracts and the frontend's disposal run after lowering without checks.
relieveHeapPressure(true);
const mod = unshareNodes(input);
const parts = new LlEmitter(
options.debugSources === undefined
? versionParameters(
addRecordResultEntries(
scalarizeNumericRecords(
specializeCallbackCalls(
specializeNumericCalls(
foldConstantGlobals(
copyStableCaptures(
resolveSelfCaptures(
hoistFinalFieldReads(
freezeInitOnlyGlobals(
holdCharLocalsAsUnits(dropUnwrittenPropertyBags(mod)),
),
),
),
),
),
mangleFunction,
),
mangleFunction,
),
),
),
)
: mod,
options,
).emitParts();
const parts = moduleEmitter(input, options).emitParts();
const length = parts.reduce((total, part) => total + part.length, Math.max(0, parts.length - 1));
if (length <= 256 * 1024 * 1024) return parts.join("\n");
return parts.flatMap((part, index) => (index === 0 ? [part] : ["\n", part]));
@@ -734,6 +747,228 @@ export class LlEmitter {
return `, !tbaa !${tag}`;
}
/** The current function's int32 array allocations: the array literals
* its int32 arrays are written with. They construct SCR_ELEM_I32 arrays. */
int32Allocations = new Set<IrExpr>();
/** The fill temporaries (`new Array(n).fill(v)`) of those allocations:
* the same arrays, so int32 too. */
int32ArrayTemps = new Set<string>();
/** The same for the current function's int32 arrays with checked stores,
* which are only allocated by fills (ir/int32-arrays.ts
* checkableInt32Allocations): their temporaries' literals, with the fill's
* length (a literal constructs SCR_ELEM_I32 storage when that length is at
* least CHECKED_INT32_MIN_LENGTH, SCR_ELEM_F64 otherwise), and their
* temporaries. */
checkedAllocations = new Map<IrExpr, IrExpr>();
checkedArrayTemps = new Set<string>();
/** The fill calls (`.fill(v)`) of those allocations: a value that is not
* an int32 widens the new array first. */
checkedFills = new Set<IrExpr>();
/** Collect the int32 allocations of `fn`, about to be emitted. */
findInt32Allocations(fn: IrFunction): void {
this.int32Allocations.clear();
this.int32ArrayTemps.clear();
this.checkedAllocations.clear();
this.checkedArrayTemps.clear();
this.checkedFills.clear();
const { int32Arrays, checkedInt32Arrays } = this.int32Slots;
if (int32Arrays.size === 0 && checkedInt32Arrays.size === 0) return;
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind !== "varDecl" && s.kind !== "assign") return true;
const key = `${fn.name}\0${s.localId}`;
const checked = checkedInt32Arrays.has(key);
if (!checked && !int32Arrays.has(key)) return true;
const value = s.kind === "varDecl" ? s.init : s.value;
const allocation = value && allocatedLiteral(value);
if (!allocation) return true;
const length = checked ? fillAllocationLength(value) : null;
if (length !== null) this.checkedAllocations.set(allocation.literal, length);
else if (!checked) this.int32Allocations.add(allocation.literal);
if (allocation.temporary !== null)
(checked ? this.checkedArrayTemps : this.int32ArrayTemps).add(allocation.temporary);
if (checked && value !== allocation.literal) this.checkedFills.add(value!);
return true;
},
expr: () => true,
});
}
/** Whether `arr` is one of the current function's int32 arrays
* (ir/int32-arrays.ts, kept by the int32 slot analysis). */
int32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef") return false;
if (this.int32KindArray(arr)) return true;
if (this.int32Slots.int32Arrays.size === 0) return false;
return (
this.int32ArrayTemps.has(arr.localId) ||
this.int32Slots.int32Arrays.has(`${this.currentFunctionName}\0${arr.localId}`)
);
}
/** Whether `arr` is one of the current function's int32 arrays with
* checked stores: int32 slots until a store widens it to doubles. Every
* access tests its kind (emitByElementKind), except in the fast copy of
* an int32 kind loop, which reads and writes it as an int32 array. */
checkedInt32Array(arr: IrExpr): boolean {
if (arr.kind !== "varRef" || this.int32KindArray(arr)) return false;
if (this.int32Kinds?.wide.has(`${this.currentFunctionName}\0${arr.localId}`)) return false;
return this.checkedInt32Local(arr.localId);
}
/** Whether the local `localId` holds one of the current function's int32
* arrays with checked stores. */
checkedInt32Local(localId: string): boolean {
if (this.int32Slots.checkedInt32Arrays.size === 0) return false;
return (
this.checkedArrayTemps.has(localId) ||
this.int32Slots.checkedInt32Arrays.has(`${this.currentFunctionName}\0${localId}`)
);
}
/** Whether `arr` is an int32 array with checked stores that the fast copy
* of the int32 kind loop being emitted holds as an int32 array. */
int32KindArray(arr: IrExpr): boolean {
return (
this.int32Kinds !== null &&
arr.kind === "varRef" &&
this.int32Kinds.int32.has(`${this.currentFunctionName}\0${arr.localId}`)
);
}
/** Before a store of `value` (of `expr`) into `arr`, in the fast copy of
* an int32 kind loop: continue when it is an int32 that is not -0, and
* otherwise leave for the statement's resume point, where it re-executes
* with kind tests. Returns the value as an i32 when it is at hand as an
* integer, or null when the store converts the double itself. */
emitInt32KindStoreCheck(arr: IrExpr, value: LlValue, expr: IrExpr): string | null {
if (!this.int32KindArray(arr) || withinInt32(this.integerRanges.get(expr))) return null;
const integer = exactInteger(this, value, expr);
if (integer !== null && withinInt32(integer.range)) return narrowInteger(this, integer);
const resume = this.int32KindResume;
if (resume === null)
throw new InternalCompilerError("llvm emitter bug: an int32 kind store without a resume");
const B = this.B;
const ok = B.tmp(),
int = B.tmp();
if (integer !== null) {
// An exact integer: a range test on its 64-bit form.
const wide = widenInteger(this, integer),
low = B.tmp(),
high = B.tmp();
B.line(`${low} = icmp sge i64 ${wide}, -2147483648`);
B.line(`${high} = icmp sle i64 ${wide}, 2147483647`);
B.line(`${ok} = and i1 ${low}, ${high}`);
const store = B.newLabel("arr.store.i32");
B.condBr(ok, store, resume);
B.startBlock(store);
B.line(`${int} = trunc i64 ${wide} to i32`);
return int;
}
this.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)");
const back = B.tmp(),
exact = B.tmp(),
bits = B.tmp(),
notNegativeZero = B.tmp();
B.line(`${int} = call i32 @llvm.fptosi.sat.i32.f64(double ${value.name})`);
B.line(`${back} = sitofp i32 ${int} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${value.name}`);
B.line(`${bits} = bitcast double ${value.name} to i64`);
B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`);
B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`);
const store = B.newLabel("arr.store.i32");
B.condBr(ok, store, resume);
B.startBlock(store);
return int;
}
/** Emit an access to the checked int32 array `array` once per element
* kind: `arm("i32", widened)` while it holds int32 slots and
* `arm("f64", widened)` after it widened, joined after both. `widened` is
* the label of the double arm, where an int32 arm's store of a value that
* is not an int32 continues after widening the array. Returns the phi of
* the arms' results (of LLVM type `ty`), or null when `ty` is null. */
emitByElementKind(
array: string,
ty: string | null,
arm: (acc: "i32" | "f64", widened: string) => string | null,
): string | null {
const B = this.B;
const narrow = B.newLabel("arr.kind.i32"),
wide = B.newLabel("arr.kind.f64"),
join = B.newLabel("arr.kind.join");
const kindPtr = B.tmp(),
kind = B.tmp(),
isInt32 = B.tmp();
B.line(
`${kindPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.elem}`,
);
this.markMemoryPointer(kindPtr, "array:header");
B.line(`${kind} = load i8, ptr ${kindPtr}${this.fieldAliasAttachment(kindPtr)}`);
B.line(`${isInt32} = icmp eq i8 ${kind}, ${SCR_ELEM_I32}`);
B.condBr(isInt32, narrow, wide);
B.startBlock(narrow);
const narrowValue = arm("i32", wide);
const narrowEnd = B.isTerminated() ? null : B.currentLabel();
if (narrowEnd !== null) B.br(join);
B.startBlock(wide);
const wideValue = arm("f64", wide);
const wideEnd = B.isTerminated() ? null : B.currentLabel();
if (wideEnd !== null) B.br(join);
B.startBlock(join);
// Arms that end in their own branches (a read's shared join) leave this
// join without predecessors.
if (narrowEnd === null && wideEnd === null) B.terminate("unreachable");
if (ty === null) return null;
const arms: string[] = [];
if (narrowEnd !== null) arms.push(`[ ${narrowValue}, %${narrowEnd} ]`);
if (wideEnd !== null) arms.push(`[ ${wideValue}, %${wideEnd} ]`);
const result = B.tmp();
B.line(`${result} = phi ${ty} ${arms.join(", ")}`);
return result;
}
/** Before storing the double `value` (of `expr`) into an int32 arm
* (emitByElementKind) of the checked int32 array `array`: continue in this
* arm when it is an int32 that is not -0, or widen the array and continue
* at `widened`. A value proven an int32 needs no test. */
emitCheckedInt32Store(array: string, value: string, expr: IrExpr, widened: string): void {
if (withinInt32(this.integerRanges.get(expr))) return;
const B = this.B;
this.declare("declare i32 @llvm.fptosi.sat.i32.f64(double)");
this.declare("declare void @scr_arr_widen_i32(ptr)");
const int = B.tmp(),
back = B.tmp(),
exact = B.tmp(),
bits = B.tmp(),
notNegativeZero = B.tmp(),
ok = B.tmp();
B.line(`${int} = call i32 @llvm.fptosi.sat.i32.f64(double ${value})`);
B.line(`${back} = sitofp i32 ${int} to double`);
B.line(`${exact} = fcmp oeq double ${back}, ${value}`);
B.line(`${bits} = bitcast double ${value} to i64`);
B.line(`${notNegativeZero} = icmp ne i64 ${bits}, -9223372036854775808`);
B.line(`${ok} = and i1 ${exact}, ${notNegativeZero}`);
const store = B.newLabel("arr.store.i32"),
widen = B.newLabel("arr.widen");
B.condBr(ok, store, widen);
B.startBlock(widen);
B.line(`call void @scr_arr_widen_i32(ptr ${array})`);
B.br(widened);
B.startBlock(store);
}
/** The access an element operation on `arr` (of element type `elem`)
* takes: int32 arrays read and write their 4-byte slots. */
arrayAccess(arr: IrExpr, elem: IrType): ArrayAccess {
if (this.checkedInt32Array(arr))
throw new InternalCompilerError(
"llvm emitter bug: a checked int32 array without its kind test",
);
return this.int32Array(arr) ? "i32" : elemAccess(elem);
}
/** Whether an element access at `index` of `arr` lies in the hole-free
* prefix: the counter of a dense array loop's fast copy indexing one of
* the arrays its entry test covered. */
@@ -1180,6 +1415,14 @@ export class LlEmitter {
* (`enterLoopHeader`): records the ordinary copy's header, or sends the
* third copy's header there. */
speculationHeader: ((header: string) => void) | null = null;
/** A copy of an int32 kind loop (int32-kind-loops.ts) being emitted: the
* int32 arrays with checked stores it reads and writes as int32 arrays
* (the fast copy) or as double arrays (an ordinary copy entered only
* after they widened), as `function\0local` keys, and where each of the
* fast copy's writes resumes in the third copy. */
int32Kinds: { int32: Set<string>; wide: Set<string>; resume: Map<IrStmt, string> } | null = null;
/** The resume label of the fast-copy write being emitted. */
int32KindResume: string | null = null;
/** The function being emitted, for speculative globals. */
speculativeFunction = "";
/** Every function that writes each number global (computed on demand). */
@@ -1201,6 +1444,10 @@ export class LlEmitter {
* (findIntegerOrNaNLocals in wrapping-sums.ts). */
integerOrNaNLocals: ReadonlyMap<string, IntegerRange> = new Map();
bytesBounds: ReadonlySet<IrExpr | IrStmt> = new Set();
/** The charCodeAt reads standing for charAt reads the emitter rewrote
* (one-unit tests and switches, charAtUnitRead), with the charAt each
* stands for: a loop version's proof for the charAt covers its read. */
readonly charAtUnitReads = new Map<IrExpr, IrExpr>();
/** Worker programs: typed-array accesses whose views an enclosing version
* guard proved thread-owned. */
bytesOwned: ReadonlySet<IrExpr | IrStmt> = new Set();
@@ -5268,10 +5515,14 @@ export class LlEmitter {
const loop =
s.kind === "while" || s.kind === "doWhile" || s.kind === "for" || s.kind === "forOf";
if (loop) this.loopDepth++;
// An int32 kind loop's fast-copy write leaves for its resume point.
const kindResume = this.int32KindResume;
this.int32KindResume = this.int32Kinds?.resume.get(s) ?? null;
try {
this.emitStmtBody(s);
} finally {
this.B.debugLocation = previous;
this.int32KindResume = kindResume;
if (loop) this.loopDepth--;
}
}
@@ -6518,7 +6769,7 @@ export class LlEmitter {
return tagInSet(this, uName, unionId, tags);
}
arrPush(arr: string, acc: "f64" | "bool" | "ref", value: string, element: IrType | null): string {
arrPush(arr: string, acc: ArrayAccess, value: string, element: IrType | null): string {
return arrPush(this, arr, acc, value, element);
}
@@ -1118,7 +1118,9 @@ export function emitInlineAtomicsOp(
if (op !== 0 && op !== 1 && op !== 3 && ATOMIC_RMW[op] === undefined) return null;
const elem = array.type.elem;
const B = host.B;
const receiver = host.emitExpr(array).name;
// The instruction and scr_atomics_op borrow the array: an unchanged local
// owns it across the later operands and the operation.
const receiver = emitBorrowedInput(host, array).name;
const idxValue = host.emitExpr(index);
const operand = host.emitExpr(value);
const swap = host.emitExpr(replacement);
@@ -60,7 +60,10 @@ import {
emitDenseArrayNumber,
emitDenseArrayPop,
emitDenseArrayPush,
runtimeAccess,
type ArrayAccess,
} from "./dense-array-access.js";
import { INT32_ARRAY_METHODS } from "../../ir/int32-arrays.js";
import { emitUnionWiden } from "./expr-records.js";
import { registerReplay } from "./emit-journal.js";
@@ -115,7 +118,7 @@ export function tagInSet(
export function arrPush(
host: LlvmEmitterContext,
arr: string,
acc: "f64" | "bool" | "ref",
acc: ArrayAccess,
value: string,
element: IrType | null,
): string {
@@ -126,7 +129,7 @@ export function arrPush(
export function emitArrayValues(
host: LlvmEmitterContext,
arr: string,
acc: "f64" | "bool" | "ref",
acc: ArrayAccess,
element: IrType,
values: LlValue[],
prepend = false,
@@ -142,12 +145,15 @@ export function emitArrayValues(
}
if (values.length === 1) {
if (!prepend) return arrPush(host, arr, acc, values[0]!.name, element);
const ty = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const flat = runtimeAccess(acc);
const ty = flat === "f64" ? "double" : flat === "bool" ? "i1" : "ptr";
host.declare(
`declare double @scr_arr_unshift_${acc}(ptr, ${ty}${acc === "bool" ? " zeroext" : ""})`,
`declare double @scr_arr_unshift_${flat}(ptr, ${ty}${flat === "bool" ? " zeroext" : ""})`,
);
const result = B.tmp();
B.line(`${result} = call double @scr_arr_unshift_${acc}(ptr ${arr}, ${ty} ${values[0]!.name})`);
B.line(
`${result} = call double @scr_arr_unshift_${flat}(ptr ${arr}, ${ty} ${values[0]!.name})`,
);
return result;
}
const capacity = Math.min(values.length, 64);
@@ -165,8 +171,9 @@ export function emitArrayValues(
const slot = B.tmp(),
pointer = B.tmp();
const value = values[start + i]!.name;
// The runtime takes double bits for int32 arrays too, and converts.
const pack =
acc === "f64"
acc === "f64" || acc === "i32"
? `bitcast double ${value} to i64`
: acc === "bool"
? `zext i1 ${value} to i64`
@@ -286,14 +293,11 @@ export function emitArrayCopyLoop(
host.B.line(`call double @scr_arr_push_spread(ptr ${dst}, ptr ${src})`);
}
/** The `charCodeAt` reads that stand for `charAt` reads the emitter
* rewrote, such as one-unit tests and switches: a loop version's proof
* for the charAt covers its read. */
const unitReadOrigins = new WeakMap<IrExpr, IrExpr>();
/** `s.charCodeAt(i)` in place of `s.charAt(i)`, the same index read as a
* code unit. */
* code unit. The emitter records the charAt it stands for
* (charAtUnitReads), so a loop version's proof for the charAt covers it. */
export function charAtUnitRead(
host: LlvmEmitterContext,
charAt: IrExpr & { kind: "strIntrinsic" },
): IrExpr & { kind: "strIntrinsic" } {
const read: IrExpr & { kind: "strIntrinsic" } = {
@@ -304,7 +308,7 @@ export function charAtUnitRead(
type: F64,
loc: charAt.loc,
};
unitReadOrigins.set(read, charAt);
host.charAtUnitReads.set(read, charAt);
return read;
}
@@ -312,7 +316,7 @@ export function charAtUnitRead(
* below the length (bytes-loop-versions.ts). */
function provenStringRead(host: LlvmEmitterContext, e: IrExpr): boolean {
if (host.bytesBounds.has(e)) return true;
const origin = unitReadOrigins.get(e);
const origin = host.charAtUnitReads.get(e);
return origin !== undefined && host.bytesBounds.has(origin);
}
@@ -610,7 +614,15 @@ export function emitArrIntrinsic(
if (e.receiver.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrIntrinsic on non-array");
const elem = e.receiver.type.elem;
const acc = elemAccess(elem);
// Int32 arrays (ir/int32-arrays.ts) reach only these methods; the others
// take the runtime family's flat access. One with checked stores tests
// its kind at each inline access (emitByElementKind) and otherwise takes
// the double entry points, which serve both kinds.
const checked = host.checkedInt32Array(e.receiver);
const access = checked ? "f64" : host.arrayAccess(e.receiver, elem);
if ((access === "i32" || checked) && !INT32_ARRAY_METHODS.has(e.method))
throw new InternalCompilerError(`llvm emitter bug: ${e.method} on an int32 array`);
const acc = runtimeAccess(access);
const accTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const accArg = acc === "bool" ? "i1 zeroext" : accTy;
const method = e.method;
@@ -631,6 +643,13 @@ export function emitArrIntrinsic(
e.receiver.kind === "varRef"
? host.constantNumericTables.get(e.receiver.localId)
: undefined;
if (!table && elem.kind === "f64" && checked) {
const proven = host.provenDenseIndex(e.receiver, e.args[0]!);
const value = host.emitByElementKind(r.name, "double", (kind) =>
emitDenseArrayNumber(host, r.name, index, e.args[0], proven, kind),
)!;
return { name: value, type: e.type };
}
if (!table && elem.kind === "f64")
return {
name: emitDenseArrayNumber(
@@ -639,6 +658,7 @@ export function emitArrIntrinsic(
index,
e.args[0],
host.provenDenseIndex(e.receiver, e.args[0]!),
access === "i32" ? "i32" : "f64",
),
type: e.type,
};
@@ -675,7 +695,22 @@ export function emitArrIntrinsic(
// no-op zero-argument call.
const vs = e.args.map((a) => host.emitExpr(a));
host.emitPublishedArrayGuard(r.name, e.receiver.type, 1, f64Lit(0), f64Lit(vs.length));
return { name: emitArrayValues(host, r.name, acc, elem, vs), type: e.type };
if (checked && vs.length > 0) {
// Each value appends in its own kind test: one that is not an int32
// widens the array before it is pushed.
let length = "";
vs.forEach((v, i) => {
length = host.emitByElementKind(r.name, "double", (kind, widened) => {
if (kind === "i32") host.emitCheckedInt32Store(r.name, v.name, e.args[i]!, widened);
return arrPush(host, r.name, kind, v.name, elem);
})!;
});
return { name: length, type: e.type };
}
// In an int32 kind loop's fast copy, every value is checked before
// the first appends.
vs.forEach((v, i) => host.emitInt32KindStoreCheck(e.receiver, v, e.args[i]!));
return { name: emitArrayValues(host, r.name, access, elem, vs), type: e.type };
}
case "pushSpread": {
// `a.push(...src)`: append src's elements in order (borrowed src,
@@ -749,7 +784,13 @@ export function emitArrIntrinsic(
let slowLabel = "";
if (method === "pop") {
slowLabel = B.newLabel("remove.slow");
denseRaw = emitDenseArrayPop(host, r.name, acc, elem, la, slowLabel);
const empty = la,
slow = slowLabel;
denseRaw = checked
? host.emitByElementKind(r.name, "i64", (kind) =>
emitDenseArrayPop(host, r.name, kind, elem, empty, slow),
)!
: emitDenseArrayPop(host, r.name, access, elem, la, slowLabel);
denseLabel = B.currentLabel();
B.br(lp);
B.startBlock(slowLabel);
@@ -938,6 +979,14 @@ export function emitArrIntrinsic(
case "fillUndefined": {
const args = e.args.map((arg) => host.emitExpr(arg));
host.emitPublishedArrayGuard(r.name, e.receiver.type, 7);
if (host.checkedFills.has(e)) {
// A new int32 array with checked stores, filled with a value that
// may not be an int32: widen it first then.
const filled = B.newLabel("arr.fill.kind");
host.emitCheckedInt32Store(r.name, args[0]!.name, e.args[0]!, filled);
B.br(filled);
B.startBlock(filled);
}
const helper =
e.method === "copyWithin"
? "scr_arr_copy_within"
@@ -15,11 +15,23 @@ export interface LlValue {
/** An exact integer snapshot, excluding negative zero, with no discarded
* high bits and the proven range of its original value. */
integer?: { name: string; type: "i32" | "i64"; signed: boolean; range: IntegerRange };
/** An i64 holding the exact integer that the arithmetic producing this
* number computes, when that integer has no proven safe range: its
* absolute value is at most `bound`, and the double equals it whenever
* its absolute value is below `below` (a zero may be -0). Index consumers
* use it in place of a round trip (integer-values.ts `indexInteger`). */
wideInteger?: WideInteger;
slot?: boolean;
/** A sequence local held in a capture box; release the box itself. */
boxed?: boolean;
}
export interface WideInteger {
name: string;
below: number;
bound: number;
}
export type ExprOf<K extends IrExpr["kind"]> = Extract<IrExpr, { kind: K }>;
export type LibCallExpr = ExprOf<"libCall">;
type LibCallPrefixOf<T extends string> = T extends `${infer Prefix}.${string}` ? Prefix : never;
@@ -8,6 +8,7 @@ import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import { BYTES_ELEM_NUM, closureIdentityEqual, f64Lit } from "./common.js";
import { CHECKED_INT32_MIN_LENGTH } from "../../ir/int32-arrays.js";
import { emitStringInputs } from "./string-lifetimes.js";
import { emitStringParts, numberPart, stringParts } from "./string-construction.js";
import { emitBorrowedInputs } from "./borrowed-inputs.js";
@@ -16,10 +17,12 @@ import {
int32Number,
integerNumber,
narrowInteger,
wideArithmetic,
widenInteger,
} from "./integer-values.js";
import {
bitwiseIdentityOperand,
INT32_RANGE,
integerArithmeticRange,
isBitwiseOp,
withinInt32,
@@ -32,7 +35,15 @@ import {
emitPowerOfTwoRemainder,
emitSignedIntegerRemainder,
} from "./integer-remainder.js";
import { denseSlotWidth, emitDenseArrayGet, emitDenseArrayState } from "./dense-array-access.js";
import {
denseSlotWidth,
emitDenseArrayRead,
emitDenseArrayState,
runtimeAccess,
SCR_ELEM_I32,
slotWidth,
type ArrayAccess,
} from "./dense-array-access.js";
import { emitWrappingSum, markWrappingOperands } from "./wrapping-sums.js";
export function emitLiteralExpr(
@@ -337,7 +348,14 @@ export function emitOperatorExpr(
if (arith[e.op] !== undefined) {
B.line(`${t} = ${arith[e.op]} double ${l.name}, ${r.name}`);
const uint32 = emitWrappingSum(host, e, l, r, li, ri);
if (uint32 !== undefined) return { name: t, type: e.type, uint32 };
// The exact integer beside the double, for index consumers.
const wideInteger = wideArithmetic(host, e.op, l, li, r, ri);
if (uint32 !== undefined || wideInteger) {
const value: LlValue = { name: t, type: e.type };
if (uint32 !== undefined) value.uint32 = uint32;
if (wideInteger) value.wideInteger = wideInteger;
return value;
}
} else B.line(`${t} = fcmp ${cmp[e.op]} double ${l.name}, ${r.name}`);
} else if (bit[e.op] !== undefined) {
if (e.left.type.kind !== "f64" || e.right.type.kind !== "f64") {
@@ -648,7 +666,7 @@ export function emitStringExpr(
// equals no unit, as "" equals no one-unit literal.
const unit = charAtUnitTest(e);
if (unit !== null) {
const code = host.emitExpr(charAtUnitRead(unit.charAt));
const code = host.emitExpr(charAtUnitRead(host, unit.charAt));
const t = B.tmp();
B.line(
`${t} = fcmp ${e.negated ? "une" : "oeq"} double ${code.name}, ${f64Lit(unit.code)}`,
@@ -682,7 +700,7 @@ export function emitStringExpr(
// the code unit, as the equality above does.
const unit = charAtUnitTest(e);
if (unit !== null) {
const code = host.emitExpr(charAtUnitRead(unit.charAt));
const code = host.emitExpr(charAtUnitRead(host, unit.charAt));
const t = B.tmp();
B.line(
`${t} = fcmp ${charAtUnitPredicate(e.op, unit.left)} double ${code.name}, ${f64Lit(unit.code)}`,
@@ -886,9 +904,46 @@ export function emitContainerExpr(
// the plain elements up to the next one (scr_arr_reserve_spread).
const firstSpread = e.elems.findIndex((_, i) => spreadSet.has(i));
const capacity = small ? 0 : (e.capacity ?? (firstSpread < 0 ? e.elems.length : firstSpread));
B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
// An int32 array's allocation (ir/int32-arrays.ts) constructs
// SCR_ELEM_I32 storage; its elements are proven int32s. The empty
// temporary of a fill that allocates an int32 array with checked
// stores takes SCR_ELEM_I32 storage when the fill's length (which
// evaluates without effects) is at least CHECKED_INT32_MIN_LENGTH, and
// SCR_ELEM_F64 storage otherwise.
const fillLength = host.checkedAllocations.get(e);
if (fillLength !== undefined && e.elems.length === 0) {
const length = host.emitExpr(fillLength);
const long = B.tmp(),
kind = B.tmp();
B.line(`${long} = fcmp oge double ${length.name}, ${f64Lit(CHECKED_INT32_MIN_LENGTH)}`);
B.line(`${kind} = select i1 ${long}, i32 ${SCR_ELEM_I32}, i32 0`);
host.declare(
small
? `declare ptr @scr_arr_new_small(i32)`
: `declare ptr @scr_arr_new(i32, ${host.sizeType})`,
);
B.line(
small
? `${arr} = call ptr @scr_arr_new_small(i32 ${kind})`
: `${arr} = call ptr @scr_arr_new(i32 ${kind}, ${host.sizeType} ${capacity})`,
);
return host.own({ name: arr, type: e.type });
}
const int32 = host.int32Allocations.has(e);
if (int32) {
host.declare(
small
? `declare ptr @scr_arr_new_small(i32)`
: `declare ptr @scr_arr_new(i32, ${host.sizeType})`,
);
B.line(
small
? `${arr} = call ptr @scr_arr_new_small(i32 ${SCR_ELEM_I32})`
: `${arr} = call ptr @scr_arr_new(i32 ${SCR_ELEM_I32}, ${host.sizeType} ${capacity})`,
);
} else B.line(`${arr} = ${arrNewCall(host.shapeHost, elem, String(capacity), small)}`);
const out = host.own({ name: arr, type: e.type });
const acc = elemAccess(elem);
const acc: ArrayAccess = int32 ? "i32" : elemAccess(elem);
let pending: LlValue[] = [];
let buffer: string | undefined;
const flush = (): void => {
@@ -909,7 +964,8 @@ export function emitContainerExpr(
B.line(
`call void @scr_arr_reserve_spread(ptr ${arr}, ptr ${v.name}, ${host.sizeType} ${plain})`,
);
host.emitArrayCopyLoop(arr, v.name, acc);
// A literal with spreads is never an int32 allocation.
host.emitArrayCopyLoop(arr, v.name, runtimeAccess(acc));
return;
}
pending.push(host.emitExpr(el));
@@ -954,33 +1010,65 @@ export function emitContainerExpr(
// Ref-element reads return +1 (the inline dense path retains like
// the runtime getter); own registers the owned temp in the frame.
const elem = e.arr.type.elem;
const acc = elemAccess(elem);
const t = emitDenseArrayGet(
if (host.checkedInt32Array(e.arr)) {
const proven = host.provenDenseIndex(e.arr, e.index);
const value = host.emitByElementKind(
arr.name,
"double",
(kind) =>
emitDenseArrayRead(
host,
arr.name,
idx,
e.index,
kind,
elem,
"scr_arr_get_f64",
true,
proven,
).value,
)!;
return { name: value, type: e.type };
}
const acc = host.arrayAccess(e.arr, elem);
const read = emitDenseArrayRead(
host,
arr.name,
idx,
e.index,
acc,
elem,
`scr_arr_get_${acc}`,
`scr_arr_get_${runtimeAccess(acc)}`,
true,
host.provenDenseIndex(e.arr, e.index),
);
return host.own({ name: t, type: e.type });
if (read.int32 !== null)
// An int32 array's element: its i32 is exact for index consumers.
return {
name: read.value,
type: e.type,
uint32: read.int32,
integer: { name: read.int32, type: "i32", signed: true, range: INT32_RANGE },
};
return host.own({ name: read.value, type: e.type });
}
case "arrayHas": {
const arr = host.emitStableReceiver(e.arr, [e.index]);
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayHas on non-array");
const t = emitDenseArrayState(
host,
arr.name,
idx,
e.index,
"has",
denseSlotWidth(host, e.arr.type.elem),
);
const t = host.checkedInt32Array(e.arr)
? host.emitByElementKind(arr.name, "i1", (kind) =>
emitDenseArrayState(host, arr.name, idx, e.index, "has", slotWidth(kind)),
)!
: emitDenseArrayState(
host,
arr.name,
idx,
e.index,
"has",
host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem),
);
return { name: t, type: e.type };
}
case "arrayState": {
@@ -988,14 +1076,18 @@ export function emitContainerExpr(
const idx = host.emitExpr(e.index);
if (e.arr.type.kind !== "array")
throw new InternalCompilerError("llvm emitter bug: arrayState on non-array");
const t = emitDenseArrayState(
host,
arr.name,
idx,
e.index,
"state",
denseSlotWidth(host, e.arr.type.elem),
);
const t = host.checkedInt32Array(e.arr)
? host.emitByElementKind(arr.name, "double", (kind) =>
emitDenseArrayState(host, arr.name, idx, e.index, "state", slotWidth(kind)),
)!
: emitDenseArrayState(
host,
arr.name,
idx,
e.index,
"state",
host.int32Array(e.arr) ? slotWidth("i32") : denseSlotWidth(host, e.arr.type.elem),
);
return { name: t, type: e.type };
}
case "arrIntrinsic":
@@ -0,0 +1,135 @@
import type { IrExpr, IrStmt } from "../../ir/ir.js";
import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import { MAX_LOOP_NODES, SIMPLE_STATEMENTS } from "./speculative-integers.js";
/** Int32 kind loops.
*
* An int32 array with checked stores (ir/int32-slots.ts) holds int32 slots
* (or doubles, when its fill was short) until a store of a value that is
* not an int32 widens it to doubles in place, so an access to it tests its
* element kind first (LlEmitter.emitByElementKind). An innermost loop that
* accesses such arrays tests their kinds once on entry instead
* (emitLoopCopies). The fast copy runs when every one of them holds int32
* slots and reads and writes them as int32 arrays; the wide copy runs when
* every one holds doubles and accesses them as double arrays; a loop over
* several arrays whose kinds differ runs the mixed copy, which tests the
* kinds at each access. A store of the fast copy whose value is not an
* int32 branches to the same statement in a third copy, which re-executes
* it with kind tests (widening the array) and finishes the iteration, then
* enters the mixed copy, or the wide copy when the loop has one array.
* Re-execution is exact because such a store's statement evaluates nothing
* but locals, literals, number arithmetic and element reads before it
* writes.
*
* Nothing else can widen these arrays: they never reach code other than
* the compiled element operations of their own function
* (ir/int32-arrays.ts). */
export interface Int32KindLoopPlan {
/** The arrays the fast copy reads and writes as int32 arrays, by local,
* with one of their references in the loop. */
arrays: Map<string, IrExpr>;
/** Element writes and pushes whose value may not be an int32: the
* statements the fast copy may leave at. */
writes: Set<IrStmt>;
}
type KindLoop = Extract<IrStmt, { kind: "for" | "while" | "doWhile" }>;
/** A number or boolean expression whose evaluation has no effect: locals,
* literals, arithmetic, comparisons and element reads of local arrays. */
function isPure(e: IrExpr): boolean {
if (e.type.kind !== "f64" && e.type.kind !== "bool") return false;
switch (e.kind) {
case "numLit":
case "boolLit":
case "varRef":
return true;
case "bin":
return (
(e.left.type.kind === "f64" || e.left.type.kind === "bool") &&
isPure(e.left) &&
isPure(e.right)
);
case "unary":
return isPure(e.operand);
case "ternary":
return isPure(e.cond) && isPure(e.then) && isPure(e.else_);
case "arrayGet":
case "arrayHas":
return e.arr.kind === "varRef" && isPure(e.index);
default:
return false;
}
}
/** The plan for `loop`, or null. `checked` names the int32 array locals
* with checked stores of the function; `provenInt32` the values the static
* range analysis proves int32s (stores that never leave the fast copy). */
export function planInt32KindLoop(
loop: KindLoop,
checked: (localId: string) => boolean,
provenInt32: (e: IrExpr) => boolean,
): Int32KindLoopPlan | null {
const arrays = new Map<string, IrExpr>();
const writes = new Set<IrStmt>();
const assigned = new Set<string>();
let nodes = 0;
let rejected = false;
const reject = (): false => {
rejected = true;
return false;
};
const array = (e: IrExpr): string | null =>
e.kind === "varRef" && checked(e.localId) ? e.localId : null;
const push = (e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null =>
e.kind === "arrIntrinsic" && e.method === "push" && array(e.receiver) !== null ? e : null;
const expr = (e: IrExpr): boolean => {
if (rejected) return false;
nodes++;
if (e.kind === "varRef" && checked(e.localId) && !arrays.has(e.localId))
arrays.set(e.localId, e);
// A push the statement walk did not take as a write: its values must
// be proven int32s, since it cannot leave the fast copy.
const pushed = push(e);
if (pushed && !pushed.args.every(provenInt32)) return reject();
return everyExprChild(e, expr, nested);
};
// Statements inside expressions run mid-expression, where the fast copy
// cannot leave: they may not write these arrays.
const nested = (s: IrStmt): boolean => {
nodes++;
if (s.kind === "assign" || s.kind === "varDecl") assigned.add(s.localId);
if (s.kind === "arraySet" && array(s.arr) !== null && !provenInt32(s.value)) return reject();
return everyStmtChild(s, expr, nested);
};
const stmt = (s: IrStmt): boolean => {
if (rejected) return false;
nodes++;
if (!SIMPLE_STATEMENTS.has(s.kind)) return reject();
if (s.kind === "assign" || s.kind === "varDecl") assigned.add(s.localId);
if (s.kind === "arraySet" && array(s.arr) !== null && !provenInt32(s.value)) {
if (!isPure(s.index) || !isPure(s.value)) return reject();
writes.add(s);
return everyStmtChild(s, expr, stmt);
}
const pushed = s.kind === "exprStmt" ? push(s.expr) : null;
if (pushed && !pushed.args.every(provenInt32)) {
if (!pushed.args.every(isPure)) return reject();
writes.add(s);
expr(pushed.receiver);
pushed.args.forEach(expr);
return !rejected;
}
return everyStmtChild(s, expr, stmt);
};
if (loop.cond) expr(loop.cond);
if (loop.kind === "for" && loop.update) stmt(loop.update);
for (const s of loop.body) stmt(s);
if (rejected || nodes > MAX_LOOP_NODES || arrays.size === 0) return null;
// A loop that rebinds one of the arrays could reach another array of
// either kind.
for (const id of arrays.keys()) if (assigned.has(id)) return null;
return { arrays, writes };
}
@@ -1,6 +1,6 @@
import type { IrExpr, IrType } from "../../ir/ir.js";
import type { IntegerRange } from "../../ir/integer-ranges.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import type { LlValue, LlvmEmitterContext, WideInteger } from "./expr-context.js";
export type LlInteger = NonNullable<LlValue["integer"]>;
@@ -51,6 +51,89 @@ export function exactInteger(
return { name, type: "i64", signed: true, range };
}
/** Doubles hold every integer of smaller magnitude exactly. */
const EXACT_DOUBLES = 2 ** 53;
/** Wide views stay within this magnitude: a product of two int32s reaches
* 2^62, one exact term of at most 2^53 may be added to it, and no i64
* result wraps. */
const WIDE_BOUND = 2 ** 62 + 2 ** 53;
/** Array and string indexes are below 2^32 - 1: a wide view exact below
* this many is exact at every index a length can admit. */
const INDEX_LIMIT = 2 ** 32;
function magnitude(range: IntegerRange): number {
return Math.max(Math.abs(range.min), Math.abs(range.max));
}
/** The wide view of `l op r` for `+`, `-` and `*` when the exact operands
* have no proven result range (their product or sum may pass 2^53).
*
* - A product of two exact integers equals its double whenever its
* magnitude is below 2^53: the double product of integers is exact there.
* - A sum or difference of two exact integers likewise.
* - A sum or difference of a wide view (exact below T) and an exact integer
* of magnitude at most B equals its double below min(T - B, 2^53): the
* view's magnitude is then below T, so its double is exact, and the
* double sum of two exact integers below 2^53 is exact.
*
* Every i64 result stays within 2^62 + 2^53 in magnitude, so none wraps. A view
* exact below fewer than 2^32 helps no index and is not kept. */
export function wideArithmetic(
host: LlvmEmitterContext,
op: string,
l: LlValue,
li: LlInteger | null,
r: LlValue,
ri: LlInteger | null,
): WideInteger | null {
if (op !== "+" && op !== "-" && op !== "*") return null;
const view = (value: LlValue, integer: LlInteger | null): WideInteger | null =>
integer
? { name: "", below: Infinity, bound: magnitude(integer.range) }
: (value.wideInteger ?? null);
const lv = view(l, li),
rv = view(r, ri);
if (!lv || !rv) return null;
let below: number, bound: number;
if (op === "*") {
if (lv.below !== Infinity || rv.below !== Infinity) return null;
below = EXACT_DOUBLES;
bound = lv.bound * rv.bound;
} else if (lv.below === Infinity && rv.below === Infinity) {
below = EXACT_DOUBLES;
bound = lv.bound + rv.bound;
} else if (lv.below === Infinity || rv.below === Infinity) {
const [exact, wide] = lv.below === Infinity ? [lv, rv] : [rv, lv];
below = Math.min(wide.below - exact.bound, EXACT_DOUBLES);
bound = exact.bound + wide.bound;
} else return null;
if (!(bound <= WIDE_BOUND) || below < INDEX_LIMIT) return null;
const operand = (value: LlValue, integer: LlInteger | null): string =>
integer ? widenInteger(host, integer) : value.wideInteger!.name;
const left = operand(l, li),
right = operand(r, ri);
const name = host.B.tmp();
host.B.line(
`${name} = ${op === "+" ? "add" : op === "-" ? "sub" : "mul"} nsw i64 ${left}, ${right}`,
);
return { name, below, bound };
}
/** An i64 index for a consumer that compares it, unsigned, with a length or
* capacity below 2^32 and takes the double's path otherwise: the exact
* integer of `value`, or its wide view, which equals the double at every
* such index. Null when the index needs the double round trip. */
export function indexInteger(
host: LlvmEmitterContext,
value: LlValue,
expr?: IrExpr,
): string | null {
const integer = exactInteger(host, value, expr);
if (integer) return widenInteger(host, integer);
const wide = value.wideInteger;
return wide && wide.below >= INDEX_LIMIT ? wide.name : null;
}
export function widenInteger(host: LlvmEmitterContext, value: LlInteger): string {
if (value.type === "i64") return value.name;
const wide = host.B.tmp();
@@ -862,6 +862,7 @@ export function emitLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
return host.emitGenericLibCall(e);
case "weakMap":
case "weakSet":
case "finalizationRegistry":
case "ffi":
case "sharedArrayBuffer":
case "arrayBuffer":
@@ -44,6 +44,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"weakSet.is": "scr_weak_set_is",
"weakMap.new": "scr_weak_map_new",
"weakSet.new": "scr_weak_set_new",
"finalizationRegistry.new": "scr_finalization_registry_new",
"finalizationRegistry.is": "scr_finalization_registry_is",
"dyn.fromEntries": "scr_dyn_from_entries",
"bytes.constructor": "scr_bytes_constructor",
"bytes.construct": "scr_bytes_construct",
@@ -1598,6 +1600,8 @@ export const LIB_FN_SYMS: Record<string, string> = {
"regex.resultProp": "scr_regex_result_prop",
"array.namedKeys": "scr_arr_named_keys",
"array.hasNamedKey": "scr_arr_has_named_key",
"array.hasProperties": "scr_arr_has_properties",
"array.undefinedKey": "scr_arr_undefined_key",
// queueMicrotask's checked-dynamic form (borrowed dyn; a non-function
// throws synchronously), the minted setImmediate value, and the
// timers/promises immediate — all mark the loop live.
@@ -13,7 +13,7 @@ import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { ReferenceEffects } from "./reference-effects.js";
import { SCR_ARR_FIELD, loadArrCount, scrArrStateAt } from "./common.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { indexInteger } from "./integer-values.js";
import { READ_FALLBACK } from "./dense-array-access.js";
import { compactRefLoadLines } from "./classes.js";
@@ -476,7 +476,8 @@ function emitDenseReferenceArrayRead(
no: string,
slow: string,
join: string,
loadType: "ptr" | "cref" | "i64" | "i8" = "ptr",
/** `i32`: an int32 array's slot, presented as the int32. */
loadType: "ptr" | "cref" | "i64" | "i8" | "i32" = "ptr",
): void {
const B = host.B;
const header = (field: number): string => {
@@ -567,7 +568,7 @@ function emitDenseReferenceArrayRead(
statePtr,
data,
cap,
loadType === "i8" ? 1 : loadType === "cref" ? 4 : 8,
loadType === "i8" ? 1 : loadType === "cref" || loadType === "i32" ? 4 : 8,
offset,
);
host.markMemoryPointer(statePtr, "array:present");
@@ -578,9 +579,10 @@ function emitDenseReferenceArrayRead(
B.startBlock(valueLabel);
const valuePtr = B.tmp(),
raw = B.tmp();
// Boolean arrays store one byte per element, compact references four,
// every other kind an 8-byte slot.
const slotType = loadType === "i8" ? "i8" : loadType === "cref" ? "i32" : "i64";
// Boolean arrays store one byte per element, compact references and int32
// arrays four, every other kind an 8-byte slot.
const slotType =
loadType === "i8" ? "i8" : loadType === "cref" || loadType === "i32" ? "i32" : "i64";
B.line(
`${valuePtr} = getelementptr inbounds ${slotType}, ptr ${data}, ${host.sizeType} ${offset}`,
);
@@ -638,11 +640,11 @@ export function emitBorrowedArrayRead(
return;
}
const index = host.emitExpr(read.index);
// A proven integer index (an int32 local, say) needs no double
// round-trip; a negative one compares above the capacity as unsigned.
const exact =
loopIndex === null && host.sizeType === "i64" ? exactInteger(host, index, read.index) : null;
const integerIndex = loopIndex ?? (exact ? widenInteger(host, exact) : null);
// A proven integer index (an int32 local, say), or a wide view exact at
// every index below the capacity, needs no double round-trip; a negative
// one compares above the capacity as unsigned.
const integerIndex =
loopIndex ?? (host.sizeType === "i64" ? indexInteger(host, index, read.index) : null);
const slow = B.newLabel("borrow.array.slow"),
join = B.newLabel("borrow.array.join");
emitDenseReferenceArrayRead(
@@ -680,13 +682,10 @@ export function emitLocalArrayRead(
const array = host.emitStableReceiver(read.array, [read.index]);
const loopIndex = inline ? host.emitIntegerLoopIndex(read.index) : null;
const index = host.emitExpr(read.index);
// A proven integer index (a counter with a known range, say) needs no
// double round trip, as in a borrowed read.
const exact =
inline && loopIndex === null && host.sizeType === "i64"
? exactInteger(host, index, read.index)
: null;
const integerIndex = loopIndex ?? (exact ? widenInteger(host, exact) : null);
// As in emitBorrowedArrayRead: an exact integer or wide view needs no
// round trip, and only the loop's own counter is an induction variable.
const integerIndex =
loopIndex ?? (inline && host.sizeType === "i64" ? indexInteger(host, index, read.index) : null);
const nullable = read.type.kind === "union" ? host.nullableUnions.get(read.type.unionId) : null;
if (nullable) {
// A nullable union is the element pointer itself: borrowed from an
@@ -743,8 +742,15 @@ export function emitLocalArrayRead(
const no = B.newLabel("local.array.missing"),
join = B.newLabel("local.array.join");
const scalar = isScalarElement(read.element);
// An int32 array's arms join on the int32 element, converted to the
// payload's double once at the join, so a consumer converting the payload
// back to an integer folds to the element.
const int32 = scalar && read.element.kind === "f64" && host.int32Array(read.array);
const element = int32 ? B.slot() : null;
if (element) B.entryAllocas.push(`${element} = alloca i32`);
const storeValue = (value: string): void => {
if (scalar) {
if (element) B.line(`store i32 ${value}, ptr ${element}`);
else if (scalar) {
// Slot bits for f64; a canonical 0/1 for bool, like scr_union_new_bool.
let bits = value;
if (read.element.kind === "bool") {
@@ -761,7 +767,37 @@ export function emitLocalArrayRead(
}
B.line(`store i32 ${read.presentTag}, ptr ${tag}`);
};
if (inline)
if (inline && scalar && host.checkedInt32Array(read.array))
// An int32 array with checked stores: its int32 arm stores the element's
// double bits, as the double arm does.
host.emitByElementKind(array.name, null, (kind) => {
const present =
kind === "i32"
? (value: string): void => {
const number = B.tmp(),
bits = B.tmp();
B.line(`${number} = sitofp i32 ${value} to double`);
B.line(`${bits} = bitcast double ${number} to i64`);
storeValue(bits);
}
: storeValue;
const loadType = kind === "i32" ? "i32" : "i64";
const induction = loopIndex !== null;
emitDenseReferenceArrayRead(
host,
array,
index,
integerIndex,
induction,
present,
no,
slow,
join,
loadType,
);
return null;
});
else if (inline)
emitDenseReferenceArrayRead(
host,
array,
@@ -772,7 +808,13 @@ export function emitLocalArrayRead(
no,
slow,
join,
!scalar ? referenceLoad(host, read.array.type) : read.element.kind === "bool" ? "i8" : "i64",
!scalar
? referenceLoad(host, read.array.type)
: read.element.kind === "bool"
? "i8"
: int32
? "i32"
: "i64",
);
else B.br(slow);
B.startBlock(slow);
@@ -799,7 +841,8 @@ export function emitLocalArrayRead(
B.line(
`${value} = call double @scr_arr_get_f64(ptr ${array.name}, double ${index.name})${READ_FALLBACK}`,
);
B.line(`${bits} = bitcast double ${value} to i64`);
if (element) B.line(`${bits} = fptosi double ${value} to i32`);
else B.line(`${bits} = bitcast double ${value} to i64`);
} else {
host.declare(`declare zeroext i1 @scr_arr_get_bool(ptr, double)`);
B.line(
@@ -807,7 +850,8 @@ export function emitLocalArrayRead(
);
B.line(`${bits} = zext i1 ${value} to i64`);
}
B.line(`store i64 ${bits}, ptr ${payload}`);
if (element) B.line(`store i32 ${bits}, ptr ${element}`);
else B.line(`store i64 ${bits}, ptr ${payload}`);
B.line(`store i32 ${read.presentTag}, ptr ${tag}`);
} else {
const value = B.tmp(),
@@ -821,10 +865,20 @@ export function emitLocalArrayRead(
}
B.br(join);
B.startBlock(no);
B.line(`store i64 0, ptr ${payload}`);
if (element) B.line(`store i32 0, ptr ${element}`);
else B.line(`store i64 0, ptr ${payload}`);
B.line(`store i32 ${read.missingTag}, ptr ${tag}`);
B.br(join);
B.startBlock(join);
if (element) {
const value = B.tmp(),
number = B.tmp(),
bits = B.tmp();
B.line(`${value} = load i32, ptr ${element}`);
B.line(`${number} = sitofp i32 ${value} to double`);
B.line(`${bits} = bitcast double ${number} to i64`);
B.line(`store i64 ${bits}, ptr ${payload}`);
}
B.line(`store ptr ${box}, ptr ${localSlot}`);
return read.borrow || scalar ? null : { slot: payload, type: read.element };
}
@@ -107,6 +107,7 @@ const INERT_LIB_CALLS = new Set([
"regex.resultProp",
"array.namedKeys",
"array.hasNamedKey",
"array.hasProperties",
"process.cwd",
"process.argv",
"process.execArgv",
@@ -35,12 +35,12 @@ export interface SpeculativeLoopPlan {
type SpeculativeLoop = Extract<IrStmt, { kind: "for" | "while" | "doWhile" }>;
/** The fast copy duplicates the loop; keep the duplicated code small. */
const MAX_LOOP_NODES = 400;
export const MAX_LOOP_NODES = 400;
/** Statements whose emission is plain control flow or a single write.
* Nested loops, switches and try statements keep the loop on the ordinary
* path (their emission keeps state that the two copies would not share). */
const SIMPLE_STATEMENTS = new Set<string>([
export const SIMPLE_STATEMENTS: ReadonlySet<string> = new Set<string>([
"varDecl",
"assign",
"exprStmt",
+4 -3
View File
@@ -285,9 +285,10 @@ class ContractChecker {
* the lifetimes of the objects they receive). Publication to other
* threads needs no check: an instance carries a reference count of its
* own until publication marks it like any other object it reaches
* (classes.ts programLifetimeRc). Static code has no WeakRef or
* FinalizationRegistry; weak collections stay correct because an entry
* keyed by an object that is never freed is never observable as removed. */
* (classes.ts programLifetimeRc). Static code has no WeakRef, and a
* FinalizationRegistry never runs its cleanup callbacks; weak collections
* stay correct because an entry keyed by an object that is never freed is
* never observable as removed. */
private checkProgramLifetime(): void {
if (this.notes.classes.size === 0) return;
const roots: string[] = [];
@@ -122,7 +122,8 @@ const COPIES = new Set([
const COPY_IN = new Set(["pushSpread", "unshiftSpread", "spliceInsert"]);
/** Library calls that only read array arguments. */
const READ_ONLY_LIB = /^(console\.|inspect\.|json\.stringify|math\.|insp\.)/;
const READ_ONLY_LIB =
/^(console\.|inspect\.|json\.stringify|math\.|insp\.|array\.(hasProperties|namedKeys|hasNamedKey)$)/;
/** Library calls that always throw (the value they stand for never exists). */
const THROWING_LIB = /^error\.\w*Throw$/;
@@ -872,6 +873,9 @@ export class Int32ElementContracts {
};
const isNumberArray = (t: IrType): boolean => t.kind === "array" && t.elem.kind === "f64";
// Live views handed straight to a read-only library call (util.inspect
// of an array with properties) are only read through.
const readOnlyViews = new Set<IrExpr>();
const expr = (e: IrExpr): void => {
switch (e.kind) {
case "assignExpr":
@@ -1000,6 +1004,7 @@ export class Int32ElementContracts {
case "libCall":
if (!READ_ONLY_LIB.test(e.fn))
for (const a of e.args) escape(a, a.loc, `the library call ${e.fn}`);
else for (const a of e.args) if (a.kind === "dynFrom") readOnlyViews.add(a);
break;
case "callValue":
for (const a of e.args) escape(a, a.loc, "a call through a function value");
@@ -1008,7 +1013,10 @@ export class Int32ElementContracts {
case "dynFrom":
// Plain arrays and records cross into dynamic code as deep copies;
// class instances and live references stay connected.
if (e.liveRef === true || this.containsObject(e.value.type, new Set()))
if (
!readOnlyViews.has(e) &&
(e.liveRef === true || this.containsObject(e.value.type, new Set()))
)
escape(e.value, e.loc, "dynamic (any or unknown) code");
break;
default:
@@ -60,6 +60,28 @@ test("semantic validation reads non-source dependencies once and still invalidat
expect(reads.mock.calls.filter(([path]) => path === dependency)).toHaveLength(1);
});
test("an equivalent edit of a file whose raw text the frontend read does not match", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const source = join(dir, "entry.ts");
const previous = "export const answer = 1;\n";
await writeFile(source, previous);
const tracker = new FrontendInputTracker();
tracker.run(() => {
trackedReadFile(source);
tracker.record({ op: "text", path: source });
});
const snapshot = tracker.snapshot();
expect(validFrontendInputSnapshot(snapshot)).toBe(true);
expect(frontendInputsStillMatch(snapshot)).toBe(true);
await writeFile(source, `// comment\n${previous}`);
const equivalent = vi.fn(() => true);
expect(
frontendInputsSemanticallyMatch(snapshot, new Map([[source, previous]]), equivalent),
).toBeNull();
expect(equivalent).not.toHaveBeenCalled();
});
test("tracked frontend reads invalidate on byte edits", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
+51 -26
View File
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
import { AsyncLocalStorage } from "node:async_hooks";
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
import { basename, dirname, resolve } from "node:path";
import { observeSourceText } from "./ts7/ast-node.js";
/**
* Filesystem observations made while constructing and lowering one frontend
@@ -16,7 +17,10 @@ export type FrontendInputProbe =
| { op: "kind"; path: string; kind: "file" | "directory" | "other" | "missing" }
| { op: "entries"; path: string; files: string[]; directories: string[] }
| { op: "entries-error"; path: string }
| { op: "realpath"; path: string; target: string | null };
| { op: "realpath"; path: string; target: string | null }
/** The frontend's output depends on the file's raw text or line
* positions, not only on its tokens (observeSourceText). */
| { op: "text"; path: string };
export interface FrontendInputSnapshot {
version: 1;
@@ -105,6 +109,7 @@ export class FrontendInputTracker {
record(probe: FrontendInputProbe): void {
const key = `${probe.op}\0${probe.path}`;
if (probe.op === "text" && this.probes.has(key)) return;
const previous = this.probes.get(key);
if (previous !== undefined && JSON.stringify(previous) !== JSON.stringify(probe)) {
// An input changed while this frontend was running. The build may
@@ -158,6 +163,13 @@ function record(probe: FrontendInputProbe): void {
(synchronousTracker ?? activeTracker.getStore())?.record(probe);
}
// Source text the frontend reads raw (call-site text in messages, comptime
// callbacks, line numbers) changes with edits that keep the tokens.
observeSourceText((fileName) => {
const tracker = synchronousTracker ?? activeTracker.getStore();
if (tracker !== undefined) tracker.record({ op: "text", path: resolve(fileName) });
});
/** Mark the active frontend as unsafe to persist because an exact host query
* bypassed the tracked filesystem wrappers. No-op outside a frontend run. */
export function markFrontendInputsUnstable(): void {
@@ -394,6 +406,9 @@ export function frontendInputsStillMatch(
return probe.target === null;
}
}
case "text":
// The file probe of the same path checks its content.
return true;
}
});
}
@@ -410,36 +425,44 @@ export function frontendInputsSemanticallyMatch(
exclusions: FrontendInputExclusions = {},
): FrontendSemanticMatch | null {
if (!validFrontendInputSnapshot(snapshot)) return null;
const sourceProbes = new Map(
snapshot.probes
.filter((probe): probe is Extract<FrontendInputProbe, { op: "file" }> => probe.op === "file")
.map((probe) => [probe.path, probe]),
);
const sourceProbes = new Map<string, Extract<FrontendInputProbe, { op: "file" }>>();
const rawText = new Set<string>();
for (const probe of snapshot.probes) {
if (probe.op === "file") sourceProbes.set(probe.path, probe);
else if (probe.op === "text") rawText.add(probe.path);
}
for (const [path, previous] of previousSources) {
if (sourceProbes.get(path)?.digest !== frontendSourceDigest(previous)) return null;
}
const changed: FrontendSemanticChange[] = [];
const currentSources = new Map<string, string>();
const adjusted: FrontendInputSnapshot = {
...snapshot,
probes: snapshot.probes.map((probe) => {
if (probe.op !== "file") return probe;
const previous = previousSources.get(probe.path);
if (previous === undefined || frontendSourceDigest(previous) !== probe.digest) return probe;
let current: string;
try {
current = readFileSync(probe.path, "utf8");
} catch {
return probe;
}
const currentDigest = frontendSourceDigest(current);
currentSources.set(probe.path, current);
if (currentDigest === probe.digest) return probe;
if (!isSemanticEquivalent(probe.path, previous, current)) return probe;
changed.push({ path: probe.path, previous, current });
return { ...probe, digest: currentDigest };
}),
};
const probes: FrontendInputProbe[] = [];
for (const probe of snapshot.probes) {
const previous = probe.op === "file" ? previousSources.get(probe.path) : undefined;
if (probe.op !== "file" || previous === undefined) {
probes.push(probe);
continue;
}
let current: string;
try {
current = readFileSync(probe.path, "utf8");
} catch {
return null;
}
const currentDigest = frontendSourceDigest(current);
currentSources.set(probe.path, current);
if (currentDigest === probe.digest) {
probes.push(probe);
continue;
}
// One edit that is not equivalent already rules out the match, and so
// does any edit of a file whose raw text the frontend read.
if (rawText.has(probe.path) || !isSemanticEquivalent(probe.path, previous, current))
return null;
changed.push({ path: probe.path, previous, current });
probes.push({ ...probe, digest: currentDigest });
}
const adjusted: FrontendInputSnapshot = { ...snapshot, probes };
if (!frontendInputsStillMatch(adjusted, exclusions)) return null;
for (const [path, previous] of previousSources) {
if (!currentSources.has(path)) {
@@ -486,6 +509,8 @@ export function validFrontendInputSnapshot(snapshot: unknown): snapshot is Front
return true;
case "realpath":
return value.target === null || typeof value.target === "string";
case "text":
return true;
default:
return false;
}
@@ -1,7 +1,7 @@
import * as ts from "../../ts7/adapter.js";
import { type Lowerer, own } from "../lowerer.js";
import { lowerCheckedPredicateValue } from "../lower-builtin-values.js";
import { locOf } from "../../program.js";
import { isJsSourceFile, locOf } from "../../program.js";
import { BOOL, F64, STRING, type IrExpr, type IrLibFn } from "../../../ir/ir.js";
import { lowerBuiltinValuePreservingUndefined, lowerOptionalNumberPredicate } from "./arguments.js";
import { tryLowerExpression } from "../expressions/try-lower-expression.js";
@@ -104,6 +104,13 @@ export function lowerNumberStaticCall(
: { kind: "numLit", value: 0, type: F64, loc };
return { kind: "libCall", fn: "num.parseInt", args: [text, radix], type: F64, loc };
}
// JavaScript whose argument does not lower on its own (zod's
// `Number.parseInt(val.toFixed(n).replace(".", ""))` over an untyped
// parameter): the global parser's own JavaScript forms.
if (probed === null && member === "parseInt" && isJsSourceFile(ts.getSourceFileOfNode(call))) {
const parsed = lowerStaticNumberGlobal(lowerer, member, call, call.arguments.nodes, loc);
if (parsed !== null) return parsed;
}
const argText =
probed !== null
? lowerer.fmt(probed.type)
@@ -1,10 +1,10 @@
import * as ts from "../../ts7/adapter.js";
import { lowerOptionalIndex } from "../undefined-index.js";
import { locOf } from "../../program.js";
import { varRef } from "../../../ir/build.js";
import { DYN, type IrExpr, type IrStmt, isUnitType } from "../../../ir/ir.js";
import type { Lowerer } from "../lowerer.js";
import { completeFuncValueArgs } from "../call-arguments.js";
import { lowerOptionalNumber } from "../lower-exprs.js";
import { lowerSafeIndexRead } from "../array-values.js";
/** Retain native function slots during indexed calls. The array remains
@@ -37,7 +37,7 @@ export function lowerArrayCallbackCall(
return varRef(local.id, local.type, loc);
};
const receiver = store(array, "%callbackArray");
const index = lowerOptionalNumber(
const index = lowerOptionalIndex(
lowerer,
lowerer.lowerExpr(access.argumentExpression),
locOf(access.argumentExpression),
@@ -464,8 +464,11 @@ function identityUse(root: ts.Node, name: string, from = -1): IdentityUse | null
cur = cur.expression;
return cur;
};
// The text appears only in warnings. A build that reported warnings is
// not reused after a source edit, so the read is not recorded as a source
// text dependency (ts.getTextOfNode would record it).
const textOf = (node: ts.Node): string => {
const text = ts.getTextOfNode(node);
const text = ts.getSourceFileOfNode(node).text.slice(ts.tokenStartOfNode(node), node.end);
return text.length > 60 ? `${text.slice(0, 57)}...` : text;
};
const visit = (node: ts.Node): "skip" | "stop" | undefined => {
@@ -1,6 +1,11 @@
/* Binary operators: arithmetic, comparisons and equality, logical and nullish pairs, and string conversion for concatenation. */
import { keyedHelperName } from "../stable-names.js";
import * as ts from "../../ts7/adapter.js";
import {
lowerOptionalIndex,
splitUndefinedIndex,
undefinedTypedArrayKeyFence,
} from "../undefined-index.js";
import { cleanFuncAdaptable } from "../coercions/function-adapters.js";
import { type Lowerer, jsFuncNameOf } from "../lowerer.js";
import {
@@ -1160,12 +1165,26 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
}
if (recv?.type.kind === "array" || recv?.type.kind === "bytes") {
const receiver = lowerer.declareHiddenLocal("%setArray", recv.type);
const index = lowerOptionalNumber(
lowerer,
lowerer.lowerExpr(expr.left.argumentExpression),
locOf(expr.left.argumentExpression),
expr.left.argumentExpression,
);
// An undefined index names the "undefined" property
// (undefined-index.ts), which a typed array cannot hold.
const rawIndex = lowerer.lowerExpr(expr.left.argumentExpression);
const split =
recv.type.kind === "bytes"
? splitUndefinedIndex(
lowerer,
rawIndex,
expr.left.argumentExpression,
locOf(expr.left.argumentExpression),
)
: null;
const index =
split?.index ??
lowerOptionalIndex(
lowerer,
rawIndex,
locOf(expr.left.argumentExpression),
expr.left.argumentExpression,
);
if (index.type.kind !== "f64")
lowerer.unsupported(
"SC1090",
@@ -1178,7 +1197,7 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
const result = varRef(value.id, raw.type, loc);
const arr = varRef(receiver.id, recv.type, loc);
const idx = varRef(key.id, F64, loc);
const write: IrStmt =
const store: IrStmt =
recv.type.kind === "array"
? arrayValueStore(lowerer, arr, idx, result, recv.type.elem, loc)
: {
@@ -1188,10 +1207,20 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
value: lowerer.coerceInto(expr.right, result, F64),
loc,
};
const write: IrStmt = split
? {
kind: "if",
cond: split.isUndefined,
then: [undefinedTypedArrayKeyFence(lowerer, expr.left, loc)],
else_: [store],
loc,
}
: store;
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: receiver.id, init: recv, loc },
...(split?.stmts ?? []),
{ kind: "varDecl", localId: key.id, init: index, loc },
{ kind: "varDecl", localId: value.id, init: raw, loc },
write,
@@ -1,5 +1,10 @@
/* Element reads and writes (`a[i]`, `a[i] = v`), compound element updates, and keyed record reads. */
import * as ts from "../../ts7/adapter.js";
import {
lowerOptionalIndex,
splitUndefinedIndex,
undefinedTypedArrayKeyFence,
} from "../undefined-index.js";
import { type Lowerer, jsFuncNameOf, neverTaintedJsType } from "../lowerer.js";
import {
BOOL,
@@ -65,6 +70,7 @@ import { lowerClassPrototypeComputedAssignment } from "../class-prototypes.js";
import { isClassCallback } from "../class-callbacks.js";
import { isKeyedFieldAccess } from "./keyed-field-targets.js";
import { lowerFieldCompoundValue } from "./updates.js";
import { readsHeldEvolvingArray } from "../lower-stmts.js";
/** `a[i]` reads. Only f64 indices into array receivers are modeled; JS
* string-key element access (`a["length"]`) and string indexing (`s[0]`
@@ -751,12 +757,16 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
: lowerer.maybeNarrow(arr, expr.expression);
}
// An alias or container of an evolving array whose reads disagree
// (`rows[0]` after `const rows = [xs]`) holds elements the checker's type
// does not list, so its element read stays checked-dynamic.
const heldEvolving = arr.type.kind === "dyn" && readsHeldEvolvingArray(lowerer, expr.expression);
// The checker sees an array but the VALUE lowered checked-dynamic (a
// jsdoc-typed default export from a .js module — signature 14): the
// read rides the dynCheck boundary when the array shape is one the checked-dynamic tree
// can validate, and fences by name when it is not — an arrayGet over a
// dyn receiver is never emitted (the validator ICE).
if (arr.type.kind === "dyn") {
if (arr.type.kind === "dyn" && !heldEvolving) {
if (
canDynCheckTo(
receiverIr,
@@ -774,14 +784,22 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
}
}
// Array reads can themselves supply an index. A missing numeric value
// is undefined, whose property lookup misses just like NaN; keep that
// value instead of rejecting a checker-number index with optional IR.
const index = lowerOptionalNumber(
// is undefined, which names the array's "undefined" property
// (undefined-index.ts).
const index = lowerOptionalIndex(
lowerer,
lowerer.lowerExpr(expr.argumentExpression),
locOf(expr.argumentExpression),
expr.argumentExpression,
);
if (heldEvolving)
return {
kind: "dynKeyGet",
value: arr,
key: lowerer.coerceToExpected(index, DYN),
type: DYN,
loc: locOf(expr),
};
if (
arr.type.kind === "array" &&
(index.type.kind === "dyn" ||
@@ -1518,12 +1536,26 @@ export function lowerElementCompoundParts(
"compound assignment through a non-native array or byte view",
);
}
const index = lowerOptionalNumber(
lowerer,
lowerer.lowerExpr(target.argumentExpression),
locOf(target.argumentExpression),
target.argumentExpression,
);
// An undefined index names the "undefined" property (undefined-index.ts),
// which a typed array cannot hold.
const rawIndex = lowerer.lowerExpr(target.argumentExpression);
const split =
receiver.type.kind === "bytes"
? splitUndefinedIndex(
lowerer,
rawIndex,
target.argumentExpression,
locOf(target.argumentExpression),
)
: null;
const index =
split?.index ??
lowerOptionalIndex(
lowerer,
rawIndex,
locOf(target.argumentExpression),
target.argumentExpression,
);
if (index.type.kind !== "f64")
lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys");
const receiverLocal = lowerer.declareHiddenLocal("%compoundArray", receiver.type);
@@ -1632,15 +1664,25 @@ export function lowerElementCompoundParts(
}
const resultLocal = lowerer.declareHiddenLocal("%compoundResult", computed.type);
const resultRef = (): IrExpr => varRef(resultLocal.id, computed.type, loc);
const write: IrStmt =
const store: IrStmt =
receiver.type.kind === "array"
? arrayValueStore(lowerer, receiverRef(), indexRef(), resultRef(), receiver.type.elem, loc)
: { kind: "bytesSet", arr: receiverRef(), index: indexRef(), value: resultRef(), loc };
const write: IrStmt = split
? {
kind: "if",
cond: split.isUndefined,
then: [undefinedTypedArrayKeyFence(lowerer, target, loc)],
else_: [store],
loc,
}
: store;
return {
expr: {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: receiverLocal.id, init: receiver, loc },
...(split?.stmts ?? []),
{ kind: "varDecl", localId: indexLocal.id, init: index, loc },
{ kind: "varDecl", localId: oldLocal.id, init: oldValue, loc },
{ kind: "varDecl", localId: resultLocal.id, init: computed, loc },
@@ -1867,13 +1909,23 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
// The checker's flow type is a typed array, but the storage is wider.
if (recv.type.kind !== "bytes") return lowerWiderBytesWrite(lowerer, expr, target, recv);
// An index the checker types as a number but whose storage may hold
// undefined (a hole read) converts with ToNumber, as the reads do.
const index = lowerOptionalNumber(
// undefined (a hole read) converts with ToNumber, as the reads do; an
// undefined index names a property the typed array cannot hold.
const rawIndex = lowerer.lowerExpr(target.argumentExpression);
const split = splitUndefinedIndex(
lowerer,
lowerer.lowerExpr(target.argumentExpression),
locOf(target.argumentExpression),
rawIndex,
target.argumentExpression,
locOf(target.argumentExpression),
);
const index =
split?.index ??
lowerOptionalNumber(
lowerer,
rawIndex,
locOf(target.argumentExpression),
target.argumentExpression,
);
if (index.type.kind === "dyn" && recv.type.kind === "bytes") {
return {
kind: "exprStmt",
@@ -1889,7 +1941,34 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
lowerer.unsupported("SC1090", target.argumentExpression, "indexing with non-number keys");
}
const value = lowerer.lowerExprExpecting(expr.right, F64);
return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) };
if (split === null) return { kind: "bytesSet", arr: recv, index, value, loc: locOf(expr) };
const loc = locOf(expr);
const receiver = lowerer.declareHiddenLocal("%indexedBytes", recv.type);
const valueLocal = lowerer.declareHiddenLocal("%elementValue", F64);
return {
kind: "block",
body: [
{ kind: "varDecl", localId: receiver.id, init: recv, loc },
...split.stmts,
{ kind: "varDecl", localId: valueLocal.id, init: value, loc },
{
kind: "if",
cond: split.isUndefined,
then: [undefinedTypedArrayKeyFence(lowerer, target, loc)],
else_: [
{
kind: "bytesSet",
arr: varRef(receiver.id, recv.type, loc),
index,
value: varRef(valueLocal.id, F64, loc),
loc,
},
],
loc,
},
],
loc,
};
}
// Tuple element write `t[0] = v`: a positional-field write (recordSet)
// — literal indices only, like the read.
@@ -2184,7 +2263,7 @@ export function lowerElementWrite(lowerer: Lowerer, expr: ts.BinaryExpression):
"assignment through a possibly missing nested array receiver",
);
}
const index = lowerOptionalNumber(
const index = lowerOptionalIndex(
lowerer,
lowerer.lowerExpr(target.argumentExpression),
locOf(target.argumentExpression),
@@ -801,8 +801,6 @@ export function lowerIdentifierExpr(lowerer: Lowerer, expr: ts.Identifier, loc:
"ArrayBuffer constructor values have no native lowering; use new ArrayBuffer(length) for fixed-length shared storage",
WeakRef:
"deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead",
FinalizationRegistry:
"finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead",
eval: "runtime code evaluation cannot be compiled ahead of time",
};
lowerer.noLowering(expr.text, expr, globalHints[expr.text], sym ?? undefined);
@@ -41,6 +41,7 @@ import {
import { planUnionRetag } from "../union-retag.js";
import { jsHoldsDynObject, runtimeOptionalStorageOperand } from "../lower-exprs.js";
import { builtinGlobalValue } from "./property-access.js";
import { holdsEvolvingArray } from "../lower-stmts.js";
/** `{ a: 1, b: "x" }` → recordLit. The record type comes from the
* contextual type when tsc has one (annotated declarations, arguments,
@@ -413,7 +414,10 @@ export function lowerDynObjectLiteral(
? lowerer.lowerLambda(prop, true)
: ts.isShorthandPropertyAssignment(prop) && shorthandBuiltinValue(lowerer, prop)
? shorthandBuiltinValue(lowerer, prop)!
: !boxValue && ts.isObjectLiteralExpression(valueExpr)
: !boxValue &&
(ts.isObjectLiteralExpression(valueExpr) ||
(ts.isArrayLiteralExpression(valueExpr) &&
holdsEvolvingArray(lowerer, valueExpr)))
? lowerer.lowerExprExpecting(valueExpr, DYN)
: lowerer.lowerExpr(valueExpr as ts.Expression);
raw = fenceClosureProbe(lowerer, valueExpr, undefined, lowerValue) ?? lowerValue();
@@ -4137,6 +4137,7 @@ const GLOBAL_CONSTRUCTOR_VALUES: ReadonlySet<string> = new Set([
"Promise",
"WeakMap",
"WeakSet",
"FinalizationRegistry",
...ERROR_CONSTRUCTOR_VALUES,
]);
@@ -148,6 +148,10 @@ export function lowerInstanceOf(lowerer: Lowerer, expr: ts.BinaryExpression, loc
loc,
};
}
if (lowerer.isStdlibGlobal(expr.right, "FinalizationRegistry")) {
const value = lowerer.coerceInto(expr.left, lowerer.lowerExpr(expr.left), DYN);
return { kind: "libCall", fn: "finalizationRegistry.is", args: [value], type: BOOL, loc };
}
const rhsBuiltin = ts.isIdentifier(expr.right)
? lowerer.builtinImportOf(expr.right)
: ts.isPropertyAccessExpression(expr.right)
@@ -1500,6 +1504,32 @@ export function lowerInExpression(
);
if (helper) recv = { kind: "call", callee: helper, args: [recv], type: siteType, loc };
}
// A builtin class held as a brand record (TextEncoder, TextDecoder: only
// `%`-prefixed internal fields): its members are the class's prototype
// members, which the standard library declares, so `"encodeInto" in
// encoder` answers from the checker's type, plus Object.prototype's keys.
// The receiver evaluates for its effects.
if (recv.type.kind === "record") {
const shape = lowerer.shapes.get(recv.type.shapeId);
if (shape && shape.fields.length > 0 && shape.fields.every((f) => f.name.startsWith("%"))) {
const declared =
lowerer.checker.getPropertyOfType(lowerer.typeOf(expr.right), key) !== undefined;
const answer: IrExpr = {
kind: "boolLit",
value: declared || OBJECT_PROTOTYPE_KEYS.has(key),
type: BOOL,
loc,
};
if (recv.kind === "varRef") return answer;
return {
kind: "seqExpr",
stmts: [{ kind: "exprStmt", expr: recv, loc }],
result: answer,
type: BOOL,
loc,
};
}
}
// Error-rooted receivers (builtin or user subclass — the isErrnoException
// predicate's `err instanceof Error && "code" in err` shape): `code`
// answers from the runtime error's code slot (stamped by fs/system/
@@ -1,5 +1,10 @@
/* Increment, decrement and prefix unary operators, and compound field updates. */
import * as ts from "../../ts7/adapter.js";
import {
lowerOptionalIndex,
splitUndefinedIndex,
undefinedTypedArrayKeyFence,
} from "../undefined-index.js";
import { isStrictCode } from "../strict-mode.js";
import { isPrivateWriteReceiver } from "../private-brands.js";
import type { Lowerer } from "../lowerer.js";
@@ -311,12 +316,27 @@ export function lowerIncDec(
(raw.type.kind === "array" || raw.type.kind === "bytes")
) {
const receiver = save(raw, "%incrementArray");
const rawIndex = lowerOptionalNumber(
lowerer,
lowerer.lowerExpr(access.argumentExpression),
locOf(access.argumentExpression),
access.argumentExpression,
);
// An undefined index names the "undefined" property
// (undefined-index.ts), which a typed array cannot hold.
const loweredIndex = lowerer.lowerExpr(access.argumentExpression);
const split =
raw.type.kind === "bytes"
? splitUndefinedIndex(
lowerer,
loweredIndex,
access.argumentExpression,
locOf(access.argumentExpression),
)
: null;
if (split) stmts.push(...split.stmts);
const rawIndex =
split?.index ??
lowerOptionalIndex(
lowerer,
loweredIndex,
locOf(access.argumentExpression),
access.argumentExpression,
);
const index = save(
rawIndex.type.kind === "dyn" && isJsSourceFile(ts.getSourceFileOfNode(expr))
? { kind: "libCall", fn: "dyn.toNumberCoerce", args: [rawIndex], type: F64, loc }
@@ -340,7 +360,7 @@ export function lowerIncDec(
loc,
invalidNaN: true,
};
return finish(read, (value) =>
const store = (value: IrExpr): IrStmt =>
type.kind === "array"
? arrayValueStore(
lowerer,
@@ -356,8 +376,15 @@ export function lowerIncDec(
index,
value: lowerer.coerceInto(access, value, F64),
loc,
},
);
};
if (!split) return finish(read, store);
return finish(read, (value) => ({
kind: "if",
cond: split.isUndefined,
then: [undefinedTypedArrayKeyFence(lowerer, access, loc)],
else_: [store(value)],
loc,
}));
}
// A TS member statically typed number on a checked-dynamic receiver (a
// class-backed protocol view): the expanded form `o.v = o.v + 1`, its
@@ -15,6 +15,7 @@ import {
import { isJsSourceFile } from "../program.js";
import { PoisonError, dynFallbackType } from "./lowerer.js";
import { functionCanReturnUndefined } from "../function-completion.js";
import { jsBindingAppendsOnly, jsBindingHasOpenWrites } from "./lower-stmts.js";
import type { FnCtx } from "./function-context.js";
/** Keep the conservative completion rule shared by return construction
@@ -124,6 +125,32 @@ export function producesConstructor(lowerer: Lowerer, type: ts.Type): boolean {
return false;
}
/** Whether a return of `decl` itself (not of a nested function) is a bare
* read of a JavaScript variable whose writes the checker does not
* constrain. A string that is only appended to stays a string. */
function returnsOpenJsBinding(lowerer: Lowerer, decl: ts.SignatureDeclaration): boolean {
let open = false;
const opens = (value: ts.Expression | undefined): boolean => {
while (value && ts.isParenthesizedExpression(value)) value = value.expression;
if (!value || !ts.isIdentifier(value)) return false;
const binding = lowerer.resolveValueSymbol(value)?.valueDeclaration;
return (
binding !== undefined &&
ts.isVariableDeclaration(binding) &&
jsBindingHasOpenWrites(lowerer, binding) &&
!jsBindingAppendsOnly(lowerer, binding)
);
};
const scan = (node: ts.Node): void => {
if (open || (node !== decl && ts.isFunctionLike(node))) return;
if (ts.isReturnStatement(node) && opens(node.expression)) open = true;
ts.forEachChild(node, scan);
};
if (ts.isArrowFunction(decl) && decl.body && !ts.isBlock(decl.body)) open = opens(decl.body);
else scan(decl);
return open;
}
/** Map a declaration's return contract while retaining JavaScript
* inference fallbacks. Diagnostics preserve the caller's blame node. */
export function declaredReturnType(
@@ -212,6 +239,23 @@ export function declaredReturnType(
lowerer.unions.get(mappedReturn.unionId)?.arms.some((arm) => arm.kind === "func")
)
return DYN;
// A JS function returning a local that holds whatever its writes store
// (jsBindingHasOpenWrites): the inferred result is the initializer's
// type, which the returned value need not have.
// An async function's promise fulfills with that value.
if (
isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!decl.type &&
!decl.asteriskToken &&
!hasExplicitJsDocReturn(decl) &&
mappedReturn !== null &&
mappedReturn.kind !== "dyn" &&
!(mappedReturn.kind === "promise" && mappedReturn.inner.kind === "dyn") &&
returnsOpenJsBinding(lowerer, decl)
)
return decl.modifiers?.nodes.some((modifier) => modifier.kind === ts.SyntaxKind.AsyncKeyword)
? { kind: "promise", inner: DYN }
: DYN;
// JS inference assigns Number to arithmetic over any, although the
// runtime operands can both be BigInts. Preserve that native result.
if (
@@ -342,10 +342,15 @@ export function genericCallInstance(
const local = localClassResultInstance(lowerer, expr, info, rsig, params, retTs);
if (local) return local;
// A result instantiated at `any` (a type argument inferred from an
// untyped value, such as an untyped forwarding call) is checked-dynamic.
// untyped value, such as an untyped forwarding call) is checked-dynamic,
// and so is a JavaScript result whose JSDoc names a type the file never
// declares or imports (`@returns {MiddlewareHandler<E>}` in a published
// bundle): the checker's error type is documentation it could not
// resolve, not a contract.
const mappedReturn =
lowerer.mapTypeOf(retTs) ??
((retTs.flags & ts.TypeFlags.Any) !== 0 && !isJsSourceFile(ts.getSourceFileOfNode(info.decl))
((retTs.flags & ts.TypeFlags.Any) !== 0 &&
(!isJsSourceFile(ts.getSourceFileOfNode(info.decl)) || retTs.isErrorType())
? DYN
: null);
if (!mappedReturn) {
@@ -3872,7 +3872,14 @@ function lowerDynReceiverMethodCall(
}
// The argument is a radix for bigint and an encoding for Buffer.
// Preserve its runtime value so receiver dispatch can choose correctly.
if (access.name.text === "toString" && call.arguments.nodes.length <= 1) {
// An authored own `toString` member (zod's `errorUtil.toString`, which
// answers string | undefined) is an ordinary method call instead.
if (
access.name.text === "toString" &&
call.arguments.nodes.length <= 1 &&
!ownCallable &&
!(checkerUntyped && jsAssignsOwnToString(lowerer, access.expression))
) {
const argument = call.arguments.nodes[0];
return {
kind: "libCall",
@@ -3982,12 +3989,22 @@ function lowerDynReceiverMethodCall(
// The same holds when only the MEMBER is checker-untyped: TypeScript
// importing chalk's JavaScript source types the default export as a
// plain function, so `chalk.bold` reads as `any` on a typed receiver.
const ownToString =
access.name.text === "toString" &&
checkerUntyped &&
jsAssignsOwnToString(lowerer, access.expression);
const stringOnlyName =
(checkerUntyped ||
(lowerer.typeOf(access).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) &&
!ownCallable &&
DYN_STRING_PROTO_ONLY_NAMES.has(access.name.text);
if (DYN_PROTO_METHOD_NAMES.has(access.name.text) && !ownCallable && !stringOnlyName) return null;
if (
DYN_PROTO_METHOD_NAMES.has(access.name.text) &&
!ownCallable &&
!ownToString &&
!stringOnlyName
)
return null;
// Optional forms (`obj.cb?.()`, `obj?.cb()`) belong to the chain
// machinery's short-circuit semantics — not modeled here yet.
if (call.questionDotToken || access.questionDotToken) return null;
@@ -4414,6 +4431,9 @@ const DYN_DISPATCH_METHODS = new Set([
"additionalHeaders",
"altsvc",
"origin",
// FinalizationRegistry's methods (scr_finalization_registry_new).
"register",
"unregister",
// Worker and MessagePort methods use the owning context's handle adapter.
"postMessage",
"terminate",
@@ -6286,7 +6306,21 @@ export function lowerPromiseMethodCall(
`then handlers whose parameter is not the settled value's type (expected '${lowerer.fmt(inner)}', got '${lowerer.fmt(param)}')`,
);
}
const resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
let resultT = lowerer.mapTypeOf(lowerer.typeOf(call));
// JavaScript whose declared result type has no mapping (zod's
// `Promise.all(items).then((r) => ParseStatus.mergeArray(status, r))`,
// typed by a .d.ts union): the handler's value settles the promise as
// a checked-dynamic value, checked where it is used.
if (
resultT?.kind !== "promise" &&
isJsSourceFile(ts.getSourceFileOfNode(call)) &&
cb.type.kind === "func" &&
cb.type.ret.kind !== "promise" &&
cb.type.ret.kind !== "void" &&
(cb.type.ret.kind === "dyn" || lowerer.dynConvertible(cb.type.ret))
) {
resultT = { kind: "promise", inner: DYN };
}
if (resultT?.kind !== "promise") {
lowerer.noLowering(
"then with this handler's result type",
@@ -8658,11 +8692,15 @@ function lowerObjectStaticCall(
probed?.type.kind === "union" &&
isJsSourceFile(ts.getSourceFileOfNode(call)) &&
lowerer.dynConvertible(probed.type);
// An array walks its live view: the present indices, then its numeric
// and named properties.
const isArray = probed?.type.kind === "array" && lowerer.dynConvertible(probed.type);
if (
isDyn ||
isPrimitive ||
isFunction ||
isJsUnion ||
isArray ||
(probed && isDynTypedRefType(probed.type))
) {
const fn =
@@ -8672,7 +8710,14 @@ function lowerObjectStaticCall(
? "dyn.objValues"
: "dyn.objEntries";
let v = lowerer.lowerExpr(argNode);
if (v.type.kind !== "dyn") v = { kind: "dynFrom", value: v, type: DYN, loc: locOf(call) };
if (v.type.kind !== "dyn")
v = {
kind: "dynFrom",
value: v,
...(isArray ? { liveRef: true as const } : {}),
type: DYN,
loc: locOf(call),
};
// In TypeScript the keys come back as a native string[] and the
// entries as a native [string, any][]: the checker keeps every later
// write to the arrays well typed. JavaScript may store anything into
@@ -11057,6 +11102,93 @@ function fenceClassCopyIntoFreshObject(
);
}
/** True when an untyped JavaScript receiver names a binding whose declaring
* file assigns it an own `toString` (the TypeScript namespace downlevel
* `errorUtil.toString = (message) => ...` in zod): its calls answer what
* that function returns, not Object.prototype.toString's string. */
function jsAssignsOwnToString(lowerer: Lowerer, receiver: ts.Expression): boolean {
if (!ts.isIdentifier(receiver)) return false;
const decl = bindingDeclaration(lowerer, receiver);
if (decl === undefined) return false;
const sf = ts.getSourceFileOfNode(decl);
if (!isJsSourceFile(sf)) return false;
let targets = lowerer.ownToStringTargets.get(sf);
if (targets === undefined) {
targets = ownToStringTargets(lowerer, sf);
lowerer.ownToStringTargets.set(sf, targets);
}
return targets.has(decl);
}
const unparenthesized = (e: ts.Expression): ts.Expression => {
let out = e;
while (ts.isParenthesizedExpression(out)) out = out.expression;
return out;
};
/** The declaration of the binding an identifier names, through imports. */
function bindingDeclaration(lowerer: Lowerer, id: ts.Identifier): ts.Node | undefined {
let sym = lowerer.checker.getSymbolAtLocation(id);
if (sym !== undefined && (sym.flags & ts.SymbolFlags.Alias) !== 0)
sym = lowerer.checker.getAliasedSymbol(sym);
return sym?.valueDeclaration ?? sym?.declarations?.[0];
}
/** The declarations of the bindings a file's assignments `x.toString = ...`
* (or `x["toString"] = ...`) give an own `toString`. An assignment through
* the parameter of an immediately called function counts for the binding
* the call passes (`(function (ns) { ns.toString = f; })(ns || (ns = {}))`). */
function ownToStringTargets(lowerer: Lowerer, sf: ts.SourceFile): Set<ts.Node> {
const targets = new Set<ts.Node>();
const visit = (node: ts.Node): void => {
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
const left = unparenthesized(node.left);
const named =
(ts.isPropertyAccessExpression(left) && left.name.text === "toString") ||
(ts.isElementAccessExpression(left) &&
ts.isStringLiteralLike(left.argumentExpression) &&
left.argumentExpression.text === "toString");
const target = named
? unparenthesized(
(left as ts.PropertyAccessExpression | ts.ElementAccessExpression).expression,
)
: undefined;
if (target !== undefined && ts.isIdentifier(target)) {
const decl = bindingDeclaration(lowerer, target);
if (decl !== undefined) {
targets.add(decl);
const passed = iifeArgumentDeclaration(lowerer, decl);
if (passed !== undefined) targets.add(passed);
}
}
}
ts.forEachChild(node, visit);
};
visit(sf);
return targets;
}
/** For a parameter of an immediately called function, the declaration of
* the binding its argument names: `x`, `x || (x = {})` or `(x = {})`. */
function iifeArgumentDeclaration(lowerer: Lowerer, param: ts.Node): ts.Node | undefined {
if (!ts.isParameter(param)) return undefined;
const fn = param.parent;
if (!ts.isFunctionExpression(fn) && !ts.isArrowFunction(fn)) return undefined;
let callee: ts.Node = fn;
while (ts.isParenthesizedExpression(callee.parent)) callee = callee.parent;
const call = callee.parent;
if (!ts.isCallExpression(call) || call.expression !== callee) return undefined;
const index = fn.parameters.nodes.indexOf(param);
let arg = index < 0 ? undefined : call.arguments.nodes[index];
if (arg === undefined) return undefined;
arg = unparenthesized(arg);
if (ts.isBinaryExpression(arg) && arg.operatorToken.kind === ts.SyntaxKind.BarBarToken)
arg = unparenthesized(arg.left);
if (ts.isBinaryExpression(arg) && arg.operatorToken.kind === ts.SyntaxKind.EqualsToken)
arg = unparenthesized(arg.left);
return ts.isIdentifier(arg) ? bindingDeclaration(lowerer, arg) : undefined;
}
/** The runtime calls package shims declare as bodiless functions. */
const SHIM_INTRINSICS: ReadonlyMap<string, { fn: IrLibFn; args: IrType[]; ret: IrType }> = new Map<
string,
@@ -239,7 +239,7 @@ export function returnsOnlyThisValue(member: ts.MethodDeclaration): boolean {
return valid;
}
function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
export function functionLocalClass(decl: ts.ClassLikeDeclaration): boolean {
for (
let parent: ts.Node | undefined = decl.parent;
parent && !ts.isSourceFile(parent);
@@ -6651,7 +6651,7 @@ function staticCallOn(
// functions, called directly as `%C.static:m%n` — with the same
// through-a-VALUE shadowing fence as plain statics.
const gfound = findGenericStaticOn(lowerer, info, access.name.text);
if (!gfound) return null;
if (!gfound) return classConstructorCall(lowerer, call, access, info);
if (throughValue && staticShadowBelow(info, access.name.text)) {
lowerer.unsupported(
"SC1090",
@@ -6744,6 +6744,89 @@ function staticCallOn(
return { kind: "call", callee: fnName, args, type: found.method.ret, loc };
}
/** `C.call(thisArg, ...)` / `C.apply(thisArg, args)` on a program class that
* declares no such static: Function.prototype.call/apply invoke the class
* constructor without `new`, which throws Node's TypeError "Class
* constructor C cannot be invoked without 'new'" after the arguments are
* evaluated (and, for apply, after the argument list is read). The boxed
* class value's call path throws exactly that. Neither expando statics nor
* writes through untyped values can give a class an own `call` or `apply`.
* A receiver that may hold a subclass at run time (a class value, `this` in
* a static method) needs every subclass to lack such a static too. The
* receiver must be one the caller does not evaluate again (a name, `this`,
* or a namespace member). Null otherwise. */
function classConstructorCall(
lowerer: Lowerer,
call: ts.CallExpression,
access: ts.PropertyAccessExpression,
info: ClassInfo,
): IrExpr | null {
const name = access.name.text;
const receiver = access.expression;
if (
(name !== "call" && name !== "apply") ||
!info.decl ||
info.def.runtime ||
info.builtinError ||
info.builtinEmitter ||
info.builtinStream ||
info.builtinEventTarget ||
info.runtimeStatics?.length ||
declaresStaticAbove(info, name) ||
(exactClassOfReceiver(lowerer, receiver) !== info &&
info.subclasses.some((subclass) => declaresStaticBelow(subclass, name))) ||
call.arguments.nodes.some(ts.isSpreadElement) ||
!(
ts.isIdentifier(receiver) ||
receiver.kind === ts.SyntaxKind.ThisKeyword ||
(ts.isPropertyAccessExpression(receiver) && nsMemberIdentOf(lowerer, receiver) !== null)
)
)
return null;
return {
kind: "dynInvoke",
recv: lowerer.lowerExprExpecting(receiver, DYN),
method: name,
calleeName: ts.getTextOfNode(access),
args: call.arguments.nodes.map((argument) => lowerer.lowerExprExpecting(argument, DYN)),
type: DYN,
loc: locOf(call),
};
}
/** Whether `info`'s declaration has a static member that may be named `name`
* (a method, field or accessor of that name, or a computed name), or `info`
* has no declaration to tell. */
function declaresStatic(info: ClassInfo, name: string): boolean {
if (!info.decl) return true;
return info.decl.members.nodes.some(
(member) =>
member.name !== undefined &&
ts.canHaveModifiers(member) &&
ts.getModifiers(member)?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword) ===
true &&
(ts.isComputedPropertyName(member.name) ||
((ts.isIdentifier(member.name) || ts.isStringLiteral(member.name)) &&
member.name.text === name)),
);
}
/** declaresStatic over `info` and its bases, which it inherits statics from. */
function declaresStaticAbove(info: ClassInfo, name: string): boolean {
for (let c: ClassInfo | null = info; c; c = c.base) if (declaresStatic(c, name)) return true;
return false;
}
/** declaresStatic over `info` and its subclasses, or runtime statics, which a
* function-local JavaScript class defines at run time. */
function declaresStaticBelow(info: ClassInfo, name: string): boolean {
return (
declaresStatic(info, name) ||
!!info.runtimeStatics?.length ||
info.subclasses.some((subclass) => declaresStaticBelow(subclass, name))
);
}
/** `C.m(...)` where `m` is a static method that a function-local
* JavaScript class defines at runtime (a factory's `class extends base`),
* inherited by `C` through its statically known base chain. When the body
@@ -10569,6 +10652,45 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
loc,
};
}
// A FinalizationRegistry construction: register and unregister keep
// their bookkeeping, and the cleanup callback never runs, which
// ECMAScript allows (scr_finalization_registry_new).
if (
symbol !== null &&
ts.symbolName(symbol) === "FinalizationRegistry" &&
lowerer.isStdlibSymbol(symbol)
) {
const args = expr.arguments?.nodes ?? [];
if (args.some(ts.isSpreadElement)) {
lowerer.unsupported(
"SC1090",
expr,
"FinalizationRegistry construction with spread arguments",
);
}
const cleanup = args[0] ? lowerer.lowerExprExpecting(args[0], DYN) : dynUndefinedExpr(loc);
const surplus = args.slice(1).map((arg) => lowerer.lowerExpr(arg));
const created: IrExpr = {
kind: "libCall",
fn: "finalizationRegistry.new",
args: [cleanup],
type: DYN,
loc,
};
if (surplus.length === 0) return created;
// Surplus arguments evaluate after the cleanup argument and are ignored.
const local = lowerer.declareHiddenLocal("%finalizationCleanup", DYN);
return {
kind: "seqExpr",
stmts: [
{ kind: "varDecl", localId: local.id, init: cleanup, loc },
...surplus.map((value): IrStmt => ({ kind: "exprStmt", expr: value, loc })),
],
result: { ...created, args: [varRef(local.id, DYN, loc)] },
type: DYN,
loc,
};
}
// `new Set<T>()`: construct + bulk add from supported values,
// including live collection cursors. Duplicates collapse in insertion order.
if (ts.isIdentifier(identifier) && lowerer.isStdlibGlobal(identifier, "Set")) {
@@ -10780,8 +10902,6 @@ export function lowerNew(lowerer: Lowerer, expr: ts.NewExpression): IrExpr {
Boolean: "boxed wrapper objects have no lowering — use the boolean primitive",
WeakRef:
"deref()-after-collect exposes GC timing — genuinely dynamic; hold a strong reference instead",
FinalizationRegistry:
"finalization callbacks expose GC timing — genuinely dynamic; release resources explicitly instead",
SharedArrayBuffer:
"no shared-memory threads exist in a compiled program — Uint8Array is the byte storage",
ArrayBuffer:
@@ -1,4 +1,10 @@
import { keyedHelperName } from "./stable-names.js";
import {
isNewTarget,
lowerNewTarget,
lowerNewTargetComparison,
lowerNewTargetName,
} from "./new-target.js";
import { lowerWebAssemblyValue, refuseWebAssemblyUse } from "./builtins/webassembly.js";
import { lowerAwaitedUniformPromiseAll } from "./builtins/promises.js";
import { dynUndefinedExpr, nodeThrowExpr, numLit, strLit, varRef } from "../../ir/build.js";
@@ -713,7 +719,8 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
if (ts.isPrefixUnaryExpression(expr)) return lowerer.lowerPrefixUnary(expr);
// `x++` / `x--` in expression position: yields the OLD value.
if (ts.isPostfixUnaryExpression(expr)) return lowerIncDec(lowerer, expr, false);
if (ts.isBinaryExpression(expr)) return lowerer.lowerBinary(expr);
if (ts.isBinaryExpression(expr))
return lowerNewTargetComparison(lowerer, expr) ?? lowerer.lowerBinary(expr);
if (ts.isCallExpression(expr)) {
// `h.return?.()` on a helper object: the method exists, so the
// optional call is the call.
@@ -729,13 +736,15 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
if (ts.isElementAccessExpression(expr)) return lowerer.lowerElementAccess(expr);
if (ts.isConditionalExpression(expr)) return lowerTernary(lowerer, expr);
if (ts.isPropertyAccessExpression(expr)) return lowerPropertyAccessExpr(lowerer, expr, loc);
if (ts.isPropertyAccessExpression(expr))
return lowerNewTargetName(lowerer, expr) ?? lowerPropertyAccessExpr(lowerer, expr, loc);
// Meta-properties, named: `new.target` reflects HOW a function was
// invoked (compiled functions are never constructors of themselves —
// no runtime invocation record exists), and `import.meta`/
// `import.defer` are module-loader surface a native binary does not
// carry.
if (isNewTarget(expr)) return lowerNewTarget(lowerer, expr);
if (ts.isMetaProperty(expr)) {
const name =
expr.keywordToken === ts.SyntaxKind.NewKeyword ? "new.target" : `import.${expr.name.text}`;
@@ -1,6 +1,5 @@
import * as ts from "../ts7/adapter.js";
import { DYN, STRING, arrayOf, type IrExpr } from "../../ir/ir.js";
import { strLit } from "../../ir/build.js";
import { locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import { TEMPORAL_GLOBAL } from "../builtin-modules.js";
@@ -19,10 +18,14 @@ export function lowerGlobalValue(lowerer: Lowerer, expr: ts.Expression): IrExpr
return {
kind: "libCall",
fn: "global.native",
// One immortal name table per program (keyed by its contents): reading
// the global object in a hot path allocates nothing, and the runtime
// recognizes a table it has already registered.
args: [
{
kind: "arrayLit",
elems: names.map((name) => strLit(name, loc)),
kind: "templateStrings",
key: `%globalThis.names:${names.join(",")}`,
cooked: names,
type: arrayOf(STRING),
loc,
},
@@ -2188,13 +2188,21 @@ function inspectHelper(lowerer: Lowerer, t: IrType, loc: SrcLoc): string {
// A closed record that kept the undeclared properties of its checked
// source renders through its live checked view, which holds them in the
// source's own-key order beside the current field values.
// source's own-key order beside the current field values. An array with
// properties besides its elements does too: the view lists them after
// the elements.
let prependSourceGate: (() => void) | null = null;
if (t.kind === "record" && recordMayKeepSource(lowerer, t)) {
const sourceTest: IrExpr | null =
t.kind === "record" && recordMayKeepSource(lowerer, t)
? recordSourceTest(v(), loc)
: t.kind === "array" && lowerer.dynConvertible(t)
? { kind: "libCall", fn: "array.hasProperties", args: [v()], type: BOOL, loc }
: null;
if (sourceTest !== null) {
prependSourceGate = (): void => {
body.unshift({
kind: "if",
cond: recordSourceTest(v(), loc),
cond: sourceTest,
then: [
ret({
kind: "libCall",
@@ -25,6 +25,7 @@ import { InternalCompilerError } from "../../errors.js";
* flow (if/while/for/for-of/do, switch, try/catch, abrupt completions),
* explicit resource management, and blocked-binding poisoning. */
import * as ts from "../ts7/adapter.js";
import { lowerOptionalIndex } from "./undefined-index.js";
import { processDataMemberValue, programReadsExitCode } from "./builtins/process.js";
import { requireInitFlagStmt } from "./require-initialization.js";
import { isJsonRequireDecl } from "./json-require.js";
@@ -204,7 +205,6 @@ import {
lowerElementCompoundStatement,
lowerIncDec,
lowerIncDecTarget,
lowerOptionalNumber,
lowerGroupsProjection,
matchResultNamedGroupsOf,
runtimeOptionalGuardIds,
@@ -1220,6 +1220,43 @@ function lowerJsVarFromExisting(
};
}
/** Whether a JavaScript declaration's object value changes shape after its
* initializer (jsOpenObjectType, jsObjectBindingExpands) or carries
* methods, so only the checked-dynamic representation holds it.
* `inferred` is the declaration's mapped checker type. */
function jsDeclarationExpandsObject(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
inferred: IrType | null,
): boolean {
const arms =
inferred?.kind === "union"
? (lowerer.unions.get(inferred.unionId)?.arms ?? [])
: inferred
? [inferred]
: [];
return (
(!decl.type &&
!hasJsTypeAnnotation(decl) &&
inferred !== null &&
jsOpenObjectType(decl, inferred, lowerer.shapes, lowerer.unions).kind === "dyn") ||
arms.some((arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm)) ||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!decl.type &&
!hasJsTypeAnnotation(decl) &&
decl.initializer !== undefined &&
ts.isObjectLiteralExpression(decl.initializer) &&
decl.initializer.properties.nodes.some(
(property) =>
ts.isMethodDeclaration(property) ||
ts.isAccessor(property) ||
(ts.isPropertyAssignment(property) &&
(ts.isFunctionExpression(property.initializer) ||
ts.isArrowFunction(property.initializer))),
))
);
}
/** The `var` symbol's function-scoped binding TYPE — the declared type at
* the binding name, with the JS-source fallbacks every mutable binding
* takes (an empty-object-literal type is checked-dynamic because tsc
@@ -1254,6 +1291,19 @@ function varBindingType(lowerer: Lowerer, nameNode: ts.Identifier): IrType | nul
type = staticImportNamespaceType(lowerer, nameNode.parent.initializer) ?? type;
}
if (!type || type.kind === "void") return null;
// A JavaScript `var` holds whatever its writes store, as a `let` does:
// the checker types it by its initializer and checks no later write
// (`var fraction = 0;`, later `fraction = match[7].slice(0, 3)`), and an
// object it holds may gain members (`mark.snippet = snippet(mark)`).
if (
type.kind !== "dyn" &&
ts.isVariableDeclaration(nameNode.parent) &&
nameNode.parent.name === nameNode &&
isJsSourceFile(ts.getSourceFileOfNode(nameNode)) &&
(jsBindingHasOpenWrites(lowerer, nameNode.parent) ||
jsDeclarationExpandsObject(lowerer, nameNode.parent, type))
)
return DYN;
// A binding readable before its first store holds undefined until then.
return lowerer.runtimeOptionalBindingType(nameNode, type);
}
@@ -4870,12 +4920,16 @@ export function bindPatternTarget(
}
// Preserve checked-value pattern bindings when the inferred type
// cannot represent them without a refusal or an alias-breaking copy.
// A name destructured from a container of an evolving array
// (evolvingArrayHold) may hold the array, which the checker types by
// the elements written before it.
const dynStays =
value.type.kind === "dyn" &&
(() => {
((() => {
const mapped = lowerer.mapTypeOf(lowerer.typeOf(name));
return mapped === null || jsBindingKeepsCheckedObject(lowerer, name, mapped);
})();
})() ||
readsHeldEvolvingArray(lowerer, name));
let type = dynStays
? DYN
: (lowerer.runtimeOptionalBindingType(name, lowerer.irTypeOf(name)) ??
@@ -5004,13 +5058,16 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
// type once the reads settle it.
const written: ts.Expression[] = [];
// The file's identifiers are indexed by name once (identifiersNamed), so
// each binding resolves only the uses spelled like it.
// each binding resolves only the uses spelled like it. A shorthand
// property (`{ xs }`) is a use too.
for (const node of identifiersNamed(lowerer, ts.getSourceFileOfNode(decl), decl.name.text)) {
if (
node === decl.name ||
node.pos < scope.pos ||
node.end > scope.end ||
lowerer.checker.getSymbolAtLocation(node) !== symbol
(ts.isShorthandPropertyAssignment(node.parent) && node.parent.name === node
? lowerer.checker.getShorthandAssignmentValueSymbol(node.parent)
: lowerer.checker.getSymbolAtLocation(node)) !== symbol
)
continue;
let target: ts.Node = node;
@@ -5068,28 +5125,105 @@ function evolvingArrayType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrTy
return result;
}
/** An unannotated alias of an evolving array binding whose reads disagree
* (`const alias = xs` between `xs.push(1)` and `xs.push("two")`): the
* checker types the alias by the elements written before it, but both names
* hold one array, so the alias takes the binding's checked-dynamic type and
* sees the later writes. Aliases of such an alias follow it. Null for other
* bindings. */
function evolvingArrayAliasType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null {
let current = decl;
for (let depth = 0; depth < 8; depth++) {
let initializer = current.initializer;
while (initializer !== undefined && ts.isParenthesizedExpression(initializer))
initializer = initializer.expression;
if (initializer === undefined || !ts.isIdentifier(initializer)) return null;
const source = lowerer.checker.getSymbolAtLocation(initializer)?.valueDeclaration;
/** How a TypeScript expression holds an evolving array binding whose reads
* disagree (evolvingArrayType answers checked-dynamic): "evolving" for the
* binding itself, "alias" for an unannotated binding initialized with it or
* with an alias of it (`const alias = xs`), and "container" for an object or
* array literal with it as a property, element or spread source at any
* depth (`{ items: xs }`, `[xs]`), an unannotated binding initialized with
* or destructured from such a literal, and a property or element read of a
* container (`box.items`). The checker types an alias or container by the
* elements written before it was taken, but it holds the same array. Null
* for other expressions. */
function evolvingArrayHold(
lowerer: Lowerer,
expr: ts.Expression,
depth = 0,
): "evolving" | "alias" | "container" | null {
while (ts.isParenthesizedExpression(expr)) expr = expr.expression;
if (depth >= 8) return null;
const binding = (symbol: ts.Symbol | undefined): "evolving" | "alias" | "container" | null => {
const source = symbol?.valueDeclaration;
// A name destructured from a container (`const { items } = box`) may
// be the array or another container.
if (source !== undefined && ts.isBindingElement(source) && ts.isIdentifier(source.name)) {
const host = hostVariableDeclarationOf(source.name);
return host !== null &&
!host.type &&
host.initializer !== undefined &&
evolvingArrayHold(lowerer, host.initializer, depth + 1) === "container"
? "container"
: null;
}
if (source === undefined || !ts.isVariableDeclaration(source) || source.type) return null;
const evolved = evolvingArrayType(lowerer, source);
if (evolved !== null) return evolved.kind === "dyn" ? evolved : null;
current = source;
if (evolved !== null) return evolved.kind === "dyn" ? "evolving" : null;
if (source.initializer === undefined) return null;
const held = evolvingArrayHold(lowerer, source.initializer, depth + 1);
return held === "evolving" ? "alias" : held;
};
if (ts.isIdentifier(expr)) return binding(lowerer.checker.getSymbolAtLocation(expr));
if (ts.isPropertyAccessExpression(expr) || ts.isElementAccessExpression(expr))
return evolvingArrayHold(lowerer, expr.expression, depth + 1) === "container"
? "container"
: null;
// A spread of the array itself (`{ ...xs }`, `[...xs]`) copies its
// elements, as in Node; a spread of a container copies its references.
if (ts.isObjectLiteralExpression(expr)) {
for (const property of expr.properties.nodes) {
const held = ts.isPropertyAssignment(property)
? evolvingArrayHold(lowerer, property.initializer, depth + 1)
: ts.isShorthandPropertyAssignment(property)
? binding(lowerer.checker.getShorthandAssignmentValueSymbol(property))
: ts.isSpreadAssignment(property) &&
evolvingArrayHold(lowerer, property.expression, depth + 1) === "container"
? "container"
: null;
if (held !== null) return "container";
}
return null;
}
if (ts.isArrayLiteralExpression(expr)) {
for (const element of expr.elements.nodes) {
const held = ts.isSpreadElement(element)
? evolvingArrayHold(lowerer, element.expression, depth + 1) === "container"
? "container"
: null
: evolvingArrayHold(lowerer, element, depth + 1);
if (held !== null) return "container";
}
}
return null;
}
/** Whether a TypeScript expression is an alias or container of an evolving
* array binding whose reads disagree (evolvingArrayHold). Its value is
* checked-dynamic, and the checker's type for it lists only the elements
* written before it was taken, so element reads through it stay
* checked-dynamic instead of checking against that type. */
export function readsHeldEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false;
const held = evolvingArrayHold(lowerer, expr);
return held === "alias" || held === "container";
}
/** Whether a TypeScript object or array literal holds an evolving array
* binding whose reads disagree (evolvingArrayHold), so it builds as a
* checked-dynamic literal that keeps the array. */
export function holdsEvolvingArray(lowerer: Lowerer, expr: ts.Expression): boolean {
if (isJsSourceFile(ts.getSourceFileOfNode(expr))) return false;
return evolvingArrayHold(lowerer, expr) === "container";
}
/** An unannotated binding initialized with an evolving array binding whose
* reads disagree, or with an alias or container of one (evolvingArrayHold):
* it takes the array's checked-dynamic type, so it sees the later writes.
* Null for other bindings. */
function evolvingArrayHolderType(lowerer: Lowerer, decl: ts.VariableDeclaration): IrType | null {
if (decl.initializer === undefined) return null;
return evolvingArrayHold(lowerer, decl.initializer) === null ? null : DYN;
}
/** A single static type for an initializer-less JavaScript `let` whose
* later plain writes all agree. An evolving JS local reads as `undefined`
* until control flow proves a write, so a scalar slot is sound only when
@@ -5597,26 +5731,7 @@ export function lowerVarDecl(
: inferredObjectType
? [inferredObjectType]
: [];
const expandsObject =
(!decl.type &&
!hasJsTypeAnnotation(decl) &&
inferredObjectType !== null &&
jsOpenObjectType(decl, inferredObjectType, lowerer.shapes, lowerer.unions).kind === "dyn") ||
inferredObjectArms.some(
(arm) => arm.kind === "record" && jsObjectBindingExpands(lowerer, decl, arm),
) ||
(isJsSourceFile(ts.getSourceFileOfNode(decl)) &&
!decl.type &&
!hasJsTypeAnnotation(decl) &&
ts.isObjectLiteralExpression(decl.initializer) &&
decl.initializer.properties.nodes.some(
(property) =>
ts.isMethodDeclaration(property) ||
ts.isAccessor(property) ||
(ts.isPropertyAssignment(property) &&
(ts.isFunctionExpression(property.initializer) ||
ts.isArrowFunction(property.initializer))),
));
const expandsObject = jsDeclarationExpandsObject(lowerer, decl, inferredObjectType);
let init: IrExpr;
try {
init =
@@ -5803,7 +5918,7 @@ export function lowerVarDecl(
!decl.type &&
!isJsSourceFile(ts.getSourceFileOfNode(decl))
) {
const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayAliasType(lowerer, decl);
const evolved = evolvingArrayType(lowerer, decl) ?? evolvingArrayHolderType(lowerer, decl);
if (evolved !== null) {
type = evolved;
init = lowerer.lowerExprExpecting(decl.initializer, evolved);
@@ -6327,18 +6442,15 @@ export function hasJsTypeAnnotation(decl: ts.VariableDeclaration): boolean {
);
}
/** Inferred JS scalar initializers do not constrain later writes through
* an any-valued callback. Keep those bindings open, including captured writes. */
export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (
!isJsSourceFile(ts.getSourceFileOfNode(decl)) ||
decl.type ||
hasJsTypeAnnotation(decl) ||
!ts.isIdentifier(decl.name)
)
return false;
const symbol = lowerer.resolveValueSymbol(decl.name);
if (!symbol) return false;
/** The assignments to a declaration's binding in its function (or file),
* `=` and compound alike. Null when the name has no symbol. */
function jsBindingWrites(
lowerer: Lowerer,
decl: ts.VariableDeclaration,
name: ts.Identifier,
): ts.BinaryExpression[] | null {
const symbol = lowerer.resolveValueSymbol(name);
if (!symbol) return null;
let owner: ts.Node = decl;
while (owner.parent && !ts.isFunctionLike(owner) && !ts.isSourceFile(owner)) owner = owner.parent;
let writes = lowerer.jsBindingWritesByOwner.get(owner);
@@ -6359,9 +6471,36 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
writes = indexed;
lowerer.jsBindingWritesByOwner.set(owner, writes);
}
const candidates = (writes.get(decl.name.text) ?? []).filter(
return (writes.get(name.text) ?? []).filter(
(node) => lowerer.resolveValueSymbol(node.left as ts.Identifier) === symbol,
);
}
/** Whether a JavaScript binding always holds a string: the checker types it
* as one, and every write appends to it (`result += part`), which yields a
* string whatever the part is. */
export function jsBindingAppendsOnly(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (!ts.isIdentifier(decl.name)) return false;
if (lowerer.mapTypeOf(lowerer.typeOf(decl.name))?.kind !== "string") return false;
const writes = jsBindingWrites(lowerer, decl, decl.name);
return (
writes !== null &&
writes.every((node) => node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken)
);
}
/** Inferred JS scalar initializers do not constrain later writes through
* an any-valued callback. Keep those bindings open, including captured writes. */
export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclaration): boolean {
if (
!isJsSourceFile(ts.getSourceFileOfNode(decl)) ||
decl.type ||
hasJsTypeAnnotation(decl) ||
!ts.isIdentifier(decl.name)
)
return false;
const candidates = jsBindingWrites(lowerer, decl, decl.name);
if (candidates === null) return false;
if (
candidates.some(
(node) =>
@@ -6369,6 +6508,18 @@ export function jsBindingHasOpenWrites(lowerer: Lowerer, decl: ts.VariableDeclar
)
)
return true;
// A `+=` of a string turns a number or boolean binding into a string
// (`n += "px"`); the checker keeps the declared type.
if (
candidates.some(
(node) =>
node.operatorToken.kind === ts.SyntaxKind.PlusEqualsToken &&
(lowerer.typeOf(node.right).flags & ts.TypeFlags.StringLike) !== 0,
)
) {
const declared = lowerer.mapTypeOf(lowerer.typeOf(decl.name));
if (declared?.kind === "f64" || declared?.kind === "bool") return true;
}
// A write that can store null or undefined into a binding the checker
// types as a primitive (`offset = match[3] || null`): JavaScript files
// are checked without strict null checks, so the declared type does not
@@ -9212,7 +9363,7 @@ function lowerAssignTargetInto(
if (arr.type.kind === "array") {
const recv = lowerer.declareHiddenLocal("%dtRecv", arr.type);
out.push({ kind: "varDecl", localId: recv.id, init: arr, loc });
const index = lowerOptionalNumber(
const index = lowerOptionalIndex(
lowerer,
lowerer.lowerExpr(t.argumentExpression),
locOf(t.argumentExpression),
@@ -4637,10 +4637,12 @@ function lowerOperatorCallback(
"the callback's parameter and result types must cross the checked-dynamic boundary",
);
}
const fn: IrExpr = { kind: "dynFrom", value: v, type: DYN, loc };
// flatMap over a compiled generator: the runtime steps it through the
// helpers over its type
const gen = member === "flatMap" && v.type.ret.kind === "generator" ? v.type.ret : undefined;
return { fn: { kind: "dynFrom", value: v, type: DYN, loc }, takesOptions, ...(gen && { gen }) };
if (member === "flatMap" && v.type.ret.kind === "generator")
return { fn, takesOptions, gen: v.type.ret };
return { fn, takesOptions };
}
/** The operator callbacks' `{ signal }` (operators.js): map and the
@@ -614,6 +614,7 @@ import {
lowerForStatement,
matchGroupsPatternSource,
isGuardedRecordRead,
holdsEvolvingArray,
} from "./lower-stmts.js";
import {
type FieldTarget,
@@ -1922,6 +1923,9 @@ export class Lowerer {
/** Identifiers by spelling, per file, for the generic bindings' use
* ordering (runsAfterDeclaration). */
readonly bindingIdentifiers = new Map<ts.SourceFile, Map<string, ts.Identifier[]>>();
/** Per JavaScript file, the declarations of the bindings it assigns an
* own `toString` (jsAssignsOwnToString in lower-calls.ts). */
readonly ownToStringTargets = new Map<ts.SourceFile, Set<ts.Node>>();
/** Per-symbol result of the never-reassigned file scan
* (bindingNeverReassigned — object-literal generic-method receivers). */
readonly neverReassignedCache = new Map<ts.Symbol, boolean>();
@@ -2184,6 +2188,9 @@ export class Lowerer {
* and erased instances). */
erasingDepth = 0;
localClassInstantiations: { owner: ts.FunctionLikeDeclaration; name: string }[] = [];
/** The function-local classes of the program with an `extends` clause, whether lowering collected
* them yet or not (new-target.ts); found on first use. */
functionLocalSubclasses: ts.ClassLikeDeclaration[] | undefined = undefined;
/** Instance types (by checker type id) of the local classes generic
* factory calls return, and the per-instance class each names. */
readonly localClassInstanceTypes = new Map<number, string>();
@@ -10652,7 +10659,8 @@ export class Lowerer {
builtinOption("UrlObject") ||
builtinOption("URLFormatOptions") ||
!this.mapTypeOf(this.typeOf(x)) ||
this.excessJsContextualProperty(x, parts)
this.excessJsContextualProperty(x, parts) ||
holdsEvolvingArray(this, x)
) {
return lowerDynObjectLiteral(this, x);
}
@@ -0,0 +1,357 @@
/* `new.target`. Compiled code has no invocation record, but inside a class
* constructor `new.target` is always the class of the object under
* construction: a compiled `new` allocates the instance with its concrete
* class before any constructor of the chain runs, and every constructor of
* the chain receives that instance as `this`. The program's class
* hierarchy is closed, so the value is a static dispatch over the
* constructor's class and its descendants: the deepest class the instance
* is an instance of is its exact class.
*
* The checker rejects `new.target` in methods, accessors, static blocks and
* field initializers. Plain functions (constructible in JavaScript) keep
* the refusal. */
import * as ts from "../ts7/adapter.js";
import { BOOL, STRING, type IrExpr, type IrType, type SrcLoc } from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import { locOf } from "../program.js";
import type { Lowerer } from "./lowerer.js";
import type { ClassInfo } from "./lower-classes.js";
import { classValueRef, functionLocalClass } from "./lower-classes.js";
export function isNewTarget(node: ts.Node): node is ts.MetaProperty {
return (
ts.isMetaProperty(node) &&
node.keywordToken === ts.SyntaxKind.NewKeyword &&
node.name.text === "target"
);
}
/** Where `new.target` is bound: a class constructor, or a plain function
* (refused). */
type Binding =
| { kind: "constructor"; declaration: ts.ConstructorDeclaration }
| { kind: "function" };
function bindingOf(node: ts.Node): Binding {
for (let p = node.parent; p !== undefined; p = p.parent) {
if (ts.isArrowFunction(p)) continue;
if (ts.isConstructorDeclaration(p)) return { kind: "constructor", declaration: p };
if (ts.isFunctionLike(p) || ts.isSourceFile(p)) return { kind: "function" };
}
return { kind: "function" };
}
interface Dispatch {
info: ClassInfo;
/** The constructor's descendants, deepest first, then its class. A
* generic family stands for its instantiations, also those that later
* bodies instantiate: its interval holds them all. */
classes: ClassInfo[];
self: IrExpr | null;
}
function depth(info: ClassInfo): number {
let d = 0;
for (let c = info.base; c; c = c.base) d++;
return d;
}
/** The function-local classes of the program that extend a class. */
function functionLocalSubclasses(lowerer: Lowerer): ts.ClassLikeDeclaration[] {
let found = lowerer.functionLocalSubclasses;
if (found === undefined) {
const classes: ts.ClassLikeDeclaration[] = [];
for (const sf of lowerer.moduleOrder)
ts.walkPreorder(sf, (node) => {
if (
(ts.isClassDeclaration(node) || ts.isClassExpression(node)) &&
baseExpressionOf(node) !== undefined &&
functionLocalClass(node)
)
classes.push(node);
});
lowerer.functionLocalSubclasses = found = classes;
}
return found;
}
function baseExpressionOf(node: ts.ClassLikeDeclaration): ts.Expression | undefined {
let base = node.heritageClauses?.nodes.find(
(clause) => clause.token === ts.SyntaxKind.ExtendsKeyword,
)?.types?.nodes[0]?.expression;
while (base !== undefined && ts.isParenthesizedExpression(base)) base = base.expression;
return base;
}
/** Whether the class `node` extends the class of `info` (for an
* instantiation, its family), directly or through other classes, as far as
* its `extends` clauses name classes. */
function derivesFrom(
lowerer: Lowerer,
node: ts.ClassLikeDeclaration,
info: ClassInfo,
seen: Set<ts.Node>,
): boolean {
if (seen.has(node)) return false;
seen.add(node);
const base = baseExpressionOf(node);
const name =
base === undefined
? undefined
: ts.isIdentifier(base)
? base
: ts.isPropertyAccessExpression(base)
? base.name
: undefined;
let symbol = name === undefined ? undefined : lowerer.checker.getSymbolAtLocation(name);
if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
symbol = lowerer.checker.getAliasedSymbol(symbol);
if (symbol === undefined) return false;
const named = lowerer.classBySymbol.get(symbol);
if (named !== undefined)
return (
named === info ||
named === info.genericInstance?.family ||
lowerer.isSubclassOf(named.def.name, info.def.name)
);
// A class lowering has not collected: through its declaration.
for (const declaration of symbol.declarations) {
let cls: ts.Node | undefined = declaration;
if (ts.isVariableDeclaration(cls)) {
cls = cls.initializer;
while (cls !== undefined && ts.isParenthesizedExpression(cls)) cls = cls.expression;
}
if (
cls !== undefined &&
(ts.isClassDeclaration(cls) || ts.isClassExpression(cls)) &&
derivesFrom(lowerer, cls, info, seen)
)
return true;
}
return false;
}
/** `node`'s name, quoted, for diagnostics. */
function localClassName(node: ts.ClassLikeDeclaration): string {
if (node.name) return `'${node.name.text}'`;
const parent = node.parent;
return parent && ts.isVariableDeclaration(parent) && ts.isIdentifier(parent.name)
? `'${parent.name.text}'`
: "a class expression";
}
/** The classes `new.target` can denote in the constructor being lowered,
* or null after a diagnostic. */
function dispatchOf(lowerer: Lowerer, expr: ts.MetaProperty): Dispatch | null {
const binding = bindingOf(expr);
if (binding.kind !== "constructor") return null;
const info = lowerer.currentClass;
const cls = binding.declaration.parent;
if (!info || (info.decl !== null && info.decl !== cls)) {
lowerer.unsupported(
"SC1090",
expr,
"'new.target' in this constructor (its class has no lowering here)",
);
return null;
}
// An instantiation's subclasses can also be instantiations of generic
// subclasses of its family (`class G<T> extends Box<T>` under Box<number>).
const family = info.genericInstance?.family;
const descendants: ClassInfo[] = [];
for (const other of lowerer.classes.values()) {
if (other === info) continue;
if (
lowerer.isSubclassOf(other.def.name, info.def.name) ||
(family !== undefined &&
other.generic !== undefined &&
other !== family &&
lowerer.isSubclassOf(other.def.name, family.def.name))
)
descendants.push(other);
}
descendants.sort((a, b) => depth(b) - depth(a));
const classes = [...descendants, info];
for (const c of classes) {
if (c.localClass) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' where '${c.def.jsName ?? c.def.name}' is a function-local class (each evaluation creates a new class)`,
);
return null;
}
}
// Lowering collects a function-local class with the body declaring it,
// which may come after this constructor.
for (const local of functionLocalSubclasses(lowerer)) {
if (!derivesFrom(lowerer, local, info, new Set())) continue;
lowerer.unsupported(
"SC1090",
expr,
`'new.target' where ${localClassName(local)} is a function-local class (each evaluation creates a new class)`,
);
}
let self: IrExpr | null = null;
if (classes.length > 1) {
const thisLocal = lowerer.resolveThis(expr);
if (!thisLocal || thisLocal.type.kind !== "object") {
lowerer.unsupported(
"SC1090",
expr,
"'new.target' where the constructor's 'this' has no class representation",
);
return null;
}
self = varRef(thisLocal.id, thisLocal.type, locOf(expr));
}
return { info, classes, self };
}
/** A conditional chain selecting `arm(D)` for the exact class D of the
* instance under construction. */
function select(
dispatch: Dispatch,
type: IrType,
loc: SrcLoc,
arm: (info: ClassInfo) => IrExpr,
): IrExpr {
const { classes, self } = dispatch;
let result = arm(classes[classes.length - 1]!);
for (let i = classes.length - 2; i >= 0; i--) {
const c = classes[i]!;
result = {
kind: "ternary",
cond: { kind: "instanceOf", value: self!, className: c.def.name, type: BOOL, loc },
then: arm(c),
else_: result,
type,
loc,
};
}
return result;
}
function refuseFunction(lowerer: Lowerer, expr: ts.MetaProperty): never {
return lowerer.unsupported(
"SC1090",
expr,
"'new.target' in a plain function (a constructible function's invocation record does not exist in compiled code; use a class constructor)",
);
}
/** `new.target` as a value. */
export function lowerNewTarget(lowerer: Lowerer, expr: ts.MetaProperty): IrExpr {
const loc = locOf(expr);
if (bindingOf(expr).kind === "function") refuseFunction(lowerer, expr);
const dispatch = dispatchOf(lowerer, expr)!;
const type: IrType = { kind: "classval", className: dispatch.info.def.name };
for (const c of dispatch.classes) {
if (c.generic) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' as a value where '${c.def.jsName ?? c.def.name}' is a generic subclass (its instantiations have no common class value; read 'new.target.name' or compare 'new.target' with a class instead)`,
);
}
if (c !== dispatch.info && !lowerer.ctorAbiEquals(c, dispatch.info)) {
lowerer.unsupported(
"SC1090",
expr,
`'new.target' as a value where '${c.def.jsName ?? c.def.name}' constructs with a different signature than '${dispatch.info.def.jsName ?? dispatch.info.def.name}' (read 'new.target.name' or compare 'new.target' with a class instead)`,
);
}
}
return select(dispatch, type, loc, (c) =>
lowerer.coerceToExpected(classValueRef(lowerer, c, expr), type),
);
}
/** `new.target.name`: the exact class's name, without materializing a
* class value (subclasses may construct with other signatures). */
export function lowerNewTargetName(
lowerer: Lowerer,
access: ts.PropertyAccessExpression,
): IrExpr | null {
if (access.questionDotToken || access.name.text !== "name") return null;
let target: ts.Expression = access.expression;
while (ts.isParenthesizedExpression(target)) target = target.expression;
if (!isNewTarget(target) || bindingOf(target).kind !== "constructor") return null;
const dispatch = dispatchOf(lowerer, target);
if (!dispatch) return null;
const loc = locOf(access);
return select(dispatch, STRING, loc, (c) => {
// An own static `name` field replaces the class's name; a subclass has
// its own name. An instantiation's statics are its family's.
const owner = c.genericInstance?.family ?? c;
const field = owner.staticFields.find((f) => f.name === "name");
if (field !== undefined) {
if (field.type.kind !== "string")
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static 'name' that is not a string`,
);
return { kind: "varRef", localId: field.globalId, type: field.type, loc };
}
if (owner.staticMethods?.has("name") || owner.genericStatics?.has("name"))
lowerer.unsupported(
"SC1090",
access,
`'new.target.name' where '${c.def.jsName ?? c.def.name}' declares a static method 'name'`,
);
// A generic family's instantiations share its name.
if (c.generic)
return { kind: "strLit", value: c.def.jsName ?? c.generic.baseName, type: STRING, loc };
return {
kind: "libCall",
fn: "class.name",
args: [classValueRef(lowerer, c, target)],
type: STRING,
loc,
};
});
}
/** `new.target === C` (and `!==`, `==`, `!=`, either operand order) with a
* program class named by the other operand: an exact-class test. */
export function lowerNewTargetComparison(
lowerer: Lowerer,
expr: ts.BinaryExpression,
): IrExpr | null {
const op = expr.operatorToken.kind;
const negated =
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
op === ts.SyntaxKind.ExclamationEqualsToken;
if (
!negated &&
op !== ts.SyntaxKind.EqualsEqualsEqualsToken &&
op !== ts.SyntaxKind.EqualsEqualsToken
)
return null;
const strip = (e: ts.Expression): ts.Expression => {
while (ts.isParenthesizedExpression(e)) e = e.expression;
return e;
};
const left = strip(expr.left);
const right = strip(expr.right);
const target = isNewTarget(left) ? left : isNewTarget(right) ? right : null;
if (!target || bindingOf(target).kind !== "constructor") return null;
const other = target === left ? right : left;
if (!ts.isIdentifier(other)) return null;
// A constructor's new.target is never undefined.
if (other.text === "undefined" && (lowerer.typeOf(other).flags & ts.TypeFlags.Undefined) !== 0)
return { kind: "boolLit", value: negated, type: BOOL, loc: locOf(expr) };
const symbol = lowerer.resolveValueSymbol(other);
const named = symbol ? lowerer.classBySymbol.get(symbol) : undefined;
if (!named || named.generic || named.localClass) return null;
const dispatch = dispatchOf(lowerer, target);
if (!dispatch) return null;
const loc = locOf(expr);
return select(dispatch, BOOL, loc, (c) => ({
kind: "boolLit",
value: (c === named) !== negated,
type: BOOL,
loc,
}));
}
@@ -0,0 +1,101 @@
import * as ts from "../ts7/adapter.js";
import { BOOL, DYN, F64, UNDEFINED_T, type IrExpr, type IrStmt, type SrcLoc } from "../../ir/ir.js";
import { varRef } from "../../ir/build.js";
import type { Lowerer } from "./lowerer.js";
import { lowerOptionalNumber } from "./lower-exprs.js";
import { keyedHelperName } from "./stable-names.js";
/** An element index the checker types as a number can be undefined at run
* time (an element read from a holey array). JavaScript then names the
* property "undefined", not "NaN". */
/** The array element index `operand` (the lowered index of `node`) as a
* number. An undefined index is the runtime's "undefined" key
* (`array.undefinedKey`, a NaN the array's property table spells
* "undefined"); other values convert with ToNumber, as lowerOptionalNumber
* does. */
export function lowerOptionalIndex(
lowerer: Lowerer,
operand: IrExpr,
loc: SrcLoc,
node: ts.Expression,
): IrExpr {
const split = splitUndefinedIndex(lowerer, operand, node, loc);
if (split === null) return lowerOptionalNumber(lowerer, operand, loc, node);
return {
kind: "seqExpr",
stmts: split.stmts,
result: {
kind: "ternary",
cond: split.isUndefined,
then: { kind: "libCall", fn: "array.undefinedKey", args: [], type: F64, loc },
else_: split.index,
type: F64,
loc,
},
type: F64,
loc,
};
}
/** A typed-array element index that can be undefined: `stmts` evaluate it
* once, `isUndefined` tests it, and `index` is the number a defined index
* converts to. */
export interface SplitIndex {
stmts: IrStmt[];
isUndefined: IrExpr;
index: IrExpr;
}
/** Splits `raw`, the lowered index of `node`, when it is a number that can
* be undefined; null otherwise. */
export function splitUndefinedIndex(
lowerer: Lowerer,
raw: IrExpr,
node: ts.Expression,
loc: SrcLoc,
): SplitIndex | null {
if (raw.type.kind !== "union") return null;
const undefinedTag = lowerer.armTag(raw.type.unionId, UNDEFINED_T);
if (undefinedTag < 0) return null;
const local = lowerer.declareHiddenLocal("%elementIndex", raw.type);
const stable = varRef(local.id, raw.type, loc);
const index = lowerOptionalNumber(lowerer, stable, loc, node);
if (index.type.kind !== "f64") return null;
return {
stmts: [{ kind: "varDecl", localId: local.id, init: raw, loc }],
isUndefined: {
kind: "unionIsTag",
unionId: raw.type.unionId,
tag: undefinedTag,
negated: false,
value: stable,
type: BOOL,
loc,
},
index,
};
}
/** A typed array has no property named "undefined" to store into: the
* store throws the catchable SC1090 error when it runs. A call, so that it
* can sit among an expression's statements. */
export function undefinedTypedArrayKeyFence(lowerer: Lowerer, node: ts.Node, loc: SrcLoc): IrStmt {
const line = ts.getLineAndCharacterOfPosition(ts.getSourceFileOfNode(node), loc.start).line + 1;
const message = `typed array properties named 'undefined' (an element index that is undefined) are not supported yet [SC1090 at ${loc.file}:${line}]`;
const key = `index.undefinedFence:${message}`;
let name = lowerer.arrHofHelpers.get(key);
if (name === undefined) {
name = keyedHelperName(lowerer, "%index.undefinedFence", key);
lowerer.arrHofHelpers.set(key, name);
lowerer.liftedFns.push({
name,
params: [],
locals: [],
returnType: DYN,
body: [{ kind: "runtimeFence", code: "SC1090", message, loc }],
loc,
});
}
return { kind: "exprStmt", expr: { kind: "call", callee: name, args: [], type: DYN, loc }, loc };
}
@@ -0,0 +1,107 @@
import { expect, test } from "vitest";
import { tokenEquivalentSource } from "./token-equivalence.js";
const equivalent = (previous: string, current: string, path = "/src/a.ts") =>
tokenEquivalentSource(path, previous, current);
test("whitespace and plain comments are trivia", () => {
expect(equivalent("const a = 1;\n", "const a = 1; // one\n")).toBe(true);
expect(equivalent("f(a,b);\n", "f( a, /* first */ b );\n")).toBe(true);
expect(equivalent("const s = `x${a}y`;\n", "const s = `x${ a /* the value */ }y`;\n")).toBe(true);
});
test("tokens, literals and line breaks that change semicolon insertion are not", () => {
expect(equivalent("const a = 1;\n", "const a = 2;\n")).toBe(false);
expect(equivalent('const s = "a // b";\n', 'const s = "a // c";\n')).toBe(false);
expect(equivalent("const s = `a // ${b} /* c */`;\n", "const s = `a // ${b} /* d */`;\n")).toBe(
false,
);
// `return` followed by a line break returns undefined.
expect(equivalent("return a;\n", "return\na;\n")).toBe(false);
expect(equivalent("return a;\n", "return /*\n*/ a;\n")).toBe(false);
});
test("comment-like text inside regular expressions is code", () => {
expect(equivalent("const r = /[//]/; x = 1;\n", "const r = /[//]/; x = 2;\n")).toBe(false);
expect(equivalent("if (a) /\\/*/.test(b); c = 1;\n", "if (a) /\\/*/.test(b); c = 2;\n")).toBe(
false,
);
expect(equivalent("const q = a / b; // ratio\n", "const q = a / b; // the ratio\n")).toBe(true);
expect(equivalent("return /a/; // one\n", "return /a/; // two\n")).toBe(true);
// A `for await` header closes like a `for` header.
expect(
equivalent(
"for await (const x of xs) /[//]/.test(x); c = 1;\n",
"for await (const x of xs) /[//]/.test(x); c = 2;\n",
),
).toBe(false);
});
test("a / after a line break is never equivalent", () => {
const loop = (body: string) => `a: for (;;) {\n${body}\n}\n`;
// Automatic semicolon insertion decides whether these start a regular
// expression.
expect(
equivalent(loop("break\n/[//]/.test(x); c = 1;"), loop("break\n/[//]/.test(x); c = 2;")),
).toBe(false);
expect(
equivalent(loop("break a\n/[//]/.test(x); c = 1;"), loop("break a\n/[//]/.test(x); c = 2;")),
).toBe(false);
expect(
equivalent("type A = B\n/[//]/.test(x); c = 1;\n", "type A = B\n/[//]/.test(x); c = 2;\n"),
).toBe(false);
// A comment spanning lines separates them too; JavaScript keeps its comments as tokens.
expect(
equivalent(
"x\n/* a\n */ /[//]/.test(y); c = 1;\n",
"x\n/* a\n */ /[//]/.test(y); c = 2;\n",
"/src/a.js",
),
).toBe(false);
// A / on the line of the token before it still divides.
expect(equivalent(loop("continue\nx / 2; // one"), loop("continue\nx / 2; // two"))).toBe(true);
});
test("code units outside ASCII are equivalent only inside strings, templates and comments", () => {
expect(equivalent('const s = "é"; // one\n', 'const s = "é"; // two\n')).toBe(true);
expect(equivalent("const s = `é${a}`; // one\n", "const s = `é${a}`; // two\n")).toBe(true);
expect(equivalent("const a = 1; // é\n", "const a = 1; // è\n")).toBe(true);
expect(equivalent("const café = 1; // one\n", "const café = 1; // two\n")).toBe(false);
expect(
equivalent(
"ä: for (;;) { break ä\n/[//]/.test(x); c = 1; }\n",
"ä: for (;;) { break ä\n/[//]/.test(x); c = 2; }\n",
),
).toBe(false);
expect(equivalent("const r = /é/; // one\n", "const r = /é/; // two\n")).toBe(false);
});
test("whitespace outside ASCII is never equivalent", () => {
expect(
equivalent("return\u3000/[//]/.test(x); c = 1;\n", "return\u3000/[//]/.test(x); c = 2;\n"),
).toBe(false);
expect(equivalent("const a = 1;\u00a0// one\n", "const a = 1;\u00a0// two\n")).toBe(false);
});
test("U+2028 and U+2029 end a line comment, so their files are never equivalent", () => {
expect(equivalent("// a\u2028c = 1;\n", "// a\u2028c = 2;\n")).toBe(false);
expect(equivalent("/* a\u2029*/ c = 1;\n", "/* b\u2029*/ c = 1;\n")).toBe(false);
});
test("comments that can affect compilation are kept", () => {
expect(equivalent("/** @type {number} */ let a;\n", "/** @type {string} */ let a;\n")).toBe(
false,
);
expect(equivalent("// @ts-ignore\nf();\n", "f();\n")).toBe(false);
expect(equivalent('/// <reference path="a.d.ts" />\n', "\n")).toBe(false);
// Every comment is kept in JavaScript, whose types come from JSDoc.
expect(equivalent("let a; // one\n", "let a; // two\n", "/src/a.js")).toBe(false);
});
test("text the scanner cannot classify is never equivalent", () => {
expect(equivalent("x = {} / 2; // a\n", "x = {} / 2; // b\n")).toBe(false);
expect(equivalent("++/a/.lastIndex; // a\n", "++/a/.lastIndex; // b\n")).toBe(false);
expect(equivalent("const \\u0061 = 1; // a\n", "const \\u0061 = 1; // b\n")).toBe(false);
expect(equivalent("const a = <b/>; // a\n", "const a = <b/>; // b\n", "/src/a.tsx")).toBe(false);
expect(equivalent('const s = "open\n', 'const s = "open // \n')).toBe(false);
});
@@ -0,0 +1,391 @@
/* Token equivalence of two versions of a source file, for frontend cache
* reuse after edits that touch only comments and whitespace.
*
* Two versions are equivalent when they have the same tokens, in the same
* order, with line breaks between the same pairs of tokens (automatic
* semicolon insertion depends on them), and differ only in whitespace and
* in comments that cannot affect compilation. The scanner answers "not
* equivalent" whenever it cannot classify text with certainty, so a wrong
* answer can only cost a cache hit:
*
* - Every comment in a file that is not TypeScript (`.ts`, `.mts`, `.cts`)
* is kept as a token: JavaScript types come from JSDoc. TypeScript
* comments are kept when they start with `///` (references and
* directives) or contain `@` or `#` (checker directives, JSDoc tags, and
* annotations lowering reads).
* - A `/` starts a regular expression or divides according to the token
* before it. After `)` it divides unless the parenthesis closes an `if`,
* `while`, `for`, `for await`, or `with` header. After `}`, `++` or
* `--` the scanner cannot tell which, so it gives up, as it does for
* escaped identifiers, which can spell keywords.
* - A `/` with a line break (or a comment that spans lines) since the
* previous token gives up: whether automatic semicolon insertion ends the
* statement before it decides how it reads.
* - Template literals track their `${` substitutions on a brace stack.
* - Any code unit outside ASCII gives up, except inside strings, templates
* and comments. So do files that contain whitespace or a line terminator
* outside ASCII anywhere (U+2028 and U+2029 end a line comment), JSX
* files, and unterminated strings, comments, templates and regular
* expressions.
*
* The scanner models only what these rules name; anything else makes the
* versions not equivalent, which rebuilds. */
interface Token {
text: string;
comment: boolean;
lineBreakBefore: boolean;
}
/** Keywords after which `/` starts a regular expression. */
const EXPRESSION_KEYWORDS = new Set([
"return",
"typeof",
"instanceof",
"in",
"of",
"new",
"delete",
"void",
"throw",
"case",
"do",
"else",
"yield",
"await",
"default",
"extends",
]);
/** Whitespace and line terminators outside ASCII. */
const NON_ASCII_SPACE = /[\u00a0\u1680\u2000-\u200a\u2028\u2029\u202f\u205f\u3000\ufeff]/;
/** Keywords whose parenthesized header is followed by a statement. */
const HEADER_KEYWORDS = new Set(["if", "while", "for", "with"]);
const PUNCTUATORS = [
">>>=",
"...",
"===",
"!==",
"**=",
"<<=",
">>=",
">>>",
"&&=",
"||=",
"??=",
"=>",
"==",
"!=",
"<=",
">=",
"&&",
"||",
"??",
"?.",
"++",
"--",
"+=",
"-=",
"*=",
"%=",
"&=",
"|=",
"^=",
"**",
"<<",
">>",
];
function isTypeScript(path: string): boolean {
return path.endsWith(".ts") || path.endsWith(".mts") || path.endsWith(".cts");
}
function isIdentifierCode(code: number): boolean {
return (
(code >= 97 && code <= 122) ||
(code >= 65 && code <= 90) ||
(code >= 48 && code <= 57) ||
code === 36 ||
code === 95 ||
code === 92
);
}
function isLineBreak(code: number): boolean {
return code === 10 || code === 13;
}
function isSpace(code: number): boolean {
return code === 32 || code === 9 || code === 11 || code === 12 || isLineBreak(code);
}
/** The tokens of `source`, or null when the scanner cannot classify it. */
function scan(path: string, source: string): Token[] | null {
if (path.endsWith(".tsx") || path.endsWith(".jsx")) return null;
if (NON_ASCII_SPACE.test(source)) return null;
const typeScript = isTypeScript(path);
const tokens: Token[] = [];
// Brace kinds: true for a template substitution, false otherwise.
const braces: boolean[] = [];
// Paren kinds: true for a statement header.
const parens: boolean[] = [];
let lineBreakBefore = false;
// A line break, or a comment spanning lines, since the last token other
// than a comment.
let breakSinceCode = false;
// The previous token and the one before it, and whether a `)` closed a
// statement header.
const last: { token: string | null; before: string | null } = { token: null, before: null };
let closedHeader = false;
const push = (text: string, comment = false): void => {
tokens.push({ text, comment, lineBreakBefore });
lineBreakBefore = false;
if (!comment) {
breakSinceCode = false;
last.before = last.token;
last.token = text;
}
};
let index = 0;
const length = source.length;
// A shebang is one token.
if (source.startsWith("#!")) {
while (index < length && !isLineBreak(source.charCodeAt(index))) index++;
push(source.slice(0, index));
}
/** Scans template text from `index` (after a backtick or a closing
* substitution brace) through the closing backtick or `${`. */
const template = (start: number): boolean => {
let position = index;
while (position < length) {
const code = source.charCodeAt(position);
if (code === 92) {
position += 2;
continue;
}
if (code === 96) {
position++;
index = position;
push(source.slice(start, position));
closedHeader = false;
return true;
}
if (code === 36 && source.charCodeAt(position + 1) === 123) {
position += 2;
index = position;
push(source.slice(start, position));
braces.push(true);
last.before = last.token;
last.token = "${";
return true;
}
position++;
}
return false;
};
while (index < length) {
const code = source.charCodeAt(index);
if (isSpace(code)) {
if (isLineBreak(code)) lineBreakBefore = breakSinceCode = true;
index++;
continue;
}
// Outside strings, templates and comments, the scanner models ASCII only.
if (code > 127) return null;
const next = source.charCodeAt(index + 1);
// Comments.
if (code === 47 && (next === 47 || next === 42)) {
const start = index;
if (next === 47) {
while (index < length && !isLineBreak(source.charCodeAt(index))) index++;
} else {
const end = source.indexOf("*/", index + 2);
if (end < 0) return null;
index = end + 2;
}
const text = source.slice(start, index);
const semantic =
!typeScript || text.startsWith("///") || text.includes("@") || text.includes("#");
const multiline = /[\r\n]/.test(text);
if (multiline) breakSinceCode = true;
if (semantic) push(text, true);
else if (multiline) lineBreakBefore = true;
continue;
}
// Strings.
if (code === 34 || code === 39) {
const start = index;
index++;
while (index < length) {
const inner = source.charCodeAt(index);
if (inner === 92) {
index += 2;
continue;
}
if (isLineBreak(inner)) return null;
index++;
if (inner === code) break;
if (index >= length) return null;
}
if (source.charCodeAt(index - 1) !== code || index - start < 2) return null;
push(source.slice(start, index));
closedHeader = false;
continue;
}
// Templates.
if (code === 96) {
const start = index;
index++;
if (!template(start)) return null;
continue;
}
// Identifiers, keywords and numbers.
if (isIdentifierCode(code) || (code === 46 && next >= 48 && next <= 57)) {
const start = index;
index++;
while (index < length) {
const inner = source.charCodeAt(index);
if (isIdentifierCode(inner) || inner === 46) {
// A dot continues only a number (`1.5`), not member access.
if (inner === 46) {
const first = source.charCodeAt(start);
if (!((first >= 48 && first <= 57) || first === 46)) break;
}
index++;
continue;
}
// Exponent signs (`1e-5`).
if (
(inner === 43 || inner === 45) &&
(source.charCodeAt(index - 1) === 101 || source.charCodeAt(index - 1) === 69)
) {
const first = source.charCodeAt(start);
if ((first >= 48 && first <= 57) || first === 46) {
index++;
continue;
}
}
break;
}
const word = source.slice(start, index);
// An escaped identifier can spell a keyword (`\u0072eturn`).
if (word.includes("\\")) return null;
push(word);
closedHeader = false;
continue;
}
// Division or a regular expression.
if (code === 47) {
if (breakSinceCode) return null;
const before = last.token;
let regex: boolean;
if (before === null) regex = true;
else if (before === "}") return null;
else if (before === ")") regex = closedHeader;
// `++/re/.lastIndex` is valid, `x++ / 2` too.
else if (before === "++" || before === "--") return null;
else if (before === "]") regex = false;
else if (/^[A-Za-z0-9_$\\]/.test(before)) regex = EXPRESSION_KEYWORDS.has(before);
else if (before.startsWith("'") || before.startsWith('"') || before.endsWith("`"))
regex = false;
else if (before.startsWith("/") && before.length > 1 && before !== "/=") regex = false;
else regex = true;
if (regex) {
const start = index;
index++;
let inClass = false;
while (index < length) {
const inner = source.charCodeAt(index);
if (isLineBreak(inner) || inner > 127) return null;
if (inner === 92) {
index += 2;
continue;
}
if (inner === 91) inClass = true;
else if (inner === 93) inClass = false;
else if (inner === 47 && !inClass) break;
index++;
}
if (index >= length) return null;
index++;
while (index < length && isIdentifierCode(source.charCodeAt(index))) index++;
push(source.slice(start, index));
closedHeader = false;
continue;
}
const text = next === 61 ? "/=" : "/";
index += text.length;
push(text);
closedHeader = false;
continue;
}
// Brackets.
if (code === 40) {
parens.push(
last.token !== null &&
(HEADER_KEYWORDS.has(last.token) || (last.token === "await" && last.before === "for")),
);
index++;
push("(");
closedHeader = false;
continue;
}
if (code === 41) {
const header = parens.pop() ?? false;
index++;
push(")");
closedHeader = header;
continue;
}
if (code === 123) {
braces.push(false);
index++;
push("{");
closedHeader = false;
continue;
}
if (code === 125) {
if (braces.pop() === true) {
const start = index;
index++;
if (!template(start)) return null;
continue;
}
index++;
push("}");
closedHeader = false;
continue;
}
// Other punctuators.
let text = source[index]!;
for (const punctuator of PUNCTUATORS)
if (source.startsWith(punctuator, index)) {
text = punctuator;
break;
}
index += text.length;
push(text);
closedHeader = false;
}
if (braces.length !== 0 || parens.length !== 0) return null;
return tokens;
}
/** Whether `current` differs from `previous` only in whitespace and in
* comments that cannot affect compilation. */
export function tokenEquivalentSource(path: string, previous: string, current: string): boolean {
if (previous === current) return true;
const left = scan(path, previous);
if (left === null) return false;
const right = scan(path, current);
if (right === null || left.length !== right.length) return false;
for (let index = 0; index < left.length; index++) {
const a = left[index]!;
const b = right[index]!;
if (a.text !== b.text || a.comment !== b.comment) return false;
if (index > 0 && a.lineBreakBefore !== b.lineBreakBefore) return false;
}
return true;
}
+22 -1
View File
@@ -17,6 +17,16 @@ import {
import type { SourceFile } from "./ast-types.js";
import { SyntaxKind, NodeFlags } from "./enums.js";
/** Receives the file name of a source file whose raw text or line
* positions a reader takes: the text of a node other than a token
* (getText), a node's text with its trivia (getFullText), or line starts. */
let sourceTextObserver: ((fileName: string) => void) | undefined;
/** Installs the observer of raw source text reads (sourceTextObserver). */
export function observeSourceText(observer: ((fileName: string) => void) | undefined): void {
sourceTextObserver = observer;
}
/** A list of child nodes with the list's own position, the shape of tsc-ts's
* NodeList. The file keeps the nodes array, not this object: each read of a
* list field returns a new AstNodeList over the same array, so `nodes` keeps
@@ -210,6 +220,12 @@ export class AstFile {
return result;
}
/** Reports a read of this file's raw text or line positions. */
noteSourceText(): void {
if (sourceTextObserver !== undefined && this.root.kind === SyntaxKind.SourceFile)
sourceTextObserver(this.root.fileName);
}
/** The file name of the source file node at `index`; the root's is
* read once. */
sourceFileName(index: number): string {
@@ -220,6 +236,7 @@ export class AstFile {
}
lineStarts(): number[] {
this.noteSourceText();
if (this.lines === undefined) this.lines = astLineStarts(this.root.text ?? "");
return this.lines;
}
@@ -495,10 +512,14 @@ export class AstNode {
return this.getStart(sourceFile) - this.pos;
}
getFullText(sourceFile?: AstNode): string {
return ((sourceFile ?? this.file.root).text ?? "").substring(this.pos, this.end);
const source = sourceFile ?? this.file.root;
source.file.noteSourceText();
return (source.text ?? "").substring(this.pos, this.end);
}
getText(sourceFile?: AstNode): string {
const source = sourceFile ?? this.file.root;
// A token's text is the same in every token-equivalent version.
if (this.kind > SyntaxKind.LastToken) source.file.noteSourceText();
return (source.text ?? "").substring(this.getStart(source), this.end);
}
@@ -3207,6 +3207,8 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
// Native WeakMap keeps reference identities in checked-dynamic storage.
// The runtime rejects reference kinds without a weak lifetime contract.
if (isStdlibInterface("WeakMap") || isStdlibInterface("WeakSet")) return DYN;
// FinalizationRegistry is a native handle with register and unregister.
if (isStdlibInterface("FinalizationRegistry")) return DYN;
// Set<T>: Map's sibling — same provenance rule and key domain.
// Anything else stays unmapped;
// the `new Set` lowering names the offending element type specifically.
@@ -7748,6 +7750,22 @@ function wideningAddedProperties(
return added;
}
/** Whether `type` is an interface one of whose `extends` clauses names a
* type the checker answers `any` (or an error type) for. */
function interfaceExtendsAny(type: ts.Type, checker: ts.TypeChecker): boolean {
const symbol = type.getSymbol();
if (symbol === undefined || (symbol.flags & ts.SymbolFlags.Interface) === 0) return false;
for (const decl of symbol.declarations) {
if (!ts.isInterfaceDeclaration(decl)) continue;
for (const clause of decl.heritageClauses?.nodes ?? []) {
for (const base of clause.types.nodes) {
if ((checker.getTypeAtLocation(base).flags & ts.TypeFlags.Any) !== 0) return true;
}
}
}
return false;
}
function mapRecordTypeInner(
widened: ts.Type,
ctx: TypeMapperCtx,
@@ -7763,6 +7781,13 @@ function mapRecordTypeInner(
// names the construct.
const widenedSym = widened.getSymbol();
if (widenedSym !== undefined && (widenedSym.flags & ts.SymbolFlags.Enum) !== 0) return null;
// An interface extending a type the checker answers `any` for (in the
// compiled world, a package type computed from members only its .d.ts
// declares, such as `interface P extends z.infer<typeof S>`) has members
// the checker cannot list: its own declared fields are not the whole
// object. The value is checked-dynamic, never a record of the declared
// fields alone.
if (interfaceExtendsAny(widened, checker)) return DYN;
// An index signature maps to a hybrid shape: declared fields keep
// struct slots, undeclared keys ride the shape's overflow map, valued
// uniformly by the signature's value type (`unknown` → dyn — a model-pricing table's
@@ -94,6 +94,8 @@ export const MAY_THROW_LIB_FNS: ReadonlySet<IrLibFn> = new Set([
"stream.unpipeDyn",
"weakMap.new",
"weakSet.new",
// Node's "cleanup must be callable" TypeError.
"finalizationRegistry.new",
"dyn.fromEntries",
"bytes.constructor",
"bytes.instanceOf",
@@ -3617,6 +3617,8 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
"weakSet.is": { argTypes: [DYN], result: BOOL },
"weakMap.new": { argTypes: [DYN], result: DYN },
"weakSet.new": { argTypes: [DYN], result: DYN },
"finalizationRegistry.new": { argTypes: [DYN], result: DYN },
"finalizationRegistry.is": { argTypes: [DYN], result: BOOL },
"dyn.fromEntries": { argTypes: [DYN], result: DYN },
"bytes.constructor": { argTypes: [STRING], result: DYN },
"bytes.instanceOf": { argTypes: [DYN, DYN, STRING], result: BOOL },
@@ -3811,6 +3813,13 @@ export const LIB_FN_SIGS = defineLibFnSignatures({
// Whether the array (args[0]) still has the key (args[1]) from its
// namedKeys list. Borrowed; never throws.
"array.hasNamedKey": { argTypes: [null, STRING], result: BOOL },
// Whether an array (args[0], any element type) has properties besides its
// elements: noncanonical numeric keys, match metadata or named
// properties. Borrowed; never throws.
"array.hasProperties": { argTypes: [null], result: BOOL },
// The key of an element access whose index is undefined (the property
// named "undefined"; frontend/lowering/undefined-index.ts).
"array.undefinedKey": { argTypes: [], result: F64 },
// node:events EventEmitter: receivers are emitter-hierarchy objects and
// the chaining forms (on/off/removeAll/setMax) return the receiver's
// own class — program-dependent object types, checked in the libCall
+19 -2
View File
@@ -1,4 +1,12 @@
import { BOOL, F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
import {
BOOL,
F64,
type IrExpr,
type IrFunction,
type IrLocal,
type IrModule,
type IrStmt,
} from "./ir.js";
import { mapExprChildren, mapStmtChildren } from "./traverse.js";
type CharAt = IrExpr & { kind: "strIntrinsic" };
@@ -140,14 +148,23 @@ function rewriteFunction(fn: IrFunction): IrFunction {
return {
...fn,
body: fn.body.map(rewriteStmt),
locals: fn.locals.map((l) => (held.has(l.id) ? { ...l, type: F64 } : l)),
locals: fn.locals.map((l) => (held.has(l.id) ? unitLocal(l) : l)),
};
}
/** The local, holding the code unit instead of the one-character string. */
function unitLocal(local: IrLocal): IrLocal {
return { ...local, type: F64 };
}
/** The local a node mentions in a form the rewrite handles: the
* declaration from a one-argument charAt, a one-unit test, or a switch
* over one-unit cases. */
function understoodMention(node: Record<string, unknown>): string | null {
// count() visits every object in the body (types, locations, cases), so
// only the node kinds handled here are read as IR nodes
const kind = node.kind;
if (kind !== "varDecl" && kind !== "strEq" && kind !== "strCmp" && kind !== "switch") return null;
const n = node as unknown as IrExpr | IrStmt;
if (n.kind === "varDecl") return n.init && isCharAt(n.init) ? n.localId : null;
if (n.kind === "strEq" || n.kind === "strCmp") return unitUse(n)?.id ?? null;
@@ -208,6 +208,7 @@ const MODULE_FIELDS: Record<StringFields<Omit<IrModule, "functions">>, Role> = {
"contracts.int32Returns[]": "fact",
"contracts.int32Locals[]": "other",
"contracts.int32Elements[]": "other",
"int32ArrayCandidates[]": "other",
"contracts.noThrow[]": "fact",
"contracts.acyclic[]": "other",
"contracts.immortalGlobals[]": "other",
+502
View File
@@ -0,0 +1,502 @@
import { F64, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
import { reparseExpr } from "./serialize.js";
import {
everyExprChild,
everyStmtChild,
everyStmtList,
transformExpr,
transformStmt,
transformStmtList,
type IrTransform,
} from "./traverse.js";
/** Local number arrays the backend may store as int32 slots.
*
* A candidate is a local `number[]` of a synchronous function that no
* other binding can hold and only compiled element operations reach:
* every write of the local stores a fresh array (an array literal, or
* `new Array(n).fill(v)`), and every read of it is the receiver of an
* element read, write, state test, `length`, `push` or `pop` (a discarded
* `pop` included), or the array of an element-reader helper call. The
* array never reaches a runtime function that reads its slots, a library
* call, a closure, a return or untyped code.
*
* Candidates are only proposed here. The int32 slot analysis
* (int32-slots.ts) keeps the ones whose every stored element is a proven
* int32 (never -0), assuming the candidates' own elements are int32s, at
* a fixpoint, and the LLVM backend stores those as SCR_ELEM_I32. It keeps
* the ones whose stores are exact integers but not all proven int32s as
* int32 arrays with checked stores: SCR_ELEM_I32 until a store that is not
* an int32 widens them to doubles in place. Their element-reader helper
* calls are inlined first (rewriteCandidates), so no shared helper ever
* receives an int32 array. */
/** Array methods a candidate may be the receiver of: the runtime paths
* behind them read and write int32 slots through the double-valued slot
* accessors. */
export const INT32_ARRAY_METHODS: ReadonlySet<string> = new Set([
"length",
"push",
"pop",
"getNumber",
]);
/** An element-reader helper (`%arr.idxOr.*`): `(a, i) => a's state at i is
* a value ? the element : undefined`, as one returned ternary. */
export function isElementReader(fn: IrFunction): boolean {
if (fn.params.length !== 2 || fn.body.length !== 1) return false;
const [array, index] = fn.params;
const ret = fn.body[0]!;
if (array!.type.kind !== "array" || array!.type.elem.kind !== "f64") return false;
if (index!.type.kind !== "f64" || ret.kind !== "return" || ret.value?.kind !== "ternary")
return false;
const isParam = (e: IrExpr, id: string): boolean => e.kind === "varRef" && e.localId === id;
const { cond, then, else_ } = ret.value;
return (
cond.kind === "bin" &&
cond.op === "===" &&
cond.left.kind === "arrayState" &&
isParam(cond.left.arr, array!.localId) &&
isParam(cond.left.index, index!.localId) &&
cond.right.kind === "numLit" &&
cond.right.value === 1 &&
then.kind === "unionWrap" &&
then.value.kind === "arrayGet" &&
isParam(then.value.arr, array!.localId) &&
isParam(then.value.index, index!.localId) &&
else_.kind === "unionWrap" &&
else_.value.kind === "unitLit"
);
}
/** `new Array(n).fill(v)` as lowering spells it: `fill` over a sequence
* that declares a temporary empty array, sets its length and yields it. */
function fillAllocation(e: IrExpr): (IrExpr & { kind: "arrIntrinsic" }) | null {
if (e.kind !== "arrIntrinsic" || e.method !== "fill" || e.receiver.kind !== "seqExpr")
return null;
const seq = e.receiver;
const [decl, length] = seq.stmts;
if (
seq.stmts.length !== 2 ||
decl?.kind !== "varDecl" ||
decl.init?.kind !== "arrayLit" ||
decl.init.elems.length !== 0 ||
length?.kind !== "arraySetLength" ||
length.arr.kind !== "varRef" ||
length.arr.localId !== decl.localId ||
seq.result.kind !== "varRef" ||
seq.result.localId !== decl.localId
)
return null;
return e;
}
/** Fill-form arrays shorter than this hold doubles even when they are int32
* arrays with checked stores: their doubles fit a typical 32 KiB L1 data
* cache, so 4-byte slots save no memory traffic. */
export const CHECKED_INT32_MIN_LENGTH = 4096;
/** The length `n` of `new Array(n).fill(v)`, or null for any other
* allocation. */
export function fillAllocationLength(e: IrExpr): IrExpr | null {
const fill = fillAllocation(e);
if (!fill || fill.receiver.kind !== "seqExpr") return null;
const set = fill.receiver.stmts[1]!;
return set.kind === "arraySetLength" ? set.length : null;
}
/** A number expression whose evaluation has no effect: locals, literals and
* arithmetic. */
function isPureLength(e: IrExpr): boolean {
if (e.type.kind !== "f64") return false;
switch (e.kind) {
case "numLit":
case "varRef":
return true;
case "bin":
return e.left.type.kind === "f64" && isPureLength(e.left) && isPureLength(e.right);
case "unary":
return isPureLength(e.operand);
default:
return false;
}
}
/** Whether the candidate `id` of `fn` may hold int32 slots with checked
* stores: every array it is assigned is the fill form, with a length that
* may reach CHECKED_INT32_MIN_LENGTH and evaluates without effects (the
* backend evaluates it once more to pick the new array's kind). */
export function checkableInt32Allocations(fn: IrFunction, id: string): boolean {
let ok = true;
everyStmtList(fn.body, {
stmt: (s) => {
if ((s.kind === "varDecl" || s.kind === "assign") && s.localId === id) {
const value = s.kind === "varDecl" ? s.init : s.value;
const length = value ? fillAllocationLength(value) : null;
if (
length === null ||
!isPureLength(length) ||
(length.kind === "numLit" && length.value < CHECKED_INT32_MIN_LENGTH)
)
ok = false;
}
return ok;
},
expr: () => ok,
});
return ok;
}
/** A fresh number array: a literal without spreads, or the fill form. */
export function int32ArrayAllocation(e: IrExpr): boolean {
return (e.kind === "arrayLit" && (e.spreads?.length ?? 0) === 0) || fillAllocation(e) !== null;
}
/** The array literal a candidate's allocation builds (the literal itself,
* or the fill form's temporary's), and the fill form's temporary. */
export function allocatedLiteral(
e: IrExpr,
): { literal: IrExpr & { kind: "arrayLit" }; temporary: string | null } | null {
if (e.kind === "arrayLit") return { literal: e, temporary: null };
const fill = fillAllocation(e);
if (!fill || fill.receiver.kind !== "seqExpr") return null;
const decl = fill.receiver.stmts[0]!;
return decl.kind === "varDecl" && decl.init?.kind === "arrayLit"
? { literal: decl.init, temporary: decl.localId }
: null;
}
/** `xs.pop()` whose value is unused, as lowering spells it: a sequence
* that binds the array to a hidden local, then only reads that local's
* length and truncates it with pop stores. Returns the hidden local and the
* array read it binds. */
function discardedPop(e: IrExpr): { local: string; array: IrExpr & { kind: "varRef" } } | null {
if (e.kind !== "seqExpr") return null;
const [bind, ...rest] = e.stmts;
if (bind?.kind !== "varDecl" || bind.init?.kind !== "varRef") return null;
const local = bind.localId;
const isLocal = (x: IrExpr): boolean => x.kind === "varRef" && x.localId === local;
const expr = (x: IrExpr): boolean => {
if (x.kind === "arrIntrinsic" && x.method === "length" && isLocal(x.receiver)) return true;
return !isLocal(x) && everyExprChild(x, expr, stmt);
};
const stmt = (x: IrStmt): boolean => {
if (x.kind === "arraySetLength" && x.pop === true && isLocal(x.arr)) return expr(x.length);
return (
!((x.kind === "varDecl" || x.kind === "assign") && x.localId === local) &&
everyStmtChild(x, expr, stmt)
);
};
return rest.every(stmt) && expr(e.result) ? { local, array: bind.init } : null;
}
/** Whether evaluating `e` may assign the local `id`. */
function writesLocal(e: IrExpr, id: string): boolean {
const expr = (x: IrExpr): boolean =>
!(x.kind === "assignExpr" && x.localId === id) && everyExprChild(x, expr, stmt);
const stmt = (x: IrStmt): boolean =>
!((x.kind === "varDecl" || x.kind === "assign") && x.localId === id) &&
everyStmtChild(x, expr, stmt);
return !expr(e);
}
/** The candidate locals of `fn` (see the module comment). */
export function int32ArrayCandidates(fn: IrFunction, readers: ReadonlySet<string>): Set<string> {
const result = new Set<string>();
if (fn.async || fn.generator) return result;
const captured = new Set(
[...(fn.captures ?? []), ...(fn.classCaptures ?? [])].map((c) => c.localId),
);
const params = new Set(fn.params.map((p) => p.localId));
for (const local of fn.locals)
if (
local.type.kind === "array" &&
local.type.elem.kind === "f64" &&
!local.boxed &&
!local.tdz &&
!captured.has(local.id) &&
!params.has(local.id)
)
result.add(local.id);
if (result.size === 0) return result;
const written = new Set<string>();
// A receiver position: the candidate is read there without escaping.
const receiver = (e: IrExpr): void => {
if (e.kind !== "varRef") expr(e);
};
const expr = (e: IrExpr): boolean => {
switch (e.kind) {
case "varRef":
result.delete(e.localId);
return true;
case "assignExpr":
result.delete(e.localId);
break;
case "arrayGet":
case "arrayState":
case "arrayHas":
receiver(e.arr);
expr(e.index);
return true;
case "arrIntrinsic":
if (!INT32_ARRAY_METHODS.has(e.method)) break;
receiver(e.receiver);
e.args.forEach(expr);
return true;
case "seqExpr": {
// The pop's hidden local is the array itself; every use of it is a
// length read or a pop store.
const pop = discardedPop(e);
if (!pop) break;
e.stmts.slice(1).every(stmt);
expr(e.result);
return true;
}
case "call": {
if (!readers.has(e.callee) || e.args.length !== 2) break;
const [array, index] = e.args;
// Inlining evaluates the index before the array read.
if (array!.kind === "varRef" && writesLocal(index!, array!.localId))
result.delete(array!.localId);
receiver(array!);
expr(index!);
return true;
}
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
switch (s.kind) {
case "varDecl":
case "assign": {
const value = s.kind === "varDecl" ? s.init : s.value;
if (value && int32ArrayAllocation(value)) written.add(s.localId);
else if (value) result.delete(s.localId);
if (value) expr(value);
return true;
}
case "arraySet":
receiver(s.arr);
expr(s.index);
expr(s.value);
return true;
case "arraySetUndefined":
// Stores a present undefined: a state, and a cleared (+0) slot.
receiver(s.arr);
expr(s.index);
return true;
case "forOf":
result.delete(s.localId);
break;
case "tryCatch":
if (s.catchLocalId) result.delete(s.catchLocalId);
break;
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
for (const id of result) if (!written.has(id)) result.delete(id);
return result;
}
/** The element values stored into each candidate: element writes, pushed
* values, literal elements and fill values. */
export function int32ArrayStores(
fn: IrFunction,
candidates: ReadonlySet<string>,
): Map<string, IrExpr[]> {
const stores = new Map<string, IrExpr[]>();
const add = (id: string, value: IrExpr): void => {
const list = stores.get(id);
if (list) list.push(value);
else stores.set(id, [value]);
};
const target = (e: IrExpr): string | null =>
e.kind === "varRef" && candidates.has(e.localId) ? e.localId : null;
const expr = (e: IrExpr): boolean => {
if (e.kind === "arrIntrinsic" && e.method === "push") {
const id = target(e.receiver);
if (id !== null) for (const arg of e.args) add(id, arg);
}
return everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if ((s.kind === "varDecl" || s.kind === "assign") && candidates.has(s.localId)) {
const value = s.kind === "varDecl" ? s.init : s.value;
if (value?.kind === "arrayLit") for (const el of value.elems) add(s.localId, el);
else if (value) {
const fill = fillAllocation(value);
if (fill) add(s.localId, fill.args[0]!);
}
}
if (s.kind === "arraySet") {
const id = target(s.arr);
if (id !== null) add(id, s.value);
}
return everyStmtChild(s, expr, stmt);
};
fn.body.every(stmt);
return stores;
}
/** Rewrite a function's candidates for the backend. A discarded pop uses
* the candidate in place of its hidden local. An element-reader helper call
* whose array is a candidate is inlined: the helper's returned ternary, with
* the candidate for its array and the index for its index, so the read means
* the same. An index that is not a plain read or literal is bound to a fresh
* local first: in its own declaration just before the statement when the
* call is the whole value of a declaration or assignment (the form lowering
* gives `a[i]!`, and the one the backend reads without a box), otherwise in a
* sequence around the ternary. Selection keeps out indexes that write the
* array, so evaluating the index before the array read is unobservable. */
function rewriteCandidates(
fn: IrFunction,
candidates: ReadonlySet<string>,
readers: ReadonlyMap<string, IrFunction>,
): IrFunction {
const locals = [...fn.locals];
let fresh = 0;
const bind = (index: IrExpr): { decl: IrStmt; ref: IrExpr } => {
const id = `%int32.read.${fresh++}`;
locals.push({ id, name: id, type: F64, mutable: false });
return {
decl: { kind: "varDecl", localId: id, init: index, loc: index.loc },
ref: { kind: "varRef", localId: id, type: F64, loc: index.loc },
};
};
const readerCall = (e: IrExpr | null): (IrExpr & { kind: "call" }) | null =>
e?.kind === "call" &&
e.args.length === 2 &&
readers.has(e.callee) &&
e.args[0]!.kind === "varRef" &&
candidates.has(e.args[0]!.localId)
? e
: null;
const simple = (e: IrExpr): boolean => e.kind === "varRef" || e.kind === "numLit";
// The statements of a list, with complex read indexes declared first.
const hoist = (list: IrStmt[]): IrStmt[] =>
list.flatMap((s): IrStmt[] => {
if (s.kind !== "varDecl" && s.kind !== "assign") return [s];
const value = s.kind === "varDecl" ? s.init : s.value;
const call = readerCall(value);
if (!call || simple(call.args[1]!)) return [s];
const { decl, ref } = bind(call.args[1]!);
const read: IrExpr = { ...call, args: [call.args[0]!, ref] };
return [decl, s.kind === "varDecl" ? { ...s, init: read } : { ...s, value: read }];
});
const lists = (s: IrStmt): IrStmt => {
switch (s.kind) {
case "if":
return { ...s, then: hoist(s.then), else_: s.else_ === null ? null : hoist(s.else_) };
case "block":
case "while":
case "doWhile":
case "for":
case "forOf":
return { ...s, body: hoist(s.body) };
case "switch":
return { ...s, cases: s.cases.map((c) => ({ ...c, body: hoist(c.body) })) };
case "tryCatch":
return {
...s,
tryBody: hoist(s.tryBody),
catchBody: s.catchBody === null ? null : hoist(s.catchBody),
finallyBody: s.finallyBody === null ? null : hoist(s.finallyBody),
};
}
return s;
};
// Hidden pop locals replaced by their candidate array.
const popLocals = new Set<string>();
const rewrite = (e: IrExpr): IrExpr => {
const pop = discardedPop(e);
if (e.kind === "seqExpr" && pop && candidates.has(pop.array.localId)) {
popLocals.add(pop.local);
const array = pop.array;
const substitute: IrTransform = {
expr: (x) =>
x.kind === "varRef" && x.localId === pop.local ? { ...array, loc: x.loc } : x,
stmt: (x) => x,
};
return {
...e,
stmts: e.stmts.slice(1).map((x) => transformStmt(x, substitute)),
result: transformExpr(e.result, substitute),
};
}
const call = readerCall(e);
if (!call) return e.kind === "seqExpr" ? { ...e, stmts: hoist(e.stmts) } : e;
const helper = readers.get(call.callee)!;
const array = call.args[0]!;
const [arrayParam, indexParam] = helper.params;
const ret = helper.body[0]!;
if (ret.kind !== "return" || !ret.value) return e;
let index = call.args[1]!;
const stmts: IrStmt[] = [];
if (!simple(index)) {
const { decl, ref } = bind(index);
stmts.push(decl);
index = ref;
}
const operand = index;
const body = transformExpr(reparseExpr(ret.value), {
expr: (x) => {
if (x.kind !== "varRef") return x;
if (x.localId === arrayParam!.localId) return reparseExpr(array);
if (x.localId === indexParam!.localId) return reparseExpr(operand);
return x;
},
stmt: (x) => x,
});
const typed: IrExpr = { ...body, loc: e.loc };
return stmts.length === 0
? typed
: { kind: "seqExpr", stmts, result: typed, type: typed.type, loc: e.loc };
};
const body = transformStmtList(hoist(fn.body), { expr: rewrite, stmt: lists });
if (fresh === 0 && popLocals.size === 0) return { ...fn, body };
return { ...fn, body, locals: locals.filter((local) => !popLocals.has(local.id)) };
}
/** Propose the module's int32 array candidates (`function\0local` keys in
* `int32ArrayCandidates`), only those in `keep` when given, and rewrite
* their functions. */
export function markInt32ArrayCandidates(
mod: IrModule,
keep: ReadonlySet<string> | null = null,
): IrModule {
if (mod.lib !== undefined) return mod;
const readers = new Map<string, IrFunction>();
for (const fn of mod.functions) if (isElementReader(fn)) readers.set(fn.name, fn);
const names = new Set(readers.keys());
const keys: string[] = [];
const functions = mod.functions.map((fn) => {
const candidates = int32ArrayCandidates(fn, names);
for (const id of candidates)
if (keep === null || keep.has(`${fn.name}\0${id}`)) keys.push(`${fn.name}\0${id}`);
else candidates.delete(id);
return candidates.size === 0 ? fn : rewriteCandidates(fn, candidates, readers);
});
return keys.length === 0 ? mod : { ...mod, functions, int32ArrayCandidates: keys };
}
/** The module the backend emits: rewritten for exactly the candidates the
* int32 slot analysis (`prove`, returning its int32Arrays) keeps, so a
* module without int32 arrays is `mod` itself. A dropped candidate's
* rewrite is undone by marking again without it, until every candidate
* marked is kept. */
export function selectInt32Arrays(
mod: IrModule,
prove: (marked: IrModule) => ReadonlySet<string>,
): IrModule {
let keep: ReadonlySet<string> | null = null;
for (let round = 0; round < 4; round++) {
const marked = markInt32ArrayCandidates(mod, keep);
if (marked === mod) return mod;
const kept = prove(marked);
const proposed = marked.int32ArrayCandidates ?? [];
if (proposed.every((key) => kept.has(key))) return marked;
keep = new Set(proposed.filter((key) => kept.has(key)));
}
return mod;
}
+71 -1
View File
@@ -10,6 +10,7 @@ import {
} from "./integer-ranges.js";
import { exprFunctionReference, moduleFunctionReferences } from "./function-references.js";
import { findInitializerBindings, withInitializerBindings } from "./initializer-bindings.js";
import { checkableInt32Allocations, int32ArrayStores } from "./int32-arrays.js";
import { everyExprChild, everyStmtChild, everyStmtList, everyTypeChild } from "./traverse.js";
/** The storage slot an array-valued expression reads, as an
@@ -169,7 +170,10 @@ export class Int32Slots {
this.int32Locals.has(`${fnName}\0${localId}`) || this.int32Locals.has(`\0${localId}`)
? INT32_RANGE
: null;
if (this.int32Elements.size > 0) result.elements = (arr) => this.hasInt32Elements(fnName, arr);
if (this.int32Elements.size > 0) {
result.elements = (arr) => this.hasInt32Elements(fnName, arr);
if (this.narrowedReads) result.narrowedReads = true;
}
const params = this.paramFacts.get(fnName);
if (params)
result.params =
@@ -234,6 +238,28 @@ export class Int32Slots {
addInt32Elements(key: string): void {
this.int32Elements.add(key);
}
dropInt32Elements(key: string): boolean {
return this.int32Elements.delete(key);
}
hasInt32ElementSlot(key: string): boolean {
return this.int32Elements.has(key);
}
/** Whether the module has int32 array candidates (the narrowedReads
* range fact). */
narrowedReads = false;
/** Local number arrays (`function\0local`) stored as int32 slots: the
* module's int32 array candidates whose every stored element the
* fixpoint proved an int32. */
readonly int32Arrays = new Set<string>();
/** Local number arrays that may hold int32 slots with checked stores: the
* candidates whose element stores the fixpoint proved exact integers but
* not int32s, allocated only by fills that may be long. A fill of at least
* CHECKED_INT32_MIN_LENGTH elements creates one with int32 slots and a
* shorter one with doubles. Their elements give no int32 facts; compiled
* accesses test the array's kind (once per innermost loop where they can,
* backend/llvm/int32-kind-loops.ts), and a store that is not an int32
* widens it first (scr_arr_widen_i32). */
readonly checkedInt32Arrays = new Set<string>();
/** Whether every present element of the number array `arr` evaluates to
* (read inside `fnName`) is an int32. */
hasInt32Elements(fnName: string, arr: IrExpr): boolean {
@@ -764,6 +790,18 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
}
for (const key of contracts?.int32Locals ?? []) slots.addInt32Local(key);
for (const key of contracts?.int32Elements ?? []) slots.addInt32Elements(key);
// Int32 array candidates (ir/int32-arrays.ts): assumed to hold int32
// elements until a store the fixpoint cannot prove drops one.
const arrayCandidates = new Map<string, Set<string>>();
slots.narrowedReads = (mod.int32ArrayCandidates?.length ?? 0) > 0;
for (const key of mod.int32ArrayCandidates ?? []) {
const cut = key.indexOf("\0");
const fnName = key.slice(0, cut);
const set = arrayCandidates.get(fnName) ?? new Set<string>();
set.add(key.slice(cut + 1));
arrayCandidates.set(fnName, set);
slots.addInt32Elements(`l:${key}`);
}
/* ── candidate fields ── */
const children = new Map<string, string[]>();
@@ -1043,6 +1081,7 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
return false;
};
const hasCandidateWrite = (fn: IrFunction): boolean => {
if (arrayCandidates.has(fn.name)) return true;
const box = (localId: string): boolean => slots.hasBox(slots.boxGroup(fn.name, localId));
const returns = slots.hasReturn(fn.name);
return !everyStmtList(fn.body, {
@@ -1088,6 +1127,33 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
continue;
}
const int32 = (e: IrExpr): boolean => withinInt32(ranges.get(e));
// Every element stored into an int32 array candidate must be an int32
// under the facts that assume the candidates' own elements are; a
// failing store drops its array's element facts, and the function is
// analyzed again without them. When every failing store is still an
// exact integer under those facts (a counter, `a[i]! + 1`) and the array
// is only ever allocated by a fill that may be long
// (checkableInt32Allocations), it keeps int32 slots with checked stores
// (checkedInt32Arrays), which widen it to doubles in place at the first
// store that is not an int32.
const arrays = arrayCandidates.get(fn.name);
if (arrays) {
let dropped = false;
for (const [id, values] of int32ArrayStores(fn, arrays)) {
const key = `l:${fn.name}\0${id}`;
if (!slots.hasInt32ElementSlot(key) || values.every(int32)) continue;
slots.dropInt32Elements(key);
const checkable = (value: IrExpr): boolean => int32(value) || ranges.get(value) != null;
if (values.every(checkable) && checkableInt32Allocations(fn, id))
slots.checkedInt32Arrays.add(`${fn.name}\0${id}`);
else reason(`array:${fn.name}\0${id}`, "an element store is not int32");
dropped = true;
}
if (dropped && !queued.has(fn.name)) {
queued.add(fn.name);
queue.push(fn.name);
}
}
// An int32 written into a slot still assumed non-negative: widen the
// assumption when the value may be negative, and reanalyze its readers.
const settle = (slot: string, e: IrExpr): void => {
@@ -1156,6 +1222,10 @@ export function analyzeInt32Slots(mod: IrModule, inputs: Int32SlotInputs = {}):
}
for (const proven of provenByFunction.values())
for (const value of proven) slots.provenWrites.add(value);
for (const [fnName, ids] of arrayCandidates)
for (const id of ids)
if (slots.hasInt32ElementSlot(`l:${fnName}\0${id}`))
slots.int32Arrays.add(`${fnName}\0${id}`);
const ranged = rangedParameters(slots, functions, retry, numeric, (callee) =>
readers.get(`ret:${callee}`),
);
+130 -16
View File
@@ -220,6 +220,11 @@ export interface IntegerSlotFacts {
/** Number arrays whose every present element is an int32 (Int32[]
* contracts): an element read that found a value reads an int32. */
elements?: (arr: IrExpr) => boolean;
/** Element reads narrowed out of a union (`xs[i]!`, also when a union
* local holds the read) keep the element's range, and a ternary arm that
* always throws yields no value. Only modules with int32 array candidates
* (ir/int32-arrays.ts) set it, so no other program's ranges change. */
narrowedReads?: boolean;
}
/** A local read, or a numeric local plus or minus an integer literal
@@ -240,6 +245,50 @@ function offsetRef(e: IrExpr): { localId: string; offset: number } | null {
return null;
}
/** Library calls that always throw. */
const THROWING_LIB = /^error\.\w*Throw$/;
function throws(e: IrExpr): boolean {
return e.kind === "libCall" && THROWING_LIB.test(e.fn);
}
/** The values written to each union-typed local of `fn` by declarations
* and assignments; null for a local that another write reaches or that a
* closure captures. */
function unionLocalWrites(
fn: IrFunction,
captures: ReadonlySet<string>,
): Map<string, IrExpr[] | null> {
const unionLocals = new Set(
fn.locals
.filter((l) => l.type.kind === "union" && !l.boxed && !captures.has(l.id))
.map((l) => l.id),
);
const result = new Map<string, IrExpr[] | null>();
const add = (id: string, value: IrExpr | null): void => {
if (!unionLocals.has(id)) return;
const list = result.get(id);
if (list === null) return;
if (value === null) result.set(id, null);
else if (list) list.push(value);
else result.set(id, [value]);
};
everyStmtList(fn.body, {
stmt: (s) => {
if (s.kind === "varDecl" && s.init) add(s.localId, s.init);
else if (s.kind === "assign") add(s.localId, s.value);
else if (s.kind === "forOf") add(s.localId, null);
else if (s.kind === "tryCatch" && s.catchLocalId) add(s.catchLocalId, null);
return true;
},
expr: (e) => {
if (e.kind === "assignExpr") add(e.localId, null);
return true;
},
});
return result;
}
/** Structured local dataflow. Calls cannot write uncaptured local slots,
* but argument expressions can; unknown evaluation forms invalidate all
* syntactically written slots before their children are inspected. Loops
@@ -272,6 +321,10 @@ export function analyzeIntegerRangeFacts(
.map((l) => l.id),
);
const loopLocals = new Map(fn.locals.filter((l) => !captures.has(l.id)).map((l) => [l.id, l]));
// With narrowed reads, the values written to union locals (an element
// read held before it is narrowed), or null for a local some other write
// (assignExpr, for-of, catch, capture) reaches.
const unionWrites = slots.narrowedReads ? unionLocalWrites(fn, captures) : null;
// A captured binding has no local dataflow: only a proof that covers
// every write of the shared box anywhere applies to its reads.
const boxedRange = (localId: string): IntegerRange | null => {
@@ -490,6 +543,45 @@ export function analyzeIntegerRangeFacts(
return range;
}
/** narrowedRange through a union local: every value written to it. */
function narrowedValue(
value: IrExpr,
unionId: string,
tag: number,
): IntegerRange | null | "never" {
if (value.kind !== "varRef") return narrowedRange(value, unionId, tag);
const written = unionWrites?.get(value.localId);
if (!written || written.length === 0) return null;
let result: IntegerRange | null | "never" = "never";
for (const w of written) {
const arm = narrowedRange(w, unionId, tag);
if (arm === "never") continue;
if (arm === null) return null;
result = result === "never" ? arm : union(result, arm);
}
return result;
}
/** The range of the values of union tag `tag` that `value` (already
* analyzed) can hold: "never" when no arm holds that tag. */
function narrowedRange(
value: IrExpr,
unionId: string,
tag: number,
): IntegerRange | null | "never" {
if (value.kind === "seqExpr") return narrowedRange(value.result, unionId, tag);
if (value.kind === "ternary") {
const yes = narrowedRange(value.then, unionId, tag),
no = narrowedRange(value.else_, unionId, tag);
if (yes === "never") return no;
if (no === "never") return yes;
return yes && no ? union(yes, no) : null;
}
if (value.kind === "unionWrap" && value.unionId === unionId) {
if (value.tag !== tag) return "never";
return value.value.kind === "arrayGet" ? (ranges.get(value.value) ?? null) : null;
}
return null;
}
function remember(e: IrExpr, range: IntegerRange | null): IntegerRange | null {
// Shared IR objects have to satisfy the proof at every occurrence.
const previous = ranges.get(e);
@@ -683,6 +775,10 @@ export function analyzeIntegerRangeFacts(
right = expr(e.else_, no);
merge(facts, yes, no);
if (left && right) range = union(left, right);
// With narrowed reads: an arm that always throws yields no value,
// so the other arm's range is the result's.
else if (slots.narrowedReads && throws(e.then)) range = right;
else if (slots.narrowedReads && throws(e.else_)) range = left;
break;
}
case "logical": {
@@ -777,27 +873,45 @@ export function analyzeIntegerRangeFacts(
expr(e.obj, facts);
range = slotRange(e);
break;
case "unionNarrow":
// Only modules with narrowed reads take this rule: the narrow
// evaluates its operand first, and throws for every arm of another
// tag, so an element read `xs[i]!` (a ternary of the wrapped
// element and a wrapped undefined) has the element's range.
if (!slots.narrowedReads || e.type.kind !== "f64") {
range = conservative(e, facts);
break;
}
expr(e.value, facts);
{
const arm = narrowedValue(e.value, e.unionId, e.tag);
range = arm === "never" ? null : arm;
}
break;
default:
// Do not assume the traversal order is an evaluation order for an
// unknown node. Every child gets the same conservative entry state.
havoc(facts, [e], facts);
everyExprChild(
e,
(child) => {
expr(child, new Map(facts));
return true;
},
(child) => {
body([child], new Map(facts));
return true;
},
);
// A direct call's result carries its whole-program return proof.
range = slotRange(e);
range = conservative(e, facts);
break;
}
return remember(e, range);
}
/** An unknown node: do not assume the traversal order is an evaluation
* order. Every child gets the same conservative entry state. */
function conservative(e: IrExpr, facts: Facts): IntegerRange | null {
havoc(facts, [e], facts);
everyExprChild(
e,
(child) => {
expr(child, new Map(facts));
return true;
},
(child) => {
body([child], new Map(facts));
return true;
},
);
// A direct call's result carries its whole-program return proof.
return slotRange(e);
}
function induction(
s: IrStmt & { kind: "for" },
facts: Facts,
+9 -2
View File
@@ -1073,6 +1073,12 @@ export interface IrModule {
* write in the program (ir/contract-check.ts). Backends may rely on them
* unconditionally. Absent when the program writes no contract. */
contracts?: IrContracts;
/** Local number arrays (`function\0local`) the backend may store as
* int32 slots, proposed by ir/int32-arrays.ts after their element-reader
* calls were inlined; the int32 slot analysis keeps the ones whose every
* stored element it proves an int32, and those whose stores are exact
* integers with checked stores. Absent when there is none. */
int32ArrayCandidates?: string[];
/** The WebAssembly modules translated into this program (wasm/
* translate.ts), present exactly when the program uses the WebAssembly
* API (possibly empty). The backend appends each module's translation
@@ -2094,8 +2100,9 @@ export type IrStmt =
* and whose `code` carries the SC diagnostic code; unwinds exactly like
* `throw`.
* TypeScript sources produce it only as the tsc-unreachable fallthrough
* trap (appendImplicitUndefinedReturn's SC9002) — their construct fences
* stay compile errors. */
* trap (appendImplicitUndefinedReturn's SC9002) and for a typed-array
* store through an element index that is undefined at run time
* (undefined-index.ts); their construct fences stay compile errors. */
| { kind: "runtimeFence"; code: string; message: string; loc: SrcLoc }
/** Rethrow of a catch binding (`throw e` where e is the binding):
* re-raises the SAVED exception exactly — kind and payload preserved,
+5 -1
View File
@@ -4267,7 +4267,11 @@ function validateFunction(
if (!ok) err(`libCall dyn.objEntryPairs must return a [string, any] tuple array`, e.loc);
return true;
}
if (e.fn === "array.namedKeys" || e.fn === "array.hasNamedKey") {
if (
e.fn === "array.namedKeys" ||
e.fn === "array.hasNamedKey" ||
e.fn === "array.hasProperties"
) {
if (e.args[0]?.type.kind !== "array") {
err(`libCall ${e.fn} arg 0: expected an array, got ${e.args[0]?.type.kind}`, e.loc);
}
+8 -1
View File
@@ -14,7 +14,7 @@ import {
} from "node:fs";
import { join } from "node:path";
import { resolveBuildCacheRoot } from "../backend/cache-root.js";
import { writeTextGeneratedSync } from "./text-file.js";
import { joinedParts, writeTextGeneratedSync } from "./text-file.js";
/** UTF-8 bytes one native compiler string can hold (the runtime's
* SCR_STR_MAX_CAP). Exceeding it traps rather than throwing, so payloads
@@ -28,6 +28,13 @@ export function contentDigest(value: string | Uint8Array): string {
return hash.digest("hex");
}
/** contentDigest of the text the parts concatenate to. */
export function partsDigest(parts: readonly string[]): string {
const hash = createHash("sha256");
for (const part of joinedParts(parts)) hash.update(part);
return hash.digest("hex");
}
/** contentDigest of a file's bytes, read in 1 MiB blocks so a large file
* (a module's LLVM text) is never held whole. */
export function fileDigest(path: string): string {
+4 -4
View File
@@ -362,7 +362,7 @@ export class NativeCompiler {
mkdirSync(inputDirectory);
const llvmInput = outputKind === "llvm" ? stagedOutput : join(inputDirectory, "program.ll");
// In slices: the module text is never encoded whole.
writeTextPartsSync(llvmInput, [llvm]);
writeTextPartsSync(llvmInput, llvm);
timing("llvm-write");
if (outputKind === "llvm") {
renameSync(stagedOutput, output);
@@ -496,7 +496,7 @@ export class NativeCompiler {
renameSync(stagedOutput + ".dSYM", output + ".dSYM");
}
mkdirSync(options.outDir, { recursive: true });
writeTextPartsSync(llvmPath, [llvm]);
writeTextPartsSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.ir.json`);
@@ -585,7 +585,7 @@ export class NativeCompiler {
const llvm = prepared.llvm;
const llvmInput = join(stage, "program.ll");
// In slices: the module text is never encoded whole.
writeTextPartsSync(llvmInput, [llvm]);
writeTextPartsSync(llvmInput, llvm);
timing("llvm-write");
const features = prepared.features;
const pack = selectNativeRuntimePack(
@@ -648,7 +648,7 @@ export class NativeCompiler {
});
}
timing("native-archive");
writeTextPartsSync(llvmPath, [llvm]);
writeTextPartsSync(llvmPath, llvm);
let irPath: string | undefined;
if (options.emitIr) {
irPath = join(options.outDir, `${stem}.lib.ir.json`);
@@ -11,7 +11,7 @@ import {
import { runNativeTool } from "../backend/native-tools.js";
import type { NativeRuntimeSelection } from "../backend/runtime-pack-native.js";
import type { NativeToolchain } from "./toolchain.js";
import { contentDigest, NativeCache } from "./cache.js";
import { contentDigest, NativeCache, partsDigest } from "./cache.js";
interface InputIdentity {
path: string;
@@ -254,7 +254,7 @@ export class NativeExecutableCache {
export function openNativeExecutableCache(
cache: NativeCache | null,
toolchain: NativeToolchain,
llvm: string,
llvm: readonly string[],
options: CompileRequestOptions,
ffi: FfiProfile | null,
pack: NativeRuntimeSelection,
@@ -299,7 +299,7 @@ export function openNativeExecutableCache(
const key = contentDigest(
JSON.stringify({
schema: 1,
llvm: contentDigest(llvm),
llvm: partsDigest(llvm),
options,
toolchain,
runtimeIdentity: pack.packageText,
+88 -18
View File
@@ -11,11 +11,33 @@ import { NativeCache } from "./cache.js";
import { prepareNativeExecutable } from "./prepare.js";
import type { NativeToolchain } from "./toolchain.js";
/** A prepared input with its LLVM parts joined: a cache hit reads the text
* back as one part. */
function text(input: ReturnType<ReturnType<typeof fixture>["prepare"]>) {
return input.ok ? { ...input, llvm: input.llvm.join("") } : input;
}
const scratch: string[] = [];
afterEach(() => {
for (const path of scratch.splice(0)) rmSync(path, { recursive: true, force: true });
});
function fixtureToolchain(): NativeToolchain {
const target = nativeCodegenTarget()!;
return {
compilerVersion: "test",
target,
helper: target.helper,
helperExecutable: "unused",
helperPackageRoot: "unused",
runtimePackRoot: "unused",
linker: "unused",
linkerArgs: [],
dsymutil: "unused",
ts7Executable: ts7Executable(),
};
}
function fixture() {
const temporary = mkdtempSync(join(tmpdir(), "scriptc-native-frontend-"));
scratch.push(temporary);
@@ -32,19 +54,7 @@ function fixture() {
}),
);
writeFileSync(join(root, "package.json"), '{"type":"module"}');
const target = nativeCodegenTarget()!;
const toolchain: NativeToolchain = {
compilerVersion: "test",
target,
helper: target.helper,
helperExecutable: "unused",
helperPackageRoot: "unused",
runtimePackRoot: "unused",
linker: "unused",
linkerArgs: [],
dsymutil: "unused",
ts7Executable: ts7Executable(),
};
const toolchain = fixtureToolchain();
const cache = new NativeCache(join(temporary, "cache"));
const options = {
outPath: join(root, ".scriptc", "main"),
@@ -68,17 +78,77 @@ test("warm frontend reuse survives output creation and invalidates imported sour
if (!first.ok) return;
mkdirSync(f.options.outDir);
writeFileSync(f.options.outPath, "binary placeholder");
writeFileSync(join(f.options.outDir, "main.ll"), first.llvm);
writeFileSync(join(f.options.outDir, "main.ll"), first.llvm.join(""));
const warm = f.prepare();
expect(warm).toEqual(first);
expect(text(warm)).toEqual(text(first));
expect(f.loads()).toBe(1);
writeFileSync(f.dependency, 'export const value = "second";\n');
const edited = f.prepare();
expect(edited.ok).toBe(true);
if (edited.ok) expect(edited.llvm).not.toBe(first.llvm);
if (edited.ok) expect(edited.llvm.join("")).not.toBe(first.llvm.join(""));
expect(f.loads()).toBe(2);
});
test("comment and whitespace edits reuse the frontend output a fresh build reproduces", () => {
const f = fixture();
const first = f.prepare();
expect(first.ok, JSON.stringify(first)).toBe(true);
if (!first.ok) return;
const commented = '// The exported value.\nexport const value = /* unchanged */ "first";\n';
writeFileSync(f.dependency, commented);
const reused = f.prepare();
expect(f.loads()).toBe(1);
expect(reused.ok).toBe(true);
if (!reused.ok) return;
expect(reused.llvm.join("")).toBe(first.llvm.join(""));
expect(new Map(reused.sources).get(f.dependency)).toBe(commented);
// The reused output is the one the edited sources lower to.
const fresh = prepareNativeExecutable(
f.entry,
f.options,
null,
fixtureToolchain(),
(path, npmStatic, externalTypes) => runFrontend(path, loadProgram, npmStatic, externalTypes),
new NativeCache(join(f.root, "fresh-cache")),
);
expect(fresh.ok && fresh.llvm.join("")).toBe(first.llvm.join(""));
// The refreshed entry still notices a later code edit.
writeFileSync(f.dependency, 'export const value = "second";\n');
const edited = f.prepare();
expect(f.loads()).toBe(2);
expect(edited.ok && edited.llvm.join("")).not.toBe(first.llvm.join(""));
});
test("a comment edit inside source text the output carries is rebuilt", () => {
const f = fixture();
// A failing assert(value) reports its call's source text.
const source = (call: string) =>
`import assert from "node:assert";\nexport const value = "first";\n${call};\n`;
writeFileSync(f.dependency, source("assert(value.length > 0)"));
const first = f.prepare();
expect(first.ok, JSON.stringify(first)).toBe(true);
if (!first.ok) return;
writeFileSync(f.dependency, source("assert(value.length > 0 /* checked */)"));
const edited = f.prepare();
expect(f.loads()).toBe(2);
expect(edited.ok && edited.llvm.join("")).not.toBe(first.llvm.join(""));
});
test("a whitespace edit that moves a reported source line is rebuilt", () => {
const f = fixture();
// node:test reports the file, line and column of each test.
const source = (gap: string) =>
`import { test } from "node:test";\nexport const value = "first";\n${gap}test("value", () => {});\n`;
writeFileSync(f.dependency, source(""));
const first = f.prepare();
expect(first.ok, JSON.stringify(first)).toBe(true);
if (!first.ok) return;
writeFileSync(f.dependency, source("\n"));
const edited = f.prepare();
expect(f.loads()).toBe(2);
expect(edited.ok && edited.llvm.join("")).not.toBe(first.llvm.join(""));
});
test("structurally invalid cached features cause a rebuild even with a valid digest", () => {
const f = fixture();
expect(f.prepare().ok).toBe(true);
@@ -101,13 +171,13 @@ test("serialized IR travels as bounded parts through the frontend cache", () =>
expect(mod.functions.length).toBeGreaterThan(0);
const warm = f.prepare({ emitIr: true });
expect(f.loads()).toBe(1);
expect(warm).toEqual(first);
expect(text(warm)).toEqual(text(first));
const family = join(f.cache.root, "frontend");
const key = readdirSync(family).find((name) => /^[0-9a-f]{64}$/.test(name))!;
const payload = JSON.parse(readFileSync(join(family, key), "utf8"));
expect(payload.schema).toBe("scriptc.native-frontend.v3");
expect(payload.input.llvm).toBe("");
expect(payload.input.ir).toBeNull();
expect(payload.llvm).toBe(first.llvm);
expect(payload.llvm).toBe(first.llvm.join(""));
expect(payload.ir).toEqual(first.ir);
});
+88 -16
View File
@@ -7,7 +7,11 @@ import type {
CompileLibraryOptions,
CompileRequestOptions,
} from "../compile-types.js";
import { emitLlvmModule, type ForkPrimitives, lowRegionTarget } from "../backend/llvm/emitter.js";
import {
emitLlvmModuleParts,
type ForkPrimitives,
lowRegionTarget,
} from "../backend/llvm/emitter.js";
import { inlineRcOption } from "../backend/llvm/shapes.js";
import { executableLinkFeatures } from "../backend/executable-features.js";
import type { NativeLinkFeatures } from "../backend/native-link-info-core.js";
@@ -16,6 +20,7 @@ import type { FfiProfile } from "../ffi/ffi-manifest.js";
import type { FrontendFactory } from "../frontend/pipeline.js";
import {
FrontendInputTracker,
frontendInputsSemanticallyMatch,
frontendInputsStillMatch,
validFrontendInputSnapshot,
type FrontendInputSnapshot,
@@ -24,18 +29,22 @@ import {
} from "../frontend/input-tracker.js";
import { provenanceSources } from "../frontend/provenance-registry.js";
import { processFork } from "../frontend/process-fork.js";
import { tokenEquivalentSource } from "../frontend/token-equivalence.js";
import { llvmBytes, wtf8Bytes } from "../backend/literals.js";
import { prepareLibrary } from "../library/prepare.js";
import type { LibraryProfile } from "../library/library-profile.js";
import type { IrModule } from "../ir/ir.js";
import { deserializeModuleBytes, serializeModuleParts } from "../ir/serialize.js";
import { contentDigest, NATIVE_STRING_BYTES, type NativeCache } from "./cache.js";
import { batchTextParts, textSlices } from "./text-file.js";
import { batchTextParts, joinedParts, textSlices } from "./text-file.js";
import type { NativeToolchain } from "./toolchain.js";
import { nativeFileIdentity } from "./file-identity.js";
export interface NativeExecutableInput {
ok: true;
llvm: string;
/** The module's LLVM text as parts that concatenate to it
* (emitLlvmModuleParts), never joined: a large program's is 150 MB. */
llvm: readonly string[];
/** Compact serialized IR as bounded parts (`batchTextParts`): compiler-scale
* modules exceed one native string. */
ir: string[] | null;
@@ -71,7 +80,8 @@ function* frontendCacheParts(
input: { ...input, llvm: "", ir: null },
});
yield metadata.slice(0, metadata.length - 1) + ',"llvm":"';
for (const slice of textSlices(input.llvm)) yield JSON.stringify(slice).slice(1, -1);
for (const part of joinedParts(input.llvm))
for (const slice of textSlices(part)) yield JSON.stringify(slice).slice(1, -1);
yield '"';
if (input.ir === null) yield ',"ir":null}';
else {
@@ -111,18 +121,71 @@ function publishFrontendCache(
);
}
/** The cached input when every frontend input still matches, else the
* entry the inputs no longer match, for reuse after equivalent edits. */
function readFrontendCache(
cache: NativeCache,
key: string,
exclusions: FrontendInputExclusions,
): NativeExecutableInput | null {
): { input: NativeExecutableInput } | { stale: FrontendCacheEntry } | null {
// The entry is decoded as one string; larger entries are misses.
const bytes = cache.read("frontend", key, NATIVE_STRING_BYTES);
if (bytes === null) return null;
const cached: unknown = JSON.parse(bytes.toString("utf8"));
if (!validCachedInput(cached) || !frontendInputsStillMatch(cached.probes, exclusions))
if (!validCachedInput(cached)) return null;
if (!frontendInputsStillMatch(cached.probes, exclusions)) return { stale: cached };
return { input: { ...cached.input, llvm: [cached.llvm], ir: cached.ir } };
}
/** The stale entry's input when every changed source is token-equivalent to
* the text the entry was built from (edits to whitespace and to comments
* that cannot affect compilation; see token-equivalence.ts). Equivalent
* sources lower to the same IR apart from source locations, which only dev
* builds' debug information and the serialized IR carry. Diagnostics carry
* locations too, so an entry that reported warnings is rebuilt. A changed
* source whose raw text or line positions the frontend read (call-site text
* in messages, comptime callbacks, test locations) is rebuilt as well:
* frontendInputsSemanticallyMatch refuses its `text` probe. */
function equivalentFrontendInput(
stale: FrontendCacheEntry,
options: CompileRequestOptions,
exclusions: FrontendInputExclusions,
): { input: NativeExecutableInput; probes: FrontendInputSnapshot } | null {
if (
options.optimization === "dev" ||
options.sanitize === true ||
options.emitIr === true ||
stale.ir !== null ||
(stale.input.warnings?.length ?? 0) > 0
)
return null;
return { ...cached.input, llvm: cached.llvm, ir: cached.ir };
const match = frontendInputsSemanticallyMatch(
stale.probes,
new Map(stale.input.sources),
tokenEquivalentSource,
exclusions,
);
if (match === null || match.changed.length === 0) return null;
// Runtime fences and unsupported-construct messages name their source as
// `[<code> at <file>:<line>]`, and an equivalent edit can move the line.
if (
match.changed.some(({ path }) =>
stale.llvm.includes(` at ${llvmBytes(wtf8Bytes(path), false)}:`),
)
)
return null;
return {
input: {
...stale.input,
llvm: [stale.llvm],
ir: null,
sources: stale.input.sources.map(([path, text]) => [
path,
match.currentSources.get(path) ?? text,
]),
},
probes: match.snapshot,
};
}
function implementationIdentity(toolchain: NativeToolchain): string {
@@ -284,10 +347,19 @@ export function prepareNativeExecutable(
.sort(([a], [b]) => a.localeCompare(b)),
}),
);
const input = readFrontendCache(cache, key, exclusions);
if (input !== null) {
const cached = readFrontendCache(cache, key, exclusions);
if (cached !== null && "input" in cached) {
timing("frontend-cache-hit");
return input;
return cached.input;
}
const equivalent =
cached === null || outputKind === "ir"
? null
: equivalentFrontendInput(cached.stale, options, exclusions);
if (equivalent !== null) {
publishFrontendCache(cache, key, equivalent.probes, equivalent.input);
timing("frontend-cache-equivalent");
return equivalent.input;
}
} catch {
key = null;
@@ -310,8 +382,8 @@ export function prepareNativeExecutable(
timing("ir-serialize");
const llvm =
outputKind === "ir"
? ""
: emitLlvmModule(prepared.mod, {
? []
: emitLlvmModuleParts(prepared.mod, {
targetTriple: toolchain.target.llvmTriple,
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
@@ -399,10 +471,10 @@ export function prepareNativeLibrary(
.sort(([a], [b]) => a.localeCompare(b)),
}),
);
const input = readFrontendCache(cache, key, exclusions);
if (input !== null) {
const cached = readFrontendCache(cache, key, exclusions);
if (cached !== null && "input" in cached) {
timing("frontend-cache-hit");
return input;
return cached.input;
}
} catch {
key = null;
@@ -427,7 +499,7 @@ export function prepareNativeLibrary(
);
});
if (!prepared.ok) return prepared;
const llvm = emitLlvmModule(prepared.mod, {
const llvm = emitLlvmModuleParts(prepared.mod, {
targetTriple: toolchain.target.llvmTriple,
pointerBits: toolchain.target.pointerBits,
wasi: toolchain.target.platform === "wasi",
+11 -1
View File
@@ -2,7 +2,7 @@ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import { textSlices, writeTextPartsSync } from "./text-file.js";
import { joinedParts, textSlices, writeTextPartsSync } from "./text-file.js";
const directories: string[] = [];
afterEach(() => {
@@ -40,3 +40,13 @@ test("a part larger than one slice is written as its UTF-8 bytes", () => {
expect(seen.join("")).toBe("head," + large + "tail");
expect(Math.max(...seen.map((slice) => slice.length))).toBeLessThanOrEqual(1024 * 1024);
});
test("small parts join into batches and a long part passes through whole", () => {
const small = Array.from({ length: 3000 }, (_, index) => `line ${index}\n`);
const long = "x".repeat(2 * 1024 * 1024);
const parts = [...small, long, "tail"];
const joined = [...joinedParts(parts)];
expect(joined.join("")).toBe(parts.join(""));
expect(joined.length).toBeLessThan(5);
expect(joined).toContain("tail");
});
+21 -3
View File
@@ -42,6 +42,24 @@ export function* textSlices(text: string, length = TEXT_WRITE_LENGTH): Generator
}
}
/** `parts` with consecutive parts joined into strings of about
* TEXT_WRITE_LENGTH UTF-16 units (a longer part passes through whole), so
* writing or escaping many small parts takes few calls. */
export function* joinedParts(parts: readonly string[]): Generator<string> {
let batch: string[] = [];
let length = 0;
for (const part of parts) {
batch.push(part);
length += part.length;
if (length >= TEXT_WRITE_LENGTH) {
yield batch.length === 1 ? batch[0]! : batch.join("");
batch = [];
length = 0;
}
}
if (batch.length > 0) yield batch.join("");
}
/** Encodes and writes `part` to `fd` in slices (`textSlices`), so a large
* part is never encoded whole. `written` sees each slice after it is
* written. */
@@ -55,8 +73,8 @@ function writeTextPart(fd: number, part: string, written?: (slice: string) => vo
}
}
/** Write text parts to `path` in order without joining them first; pass
* batched parts (`batchTextParts`) for many small ones. */
/** Write text parts to `path` in order without joining them whole: small
* parts are written in batches (`joinedParts`). */
export function writeTextPartsSync(
path: string,
parts: readonly string[],
@@ -64,7 +82,7 @@ export function writeTextPartsSync(
): void {
const fd = openSync(path, "w");
try {
for (const part of parts) writeTextPart(fd, part, written);
for (const part of joinedParts(parts)) writeTextPart(fd, part, written);
} finally {
closeSync(fd);
}
@@ -0,0 +1,55 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile } from "../src/index.js";
async function emitLlvm(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-index-product-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const source = `
function at(a: number[], x: number, y: number, k: number): number | undefined {
return a[x * y + k];
}
function cancel(a: number[], p: number, q: number, r: number, s: number): number | undefined {
return a[p * q - r * s];
}
const a = [1, 2, 3];
const v = process.argv.length;
console.log(at(a, v | 0, (v + 1) | 0, -v | 0), cancel(a, v | 0, v | 0, v | 0, v | 0));
`;
function body(llvm: string, name: string): string {
return new RegExp(`^define [^@]*@sc_\\w+?_${name}(\\.i32)?\\([^]*?^}`, "m").exec(llvm)?.[0] ?? "";
}
test("an int32 product plus an int32 indexes as an exact i64 without a round trip", async () => {
const llvm = await emitLlvm(source);
// The int32 entry: the index is the i64 product and sum, compared with
// the array's counts; the double goes only to the slow path's runtime call.
const at = /^define [^@]*@sc_bf_at\.i32\([^]*?^}/m.exec(llvm)?.[0] ?? "";
const product = /(%\S+) = mul nsw i64 %\S+, %\S+/.exec(at)?.[1];
expect(product).toBeDefined();
expect(at).toContain(`= add nsw i64 ${product}, `);
expect(at).not.toMatch(/fptosi double %\S+ to i64/);
});
test("a difference of two int32 products keeps the double's round trip", async () => {
const llvm = await emitLlvm(source);
// Each product may pass 2^53, where its double rounds before the
// difference: the index keeps the exactness test of the double.
const cancel = body(llvm, "cancel");
expect(cancel).toMatch(/fptosi double %\S+ to i64/);
expect(cancel).toMatch(/fcmp oeq double/);
});
@@ -0,0 +1,198 @@
import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { expect, test } from "vitest";
import { compile, deserializeModule } from "../src/index.js";
import type { IrModule } from "../src/ir/ir.js";
import { markInt32ArrayCandidates, selectInt32Arrays } from "../src/ir/int32-arrays.js";
async function emitLlvm(source: string): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ll");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "llvm" });
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return await readFile(outPath, "utf8");
} finally {
await rm(dir, { recursive: true, force: true });
}
}
async function lowerIr(source: string): Promise<IrModule> {
const dir = await mkdtemp(join(tmpdir(), "scriptc-int32-elements-"));
try {
const entry = join(dir, "main.ts");
const outPath = join(dir, "main.ir.json");
await writeFile(entry, source);
const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" });
expect(result.ok, JSON.stringify(result.diagnostics)).toBe(true);
return deserializeModule(await readFile(outPath, "utf8"));
} finally {
await rm(dir, { recursive: true, force: true });
}
}
const source = `
function compose(n: number, rounds: number): number {
const p: number[] = [];
const q: number[] = [];
for (let i = 0; i < n; i++) p.push((i * 7919 + 3) & (n - 1));
for (let i = 0; i < n; i++) q.push(i);
let check = 0;
for (let r = 0; r < rounds; r++) {
for (let i = 0; i < n; i++) q[i] = p[q[i]!]!;
check = (check + q[r % n]!) | 0;
}
return check;
}
function counts(n: number): number {
const c = new Array(n).fill(0) as number[];
for (let i = 0; i < n; i++) c[(i * 5) % n] = i;
while (c.length > 1) c.pop();
return c[0]!;
}
function fraction(n: number): number {
const a = [1, 2, 3];
a[1] = n / 3;
return a[1]!;
}
function negativeZero(): number {
const a = [1, -0];
return 1 / a[1]!;
}
function counter(n: number): number {
const a = new Array(n).fill(0) as number[];
for (let i = 0; i < 1000; i++) a[i % n] = a[i % n]! + 1;
return a[0]!;
}
function shortCounter(): number {
const a = new Array(16).fill(0) as number[];
for (let i = 0; i < 1000; i++) a[i % 16] = a[i % 16]! + 1;
return a[0]!;
}
function literalCounter(n: number): number {
const a = [0];
for (let i = 0; i < n; i++) a[0] = a[0]! + 1;
return a[0]!;
}
function joined(n: number): string {
const a = [n | 0, 2];
return a.join(",");
}
const { n } = JSON.parse('{"n": 8}') as { n: number };
console.log(
compose(n | 0, 3),
counts(n | 0),
fraction(n),
negativeZero(),
counter(n),
shortCounter(),
literalCounter(n),
joined(n),
);
`;
/** Every function emitted for the source function \`name\` (its int32 ABI
* body and its wrapper included). */
function functions(llvm: string, name: string): string {
const pattern = new RegExp(`^define [^@]*@sc_b?f_${name}[.\\w]*\\([^]*?^}`, "gm");
return [...llvm.matchAll(pattern)].map((m) => m[0]).join("\n");
}
test("arrays storing only int32 elements get 4-byte slots", async () => {
const llvm = await emitLlvm(source);
const compose = functions(llvm, "compose");
expect(compose).not.toBe("");
// Both arrays are allocated as SCR_ELEM_I32 (7).
expect(compose.match(/@scr_arr_new_small\(i32 7\)/g)?.length).toBe(2);
// Reads load an i32 slot; writes store one.
expect(compose).toMatch(/getelementptr inbounds i32, ptr %\w+, i64 %\w+\n\s+%\w+ = load i32/);
expect(compose).toMatch(/store i32 %\w+, ptr %\w+, !tbaa/);
// The element read through the helper's ternary stays in a stack box.
expect(compose).not.toContain("@scr_union_new_f64");
expect(compose).not.toMatch(/call [^\n]*@sc_b?f__x25_arr_idxOr/);
// The fill form and a discarded pop.
const counts = functions(llvm, "counts");
expect(counts).toMatch(/@scr_arr_new\(i32 7, i64 0\)/);
expect(counts).not.toContain("popArray");
});
test("arrays with a store that has no exact integer range, or that escape, keep doubles", async () => {
const llvm = await emitLlvm(source);
for (const name of ["fraction", "negativeZero", "joined"]) {
const fn = functions(llvm, name);
expect(fn, name).not.toBe("");
expect(fn, name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/);
expect(fn, name).not.toContain("arr.kind.");
}
});
test("a counter array keeps int32 slots with checked stores", async () => {
const llvm = await emitLlvm(source);
const counter = functions(llvm, "counter");
// `a[k] = a[k]! + 1` is an exact integer but no int32 proof: a fill of at
// least 4096 elements creates SCR_ELEM_I32 storage (SCR_ELEM_F64 for a
// shorter one), accesses test the kind, and a store widens the array
// before a value that is not an int32.
expect(counter).toMatch(
/fcmp oge double %\w+, [^\n]+\n\s+%\w+ = select i1 %\w+, i32 7, i32 0\n\s+%\w+ = call ptr @scr_arr_new(_small)?\(i32 %\w+/,
);
expect(counter).toMatch(/load i8, ptr %\w+[^\n]*\n\s+%\w+ = icmp eq i8 %\w+, 7/);
expect(counter).toContain("@llvm.fptosi.sat.i32.f64");
expect(counter).toContain("call void @scr_arr_widen_i32(");
// Arrays with proven stores take no kind test, and a fill of a constant
// short length or an array literal keeps doubles.
for (const name of ["compose", "counts", "shortCounter", "literalCounter"]) {
const fn = functions(llvm, name);
expect(fn, name).not.toContain("arr.kind.");
expect(fn, name).not.toContain("kind.fast");
expect(fn, name).not.toContain("scr_arr_widen_i32");
}
for (const name of ["shortCounter", "literalCounter"])
expect(functions(llvm, name), name).not.toMatch(/@scr_arr_new(_small)?\(i32 7/);
});
test("a loop over a counter array tests its kind once on entry", async () => {
const llvm = await emitLlvm(source);
const counter = functions(llvm, "counter");
// The loop's fast copy runs while the array holds int32 slots, with no
// kind test of its own: the sum is an integer, and a sum past the int32
// range leaves for the third copy, which re-executes the store with kind
// tests and widens the array.
expect(counter).toMatch(
/icmp eq i8 %\w+, 7\n\s+br i1 %\w+, label %kind\.fast\d+, label %kind\.wide/,
);
const rest = counter.slice(counter.search(/^kind\.fast\d+:/m));
// The fast copy is the last one, before the loop's exit.
const fast = rest.slice(0, rest.search(/^loop\.e\d+:/m));
expect(fast).not.toBe("");
expect(fast).not.toMatch(/icmp eq i8 %\w+, 7/);
expect(fast).toMatch(/add nsw i64 %\w+, 1/);
expect(fast).toMatch(
/icmp sle i64 %\w+, 2147483647\n[^\n]*\n\s+br i1 %\w+, label %arr\.store\.i32\d+, label %kind\.resume\d+/,
);
expect(fast).toMatch(/store i32 %\w+, ptr %\w+, !tbaa/);
expect(counter).toMatch(/^kind\.resume\d+:/m);
});
test("only the candidates the proof keeps are rewritten", async () => {
const mod = await lowerIr(source);
const proposed = markInt32ArrayCandidates(mod).int32ArrayCandidates ?? [];
expect(proposed).toEqual(
expect.arrayContaining(["compose\0p.0", "compose\0q.0", "counts\0c.0", "counter\0a.0"]),
);
// An array that reaches `join` is never proposed.
expect(proposed.some((key) => key.startsWith("joined\0"))).toBe(false);
// Without a kept candidate, the module is the input itself.
expect(selectInt32Arrays(mod, () => new Set())).toBe(mod);
const kept = new Set(["compose\0p.0", "compose\0q.0"]);
const selected = selectInt32Arrays(
mod,
(marked) => new Set((marked.int32ArrayCandidates ?? []).filter((key) => kept.has(key))),
);
expect(selected.int32ArrayCandidates).toEqual([...kept]);
for (const fn of selected.functions)
if (fn.name !== "compose") expect(fn).toBe(mod.functions.find((f) => f.name === fn.name));
});
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7009-finalization-registry-wasm-loader/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7037-js-var-open-writes/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7038-js-var-later-writes.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7060-array-numeric-keys.cjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7061-array-object-keys.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7062-inspect-array-properties.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7325-array-index-products-near-2-53.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7326-int32-element-arrays.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7327-int32-array-widening.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7364-new-target-late-instantiations.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7440-fromentries-aliased-entries.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7480-class-call-without-new.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7481-class-call-without-new.mjs"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7540-undefined-element-index-keys.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7560-new-target-constructors.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/corpus/7561-new-target-generic-registry.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/class-call-shadowed-static.ts"
],
"diags": []
}
@@ -1,6 +0,0 @@
{
"order": [
"<repo>/tests/diagnostics/fromentries-aliased-entries.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-late-local-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-local-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-plain-function.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-value-generic-subclass.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/diagnostics/new-target-value-signatures.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/js-own-tostring/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/jsdoc-unresolved-generic-return/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/zod3-infer/main.ts"
],
"diags": []
}
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/zod3/main.ts"
],
"diags": []
}
+86 -18
View File
@@ -176,7 +176,22 @@ static size_t scr_arr_prop_index(const ScrArr *a, const char *key,
return SIZE_MAX;
}
#define SCR_ARR_UNDEFINED_KEY_BITS UINT64_C(0x7ff8000000756e64)
double scr_arr_undefined_key(void) {
uint64_t bits = SCR_ARR_UNDEFINED_KEY_BITS;
double key;
memcpy(&key, &bits, sizeof key);
return key;
}
static size_t scr_arr_prop_key(double i, char *buf) {
uint64_t bits;
memcpy(&bits, &i, sizeof bits);
if (bits == SCR_ARR_UNDEFINED_KEY_BITS) {
memcpy(buf, "undefined", sizeof "undefined");
return sizeof "undefined" - 1;
}
return scr_f64_to_str(i, buf);
}
@@ -277,6 +292,7 @@ typedef struct {
static uint64_t scr_arr_storage_get(const ScrArrStorage *s, size_t index) {
if (s->elem == SCR_ELEM_BOOL) return ((const uint8_t *)s->data)[index];
if (s->elem == SCR_ELEM_CREF) return scr_cref_decode(((const uint32_t *)s->data)[index]);
if (s->elem == SCR_ELEM_I32) return scr_arr_f64_slot((double)((const int32_t *)s->data)[index]);
return s->data[index];
}
@@ -578,6 +594,36 @@ static void scr_arr_move_inline(ScrArr *a, size_t cap) {
a->spare &= (uint16_t)~SCR_ARR_INLINE_SLOTS;
}
/* Widen an int32 array (SCR_ELEM_I32) to doubles in place, one way: its
* dense values move to 8-byte slots, with their states when it has them, and
* its kind becomes SCR_ELEM_F64. Compiled code calls it before storing a
* value that is not an int32 into an int32 array with checked stores; `data`
* may move. Sparse entries already hold doubles. */
void scr_arr_widen_i32(ScrArr *a) {
if (a->elem != SCR_ELEM_I32) return;
size_t cap = a->cap;
if (cap > 0) {
/* Int32 arrays never shift, so they have no head room; inline slots
* have no ext prefix. */
bool inline_slots = scr_arr_inline(a);
bool states = scr_arr_has_states(a);
size_t unit = sizeof(uint64_t) + (states ? 1 : 0);
size_t prefix = inline_slots ? 0 : scr_arr_prefix(a);
if (cap > (SIZE_MAX - prefix) / unit) scr_arr_oom();
char *start = scr_mem_alloc(prefix + cap * unit);
if (!start) scr_arr_oom();
if (prefix) memcpy(start, scr_arr_alloc_start(a), prefix);
uint64_t *data = (uint64_t *)(start + prefix);
const int32_t *old = (const int32_t *)a->data;
for (size_t i = 0; i < cap; i++) data[i] = scr_arr_f64_slot((double)old[i]);
if (states) memcpy((uint8_t *)data + cap * sizeof(uint64_t), scr_arr_states_end(a) - cap, cap);
if (!inline_slots && a->rc != SIZE_MAX) scr_mem_free(scr_arr_alloc_start(a));
a->data = data;
a->spare &= (uint16_t)~SCR_ARR_INLINE_SLOTS;
}
a->elem = SCR_ELEM_F64;
}
/* How a dense allocation is sized. */
typedef enum {
SCR_ARR_GROW_APPEND, /* geometric headroom for further appends */
@@ -621,7 +667,7 @@ static void scr_arr_grow_dense_slow(ScrArr *a, size_t need, ScrArrGrow mode);
* paths pick the same capacity. */
static inline void scr_arr_grow_dense_to(ScrArr *a, size_t need, ScrArrGrow mode) {
if (need <= a->cap) return;
if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF ||
if (a->elem == SCR_ELEM_BOOL || a->elem == SCR_ELEM_CREF || a->elem == SCR_ELEM_I32 ||
(a->spare & (SCR_ARR_INLINE_SLOTS | SCR_ARR_HEAD_ROOM | SCR_ARR_HAS_EXT)) || need > SCR_ARR_DENSE_LIMIT) {
scr_arr_grow_dense_slow(a, need, mode);
return;
@@ -1904,6 +1950,10 @@ static ScrArr *scr_arr_fill_slot(ScrArr *a, uint64_t slot, uint8_t state,
if (state != SCR_ARR_VALUE) scr_arr_cut(a, from);
if (a->elem == SCR_ELEM_BOOL) memset(scr_arr_dense_at(a, from), (int)slot, until - from);
else if (a->elem == SCR_ELEM_CREF) for (size_t i = from; i < until; i++) scr_arr_dense_put(a, i, slot);
else if (a->elem == SCR_ELEM_I32) {
int32_t v = scr_arr_i32_of(slot);
for (size_t i = from; i < until; i++) ((int32_t *)a->data)[i] = v;
}
else for (size_t i = from; i < until; i++) a->data[i] = slot;
if (scr_arr_has_states(a)) memset(scr_arr_state_run(a, from, until - from), state, until - from);
} else {
@@ -2007,7 +2057,7 @@ static uint64_t scr_arr_require_slot(ScrArr *a, double i) {
if (scr_arr_prop_get_state(a, i, &prop, &state)) {
if (state == SCR_ARR_UNDEFINED) {
char buf[32];
scr_f64_to_str(i, buf);
scr_arr_prop_key(i, buf);
scr_trap_fmt("scriptc: RangeError: numeric property %s is undefined\n", buf);
}
return prop;
@@ -5116,7 +5166,15 @@ ScrStr *scr_str_raw(ScrArr *raw, ScrArr *subs) {
bool scr_arr_numeric_key(const char *key, size_t len, double *out) {
char text[64];
if (len == sizeof "undefined" - 1 && memcmp(key, "undefined", len) == 0) {
*out = scr_arr_undefined_key();
return true;
}
if (len == 0 || len >= sizeof text) return false;
/* A number's spelling starts with a digit, '-', "NaN" or "Infinity":
* other names answer without parsing. */
char first = key[0];
if (!(first >= '0' && first <= '9') && first != '-' && first != 'N' && first != 'I') return false;
memcpy(text, key, len);
text[len] = '\0';
char *end;
@@ -5130,6 +5188,11 @@ bool scr_arr_numeric_key(const char *key, size_t len, double *out) {
return true;
}
bool scr_arr_has_properties(const ScrArr *a) {
const ScrArrExt *x = scr_arr_ext_of(a);
return x && (x->prop_len || x->metadata);
}
void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) {
const ScrDyn *metadata = SCR_AX(a)->metadata;
if (metadata && metadata->kind == SCR_DYN_HANDLE && metadata->v.handle.tag == SCR_DYNH_MATCH_SOURCE &&
@@ -5137,21 +5200,7 @@ void scr_arr_copy_metadata(ScrArr *a, ScrDyn *target) {
return;
metadata = SCR_AX(a)->metadata;
if (!metadata) return;
for (size_t i = 0; i < metadata->v.obj.len; i++) {
const ScrDynEntry *entry = &metadata->v.obj.entries[i];
ScrStr *key = scr_str_new(entry->key, entry->key_len);
if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) {
scr_dyn_key_set(target, key, entry->value);
} else {
/* Properties defined through a view keep their attributes. */
ScrDyn *name = scr_dyn_new_str(key);
ScrDyn *descriptor = scr_dyn_own_descriptor(metadata, key);
if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor));
scr_dyn_release(descriptor);
scr_dyn_release(name);
}
scr_str_release(key);
}
for (size_t i = 0; i < metadata->v.obj.len; i++) scr_dyn_copy_entry(target, metadata, &metadata->v.obj.entries[i]);
}
ScrArr *scr_arr_named_keys(ScrArr *a) {
@@ -5209,8 +5258,27 @@ void scr_arr_adopt_view_properties(ScrArr *a, ScrDyn *view, ScrArr *target) {
}
ScrDyn *named = view && view->kind == SCR_DYN_ARR ? view->v.arr.properties : NULL;
if (!named || named->v.obj.len == 0) return;
/* The view's numeric properties are copies of the native ones (every
* write to them goes to the native array), so only the others move. */
bool numeric = false;
for (size_t i = 0; i < named->v.obj.len && !numeric; i++) {
double number;
numeric = scr_arr_numeric_key(named->v.obj.entries[i].key, named->v.obj.entries[i].key_len, &number);
}
if (numeric) {
ScrDyn *rest = scr_dyn_new_obj();
for (size_t i = 0; i < named->v.obj.len; i++) {
const ScrDynEntry *entry = &named->v.obj.entries[i];
double number;
if (!scr_arr_numeric_key(entry->key, entry->key_len, &number)) scr_dyn_copy_entry(rest, named, entry);
}
named = rest;
} else {
scr_dyn_retain(named);
}
ScrArrExt *x = scr_arr_ext(a);
ScrDyn *old = x->metadata;
x->metadata = scr_dyn_retain(named);
x->metadata = named->v.obj.len ? named : NULL;
if (!x->metadata) scr_dyn_release(named);
scr_dyn_release(old);
}
+57 -8
View File
@@ -48,6 +48,12 @@ typedef enum {
* address over 8, 0 for NULL. Compact instances are never counted or
* traced, so these arrays retain, release and trace nothing. */
SCR_ELEM_CREF,
/* Numbers that the compiler proves are always int32 (never -0), in
* 4-byte slots: arrays that only compiled element accesses ever reach
* (backend/llvm int32 arrays). Every runtime slot is the value's double
* bits, as for SCR_ELEM_F64: scr_arr_dense_get and scr_arr_dense_put
* convert, so the paths compiled code calls need no case of their own. */
SCR_ELEM_I32,
} ScrElemKind;
typedef enum {
@@ -134,11 +140,12 @@ typedef struct ScrArr {
* 0 is always valid. */
uint32_t filled;
/* Dense values: `cap` 8-byte slots, `cap` bytes for SCR_ELEM_BOOL or
* `cap` 4-byte slots for SCR_ELEM_CREF (scr_arr_slot_width), followed in
* the same allocation, when the array has states (SCR_ARR_STATES), by one
* state byte per slot in reverse slot order (scr_arr_states_end). Sparse
* entries and properties keep 8-byte slots for every kind; a bool slot is
* 0 or 1 wherever it lives, and a CREF slot's 8-byte form is the pointer
* `cap` 4-byte slots for SCR_ELEM_CREF and SCR_ELEM_I32
* (scr_arr_slot_width), followed in the same allocation, when the array
* has states (SCR_ARR_STATES), by one state byte per slot in reverse slot
* order (scr_arr_states_end). Sparse entries and properties keep 8-byte
* slots for every kind; a bool slot is 0 or 1 wherever it lives, a CREF
* slot's 8-byte form is the pointer and an I32 slot's the double's bits
* (scr_arr_dense_get). Rarely
* used storage (sparse indices, noncanonical numeric properties,
* match-result metadata) lives out of line, and an array that has it
@@ -260,7 +267,7 @@ static inline const ScrArrOps *scr_arr_ops(const ScrArr *a) {
/* Bytes per dense value slot of element kind `elem`. */
static inline size_t scr_elem_slot_width(uint8_t elem) {
if (elem == SCR_ELEM_BOOL) return 1;
if (__builtin_expect(elem == SCR_ELEM_CREF, 0)) return sizeof(uint32_t);
if (__builtin_expect(elem == SCR_ELEM_CREF || elem == SCR_ELEM_I32, 0)) return sizeof(uint32_t);
return sizeof(uint64_t);
}
@@ -325,11 +332,38 @@ static inline void scr_arr_need_states(ScrArr *a) {
static inline uint64_t scr_cref_decode(uint32_t ref) { return (uint64_t)ref << 3; }
static inline uint32_t scr_cref_encode(uint64_t slot) { return (uint32_t)(slot >> 3); }
/* The bits of `d` as a slot, and back. */
static inline uint64_t scr_arr_f64_slot(double d) {
uint64_t slot;
memcpy(&slot, &d, sizeof slot);
return slot;
}
static inline double scr_arr_slot_f64(uint64_t slot) {
double d;
memcpy(&d, &slot, sizeof d);
return d;
}
/* An int32 array's slot for a double: its stores are proven int32s that
* are not -0 (and a cleared slot is +0), so anything else is a compiler
* bug. */
static inline int32_t scr_arr_i32_of(uint64_t slot) {
double d = scr_arr_slot_f64(slot);
if (__builtin_expect(!(d >= -2147483648.0 && d <= 2147483647.0 && d == (double)(int32_t)d) ||
slot == UINT64_C(0x8000000000000000),
0))
__builtin_trap();
return (int32_t)d;
}
/* The dense value at `index` (< cap) as an 8-byte slot, and its store. */
static inline uint64_t scr_arr_dense_get(const ScrArr *a, size_t index) {
if (a->elem == SCR_ELEM_BOOL) return ((const uint8_t *)a->data)[index];
if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0))
return scr_cref_decode(((const uint32_t *)a->data)[index]);
if (__builtin_expect(a->elem == SCR_ELEM_I32, 0))
return scr_arr_f64_slot((double)((const int32_t *)a->data)[index]);
return a->data[index];
}
@@ -337,6 +371,8 @@ static inline void scr_arr_dense_put(ScrArr *a, size_t index, uint64_t slot) {
if (a->elem == SCR_ELEM_BOOL) ((uint8_t *)a->data)[index] = (uint8_t)slot;
else if (__builtin_expect(a->elem == SCR_ELEM_CREF, 0))
((uint32_t *)a->data)[index] = scr_cref_encode(slot);
else if (__builtin_expect(a->elem == SCR_ELEM_I32, 0))
((int32_t *)a->data)[index] = scr_arr_i32_of(slot);
else a->data[index] = slot;
}
@@ -397,6 +433,9 @@ ScrArr *scr_arr_new_ops(const ScrArrOps *ops, size_t initial_cap);
* array plus five 8-byte slots). Otherwise exactly scr_arr_new(elem, 0) and
* scr_arr_new_ops(ops, 0). Returns +1. */
ScrArr *scr_arr_new_small(ScrElemKind elem);
/* An int32 array's storage as doubles, in place (SCR_ELEM_I32 to
* SCR_ELEM_F64); no-op for any other kind. */
void scr_arr_widen_i32(ScrArr *a);
ScrArr *scr_arr_new_ops_small(const ScrArrOps *ops);
/* scr_arr_new(elem, cap) for a known final length: the `cap` slots share
* the array's own block (one allocation). With `states`, the array starts
@@ -421,9 +460,15 @@ void scr_arr_copy_metadata(ScrArr *a, struct ScrDyn *target);
* properties of `target`, so neither is lost when the storage is swapped. */
void scr_arr_adopt_view_properties(ScrArr *a, struct ScrDyn *view, ScrArr *target);
/* True when `key` is the string spelling of a number that is not an array
* index ("-1", "1.5", "NaN", "Infinity"), with the number in *out. Arrays
* keep such properties in their noncanonical property table. */
* index ("-1", "1.5", "NaN", "Infinity"), or "undefined", with the number in
* *out (scr_arr_undefined_key for "undefined"). Arrays keep such properties
* in their noncanonical property table. */
bool scr_arr_numeric_key(const char *key, size_t len, double *out);
/* The number compiled code passes as the key of an element access whose
* index is undefined at run time: a NaN with its own payload, which the
* property table spells "undefined" (JavaScript names that property, not
* "NaN"). */
double scr_arr_undefined_key(void);
/* Whether the array still has the named or noncanonical numeric property
* `key`: `for...in` skips a key deleted before its turn. Borrows both. */
bool scr_arr_has_named_key(ScrArr *a, ScrStr *key);
@@ -431,6 +476,10 @@ bool scr_arr_has_named_key(ScrArr *a, ScrStr *key);
* noncanonical numeric properties, then the named properties in insertion
* order. +1. */
ScrArr *scr_arr_named_keys(ScrArr *a);
/* Whether the array has properties besides its elements (noncanonical
* numeric keys, match metadata, named properties): util.inspect lists
* them after the elements. */
bool scr_arr_has_properties(const ScrArr *a);
/* Installed by scr_regex.c when it records a pending match source; this
* unit links without the regex runtime. Writes the pending metadata of
* `a` into its dynamic copy or materializes it, answering whether it
+6
View File
@@ -169,6 +169,7 @@ typedef enum {
X(FETCH_RESPONSE, 12) /* native static-fetch Response */ \
X(FILE, 42) /* ScrBlob with a name — new File() */ \
X(FILE_HANDLE, 17) /* fs/promises.FileHandle */ \
X(FINALIZATION_REGISTRY, 72) /* FinalizationRegistry (bookkeeping only) */ \
X(GLOBAL, 22) /* native global object identity */ \
X(H2_SESSION, 4) /* ScrH2Session — Http2Session (client & server) */ \
X(H2_STREAM, 5) /* ScrH2Stream — Http2Stream (client & server) */ \
@@ -623,6 +624,9 @@ static inline const ScrDynTypedArrayOps *scr_dyn_typed_ref_array_ops(const ScrDy
ScrDyn *scr_dyn_obj_get(const ScrDyn *d, const char *key, size_t key_len);
/* Own-enumerable check on a SCR_DYN_OBJ. */
bool scr_dyn_obj_enumerable(const ScrDyn *d, const char *key, size_t key_len);
/* Stores own property `entry` of `source` on `target` with its attributes:
* plain data properties by an ordinary write, others by their descriptor. */
void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry *entry);
/* Literal discriminator tests used before selecting a typed record layout.
* Missing fields and different primitive kinds never match. Borrowed args. */
bool scr_dyn_field_eq_str(const ScrDyn *d, const ScrStr *key, const ScrStr *value);
@@ -1083,6 +1087,8 @@ ScrDyn *scr_dyn_map_seed_entry(const ScrDyn *entry);
bool scr_weak_map_is(const ScrDyn *value);
bool scr_weak_set_is(const ScrDyn *value);
ScrDyn *scr_weak_map_new(ScrDyn *entries);
ScrDyn *scr_finalization_registry_new(ScrDyn *cleanup);
bool scr_finalization_registry_is(const ScrDyn *value);
ScrDyn *scr_weak_set_new(ScrDyn *values);
ScrDyn *scr_dyn_from_entries(ScrDyn *entries);
ScrDyn *scr_dyn_mark_snapshot(ScrDyn *value); /* consumes and returns +1 */
+4 -3
View File
@@ -1856,9 +1856,10 @@ static const char dyn_s31[] SCR_DYN_COMPAT_STR = "Set";
static const char dyn_s32[] SCR_DYN_COMPAT_STR = "";
static const char dyn_s33[] SCR_DYN_COMPAT_STR = "constructing %s%s through a stored value is not supported yet";
static const char dyn_s34[] SCR_DYN_COMPAT_STR = " from a primitive";
static const char dyn_s35[] SCR_DYN_COMPAT_STR = "FinalizationRegistry";
static const char *const dyn_global_ctor_names[] = {
dyn_s26, dyn_s29, dyn_s25, dyn_s30, dyn_s31, dyn_s21, dyn_s22, dyn_s24,
dyn_s26, dyn_s29, dyn_s25, dyn_s30, dyn_s31, dyn_s21, dyn_s22, dyn_s24, dyn_s35,
};
#define DYN_GLOBAL_CTORS (sizeof dyn_global_ctor_names / sizeof dyn_global_ctor_names[0])
static SCR_TL ScrDyn *dyn_global_ctors[DYN_GLOBAL_CTORS];
@@ -1928,8 +1929,8 @@ static ScrDyn *dyn_global_ctor(const char *name) {
}
if (!dyn_global_ctors[i]) {
/* Each constructor's own length: Object 1, Date 7, RegExp 2, Promise 1,
* the collections 0. */
uint32_t length = i == 0 || i == 5 ? 1 : i == 1 ? 7 : i == 2 ? 2 : 0;
* the collections 0, FinalizationRegistry 1. */
uint32_t length = i == 0 || i == 5 || i == 8 ? 1 : i == 1 ? 7 : i == 2 ? 2 : 0;
dyn_global_ctors[i] = scr_dyn_new_func(scr_closure_new((void *)dyn_global_ctor_names[i], 0),
dyn_global_ctor_call, length, dyn_s6,
dyn_global_ctor_names[i]);
+41 -3
View File
@@ -1161,6 +1161,15 @@ ScrStr *scr_insp_error(ScrError *e, double recurse, double depth) {
/* Property-name rendering: bare when the key matches Node's keyStrRegExp
* (/^[a-zA-Z_][a-zA-Z_0-9]*$/, '__proto__' excepted), quoted otherwise. */
/* util.inspect's text for an accessor property: [Getter], [Setter] or
* [Getter/Setter], by which of the two are functions. +1. */
static ScrStr *insp_accessor_tag(const ScrDynEntry *ent) {
bool get = ent->getter && ent->getter->kind == SCR_DYN_FUNC;
bool set = ent->setter && ent->setter->kind == SCR_DYN_FUNC;
const char *text = get && set ? "[Getter/Setter]" : get ? "[Getter]" : set ? "[Setter]" : "undefined";
return scr_str_new(text, strlen(text));
}
static void insp_key_into(InspBuf *b, const char *key, size_t len) {
if (len == 9 && memcmp(key, "__proto__", 9) == 0) {
ib_cstr(b, "['__proto__']");
@@ -2018,14 +2027,20 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) {
return scr_insp_str(d->v.str);
case SCR_DYN_ARR: {
size_t len = d->v.arr.len;
if (len == 0) return scr_str_new("[]", 2);
/* Named and noncanonical numeric properties follow the elements as
* `key: value` entries (Node's array extras). */
const ScrDyn *props = d->v.arr.properties;
size_t nprops = 0;
for (size_t i = 0; props && i < props->v.obj.len; i++) nprops += props->v.obj.entries[i].enumerable;
if (len == 0 && nprops == 0) return scr_str_new("[]", 2);
if (recurse > depth) return insp_styled_lit("[Array]", 7, INSP_SPECIAL);
scr_insp_begin(recurse + 1);
/* Node's maxArrayLength (100) counts output entries; a run of holes
* is one `<n empty items>` entry (formatSpecialArray). */
const unsigned char *presence = d->v.arr.presence;
size_t i = 0;
for (size_t shown = 0; i < len && shown < 100; shown++) {
size_t shown = 0;
for (; i < len && shown < 100; shown++) {
if (presence && !presence[i]) {
size_t next = i + 1;
while (next < len && !presence[next]) next++;
@@ -2044,15 +2059,38 @@ ScrStr *scr_insp_dyn(ScrDyn *d, double recurse, double depth) {
if (scr_exc_pending()) break;
}
bool more = i < len;
size_t entries = shown + (more ? 1 : 0);
if (more) {
ScrStr *m = scr_insp_more_items((double)(len - i));
scr_insp_entry(m, d->v.arr.items[i]->kind == SCR_DYN_NUM);
scr_str_release(m);
}
for (size_t k = 0; props && k < props->v.obj.len; k++) {
const ScrDynEntry *ent = &props->v.obj.entries[k];
if (!ent->enumerable) continue;
ScrStr *val = ent->accessor ? insp_accessor_tag(ent) : scr_insp_dyn(ent->value, recurse + 1, depth);
InspBuf eb = {0};
insp_key_into(&eb, ent->key, ent->key_len);
ib_cstr(&eb, ": ");
ib_bytes(&eb, val->data, val->len);
scr_str_release(val);
ScrStr *entry = ib_take(&eb);
/* Node's grouping pads by the array element at the entry's output
* position (value[i] in groupArrayElements), not by the entry. */
bool number = entries < len && (!presence || presence[entries]) &&
d->v.arr.items[entries]->kind == SCR_DYN_NUM;
scr_insp_entry(entry, number);
scr_str_release(entry);
entries++;
}
/* Node's grouping leaves out the last entry when there are more
* entries than maxArrayLength (the `... more items` entry when the
* array has no properties). */
bool last_out = entries > 100;
ScrStr *base = scr_str_new("", 0);
ScrStr *b0 = scr_str_new("[", 1);
ScrStr *b1 = scr_str_new("]", 1);
ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, more);
ScrStr *out = scr_insp_end(base, b0, b1, recurse + 1, true, last_out);
scr_str_release(base);
scr_str_release(b0);
scr_str_release(b1);
+241 -1
View File
@@ -3472,6 +3472,43 @@ ScrDyn *scr_dyn_class_view_unavailable(void *ptr) {
return scr_dyn_retain(scr_dyn_undefined());
}
void scr_dyn_copy_entry(ScrDyn *target, const ScrDyn *source, const ScrDynEntry *entry) {
ScrStr *key = scr_str_new(entry->key, entry->key_len);
if (!entry->accessor && entry->writable && entry->enumerable && entry->configurable) {
scr_dyn_key_set(target, key, entry->value);
} else {
/* Properties defined through a view keep their attributes. */
ScrDyn *name = scr_dyn_new_str(key);
ScrDyn *descriptor = scr_dyn_own_descriptor(source, key);
if (descriptor) scr_dyn_release(scr_dyn_define_property(target, name, descriptor));
scr_dyn_release(descriptor);
scr_dyn_release(name);
}
scr_str_release(key);
}
static ScrDyn *scr_typed_ref_numeric_property(const ScrDyn *d, double key);
/* A live array's view lists the native numeric properties first, then the
* named properties the snapshot copied from the array's metadata. */
static void scr_typed_ref_view_numeric_properties(const ScrDyn *capsule, ScrDyn *view) {
const ScrDynTypedArrayOps *ops = scr_dyn_typed_ref_array_ops(capsule);
ScrArr *arr = capsule->v.typed_ref.ptr;
const ScrArrExt *x = ops ? scr_arr_ext_of(arr) : NULL;
if (!x || x->prop_len == 0 || view->kind != SCR_DYN_ARR) return;
ScrDyn *named = view->v.arr.properties;
view->v.arr.properties = NULL;
for (size_t i = 0; i < x->prop_len; i++) {
double number;
if (!scr_arr_numeric_key(x->props[i].key, x->props[i].key_len, &number)) continue;
ScrDyn *value = scr_typed_ref_numeric_property(capsule, number);
if (value) scr_dyn_arr_named_put(view, x->props[i].key, x->props[i].key_len, value);
}
if (!named) return;
for (size_t i = 0; i < named->v.obj.len; i++) scr_dyn_copy_entry(view, named, &named->v.obj.entries[i]);
scr_dyn_release(named);
}
ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d) {
ScrDyn *capsule = (ScrDyn *)d;
ScrDyn *fresh = capsule->v.typed_ref.materialize(capsule->v.typed_ref.ptr);
@@ -3479,6 +3516,7 @@ ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d) {
scr_dyn_release(fresh);
return scr_dyn_retain(scr_dyn_undefined());
}
scr_typed_ref_view_numeric_properties(capsule, fresh);
if (!capsule->v.typed_ref.materialized) {
capsule->v.typed_ref.materialized = fresh;
} else {
@@ -3715,6 +3753,16 @@ ScrDyn *scr_dyn_typed_ref_string_view(const ScrDyn *d) {
return out;
}
/* A numeric property of a live array ("-1", "1.5"): +1, or NULL when the
* native array has none. */
static ScrDyn *scr_typed_ref_numeric_property(const ScrDyn *d, double key) {
ScrArr *arr = d->v.typed_ref.ptr;
double state = scr_arr_state(arr, key);
if (state == (double)SCR_ARR_VALUE) return scr_dyn_typed_ref_array_ops(d)->get(arr, key);
if (state == (double)SCR_ARR_UNDEFINED) return scr_dyn_retain(scr_dyn_undefined());
return NULL;
}
/* An inherited Date method of an instance of a class declared `extends
* Date`, when the instance's own members do not define `key`: +1, or
* NULL. */
@@ -3739,6 +3787,8 @@ ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) {
if (date_method) return date_method;
if (!scr_dyn_typed_ref_array_ops(d)) return NULL;
if (scr_typed_ref_is_length(key)) return scr_dyn_new_num((double)((ScrArr *)d->v.typed_ref.ptr)->len);
double number;
if (scr_arr_numeric_key(key->data, key->len, &number)) return scr_typed_ref_numeric_property(d, number);
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return NULL;
return scr_dyn_typed_ref_element(d, index);
@@ -3748,6 +3798,9 @@ ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) {
static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) {
if (!scr_dyn_typed_ref_array_ops(d)) return false;
if (scr_typed_ref_is_length(key)) return true;
double number;
if (scr_arr_numeric_key(key->data, key->len, &number))
return scr_arr_state(d->v.typed_ref.ptr, number) != (double)SCR_ARR_HOLE;
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false;
ScrArr *arr = d->v.typed_ref.ptr;
@@ -3760,6 +3813,22 @@ static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) {
static bool scr_typed_ref_array_set(ScrDyn *d, const ScrStr *key, const ScrDyn *value) {
const ScrDynTypedArrayOps *ops = scr_dyn_typed_ref_array_ops(d);
if (!ops || !ops->set) return false;
/* Numeric properties live in the native array's property table, which
* holds values of the element type. */
double number;
if (scr_arr_numeric_key(key->data, key->len, &number)) {
if (ops->set(d->v.typed_ref.ptr, number, value)) return true;
ScrStr *kind = scr_dyn_typeof(value);
ScrJsonBuf message;
scr_jb_init(&message);
scr_jb_puts(&message, "expected the array's element type at $[");
scr_jb_write(&message, key->data, key->len);
scr_jb_puts(&message, "], got ");
scr_jb_write(&message, kind->data, kind->len);
scr_str_release(kind);
scr_throw_error(SCR_ERR_TYPE, scr_jb_finish(&message));
return true;
}
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false;
ScrArr *arr = d->v.typed_ref.ptr;
@@ -3989,6 +4058,7 @@ static int scr_dyn_global_constructor_instanceof(const ScrDyn *value, const ScrD
static const struct { const char *name; int tag; } kinds[] = {
{"Date", SCR_DYNH_DATE}, {"RegExp", SCR_DYNH_REGEXP}, {"Map", SCR_DYNH_MAP}, {"Set", SCR_DYNH_SET},
{"WeakMap", SCR_DYNH_WEAK_MAP}, {"WeakSet", SCR_DYNH_WEAK_SET},
{"FinalizationRegistry", SCR_DYNH_FINALIZATION_REGISTRY},
};
if (strcmp(callee->v.fn.name, "Promise") == 0) return value->kind == SCR_DYN_PROMISE;
for (size_t i = 0; i < sizeof kinds / sizeof kinds[0]; i++) {
@@ -6707,6 +6777,21 @@ static bool scr_dyn_fn_property_fence(const ScrStr *key) {
ScrDyn *scr_dyn_define_property(ScrDyn *target, ScrDyn *key, ScrDyn *descriptor) {
if (scr_dyn_error_prototype_fence(target)) return NULL;
if (target->kind == SCR_DYN_TYPED_REF && scr_dyn_typed_ref_array_ops(target) &&
(key->kind == SCR_DYN_STR || key->kind == SCR_DYN_NUM)) {
/* A native array's numeric properties ("-1", "1.5") live in its
* property table, which holds values without attributes. A number key
* names one unless it is an array index. */
double number = key->kind == SCR_DYN_NUM ? key->v.num : 0;
bool numeric = key->kind == SCR_DYN_NUM
? !(number >= 0 && number < 4294967295.0 && number == trunc(number))
: scr_arr_numeric_key(key->v.str->data, key->v.str->len, &number);
if (numeric) {
static const char message[] = "Object.defineProperty on a numeric property of a native array is not supported yet";
scr_throw_error_msg(SCR_ERR_ERROR, message, sizeof message - 1);
return NULL;
}
}
if (target->kind == SCR_DYN_TYPED_REF) {
ScrDyn *view = scr_dyn_typed_ref_materialize(target);
ScrDyn *defined = scr_exc_pending() ? NULL : scr_dyn_define_property(view, key, descriptor);
@@ -7089,6 +7174,7 @@ ScrStr *scr_dyn_object_tag(const ScrDyn *d) {
d->v.handle.tag <= SCR_DYNH_ABORT_CONTROLLER) || d->v.handle.tag == SCR_DYNH_EVENT_TARGET ||
d->v.handle.tag == SCR_DYNH_CUSTOM_EVENT || d->v.handle.tag == SCR_DYNH_ARRAY_BUFFER || d->v.handle.tag == SCR_DYNH_SHARED_ARRAY_BUFFER ||
d->v.handle.tag == SCR_DYNH_WEAK_MAP || d->v.handle.tag == SCR_DYNH_WEAK_SET ||
d->v.handle.tag == SCR_DYNH_FINALIZATION_REGISTRY ||
d->v.handle.tag == SCR_DYNH_SET || d->v.handle.tag == SCR_DYNH_MAP || d->v.handle.tag == SCR_DYNH_REGEXP ||
d->v.handle.tag == SCR_DYNH_DATE || d->v.handle.tag == SCR_DYNH_BLOB ||
d->v.handle.tag == SCR_DYNH_FILE || d->v.handle.tag == SCR_DYNH_DISPOSABLE_STACK) {
@@ -14572,7 +14658,8 @@ static int scr_weak_key(const ScrDyn *key, void **ptr, unsigned *kind) {
*kind = key->v.fn.class_obj ? 3 : 2; return 1;
case SCR_DYN_HANDLE:
if (key->v.handle.tag != SCR_DYNH_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_SHARED_ARRAY_BUFFER && key->v.handle.tag != SCR_DYNH_WEAK_MAP &&
key->v.handle.tag != SCR_DYNH_WEAK_SET && key->v.handle.tag != SCR_DYNH_STDIO &&
key->v.handle.tag != SCR_DYNH_WEAK_SET && key->v.handle.tag != SCR_DYNH_FINALIZATION_REGISTRY &&
key->v.handle.tag != SCR_DYNH_STDIO &&
key->v.handle.tag != SCR_DYNH_FETCH_REQUEST && key->v.handle.tag != SCR_DYNH_FETCH_RESPONSE &&
key->v.handle.tag != SCR_DYNH_MAP && key->v.handle.tag != SCR_DYNH_SET &&
key->v.handle.tag != SCR_DYNH_NET_SOCKET && key->v.handle.tag != SCR_DYNH_NET_SERVER) return -1;
@@ -14751,6 +14838,159 @@ bool scr_weak_set_is(const ScrDyn *value) {
ScrDyn *scr_weak_map_new(ScrDyn *seed) { return scr_weak_collection_new(seed, false); }
ScrDyn *scr_weak_set_new(ScrDyn *seed) { return scr_weak_collection_new(seed, true); }
/* FinalizationRegistry. register() and unregister() validate and keep their
* bookkeeping as Node does; the cleanup callback never runs, which
* ECMAScript allows (an implementation need not collect a target or call
* its cleanup), so the registry keeps neither the callback nor the held
* values. The unregister tokens are keys of an internal WeakMap counting
* each token's registrations, so the registry keeps no token alive. */
typedef struct {
size_t rc;
ScrDyn *tokens; /* WeakMap: token -> registration count */
} ScrFinalizationRegistry;
static void *scr_finalization_registry_retain(void *ptr) {
((ScrFinalizationRegistry *)ptr)->rc++;
return ptr;
}
static void scr_finalization_registry_release(void *ptr) {
ScrFinalizationRegistry *registry = ptr;
if (--registry->rc) return;
/* Weak collections keyed by the registry drop their entries. */
scr_weak_dispose(registry);
scr_dyn_release(registry->tokens);
free(registry);
}
static void scr_finalization_type_error(const char *message) {
scr_throw_error_msg(SCR_ERR_TYPE, message, strlen(message));
}
/* The weak-key identity of a registration target or token, or false with
* the TypeError (or the SC2020 refusal) pending. */
static bool scr_finalization_key(const ScrDyn *value, void **ptr, unsigned *kind,
const char *invalid) {
if (value->kind == SCR_DYN_SYMBOL) {
static const char message[] = "FinalizationRegistry with symbol targets or tokens is not supported yet";
scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020");
return false;
}
int supported = scr_weak_key(value, ptr, kind);
if (supported < 0) {
static const char message[] = "FinalizationRegistry targets of this native reference type have no weak lifetime lowering";
scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020");
return false;
}
if (!supported) {
if (invalid) {
scr_finalization_type_error(invalid);
} else {
/* Node's unregister message quotes the token as String() renders it. */
ScrStr *text = scr_dyn_string_coerce(value);
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, "Invalid unregisterToken ('");
if (text) scr_jb_write(&b, text->data, text->len);
scr_jb_puts(&b, "')");
scr_str_release(text);
ScrStr *message = scr_jb_finish(&b);
scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len);
scr_str_release(message);
}
return false;
}
return true;
}
static ScrDyn *scr_finalization_registry_invoke(void *ptr, ScrDyn *self, const char *method,
ScrDyn *const *args, size_t argc, const char *what) {
(void)self;
ScrFinalizationRegistry *registry = ptr;
ScrWeakMap *tokens = registry->tokens->v.handle.ptr;
ScrDyn *first = argc > 0 ? args[0] : scr_dyn_undefined();
void *identity = NULL;
unsigned kind = 0;
if (strcmp(method, "register") == 0) {
ScrDyn *held = argc > 1 ? args[1] : scr_dyn_undefined();
ScrDyn *token = argc > 2 ? args[2] : scr_dyn_undefined();
if (!scr_finalization_key(first, &identity, &kind, "FinalizationRegistry.prototype.register: invalid target"))
return NULL;
if (scr_dyn_strict_eq(first, held)) {
scr_finalization_type_error("FinalizationRegistry.prototype.register: target and holdings must not be same");
return NULL;
}
if (token->kind != SCR_DYN_UNDEF) {
if (!scr_finalization_key(token, &identity, &kind, NULL)) return NULL;
ScrWeakEntry *entry = scr_weak_find(tokens, identity, kind);
double count = entry && entry->value->kind == SCR_DYN_NUM ? entry->value->v.num : 0;
ScrDyn *next = scr_dyn_new_num(count + 1);
bool ok = scr_weak_set(tokens, token, next);
scr_dyn_release(next);
if (!ok) return NULL;
}
return scr_dyn_retain(scr_dyn_undefined());
}
if (strcmp(method, "unregister") == 0) {
if (!scr_finalization_key(first, &identity, &kind, NULL)) return NULL;
ScrWeakEntry *entry = scr_weak_find(tokens, identity, kind);
if (entry) scr_weak_remove(entry);
return scr_dyn_new_bool(entry != NULL);
}
ScrJsonBuf b;
scr_jb_init(&b);
scr_jb_puts(&b, what ? what : method);
scr_jb_puts(&b, " is not a function");
ScrStr *message = scr_jb_finish(&b);
scr_throw_error_msg(SCR_ERR_TYPE, message->data, message->len);
scr_str_release(message);
return NULL;
}
/* A property read answers undefined for names the registry does not have.
* Its methods read as values (`const register = registry.register`) are
* refused, as a weak collection's are. */
static ScrDyn *scr_finalization_registry_get(void *ptr, const char *key, size_t len) {
(void)ptr;
if ((len == 8 && memcmp(key, "register", 8) == 0) || (len == 10 && memcmp(key, "unregister", 10) == 0)) {
static const char message[] = "FinalizationRegistry method values have no native lowering";
scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020");
}
return NULL;
}
static ScrDyn *scr_finalization_registry_view(void *ptr) {
(void)ptr;
return scr_dyn_new_obj();
}
ScrDyn *scr_finalization_registry_new(ScrDyn *cleanup) {
if (!scr_dyn_is_callable(cleanup)) {
scr_finalization_type_error("FinalizationRegistry: cleanup must be callable");
return NULL;
}
static const ScrDynHandleOps ops = {
.cls = "FinalizationRegistry",
.retain = &scr_finalization_registry_retain,
.release = &scr_finalization_registry_release,
.invoke = &scr_finalization_registry_invoke,
.get = &scr_finalization_registry_get,
.inspect_view = &scr_finalization_registry_view,
.inspect_view_empty = true,
};
scr_dyn_handle_install(SCR_DYNH_FINALIZATION_REGISTRY, &ops);
ScrFinalizationRegistry *registry = calloc(1, sizeof *registry);
if (!registry) scr_json_oom();
registry->tokens = scr_weak_collection_new(scr_dyn_undefined(), false);
if (!registry->tokens) { free(registry); return NULL; }
ScrDyn *result = scr_dyn_new_handle(registry, SCR_DYNH_FINALIZATION_REGISTRY);
return result;
}
bool scr_finalization_registry_is(const ScrDyn *value) {
return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_FINALIZATION_REGISTRY;
}
ScrDyn *scr_dyn_from_entries(ScrDyn *seed) {
if (seed->kind == SCR_DYN_UNDEF || seed->kind == SCR_DYN_NULL) {
static const char message[] = "undefined is not iterable";
+232 -64
View File
@@ -1,6 +1,6 @@
/* The Linux backend of the platform readiness contract (scr_platform.h):
* epoll for fd readiness, one timerfd per armed one-shot timer. This is
* the LIVE Linux poller behind scr_net.c and scr_dgram.c (native-toolchain.ts links it
* epoll for fd readiness, one timerfd for every armed one-shot timer. This
* is the LIVE Linux poller behind scr_net.c and scr_dgram.c (native-toolchain.ts links it
* alongside scr_loop_kqueue.c whenever those units compile; each TU is
* empty off its platform). Child-exit wakeups are NOT here: scr_child.c
* keeps its narrow seam inline (kqueue EVFILT_PROC on BSD, pidfd_open +
@@ -9,18 +9,22 @@
*
* Design notes:
* - Interest is keyed by fd with ONE mask under epoll, so the poller keeps
* a linear fd -> (mask, udata) table and merges read/write interest into
* EPOLL_CTL_MOD. Linear scan is fine at the units' scale (dozens of fds,
* drained in 64-event batches today); swap for a hash if profiles say so.
* an fd -> (mask, udata) table (indexed by fd number) and merges
* read/write interest into EPOLL_CTL_MOD.
* - EPOLLHUP/EPOLLERR/EPOLLRDHUP report as READABLE (plus WRITABLE when
* write interest is armed): the units learn EOF/errors from read()==0 or
* errno exactly as they do from kqueue's EV_EOF today. Note an fd is in
* the epoll set ONLY while some interest is armed, so a consumer-less
* socket can never busy-report HUP.
* - Timers: EVFILT_TIMER has no epoll analogue, so each armed key owns a
* TFD_NONBLOCK timerfd registered EPOLLIN in the same epoll set, tagged
* through the same table (mask=TIMER). Fired timers are disarmed and
* their timerfd closed at delivery (kqueue's EV_ONESHOT semantics).
* - Timers: EVFILT_TIMER has no epoll analogue. Armed keys live in a
* deadline min-heap (a pointer hash finds a key's slot), and ONE timerfd
* in the epoll set wakes the loop at the earliest deadline. Re-arming,
* arming a later deadline and cancelling make no system call: the
* timerfd is reprogrammed only when a deadline earlier than its own
* arrives, or when it fires early (the heap then holds later deadlines).
* A server's per-request idle-timer churn therefore costs nothing in the
* kernel, like libuv's timer heap. Expired timers are delivered (and
* disarmed: kqueue's EV_ONESHOT semantics) by the next drain.
* - Units must scrp_forget a watched fd before close(2) (the contract's
* epoll obligation). As a second line of defense against a missed
* forget followed by fd-number reuse, watch self-heals: a MOD that
@@ -32,47 +36,75 @@
#include "scr_platform.h"
#include <errno.h>
#include <math.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <sys/epoll.h>
#include <sys/timerfd.h>
#include <time.h>
#include <unistd.h>
/* ── fd/timer poller ─────────────────────────────────────────────────── */
typedef struct {
int fd; /* watched fd, or the timer's timerfd */
unsigned mask; /* SCRP_READABLE|SCRP_WRITABLE, or SCRP_TIMER */
void *udata; /* delivery tag; for timers: the arm-time udata */
void *timer_key; /* timers only: the caller's key */
int fd; /* watched fd */
unsigned mask; /* SCRP_READABLE|SCRP_WRITABLE */
void *udata; /* delivery tag */
} ScrpEntry;
typedef struct {
double deadline; /* CLOCK_MONOTONIC milliseconds */
uint64_t seq; /* arm order: equal deadlines fire first-armed first */
void *key;
void *udata;
} ScrpTimer;
typedef struct {
void *key;
size_t idx; /* the key's heap index */
} ScrpKeySlot;
struct ScrPoller {
int ep;
ScrpEntry *entries;
size_t n, cap;
size_t *slot_of_fd; /* fd -> entry index + 1 (0: unwatched) */
size_t fd_cap;
int tfd; /* the shared timerfd (-1 until a timer arms) */
double tfd_deadline; /* what tfd is programmed for; INFINITY: disarmed */
ScrpTimer *heap;
size_t nt, heap_cap;
ScrpKeySlot *key_slots; /* open addressing over keys (key NULL: empty) */
size_t key_cap; /* power of two */
uint64_t seq;
};
static ScrpEntry *scrp_find_fd(ScrPoller *p, int fd) {
for (size_t i = 0; i < p->n; i++)
if (p->entries[i].fd == fd) return &p->entries[i];
return NULL;
}
static ScrpEntry *scrp_find_key(ScrPoller *p, void *key) {
for (size_t i = 0; i < p->n; i++)
if ((p->entries[i].mask & SCRP_TIMER) != 0 && p->entries[i].timer_key == key) return &p->entries[i];
return NULL;
if (fd < 0 || (size_t)fd >= p->fd_cap || p->slot_of_fd[fd] == 0) return NULL;
return &p->entries[p->slot_of_fd[fd] - 1];
}
static void scrp_remove(ScrPoller *p, ScrpEntry *e) {
size_t i = (size_t)(e - p->entries);
p->entries[i] = p->entries[p->n - 1];
p->slot_of_fd[e->fd] = 0;
p->n--;
if (i != p->n) {
p->entries[i] = p->entries[p->n];
p->slot_of_fd[p->entries[i].fd] = i + 1;
}
}
static bool scrp_push(ScrPoller *p, ScrpEntry e) {
if ((size_t)e.fd >= p->fd_cap) {
size_t cap = p->fd_cap == 0 ? 64 : p->fd_cap;
while (cap <= (size_t)e.fd) cap *= 2;
size_t *grown = realloc(p->slot_of_fd, cap * sizeof *grown);
if (grown == NULL) return false;
memset(grown + p->fd_cap, 0, (cap - p->fd_cap) * sizeof *grown);
p->slot_of_fd = grown;
p->fd_cap = cap;
}
if (p->n == p->cap) {
size_t cap = p->cap == 0 ? 16 : p->cap * 2;
ScrpEntry *grown = realloc(p->entries, cap * sizeof *grown);
@@ -81,6 +113,117 @@ static bool scrp_push(ScrPoller *p, ScrpEntry e) {
p->cap = cap;
}
p->entries[p->n++] = e;
p->slot_of_fd[e.fd] = p->n;
return true;
}
/* ── the timer heap ── */
static double scrp_now_ms(void) {
struct timespec ts;
clock_gettime(CLOCK_MONOTONIC, &ts);
return (double)ts.tv_sec * 1000.0 + (double)ts.tv_nsec / 1e6;
}
static size_t scrp_key_hash(const ScrPoller *p, const void *key) {
uintptr_t h = (uintptr_t)key;
h ^= h >> 17;
h *= (uintptr_t)0x9E3779B97F4A7C15ull;
h ^= h >> 29;
return (size_t)h & (p->key_cap - 1);
}
/* The hash slot holding `key`, or the empty slot where it would go. */
static size_t scrp_key_probe(const ScrPoller *p, const void *key) {
size_t i = scrp_key_hash(p, key);
while (p->key_slots[i].key != NULL && p->key_slots[i].key != key)
i = (i + 1) & (p->key_cap - 1);
return i;
}
static ScrpKeySlot *scrp_key_find(const ScrPoller *p, const void *key) {
if (p->key_cap == 0) return NULL;
size_t i = scrp_key_probe(p, key);
return p->key_slots[i].key != NULL ? &p->key_slots[i] : NULL;
}
/* Backward-shift deletion keeps linear probing tombstone-free. */
static void scrp_key_erase(ScrPoller *p, size_t i) {
size_t mask = p->key_cap - 1;
p->key_slots[i].key = NULL;
for (size_t j = (i + 1) & mask; p->key_slots[j].key != NULL; j = (j + 1) & mask) {
size_t home = scrp_key_hash(p, p->key_slots[j].key);
/* move j into the hole when its home is not cyclically within (i, j] */
if (((j - home) & mask) >= ((j - i) & mask)) {
p->key_slots[i] = p->key_slots[j];
p->key_slots[j].key = NULL;
i = j;
}
}
}
static bool scrp_key_grow(ScrPoller *p) {
size_t cap = p->key_cap == 0 ? 64 : p->key_cap * 2;
ScrpKeySlot *slots = calloc(cap, sizeof *slots);
if (slots == NULL) return false;
free(p->key_slots);
p->key_slots = slots;
p->key_cap = cap;
for (size_t h = 0; h < p->nt; h++)
p->key_slots[scrp_key_probe(p, p->heap[h].key)] = (ScrpKeySlot){p->heap[h].key, h};
return true;
}
static bool scrp_timer_less(const ScrpTimer *a, const ScrpTimer *b) {
return a->deadline < b->deadline || (a->deadline == b->deadline && a->seq < b->seq);
}
/* Place `t` at heap index `i` and record it in the key table. */
static void scrp_heap_put(ScrPoller *p, size_t i, ScrpTimer t) {
p->heap[i] = t;
scrp_key_find(p, t.key)->idx = i;
}
static void scrp_heap_fix(ScrPoller *p, size_t i) {
ScrpTimer t = p->heap[i];
while (i > 0) {
size_t parent = (i - 1) / 2;
if (!scrp_timer_less(&t, &p->heap[parent])) break;
scrp_heap_put(p, i, p->heap[parent]);
i = parent;
}
for (;;) {
size_t child = 2 * i + 1;
if (child >= p->nt) break;
if (child + 1 < p->nt && scrp_timer_less(&p->heap[child + 1], &p->heap[child])) child++;
if (!scrp_timer_less(&p->heap[child], &t)) break;
scrp_heap_put(p, i, p->heap[child]);
i = child;
}
scrp_heap_put(p, i, t);
}
static void scrp_heap_remove(ScrPoller *p, size_t i) {
scrp_key_erase(p, scrp_key_probe(p, p->heap[i].key));
p->nt--;
if (i == p->nt) return;
p->heap[i] = p->heap[p->nt];
scrp_heap_fix(p, i);
}
/* Program the shared timerfd for `deadline` (INFINITY disarms). */
static bool scrp_tfd_program(ScrPoller *p, double deadline) {
struct itimerspec its;
memset(&its, 0, sizeof its);
if (deadline != INFINITY) {
if (deadline < 0) deadline = 0;
its.it_value.tv_sec = (time_t)(deadline / 1000.0);
its.it_value.tv_nsec = (long)((deadline - (double)its.it_value.tv_sec * 1000.0) * 1e6);
if (its.it_value.tv_nsec >= 1000000000L) its.it_value.tv_nsec = 999999999L;
if (its.it_value.tv_sec == 0 && its.it_value.tv_nsec == 0) its.it_value.tv_nsec = 1; /* 0 disarms */
}
if (timerfd_settime(p->tfd, TFD_TIMER_ABSTIME, &its, NULL) != 0) return false;
p->tfd_deadline = deadline;
return true;
}
@@ -92,18 +235,21 @@ ScrPoller *scrp_poller_new(void) {
free(p);
return NULL;
}
p->tfd = -1;
p->tfd_deadline = INFINITY;
return p;
}
void scrp_poller_free(ScrPoller *p) {
if (p == NULL) return;
for (size_t i = 0; i < p->n; i++)
if ((p->entries[i].mask & SCRP_TIMER) != 0) close(p->entries[i].fd);
if (p->tfd >= 0) close(p->tfd);
close(p->ep);
free(p->entries);
free(p->slot_of_fd);
free(p->heap);
free(p->key_slots);
free(p);
}
int scrp_poller_fd(const ScrPoller *p) { return p->ep; }
static uint32_t scrp_epoll_mask(unsigned mask) {
@@ -124,7 +270,7 @@ static bool scrp_watch(ScrPoller *p, int fd, void *udata, bool on, unsigned bit)
struct epoll_event ev = {.events = scrp_epoll_mask(next), .data = {.fd = fd}};
if (e == NULL) {
if (next == 0) return true; /* unwatch of an unwatched fd: no-op */
if (!scrp_push(p, (ScrpEntry){fd, next, udata, NULL})) return false;
if (!scrp_push(p, (ScrpEntry){fd, next, udata})) return false;
int rc = epoll_ctl(p->ep, EPOLL_CTL_ADD, fd, &ev);
if (rc != 0 && errno == EEXIST) rc = epoll_ctl(p->ep, EPOLL_CTL_MOD, fd, &ev);
if (rc != 0) {
@@ -163,41 +309,51 @@ void scrp_forget(ScrPoller *p, int fd) {
bool scrp_timer_arm(ScrPoller *p, void *key, double ms, void *udata) {
if (!(ms >= 0)) ms = 0;
ScrpEntry *e = scrp_find_key(p, key);
int tfd = e != NULL ? e->fd : timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
if (tfd < 0) return false;
struct itimerspec its;
memset(&its, 0, sizeof its);
its.it_value.tv_sec = (time_t)(ms / 1000.0);
its.it_value.tv_nsec = (long)((ms - (double)its.it_value.tv_sec * 1000.0) * 1e6);
if (its.it_value.tv_sec == 0 && its.it_value.tv_nsec == 0) its.it_value.tv_nsec = 1; /* 0 disarms timerfd */
if (timerfd_settime(tfd, 0, &its, NULL) != 0) {
if (e == NULL) close(tfd);
return false;
if (p->tfd < 0) {
int tfd = timerfd_create(CLOCK_MONOTONIC, TFD_NONBLOCK | TFD_CLOEXEC);
if (tfd < 0) return false;
struct epoll_event ev = {.events = EPOLLIN, .data = {.fd = tfd}};
if (epoll_ctl(p->ep, EPOLL_CTL_ADD, tfd, &ev) != 0) {
close(tfd);
return false;
}
p->tfd = tfd;
}
if (e != NULL) {
e->udata = udata;
return true; /* re-arm replaces the deadline */
ScrpTimer t = {scrp_now_ms() + ms, p->seq++, key, udata};
ScrpKeySlot *slot = scrp_key_find(p, key);
if (slot != NULL) {
/* re-arm replaces the deadline */
size_t i = slot->idx;
p->heap[i] = t;
scrp_heap_fix(p, i);
} else {
if (p->nt == p->heap_cap) {
size_t cap = p->heap_cap == 0 ? 16 : p->heap_cap * 2;
ScrpTimer *grown = realloc(p->heap, cap * sizeof *grown);
if (grown == NULL) return false;
p->heap = grown;
p->heap_cap = cap;
}
if ((p->nt + 1) * 2 > p->key_cap && !scrp_key_grow(p)) return false;
p->heap[p->nt] = t;
p->key_slots[scrp_key_probe(p, key)] = (ScrpKeySlot){key, p->nt};
p->nt++;
scrp_heap_fix(p, p->nt - 1);
}
if (!scrp_push(p, (ScrpEntry){tfd, SCRP_TIMER, udata, key})) {
close(tfd);
return false;
}
struct epoll_event ev = {.events = EPOLLIN, .data = {.fd = tfd}};
if (epoll_ctl(p->ep, EPOLL_CTL_ADD, tfd, &ev) != 0) {
scrp_remove(p, &p->entries[p->n - 1]);
close(tfd);
/* Only an earlier deadline needs the kernel: a later one is found when
* the timerfd fires early. */
if (p->heap[0].deadline < p->tfd_deadline && !scrp_tfd_program(p, p->heap[0].deadline)) {
scrp_heap_remove(p, scrp_key_find(p, key)->idx);
return false;
}
return true;
}
void scrp_timer_cancel(ScrPoller *p, void *key) {
ScrpEntry *e = scrp_find_key(p, key);
if (e == NULL) return; /* unarmed or already fired: no-op */
(void)epoll_ctl(p->ep, EPOLL_CTL_DEL, e->fd, NULL);
close(e->fd);
scrp_remove(p, e);
ScrpKeySlot *slot = scrp_key_find(p, key);
if (slot == NULL) return; /* unarmed or already fired: no-op */
/* the timerfd keeps its deadline; an early wake finds nothing due */
scrp_heap_remove(p, slot->idx);
}
int scrp_drain(ScrPoller *p, ScrPollerEvent *out, int max) {
@@ -205,20 +361,17 @@ int scrp_drain(ScrPoller *p, ScrPollerEvent *out, int max) {
struct epoll_event evs[64];
int want = max < 64 ? max : 64;
int n = epoll_wait(p->ep, evs, want, 0);
if (n <= 0) return 0; /* EINTR/none: a spurious pass */
int filled = 0;
bool timer_woke = false;
for (int i = 0; i < n; i++) {
ScrpEntry *e = scrp_find_fd(p, evs[i].data.fd);
if (e == NULL) continue; /* raced with forget */
if ((e->mask & SCRP_TIMER) != 0) {
out[filled].udata = e->udata;
out[filled++].events = SCRP_TIMER;
/* one-shot: disarm at delivery, EV_ONESHOT semantics */
(void)epoll_ctl(p->ep, EPOLL_CTL_DEL, e->fd, NULL);
close(e->fd);
scrp_remove(p, e);
if (evs[i].data.fd == p->tfd && p->tfd >= 0) {
uint64_t ticks;
(void)!read(p->tfd, &ticks, sizeof ticks);
timer_woke = true;
continue;
}
ScrpEntry *e = scrp_find_fd(p, evs[i].data.fd);
if (e == NULL) continue; /* raced with forget */
unsigned got = 0;
uint32_t ep = evs[i].events;
if ((ep & (EPOLLIN | EPOLLHUP | EPOLLERR | EPOLLRDHUP)) != 0) got |= SCRP_READABLE;
@@ -229,6 +382,21 @@ int scrp_drain(ScrPoller *p, ScrPollerEvent *out, int max) {
out[filled].udata = e->udata;
out[filled++].events = got;
}
/* The timerfd always holds a deadline no later than the heap's earliest
* (arming an earlier one reprograms it), so a timer can only be due
* after it fires. */
if (timer_woke) {
p->tfd_deadline = INFINITY; /* one-shot: it fired */
/* deliver every expired timer the batch has room for, one-shot */
double now = scrp_now_ms();
while (p->nt > 0 && filled < max && p->heap[0].deadline <= now) {
out[filled].udata = p->heap[0].udata;
out[filled++].events = SCRP_TIMER;
scrp_heap_remove(p, 0);
}
/* the next deadline (one already past fires at once) */
if (p->nt > 0) (void)scrp_tfd_program(p, p->heap[0].deadline);
}
return filled;
}

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