mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-21 05:50:55 +08:00
Merge remote-tracking branch 'origin/main' into HEAD
# Conflicts: # packages/compiler/test/ts7/baselines/order-parity.json
This commit is contained in:
@@ -24,6 +24,7 @@ RUN apt-get update \
|
||||
curl \
|
||||
git \
|
||||
libclang-rt-22-dev \
|
||||
libpolly-22-dev \
|
||||
libzstd-dev \
|
||||
llvm-22-dev \
|
||||
ninja-build \
|
||||
|
||||
@@ -90,7 +90,7 @@ test("removes an entire acyclic dependency chain to a fixed point", () => {
|
||||
check(mod, []);
|
||||
});
|
||||
|
||||
test("retains mutual cycles and every shape that can contain them", () => {
|
||||
test("retains mutual cycles but not the shapes that merely hold them", () => {
|
||||
const mod = module();
|
||||
mod.records = [
|
||||
shape("outer", [ref("a")]),
|
||||
@@ -98,6 +98,19 @@ test("retains mutual cycles and every shape that can contain them", () => {
|
||||
shape("b", [ref("a")]),
|
||||
shape("leaf", [F64]),
|
||||
];
|
||||
// Nothing in the a/b cycle can reach `outer`: its reference is an
|
||||
// external count and it dies by reference counting alone.
|
||||
check(mod, ["record:a", "record:b"]);
|
||||
});
|
||||
|
||||
test("a holder that reaches an intrinsic reference stays traced", () => {
|
||||
const mod = module();
|
||||
mod.records = [
|
||||
shape("outer", [ref("a")]),
|
||||
shape("a", [ref("b")]),
|
||||
shape("b", [ref("a"), funcOf([], F64)]),
|
||||
];
|
||||
// A closure can capture anything, including `outer`.
|
||||
check(mod, ["record:outer", "record:a", "record:b"]);
|
||||
});
|
||||
|
||||
@@ -182,7 +195,7 @@ test("map keys and set elements can close cycles without reference values", () =
|
||||
shape("leaf", [F64]),
|
||||
shape("acyclic", [mapOf(ref("leaf"), STRING), setOf(ref("leaf"))]),
|
||||
];
|
||||
check(mod, ["record:key", "record:member", "record:outer"]);
|
||||
check(mod, ["record:key", "record:member"]);
|
||||
});
|
||||
|
||||
test("boxed keys and nested array elements retain cycle capability", () => {
|
||||
@@ -247,7 +260,7 @@ test("removing one dependency keeps other branches, cycles, and intrinsic refere
|
||||
shape("intrinsic", [ref("leaf"), { kind: "caught" }]),
|
||||
shape("pruned", [mapOf(ref("leaf"), arrayOf(ref("leaf")))]),
|
||||
];
|
||||
check(mod, ["record:outer", "record:cycle", "record:intrinsic"]);
|
||||
check(mod, ["record:cycle", "record:intrinsic"]);
|
||||
});
|
||||
|
||||
test("hierarchies and unions participate in the same dependency graph", () => {
|
||||
@@ -262,12 +275,32 @@ test("hierarchies and unions participate in the same dependency graph", () => {
|
||||
{ name: "Sibling", base: "Base", fields: [], loc },
|
||||
{ name: "Base", fields: [], loc },
|
||||
];
|
||||
mod.unions = [{ id: "link", arms: [{ kind: "object", className: "Sibling" }, F64] }];
|
||||
// A Base-typed arm can hold a Child, which holds the union again.
|
||||
mod.unions = [{ id: "link", arms: [{ kind: "object", className: "Base" }, F64] }];
|
||||
check(mod, ["object:Child", "object:Sibling", "object:Base"], ["link"]);
|
||||
// A Sibling-typed arm can only hold a Sibling, which holds nothing.
|
||||
mod.unions[0]!.arms = [{ kind: "object", className: "Sibling" }, F64];
|
||||
check(mod, []);
|
||||
mod.unions[0]!.arms = [F64, STRING];
|
||||
check(mod, []);
|
||||
});
|
||||
|
||||
test("a slot reaches the subtree of its static class, not the whole hierarchy", () => {
|
||||
const mod = module();
|
||||
const base: IrType = { kind: "object", className: "Base" };
|
||||
const other: IrType = { kind: "object", className: "Other" };
|
||||
mod.classes = [
|
||||
{ name: "Base", fields: [], loc },
|
||||
{ name: "Leaf", base: "Base", fields: [{ name: "callback", type: funcOf([], VOID) }], loc },
|
||||
{ name: "Other", base: "Base", fields: [], loc },
|
||||
{ name: "HoldsOther", fields: [{ name: "other", type: other }], loc },
|
||||
{ name: "HoldsBase", fields: [{ name: "base", type: base }], loc },
|
||||
];
|
||||
// Leaf's closure headers the whole hierarchy, but an Other-typed slot
|
||||
// can never hold a Leaf.
|
||||
check(mod, ["object:Base", "object:Leaf", "object:Other", "object:HoldsBase"]);
|
||||
});
|
||||
|
||||
test("local class captures retain tracing even when the class has no fields", () => {
|
||||
const mod = module();
|
||||
mod.classes = [{ name: "Local", localCaptures: [], fields: [], loc }];
|
||||
@@ -391,20 +424,30 @@ test("field increments count as writes", () => {
|
||||
checkExact(mod, ["object:Tree"], ["link"]);
|
||||
});
|
||||
|
||||
test("this escaping from the constructor makes the hierarchy mutable", () => {
|
||||
/** tree(), with `escape` placed before the field stores. */
|
||||
function escapingTree(escape: IrStmt): IrModule {
|
||||
const mod = tree();
|
||||
mod.functions[1]!.body.unshift(escape);
|
||||
return mod;
|
||||
}
|
||||
|
||||
test("stores after this escapes from the constructor are writes", () => {
|
||||
const escape: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "call", callee: "register", args: [thisRef("Tree")], type: VOID, loc },
|
||||
loc,
|
||||
};
|
||||
checkExact(tree([escape]), ["object:Tree"], ["link"]);
|
||||
checkExact(escapingTree(escape), ["object:Tree"], ["link"]);
|
||||
// Stores that complete before the escape stay construction-only.
|
||||
checkExact(tree([escape]), []);
|
||||
const capture: IrStmt = {
|
||||
kind: "varDecl",
|
||||
localId: "f.0",
|
||||
init: { kind: "closure", fnName: "%fn0", captures: ["this.0"], type: funcOf([], F64), loc },
|
||||
loc,
|
||||
};
|
||||
checkExact(tree([capture]), ["object:Tree"], ["link"]);
|
||||
checkExact(escapingTree(capture), ["object:Tree"], ["link"]);
|
||||
checkExact(tree([capture]), []);
|
||||
const self: IrStmt = setThis("Tree", "left", link);
|
||||
(self as { value: IrExpr }).value = {
|
||||
kind: "unionWrap",
|
||||
@@ -415,6 +458,59 @@ test("this escaping from the constructor makes the hierarchy mutable", () => {
|
||||
loc,
|
||||
};
|
||||
checkExact(tree([self]), ["object:Tree"], ["link"]);
|
||||
// A compound assignment's receiver temporary is the same `this`.
|
||||
const alias = tree();
|
||||
alias.functions[1]!.locals.push({ id: "r.0", name: "r", type: obj("Tree"), mutable: false });
|
||||
alias.functions[1]!.body.push(
|
||||
{ kind: "varDecl", localId: "r.0", init: thisRef("Tree"), loc },
|
||||
{
|
||||
kind: "fieldSet",
|
||||
obj: { kind: "varRef", localId: "r.0", type: obj("Tree"), loc },
|
||||
className: "Tree",
|
||||
field: "left",
|
||||
value: value(link),
|
||||
loc,
|
||||
},
|
||||
);
|
||||
checkExact(alias, []);
|
||||
});
|
||||
|
||||
test("a subclass constructor runs after its base constructor's escape", () => {
|
||||
const mod = module();
|
||||
mod.classes = [
|
||||
{ name: "Base", fields: [{ name: "next", type: link }], loc },
|
||||
{
|
||||
name: "Sub",
|
||||
base: "Base",
|
||||
fields: [
|
||||
{ name: "next", type: link },
|
||||
{ name: "other", type: link },
|
||||
],
|
||||
loc,
|
||||
},
|
||||
];
|
||||
mod.unions = [{ id: "link", arms: [obj("Base"), { kind: "nullT" }] }];
|
||||
const superCall: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: {
|
||||
kind: "call",
|
||||
callee: "%Base.constructor",
|
||||
args: [{ kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc }],
|
||||
type: VOID,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
};
|
||||
const register: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "call", callee: "register", args: [thisRef("Base")], type: VOID, loc },
|
||||
loc,
|
||||
};
|
||||
mod.functions.push(
|
||||
ctor("Base", [setThis("Base", "next", link), register]),
|
||||
ctor("Sub", [superCall, setThis("Sub", "other", link)]),
|
||||
);
|
||||
checkExact(mod, ["object:Base", "object:Sub"], ["link"]);
|
||||
});
|
||||
|
||||
test("field reads of this inside the constructor do not escape", () => {
|
||||
@@ -494,8 +590,9 @@ test("derived constructors may initialize through super but not escape", () => {
|
||||
checkExact(method, ["object:Base", "object:Sub"], ["link"]);
|
||||
});
|
||||
|
||||
test("an immutable edge into a mutable cycle stays traced", () => {
|
||||
test("a holder of a mutable cycle needs no tracing", () => {
|
||||
// X.holder is constructor-only; Holder.x is rewritten later: x -> h -> x.
|
||||
// Wrapper holds X, but nothing reachable from X can lead back to it.
|
||||
const mod = module();
|
||||
mod.classes = [
|
||||
{ name: "X", fields: [{ name: "holder", type: obj("Holder") }], loc },
|
||||
@@ -512,7 +609,7 @@ test("an immutable edge into a mutable cycle stays traced", () => {
|
||||
value: value({ kind: "union", unionId: "maybeX" }),
|
||||
loc,
|
||||
});
|
||||
checkExact(mod, ["object:X", "object:Holder", "object:Wrapper"], ["maybeX"]);
|
||||
checkExact(mod, ["object:X", "object:Holder"], ["maybeX"]);
|
||||
});
|
||||
|
||||
test("collection edges are mutable even behind constructor-only fields", () => {
|
||||
@@ -522,25 +619,64 @@ test("collection edges are mutable even behind constructor-only fields", () => {
|
||||
checkExact(mod, ["object:Call"]);
|
||||
});
|
||||
|
||||
/** A runtime store into a dynamic value under a computed key. */
|
||||
const dynamicStore: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: {
|
||||
kind: "libCall",
|
||||
fn: "dyn.keySet",
|
||||
args: [value(DYN), value(STRING), value(DYN)],
|
||||
type: VOID,
|
||||
loc,
|
||||
},
|
||||
loc,
|
||||
};
|
||||
|
||||
test("without a dynamic store, crossing into checked-dynamic code writes nothing", () => {
|
||||
const toDyn: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "dynFrom", value: value(obj("Tree")), type: DYN, loc },
|
||||
loc,
|
||||
};
|
||||
checkExact(tree([toDyn]), []);
|
||||
checkExact(tree([{ kind: "throw", value: value(link), loc }]), []);
|
||||
// Publishing only marks objects immortal; FFI and other intrinsics count.
|
||||
const intrinsic = (name: "threads.publish" | "console.log"): IrStmt => ({
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "intrinsic", name, args: [value(obj("Tree"))], type: VOID, loc },
|
||||
loc,
|
||||
});
|
||||
checkExact(tree([intrinsic("threads.publish")]), []);
|
||||
checkExact(tree([intrinsic("console.log")]), ["object:Tree"], ["link"]);
|
||||
});
|
||||
|
||||
test("hidden property bags hold nothing without a dynamic store", () => {
|
||||
const mod = tree();
|
||||
for (const c of mod.classes!) c.fields.push({ name: "%dynProperties", type: DYN });
|
||||
checkExact(mod, []);
|
||||
mod.functions[0]!.body.push(dynamicStore);
|
||||
checkExact(mod, ["object:Tree"], ["link"]);
|
||||
});
|
||||
|
||||
test("types that can become checked-dynamic capsules are mutable", () => {
|
||||
const toDyn: IrStmt = {
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "dynFrom", value: value(obj("Tree")), type: DYN, loc },
|
||||
loc,
|
||||
};
|
||||
checkExact(tree([toDyn]), ["object:Tree"], ["link"]);
|
||||
checkExact(tree([toDyn, dynamicStore]), ["object:Tree"], ["link"]);
|
||||
const thrown: IrStmt = { kind: "throw", value: value(link), loc };
|
||||
checkExact(tree([thrown]), ["object:Tree"], ["link"]);
|
||||
checkExact(tree([thrown, dynamicStore]), ["object:Tree"], ["link"]);
|
||||
// Reached only through a boxed closure signature and a record field.
|
||||
const holder = tree();
|
||||
const holder = tree([dynamicStore]);
|
||||
holder.records = [shape("box", [obj("Tree")])];
|
||||
holder.functions[0]!.body.push({
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "dynFrom", value: value(funcOf([], ref("box"))), type: DYN, loc },
|
||||
loc,
|
||||
});
|
||||
checkExact(holder, ["object:Tree", "record:box"], ["link"]);
|
||||
const logged = tree();
|
||||
checkExact(holder, ["object:Tree"], ["link"]);
|
||||
const logged = tree([dynamicStore]);
|
||||
logged.functions[0]!.body.push({
|
||||
kind: "exprStmt",
|
||||
expr: { kind: "jsonStringify", value: value(arrayOf(link)), type: STRING, loc },
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
import type { IrModule, IrType } from "../ir/ir.js";
|
||||
import { funcOf, mapOf, RUNTIME_EMITTER_CLASS, STRING, VOID } from "../ir/ir.js";
|
||||
import {
|
||||
DYN_CLASS_PROPERTIES,
|
||||
funcOf,
|
||||
mapOf,
|
||||
RUNTIME_EMITTER_CLASS,
|
||||
STRING,
|
||||
VOID,
|
||||
} from "../ir/ir.js";
|
||||
import { computeCycleMutability, fieldKey } from "./cycle-mutability.js";
|
||||
|
||||
/** Cycle capability for native code generation.
|
||||
@@ -89,13 +96,28 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
|
||||
reverse: [],
|
||||
traced: false,
|
||||
});
|
||||
const units = new Map<string, Node>();
|
||||
// One node per class and record, plus a SUBTREE node per class with
|
||||
// subclasses: a slot of static type C can hold an instance of any class
|
||||
// in C's subtree, and of nothing else. Reachability is computed per
|
||||
// class; tracing is then decided per hierarchy unit below.
|
||||
const shapes = new Map<string, Node>();
|
||||
const unions = new Map((mod.unions ?? []).map((u) => [u.id, node()]));
|
||||
for (const s of shapeDefs) {
|
||||
let unit = units.get(s.unit);
|
||||
if (!unit) units.set(s.unit, (unit = node()));
|
||||
shapes.set(s.key, unit);
|
||||
for (const s of shapeDefs) shapes.set(s.key, node());
|
||||
const children = new Map<string, string[]>();
|
||||
for (const c of classes)
|
||||
if (c.base !== undefined && shapes.has(`object:${c.base}`)) {
|
||||
let list = children.get(c.base);
|
||||
if (!list) children.set(c.base, (list = []));
|
||||
list.push(c.name);
|
||||
}
|
||||
// Created up front and linked without recursion (hierarchies can be
|
||||
// thousands deep).
|
||||
const subtrees = new Map([...children.keys()].map((name) => [name, node()] as const));
|
||||
const subtreeOf = (className: string): Node | undefined =>
|
||||
subtrees.get(className) ?? shapes.get(`object:${className}`);
|
||||
for (const [name, sub] of subtrees) {
|
||||
sub.edges.set(shapes.get(`object:${name}`)!, false);
|
||||
for (const kid of children.get(name)!) sub.edges.set(subtreeOf(kid)!, false);
|
||||
}
|
||||
// Each field/arm is an OR of intrinsic capability and referenced nodes.
|
||||
const addType = (from: Node, t: IrType, mutable: boolean): void => {
|
||||
@@ -115,7 +137,9 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
|
||||
const dependency =
|
||||
t.kind === "union"
|
||||
? unions.get(t.unionId)
|
||||
: shapes.get(t.kind === "object" ? `object:${t.className}` : `record:${t.shapeId}`);
|
||||
: t.kind === "object"
|
||||
? subtreeOf(t.className)
|
||||
: shapes.get(`record:${t.shapeId}`);
|
||||
if (dependency)
|
||||
from.edges.set(dependency, (from.edges.get(dependency) ?? false) || mutable);
|
||||
break;
|
||||
@@ -132,19 +156,21 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
|
||||
};
|
||||
for (const s of shapeDefs) {
|
||||
const unitMutable = mutability.all || mutability.units.has(s.unit);
|
||||
for (const field of s.fields)
|
||||
for (const field of s.fields) {
|
||||
if (mutability.emptyPropertyBags && field.name === DYN_CLASS_PROPERTIES) continue;
|
||||
addType(
|
||||
shapes.get(s.key)!,
|
||||
field.type,
|
||||
unitMutable || mutability.fields.has(fieldKey(s.unit, field.name)),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const u of mod.unions ?? [])
|
||||
for (const arm of u.arms) addType(unions.get(u.id)!, arm, mutability.all);
|
||||
|
||||
// Tarjan's strongly connected components, iteratively (shape chains can
|
||||
// be thousands deep).
|
||||
const all = [...units.values(), ...unions.values()];
|
||||
const all = [...shapes.values(), ...subtrees.values(), ...unions.values()];
|
||||
const stack: Node[] = [];
|
||||
const componentCapable: boolean[] = [];
|
||||
let nextIndex = 0;
|
||||
@@ -191,18 +217,28 @@ export function computeTraced(mod: IrModule): { shapes: Set<string>; unions: Set
|
||||
componentCapable.push(capable);
|
||||
}
|
||||
}
|
||||
// Traced: reaches a cycle-capable component (reverse reachability).
|
||||
// Traced: on a potential cycle. An intrinsic node can reach anything, so
|
||||
// every node that reaches one (reverse reachability) may lie on a cycle
|
||||
// through it; otherwise only the members of a component with a mutable
|
||||
// internal edge can. A node that merely reaches such a component holds it
|
||||
// from outside: no cycle can lead back to it, so its references are
|
||||
// external counts and it dies by reference counting alone.
|
||||
for (const n of all) for (const dependency of n.edges.keys()) dependency.reverse.push(n);
|
||||
const pending = all.filter((n) => componentCapable[n.component]);
|
||||
for (const n of pending) n.traced = true;
|
||||
const reachesIntrinsic = new Set<Node>(all.filter((n) => n.intrinsic));
|
||||
const pending = [...reachesIntrinsic];
|
||||
for (let i = 0; i < pending.length; i++) {
|
||||
for (const dependent of pending[i]!.reverse) {
|
||||
if (dependent.traced) continue;
|
||||
dependent.traced = true;
|
||||
if (reachesIntrinsic.has(dependent)) continue;
|
||||
reachesIntrinsic.add(dependent);
|
||||
pending.push(dependent);
|
||||
}
|
||||
}
|
||||
const tracedShapes = new Set([...shapes].filter(([, unit]) => unit.traced).map(([key]) => key));
|
||||
for (const n of all) n.traced = componentCapable[n.component]! || reachesIntrinsic.has(n);
|
||||
// Header presence is uniform across a hierarchy: a unit is traced iff any
|
||||
// member class is.
|
||||
const tracedUnits = new Set<string>();
|
||||
for (const s of shapeDefs) if (shapes.get(s.key)!.traced) tracedUnits.add(s.unit);
|
||||
const tracedShapes = new Set(shapeDefs.filter((s) => tracedUnits.has(s.unit)).map((s) => s.key));
|
||||
const tracedUnions = new Set([...unions].filter(([, n]) => n.traced).map(([id]) => id));
|
||||
return { shapes: tracedShapes, unions: tracedUnions };
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type { IrExpr, IrFunction, IrModule, IrStmt, IrType } from "../ir/ir.js";
|
||||
import { RUNTIME_EMITTER_CLASS, RUNTIME_STREAM_CLASSES } from "../ir/ir.js";
|
||||
import { dynamicFieldStores } from "../ir/int32-slots.js";
|
||||
import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from "../ir/traverse.js";
|
||||
|
||||
/** Which shape fields can be written after their object escapes.
|
||||
@@ -16,15 +17,21 @@ import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from
|
||||
* - every `fieldSet` outside a constructor's own `this`, `fieldIncDec`, and
|
||||
* `recordSet` makes its (unit, field) mutable; `recordKeySet` on declared
|
||||
* fields and `recordKeyDelete` make the whole record mutable;
|
||||
* - a constructor in which `this` escapes (any use other than a field store
|
||||
* target, a field read, or the receiver of the direct base constructor
|
||||
* call — including closure and class captures) makes its whole hierarchy
|
||||
* mutable, as does any other call to a constructor function;
|
||||
* - a constructor's stores into its own `this` are construction-only up to
|
||||
* the first top-level statement in which `this` escapes (any use other
|
||||
* than a field store target, a field read, or the receiver of the direct
|
||||
* base constructor call — including closure and class captures); stores
|
||||
* from there on, and all stores of a constructor whose ancestor
|
||||
* constructor let `this` escape, are writes. Any other call to a
|
||||
* constructor function makes its whole hierarchy mutable;
|
||||
* - every type that can cross into the checked-dynamic world (operands of
|
||||
* `dynFrom`, including boxed closure signatures, thrown values, and
|
||||
* arguments of library/intrinsic/JSON/FFI calls) makes its entire type
|
||||
* closure mutable, because typed-ref capsules commit dynamic mutations
|
||||
* straight into native fields;
|
||||
* straight into native fields — unless the program contains no runtime
|
||||
* store into a dynamic value at all (int32-slots' dynamicFieldStores),
|
||||
* in which case no commit can happen; FFI operands and intrinsics other
|
||||
* than publish always count;
|
||||
* - runtime-rooted hierarchies (errors, emitters, streams) are mutable;
|
||||
* - library builds and dynamic-island programs disable the refinement.
|
||||
*
|
||||
@@ -33,6 +40,10 @@ import { everyExprChild, everyModuleNode, everyStmtChild, everyTypeChild } from
|
||||
export interface CycleMutability {
|
||||
/** Every edge is mutable (the refinement is disabled for this module). */
|
||||
all: boolean;
|
||||
/** No runtime store into a dynamic value exists, so the hidden property
|
||||
* bags of classes (DYN_CLASS_PROPERTIES) stay empty: they hold no
|
||||
* references. */
|
||||
emptyPropertyBags: boolean;
|
||||
/** Units whose every field is mutable. */
|
||||
units: Set<string>;
|
||||
/** Individually mutable fields. */
|
||||
@@ -65,7 +76,12 @@ export function computeCycleMutability(
|
||||
mod: IrModule,
|
||||
unitOfClass: (className: string) => string,
|
||||
): CycleMutability {
|
||||
const result: CycleMutability = { all: false, units: new Set(), fields: new Set() };
|
||||
const result: CycleMutability = {
|
||||
all: false,
|
||||
emptyPropertyBags: false,
|
||||
units: new Set(),
|
||||
fields: new Set(),
|
||||
};
|
||||
if (mod.lib !== undefined) return { ...result, all: true };
|
||||
const classes = mod.classes ?? [];
|
||||
const recordsById = new Map((mod.records ?? []).map((r) => [r.id, r] as const));
|
||||
@@ -89,6 +105,21 @@ export function computeCycleMutability(
|
||||
result.units.add(unitOfClass(c.name));
|
||||
}
|
||||
|
||||
// A typed-ref capsule writes into its native object only when a runtime
|
||||
// store into the capsule commits (dynamicFieldStores audits which helpers
|
||||
// can store; a commit rewrites every field, reference fields possibly
|
||||
// with fresh conversions). Without any such store in the program,
|
||||
// crossing into checked-dynamic code writes nothing. FFI calls and
|
||||
// intrinsics other than publish (which only marks objects immortal) stay
|
||||
// conservative.
|
||||
const dynStores = dynamicFieldStores(mod);
|
||||
const dynamicStores = dynStores.all || dynStores.names.size > 0;
|
||||
result.emptyPropertyBags = !dynamicStores;
|
||||
const boundaryWrites = (e: IrExpr): boolean =>
|
||||
dynamicStores ||
|
||||
e.kind === "ffiCall" ||
|
||||
(e.kind === "intrinsic" && e.name !== "threads.publish");
|
||||
|
||||
// Types that can become checked-dynamic values: mark their whole closure.
|
||||
const exposedTypes = new Set<string>();
|
||||
const expose = (root: IrType): void => {
|
||||
@@ -124,9 +155,42 @@ export function computeCycleMutability(
|
||||
}
|
||||
};
|
||||
|
||||
// Constructor stores are construction-only until `this` first escapes:
|
||||
// stores from the escaping statement on, and every store of a
|
||||
// constructor whose ancestor constructor already let `this` escape, are
|
||||
// ordinary writes.
|
||||
const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const));
|
||||
const classByName = new Map(classes.map((c) => [c.name, c] as const));
|
||||
const escapePoints = new Map<string, number>();
|
||||
/** The constructor's escape point; 0 without an analyzable constructor. */
|
||||
const escapePoint = (name: string): number => {
|
||||
const known = escapePoints.get(name);
|
||||
if (known !== undefined) return known;
|
||||
const fn = fnByName.get(`%${name}.constructor`);
|
||||
const cls = classByName.get(name);
|
||||
const thisId = fn?.params[0]?.localId;
|
||||
const base = cls?.base !== undefined ? `%${cls.base}.constructor` : null;
|
||||
const point =
|
||||
fn === undefined || cls === undefined || thisId === undefined
|
||||
? 0
|
||||
: constructorEscapePoint(fn, thisId, base);
|
||||
escapePoints.set(name, point);
|
||||
return point;
|
||||
};
|
||||
const escapes = (name: string): boolean =>
|
||||
escapePoint(name) < (fnByName.get(`%${name}.constructor`)?.body.length ?? 1);
|
||||
const ancestorEscaped = (name: string): boolean => {
|
||||
for (let c = classByName.get(name)?.base; c !== undefined; c = classByName.get(c)?.base)
|
||||
if (escapes(c)) return true;
|
||||
return false;
|
||||
};
|
||||
let privateUntil = 0;
|
||||
let topLevel = 0;
|
||||
|
||||
let aliases = new Set<string>();
|
||||
const isThisRef = (e: IrExpr, thisId: string | null): boolean => {
|
||||
if (thisId === null) return false;
|
||||
if (e.kind === "varRef") return e.localId === thisId;
|
||||
if (e.kind === "varRef") return aliases.has(e.localId);
|
||||
if (e.kind === "upcast") return isThisRef(e.value, thisId);
|
||||
if (e.kind === "seqExpr") return isThisRef(e.result, thisId);
|
||||
return false;
|
||||
@@ -135,9 +199,14 @@ export function computeCycleMutability(
|
||||
for (const fn of mod.functions) {
|
||||
const ctorClass = ctorOf.get(fn.name);
|
||||
const thisId = ctorClass !== undefined ? (fn.params[0]?.localId ?? null) : null;
|
||||
aliases = thisId !== null ? constructorThisAliases(fn, thisId) : new Set();
|
||||
privateUntil =
|
||||
ctorClass !== undefined && thisId !== null && !ancestorEscaped(ctorClass.name)
|
||||
? escapePoint(ctorClass.name)
|
||||
: 0;
|
||||
const baseCtor = ctorClass?.base !== undefined ? `%${ctorClass.base}.constructor` : null;
|
||||
const expr = (e: IrExpr): boolean => {
|
||||
if (DYNAMIC_BOUNDARY_EXPRS.has(e.kind)) {
|
||||
if (DYNAMIC_BOUNDARY_EXPRS.has(e.kind) && boundaryWrites(e)) {
|
||||
everyExprChild(
|
||||
e,
|
||||
(child) => {
|
||||
@@ -171,7 +240,7 @@ export function computeCycleMutability(
|
||||
const stmt = (s: IrStmt): boolean => {
|
||||
switch (s.kind) {
|
||||
case "fieldSet":
|
||||
if (!isThisRef(s.obj, thisId))
|
||||
if (!isThisRef(s.obj, thisId) || topLevel >= privateUntil)
|
||||
result.fields.add(fieldKey(unitOfClass(s.className), s.field));
|
||||
break;
|
||||
case "recordSet":
|
||||
@@ -184,25 +253,58 @@ export function computeCycleMutability(
|
||||
result.units.add(`record:${s.shapeId}`);
|
||||
break;
|
||||
case "throw":
|
||||
expose(s.value.type);
|
||||
if (dynamicStores) expose(s.value.type);
|
||||
break;
|
||||
}
|
||||
return everyStmtChild(s, expr, stmt);
|
||||
};
|
||||
fn.body.every(stmt);
|
||||
if (ctorClass !== undefined && thisId !== null && constructorEscapes(fn, thisId, baseCtor))
|
||||
result.units.add(unitOfClass(ctorClass.name));
|
||||
for (topLevel = 0; topLevel < fn.body.length; topLevel++) stmt(fn.body[topLevel]!);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Whether `this` can be observed by anything other than the constructor's
|
||||
* own field stores before construction completes. Flow-insensitive: any
|
||||
* escape anywhere in the body counts. */
|
||||
function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | null): boolean {
|
||||
let escaped = false;
|
||||
const isThisRef = (e: IrExpr): boolean =>
|
||||
/** The locals of a constructor that always hold its `this`: the parameter
|
||||
* itself plus immutable locals declared with a `this` initializer and never
|
||||
* assigned (the receiver temporaries of compound field assignments). */
|
||||
export function constructorThisAliases(fn: IrFunction, thisId: string): Set<string> {
|
||||
const aliases = new Set([thisId]);
|
||||
const immutable = new Set(fn.locals.filter((l) => !l.mutable).map((l) => l.id));
|
||||
const assigned = new Set<string>();
|
||||
const candidates: string[] = [];
|
||||
const isThisParam = (e: IrExpr): boolean =>
|
||||
(e.kind === "varRef" && e.localId === thisId) ||
|
||||
(e.kind === "upcast" && isThisParam(e.value)) ||
|
||||
(e.kind === "seqExpr" && isThisParam(e.result));
|
||||
const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
|
||||
const stmt = (s: IrStmt): boolean => {
|
||||
if (s.kind === "assign") assigned.add(s.localId);
|
||||
if (s.kind === "varDecl" && s.init !== null && immutable.has(s.localId) && isThisParam(s.init))
|
||||
candidates.push(s.localId);
|
||||
return everyStmtChild(s, expr, stmt);
|
||||
};
|
||||
fn.body.every(stmt);
|
||||
for (const id of candidates) if (!assigned.has(id)) aliases.add(id);
|
||||
return aliases;
|
||||
}
|
||||
|
||||
/** The index of the first top-level statement of a constructor body in
|
||||
* which `this` can be observed by anything other than the constructor's own
|
||||
* field stores and reads (the body length when it never is). Statements run
|
||||
* in order, so every store in an earlier statement completes while the
|
||||
* object is still private. Within and after the escaping statement nothing
|
||||
* is private any more. With `selfStores`, storing `this` into one of its
|
||||
* own fields is not an escape (it exposes nothing to other code, but it
|
||||
* does create a cycle). */
|
||||
export function constructorEscapePoint(
|
||||
fn: IrFunction,
|
||||
thisId: string,
|
||||
baseCtor: string | null,
|
||||
options: { selfStores?: boolean } = {},
|
||||
): number {
|
||||
let escaped = false;
|
||||
const aliases = constructorThisAliases(fn, thisId);
|
||||
const isThisRef = (e: IrExpr): boolean =>
|
||||
(e.kind === "varRef" && aliases.has(e.localId)) ||
|
||||
(e.kind === "upcast" && isThisRef(e.value)) ||
|
||||
(e.kind === "seqExpr" && isThisRef(e.result));
|
||||
// The non-`this` parts of a `this`-valued wrapper (seqExpr statements).
|
||||
@@ -212,12 +314,15 @@ function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | n
|
||||
return true;
|
||||
};
|
||||
const expr = (e: IrExpr): boolean => {
|
||||
if (e.kind === "varRef" && e.localId === thisId) {
|
||||
if (e.kind === "varRef" && aliases.has(e.localId)) {
|
||||
escaped = true;
|
||||
return false;
|
||||
}
|
||||
// Closures and class references are the expressions that capture locals.
|
||||
if ((e.kind === "closure" || e.kind === "classRef") && (e.captures ?? []).includes(thisId)) {
|
||||
if (
|
||||
(e.kind === "closure" || e.kind === "classRef") &&
|
||||
(e.captures ?? []).some((id) => aliases.has(id))
|
||||
) {
|
||||
escaped = true;
|
||||
return false;
|
||||
}
|
||||
@@ -233,10 +338,22 @@ function constructorEscapes(fn: IrFunction, thisId: string, baseCtor: string | n
|
||||
return everyExprChild(e, expr, stmt);
|
||||
};
|
||||
const stmt = (s: IrStmt): boolean => {
|
||||
if (s.kind === "fieldSet" && isThisRef(s.obj)) return wrapper(s.obj) && expr(s.value);
|
||||
if (s.kind === "fieldSet" && isThisRef(s.obj)) {
|
||||
if (options.selfStores === true) {
|
||||
// `this` itself, possibly wrapped into the field's union type.
|
||||
const self = s.value.kind === "unionWrap" ? s.value.value : s.value;
|
||||
if (isThisRef(self)) return wrapper(s.obj) && wrapper(self);
|
||||
}
|
||||
return wrapper(s.obj) && expr(s.value);
|
||||
}
|
||||
if (s.kind === "exprStmt" && isThisRef(s.expr)) return wrapper(s.expr);
|
||||
// Declaring an alias is not an escape; its uses are checked like `this`.
|
||||
if (s.kind === "varDecl" && aliases.has(s.localId) && s.init !== null) return wrapper(s.init);
|
||||
return everyStmtChild(s, expr, stmt);
|
||||
};
|
||||
fn.body.every(stmt);
|
||||
return escaped;
|
||||
for (let i = 0; i < fn.body.length; i++) {
|
||||
stmt(fn.body[i]!);
|
||||
if (escaped) return i;
|
||||
}
|
||||
return fn.body.length;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { IrExpr } from "../../ir/ir.js";
|
||||
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
|
||||
import { f64Lit } from "./common.js";
|
||||
import { exactInteger, widenInteger } from "./integer-values.js";
|
||||
import { emitTruncF64 } from "./trunc.js";
|
||||
|
||||
/** Supported LLVM targets are little-endian. All memory accesses use align
|
||||
* 1: Buffer fields and DataView windows can start at arbitrary byte offsets. */
|
||||
@@ -101,11 +102,9 @@ export function emitByteNumber(
|
||||
if (spec.dataView) {
|
||||
const nan = B.tmp(),
|
||||
finiteOrZero = B.tmp();
|
||||
normalized = B.tmp();
|
||||
B.line(`${nan} = fcmp uno double ${offset.name}, ${offset.name}`);
|
||||
B.line(`${finiteOrZero} = select i1 ${nan}, double ${f64Lit(0)}, double ${offset.name}`);
|
||||
host.declare("declare double @llvm.trunc.f64(double)");
|
||||
B.line(`${normalized} = call double @llvm.trunc.f64(double ${finiteOrZero})`);
|
||||
normalized = emitTruncF64(host, finiteOrZero);
|
||||
}
|
||||
const cap = B.tmp(),
|
||||
positive = B.tmp(),
|
||||
|
||||
@@ -213,7 +213,7 @@ test("length assignments skip the runtime when the length is unchanged", () => {
|
||||
module("reset", VOID, [{ kind: "arraySetLength", arr: ref("s", words), length: index, loc }]),
|
||||
);
|
||||
const fn = body(llvm, "reset");
|
||||
expect(fn).toMatch(/uitofp i64 .* to double/);
|
||||
expect(fn).toMatch(/sitofp i64 .* to double/);
|
||||
expect(fn).toMatch(/fcmp oeq double %p?\S*, %t\d+/);
|
||||
expect(fn.match(/call void @scr_arr_set_len\(/g)).toHaveLength(1);
|
||||
expect(fn).toMatch(/br i1 %t\d+, label %arr\.len\.done\d*, label %arr\.len\.set\d*/);
|
||||
|
||||
@@ -411,7 +411,7 @@ export function emitDenseArrayPush(
|
||||
fastValue = B.tmp();
|
||||
B.line(`${next} = add nuw ${size} ${len}, 1`);
|
||||
B.line(`store ${size} ${next}, ptr ${lenPtr}${host.fieldAliasAttachment(lenPtr)}`);
|
||||
B.line(`${fastValue} = uitofp ${size} ${next} to double`);
|
||||
B.line(`${fastValue} = sitofp ${size} ${next} to double`);
|
||||
B.br(join);
|
||||
B.startBlock(slow);
|
||||
const slowValue = B.tmp();
|
||||
|
||||
@@ -1668,7 +1668,7 @@ export class LlDyn {
|
||||
host.declare(`declare void @scr_arr_set_len(ptr, double)`);
|
||||
const index = B.tmp();
|
||||
const length = B.tmp();
|
||||
B.line(`${index} = uitofp ${this.S} ${i} to double`);
|
||||
B.line(`${index} = sitofp ${this.S} ${i} to double`);
|
||||
B.line(`${length} = fadd double ${index}, 1.0`);
|
||||
B.line(`call void @scr_arr_set_len(ptr ${a}, double ${length})`);
|
||||
B.br(doneLabel);
|
||||
@@ -3409,7 +3409,7 @@ export class LlDyn {
|
||||
const n = this.lenOf(B, "%d");
|
||||
const nd = B.tmp();
|
||||
const r = B.tmp();
|
||||
B.line(`${nd} = uitofp ${host.sizeType} ${n} to double`);
|
||||
B.line(`${nd} = sitofp ${host.sizeType} ${n} to double`);
|
||||
B.line(`${r} = call ptr @scr_dyn_new_num(double ${nd})`);
|
||||
B.terminate(`ret ptr ${r}`);
|
||||
B.startBlock(lStr);
|
||||
@@ -3448,7 +3448,7 @@ export class LlDyn {
|
||||
const n2 = B.tmp();
|
||||
B.line(`${n2} = call double @scr_str_utf16_len(ptr ${s})`);
|
||||
const idxD = B.tmp();
|
||||
B.line(`${idxD} = uitofp ${host.sizeType} ${idx} to double`);
|
||||
B.line(`${idxD} = sitofp ${host.sizeType} ${idx} to double`);
|
||||
const inR2 = B.tmp();
|
||||
B.line(`${inR2} = fcmp olt double ${idxD}, ${n2}`);
|
||||
const lHit2 = B.newLabel("kg.ash");
|
||||
@@ -3599,7 +3599,7 @@ export class LlDyn {
|
||||
host.declare(`declare ptr @scr_dyn_jsval_iter_n(ptr, double)`);
|
||||
const count = B.tmp();
|
||||
const out = B.tmp();
|
||||
B.line(`${count} = uitofp ${host.sizeType} %n to double`);
|
||||
B.line(`${count} = sitofp ${host.sizeType} %n to double`);
|
||||
B.line(`${out} = call ptr @scr_dyn_jsval_iter_n(ptr %d, double ${count})`);
|
||||
B.terminate(`ret ptr ${out}`);
|
||||
B.startBlock(lNotJv);
|
||||
@@ -3737,7 +3737,7 @@ export class LlDyn {
|
||||
const index = B.tmp();
|
||||
const bd = B.tmp();
|
||||
const r = B.tmp();
|
||||
B.line(`${index} = uitofp ${host.sizeType} ${i} to double`);
|
||||
B.line(`${index} = sitofp ${host.sizeType} ${i} to double`);
|
||||
B.line(`${bd} = call double @scr_bytes_get(ptr ${bts}, double ${index})`);
|
||||
B.line(`${r} = call ptr @scr_dyn_new_num(double ${bd})`);
|
||||
B.line(`store ptr ${r}, ptr ${itemSlot}`);
|
||||
|
||||
@@ -516,6 +516,10 @@ export class LlEmitter {
|
||||
readonly ffiExtendNarrowIntegers: boolean;
|
||||
readonly cycleColorOffset: number;
|
||||
readonly wasi: boolean;
|
||||
/** The target lacks a rounding instruction at its baseline CPU (x86-64
|
||||
* before SSE4.1), so `llvm.trunc` would lower to a libm call: emit
|
||||
* truncation inline through an integer round trip instead. */
|
||||
readonly inlineTrunc: boolean;
|
||||
/** ELF worker executables give thread-locals the executable TLS models. */
|
||||
private readonly executableTls: boolean;
|
||||
private readonly emitLibraryIdentity: boolean;
|
||||
@@ -807,6 +811,14 @@ export class LlEmitter {
|
||||
this.ffiExtendNarrowIntegers =
|
||||
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
|
||||
this.wasi = options.wasi === true;
|
||||
// An empty triple targets the host, as for executableTls below: the
|
||||
// Node-hosted CLI passes none, the native CLI passes its toolchain's, and
|
||||
// both must emit the same module for the same target.
|
||||
this.inlineTrunc =
|
||||
!this.wasi &&
|
||||
(options.targetTriple
|
||||
? /^(x86_64|amd64)\b/i.test(options.targetTriple)
|
||||
: process.arch === "x64");
|
||||
this.executableTls =
|
||||
mod.workers === true &&
|
||||
mod.lib === undefined &&
|
||||
@@ -1920,7 +1932,7 @@ export class LlEmitter {
|
||||
// The branch must dominate fptoui: NaN/out-of-range conversion
|
||||
// would produce poison. Fractional indices also need a fallback.
|
||||
` %index = fptoui double %i to ${this.sizeType}`,
|
||||
` %roundtrip = uitofp ${this.sizeType} %index to double`,
|
||||
` %roundtrip = sitofp ${this.sizeType} %index to double`,
|
||||
` %integer = fcmp oeq double %roundtrip, %i`,
|
||||
` br i1 %integer, label %read, label %fallback`,
|
||||
`read:`,
|
||||
@@ -2754,14 +2766,14 @@ export class LlEmitter {
|
||||
`entry:`,
|
||||
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 19`,
|
||||
` %nd = load ${sz}, ptr %ndp`,
|
||||
` %ndf = uitofp ${sz} %nd to double`,
|
||||
` %ndf = sitofp ${sz} %nd to double`,
|
||||
` %lo = fcmp oge double %k, 0.0`,
|
||||
` %hi = fcmp olt double %k, %ndf`,
|
||||
` %in = and i1 %lo, %hi`,
|
||||
` br i1 %in, label %direct, label %outside`,
|
||||
`direct:`,
|
||||
` %i = fptoui double %k to ${sz}`,
|
||||
` %back = uitofp ${sz} %i to double`,
|
||||
` %back = sitofp ${sz} %i to double`,
|
||||
` %int = fcmp oeq double %back, %k`,
|
||||
` br i1 %int, label %slot, label %miss`,
|
||||
`slot:`,
|
||||
@@ -5621,7 +5633,7 @@ export class LlEmitter {
|
||||
B.line(`${lenPtr} = getelementptr inbounds %ScrArr, ptr ${arr.name}, i32 0, i32 1`);
|
||||
this.markMemoryPointer(lenPtr, "array:header");
|
||||
B.line(`${len} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`);
|
||||
B.line(`${lenNumber} = uitofp ${this.sizeType} ${len} to double`);
|
||||
B.line(`${lenNumber} = sitofp ${this.sizeType} ${len} to double`);
|
||||
B.line(`${same} = fcmp oeq double ${length.name}, ${lenNumber}`);
|
||||
B.condBr(same, done, resize);
|
||||
B.startBlock(resize);
|
||||
@@ -6250,7 +6262,7 @@ export class LlEmitter {
|
||||
B.line(
|
||||
`${rawLen} = load ${this.sizeType}, ptr ${lenPtr}${this.fieldAliasAttachment(lenPtr)}`,
|
||||
);
|
||||
B.line(`${len} = uitofp ${this.sizeType} ${rawLen} to double`);
|
||||
B.line(`${len} = sitofp ${this.sizeType} ${rawLen} to double`);
|
||||
B.line(`${inBounds} = fcmp olt double ${i}, ${len}`);
|
||||
cur = i;
|
||||
}
|
||||
|
||||
@@ -122,7 +122,7 @@ export function emitBytesIndex(
|
||||
const nonnegative = B.tmp();
|
||||
const belowLen = B.tmp();
|
||||
const inRange = B.tmp();
|
||||
B.line(`${lenF64} = uitofp ${host.sizeType} ${len} to double`);
|
||||
B.line(`${lenF64} = sitofp ${host.sizeType} ${len} to double`);
|
||||
B.line(`${nonnegative} = fcmp oge double ${index}, ${f64Lit(0)}`);
|
||||
B.line(`${belowLen} = fcmp olt double ${index}, ${lenF64}`);
|
||||
B.line(`${inRange} = and i1 ${nonnegative}, ${belowLen}`);
|
||||
@@ -137,7 +137,7 @@ export function emitBytesIndex(
|
||||
const roundTrip = B.tmp();
|
||||
const integral = B.tmp();
|
||||
B.line(`${idx} = fptoui double ${index} to ${host.sizeType}`);
|
||||
B.line(`${roundTrip} = uitofp ${host.sizeType} ${idx} to double`);
|
||||
B.line(`${roundTrip} = sitofp ${host.sizeType} ${idx} to double`);
|
||||
B.line(`${integral} = fcmp oeq double ${roundTrip}, ${index}`);
|
||||
B.condBr(integral, valid, invalid);
|
||||
|
||||
@@ -177,7 +177,7 @@ export function emitBytesLength(
|
||||
const count = bytes && size !== 1 ? B.tmp() : len;
|
||||
if (count !== len) B.line(`${count} = shl ${host.sizeType} ${len}, ${Math.log2(size)}`);
|
||||
const out = B.tmp();
|
||||
B.line(`${out} = uitofp ${host.sizeType} ${count} to double`);
|
||||
B.line(`${out} = sitofp ${host.sizeType} ${count} to double`);
|
||||
return { name: out, type: F64 };
|
||||
}
|
||||
|
||||
@@ -371,15 +371,14 @@ export function emitToUint32(
|
||||
B.condBr(finite, finiteLabel, nonfiniteLabel);
|
||||
|
||||
B.startBlock(finiteLabel);
|
||||
host.declare(`declare double @llvm.trunc.f64(double)`);
|
||||
const truncated = B.tmp();
|
||||
// Only magnitudes above 2^53 reach this block, and every such double is
|
||||
// already an integer: no truncation is needed before the reduction.
|
||||
const residue = B.tmp();
|
||||
const negative = B.tmp();
|
||||
const wrapped = B.tmp();
|
||||
const normalized = B.tmp();
|
||||
const finiteU32 = B.tmp();
|
||||
B.line(`${truncated} = call double @llvm.trunc.f64(double ${value})`);
|
||||
B.line(`${residue} = frem double ${truncated}, ${f64Lit(4294967296)}`);
|
||||
B.line(`${residue} = frem double ${value}, ${f64Lit(4294967296)}`);
|
||||
B.line(`${negative} = fcmp olt double ${residue}, ${f64Lit(0)}`);
|
||||
B.line(`${wrapped} = fadd double ${residue}, ${f64Lit(4294967296)}`);
|
||||
B.line(`${normalized} = select i1 ${negative}, double ${wrapped}, double ${residue}`);
|
||||
|
||||
@@ -520,7 +520,7 @@ export function emitArrIntrinsic(
|
||||
B.line(`${p} = getelementptr inbounds %ScrArr, ptr ${r.name}, i32 0, i32 1`);
|
||||
host.markMemoryPointer(p, "array:header");
|
||||
B.line(`${len} = load ${host.sizeType}, ptr ${p}${host.fieldAliasAttachment(p)}`);
|
||||
B.line(`${t} = uitofp ${host.sizeType} ${len} to double`);
|
||||
B.line(`${t} = sitofp ${host.sizeType} ${len} to double`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
case "getNumber": {
|
||||
|
||||
@@ -7,6 +7,7 @@ import { arrNewCall, traceArg, vAdapters } from "./shapes.js";
|
||||
import type { LlvmEmitterContext, LibCallExpr, LlValue } from "./expr-context.js";
|
||||
import { f64Lit } from "./common.js";
|
||||
import { emitAlwaysThrowLibCall } from "./lib-shared.js";
|
||||
import { emitTruncF64 } from "./trunc.js";
|
||||
|
||||
const FS_ALWAYS_THROW_SYMS: Readonly<Record<string, string>> = {
|
||||
"fs.mkdtempChk": "scr_fs_mkdtemp_chk",
|
||||
@@ -663,8 +664,23 @@ export function emitPathUrlLibCall(host: LlvmEmitterContext, e: LibCallExpr): Ll
|
||||
|
||||
export function emitPrimitiveLibCall(host: LlvmEmitterContext, e: LibCallExpr): LlValue {
|
||||
const B = host.B;
|
||||
if (e.fn === "math.floor" || e.fn === "math.trunc" || e.fn === "math.ceil") {
|
||||
const intr = e.fn === "math.floor" ? "floor" : e.fn === "math.trunc" ? "trunc" : "ceil";
|
||||
if (e.fn === "math.min" || e.fn === "math.max") {
|
||||
// IEEE 754-2019 minimum/maximum: NaN propagates and -0 orders below +0,
|
||||
// exactly Math.min/Math.max of two numbers.
|
||||
const a = host.emitExpr(e.args[0]!);
|
||||
const b = host.emitExpr(e.args[1]!);
|
||||
const intr = e.fn === "math.min" ? "minimum" : "maximum";
|
||||
host.declare(`declare double @llvm.${intr}.f64(double, double)`);
|
||||
const t = B.tmp();
|
||||
B.line(`${t} = call double @llvm.${intr}.f64(double ${a.name}, double ${b.name})`);
|
||||
return { name: t, type: e.type };
|
||||
}
|
||||
if (e.fn === "math.trunc") {
|
||||
const v = host.emitExpr(e.args[0]!);
|
||||
return { name: emitTruncF64(host, v.name), type: e.type };
|
||||
}
|
||||
if (e.fn === "math.floor" || e.fn === "math.ceil") {
|
||||
const intr = e.fn === "math.floor" ? "floor" : "ceil";
|
||||
const v = host.emitExpr(e.args[0]!);
|
||||
host.declare(`declare double @llvm.${intr}.f64(double)`);
|
||||
const t = B.tmp();
|
||||
|
||||
@@ -348,7 +348,7 @@ function emitDenseReferenceArrayRead(
|
||||
B.line(`${cap} = load ${host.sizeType}, ptr ${capPtr}${host.fieldAliasAttachment(capPtr)}`);
|
||||
if (integerIndex) B.line(`${inRange} = icmp ult ${host.sizeType} ${integerIndex}, ${cap}`);
|
||||
else {
|
||||
B.line(`${capNumber} = uitofp ${host.sizeType} ${cap} to double`);
|
||||
B.line(`${capNumber} = sitofp ${host.sizeType} ${cap} to double`);
|
||||
B.line(`${nonnegative} = fcmp oge double ${index.name}, 0.0`);
|
||||
B.line(`${belowCap} = fcmp olt double ${index.name}, ${capNumber}`);
|
||||
B.line(`${inRange} = and i1 ${nonnegative}, ${belowCap}`);
|
||||
@@ -361,7 +361,7 @@ function emitDenseReferenceArrayRead(
|
||||
if (integerIndex) B.br(dense);
|
||||
else {
|
||||
B.line(`${offset} = fptoui double ${index.name} to ${host.sizeType}`);
|
||||
B.line(`${roundTrip} = uitofp ${host.sizeType} ${offset} to double`);
|
||||
B.line(`${roundTrip} = sitofp ${host.sizeType} ${offset} to double`);
|
||||
B.line(`${integral} = fcmp oeq double ${index.name}, ${roundTrip}`);
|
||||
B.condBr(integral, dense, slow);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
|
||||
` br i1 %ascii, label %fast, label %slow`,
|
||||
`fast:`,
|
||||
...loadLength(sz),
|
||||
` %n = uitofp ${sz} %len to double`,
|
||||
` %n = sitofp ${sz} %len to double`,
|
||||
` ret double %n`,
|
||||
`slow:`,
|
||||
` %r = call double @scr_str_utf16_len(ptr %s)`,
|
||||
@@ -62,7 +62,7 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
|
||||
` br i1 %ascii, label %bounds, label %slow`,
|
||||
`bounds:`,
|
||||
...loadLength(sz),
|
||||
` %lenf = uitofp ${sz} %len to double`,
|
||||
` %lenf = sitofp ${sz} %len to double`,
|
||||
// NaN fails both ordered comparisons; -0 passes and truncates to 0.
|
||||
` %lo = fcmp oge double %i, 0.0`,
|
||||
` %hi = fcmp olt double %i, %lenf`,
|
||||
|
||||
@@ -97,7 +97,7 @@ export function emitStringSliceRead(host: LlvmEmitterContext, value: Slice): LlV
|
||||
`${pointer} = getelementptr inbounds { ${host.sizeType}, ${host.sizeType}, i32 }, ptr ${snapshot.range}, i32 0, i32 1`,
|
||||
);
|
||||
B.line(`${length} = load ${host.sizeType}, ptr ${pointer}`);
|
||||
B.line(`${number} = uitofp ${host.sizeType} ${length} to double`);
|
||||
B.line(`${number} = sitofp ${host.sizeType} ${length} to double`);
|
||||
return { name: number, type: value.type };
|
||||
}
|
||||
if (value.method !== "charCodeAt") return null;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/* ToIntegerOrInfinity-style truncation of a double toward zero. */
|
||||
import type { LlvmEmitterContext } from "./expr-context.js";
|
||||
import { f64Lit } from "./common.js";
|
||||
|
||||
/** `Math.trunc(value)`. Targets with a rounding instruction use
|
||||
* `llvm.trunc`; on baseline x86-64 that intrinsic is a libm call, so the
|
||||
* value instead takes an integer round trip. Every double of magnitude at
|
||||
* least 2^52 is already an integer, and NaN and the infinities fail the
|
||||
* magnitude test, so those keep their value; the round trip of a smaller
|
||||
* value restores its sign so that `-0.5` and `-0` truncate to `-0`. */
|
||||
export function emitTruncF64(host: LlvmEmitterContext, value: string): string {
|
||||
const B = host.B;
|
||||
if (!host.inlineTrunc) {
|
||||
host.declare("declare double @llvm.trunc.f64(double)");
|
||||
const out = B.tmp();
|
||||
B.line(`${out} = call double @llvm.trunc.f64(double ${value})`);
|
||||
return out;
|
||||
}
|
||||
host.declare("declare double @llvm.fabs.f64(double)");
|
||||
host.declare("declare double @llvm.copysign.f64(double, double)");
|
||||
const magnitude = B.tmp(),
|
||||
small = B.tmp(),
|
||||
integer = B.tmp(),
|
||||
back = B.tmp(),
|
||||
signed = B.tmp(),
|
||||
out = B.tmp();
|
||||
B.line(`${magnitude} = call double @llvm.fabs.f64(double ${value})`);
|
||||
B.line(`${small} = fcmp olt double ${magnitude}, ${f64Lit(2 ** 52)}`);
|
||||
// Poison for a large or non-finite value, which the select never picks.
|
||||
B.line(`${integer} = fptosi double ${value} to i64`);
|
||||
B.line(`${back} = sitofp i64 ${integer} to double`);
|
||||
B.line(`${signed} = call double @llvm.copysign.f64(double ${back}, double ${value})`);
|
||||
B.line(`${out} = select i1 ${small}, double ${signed}, double ${value}`);
|
||||
return out;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { constants } from "node:fs";
|
||||
import { createRequire } from "node:module";
|
||||
import { access, chmod, mkdir, open, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { access, chmod, mkdir, readFile, rename, rm, stat } from "node:fs/promises";
|
||||
import { dirname, join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import {
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
type NativeTargetSpec,
|
||||
} from "./targets.js";
|
||||
import { compilerReleaseVersion } from "../library/sidecar.js";
|
||||
import { writeTextParts } from "../text-parts.js";
|
||||
|
||||
import {
|
||||
NativeCodegenError,
|
||||
@@ -87,39 +88,13 @@ export interface NativeCodegenOptions {
|
||||
importBitcode?: { paths: readonly string[]; digests: readonly string[] };
|
||||
}
|
||||
|
||||
/** Writes LLVM text in large batches. Programs past the engine's string
|
||||
* limit arrive as many parts, and writing each part separately is several
|
||||
* times slower than writing the same bytes in a few large chunks. */
|
||||
/** Writes LLVM text in large batches; see `writeTextParts`. */
|
||||
export async function writeLlvmSource(
|
||||
path: string,
|
||||
llvm: string | readonly string[],
|
||||
mode = 0o666,
|
||||
): Promise<void> {
|
||||
const file = await open(path, "w", mode);
|
||||
try {
|
||||
const write = async (text: string): Promise<void> => {
|
||||
const bytes = Buffer.from(text, "utf8");
|
||||
for (let offset = 0; offset < bytes.length;) {
|
||||
const { bytesWritten } = await file.write(bytes, offset, bytes.length - offset);
|
||||
offset += bytesWritten;
|
||||
}
|
||||
};
|
||||
if (typeof llvm === "string") return await write(llvm);
|
||||
let batch: string[] = [];
|
||||
let length = 0;
|
||||
for (const part of llvm) {
|
||||
batch.push(part);
|
||||
length += part.length;
|
||||
if (length >= 16 * 1024 * 1024) {
|
||||
await write(batch.join(""));
|
||||
batch = [];
|
||||
length = 0;
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) await write(batch.join(""));
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
await writeTextParts(path, llvm, mode);
|
||||
}
|
||||
|
||||
function parseJsonObject(text: string): Record<string, unknown> | null {
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
type IrStmt,
|
||||
type IrType,
|
||||
JSVAL,
|
||||
isIntegerBytesElem,
|
||||
type SrcLoc,
|
||||
UNDEFINED_T,
|
||||
typeEquals,
|
||||
@@ -282,6 +283,42 @@ export function lowerSafeBytesRead(
|
||||
type: F64,
|
||||
loc: at,
|
||||
};
|
||||
if (isIntegerBytesElem(bytesT.elem)) {
|
||||
// An integer element is never NaN, so the NaN-for-invalid read
|
||||
// decides presence with one inline index check (no separate range
|
||||
// and integrality tests).
|
||||
const v = varRef("v.0", F64, at);
|
||||
lowerer.liftedFns.push({
|
||||
name,
|
||||
params: [
|
||||
{ localId: "b.0", name: "b", type: bytesT },
|
||||
{ localId: "i.0", name: "i", type: F64 },
|
||||
],
|
||||
returnType: resultT,
|
||||
locals: [
|
||||
{ id: "b.0", name: "b", type: bytesT, mutable: false },
|
||||
{ id: "i.0", name: "i", type: F64, mutable: false },
|
||||
{ id: "v.0", name: "v", type: F64, mutable: false },
|
||||
],
|
||||
body: [
|
||||
{ kind: "varDecl", localId: "v.0", init: { ...read, invalidNaN: true }, loc: at },
|
||||
{
|
||||
kind: "return",
|
||||
value: {
|
||||
kind: "ternary",
|
||||
cond: { kind: "libCall", fn: "num.isNaN", args: [v], type: BOOL, loc: at },
|
||||
then: lowerer.wrappedUndefined(resultT, at)!,
|
||||
else_: lowerer.coerceToExpected(v, resultT),
|
||||
type: resultT,
|
||||
loc: at,
|
||||
},
|
||||
loc: at,
|
||||
},
|
||||
],
|
||||
loc: at,
|
||||
});
|
||||
return { kind: "call", callee: name, args: [receiver, index], type: resultT, loc };
|
||||
}
|
||||
lowerer.liftedFns.push({
|
||||
name,
|
||||
params: [
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as ts from "../../ts7/adapter.js";
|
||||
import { type Lowerer } from "../lowerer.js";
|
||||
import { locOf } from "../../program.js";
|
||||
import { lowerOptionalArgument, lowerStringSearchArgument } from "../optional-arguments.js";
|
||||
import { lowerToNumberArgument } from "../lower-exprs.js";
|
||||
import { F64, type IrExpr, STRING, arrayOf, isUnitType, typeEquals } from "../../../ir/ir.js";
|
||||
|
||||
/** `String.fromCharCode/fromCodePoint(...codes)` on THE String global: every argument
|
||||
@@ -97,7 +98,9 @@ export function lowerStringStaticCall(
|
||||
const packed = lowerer.lowerExprExpecting(spread.expression, arrayOf(F64));
|
||||
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
|
||||
}
|
||||
const elems = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64));
|
||||
// Each code converts with ToNumber: a missing value is NaN (code unit 0
|
||||
// for fromCharCode, a RangeError for fromCodePoint), exactly Node.
|
||||
const elems = call.arguments.map((a) => lowerToNumberArgument(lowerer, a));
|
||||
const packed: IrExpr = { kind: "arrayLit", elems, type: arrayOf(F64), loc };
|
||||
return { kind: "libCall", fn: `string.${member}`, args: [packed], type: STRING, loc };
|
||||
}
|
||||
|
||||
@@ -32,6 +32,7 @@ import {
|
||||
lowerStaticallyUndefinedArgument,
|
||||
} from "../optional-arguments.js";
|
||||
import { lowerCheckedArrayFrom } from "./array-construction.js";
|
||||
import { lowerToNumberArgument } from "../lower-exprs.js";
|
||||
|
||||
/** Uint8Array.prototype.toSorted. The receiver/comparator expressions are
|
||||
* evaluated before entering the helper; the helper snapshots with
|
||||
@@ -486,7 +487,16 @@ export function lowerBytesMethodCall(
|
||||
lowerer.noLowering(`.${name} with ${nArgs} arguments on typed arrays`, call);
|
||||
}
|
||||
const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
|
||||
const args = call.arguments.map((a) => lowerer.lowerExprExpecting(a, F64));
|
||||
// A missing start is 0 and a missing end is the length (+Infinity
|
||||
// clamps to it), as for omitted arguments.
|
||||
const args = call.arguments.map((a, i) =>
|
||||
lowerOptionalArgument(lowerer, a, F64, {
|
||||
kind: "numLit",
|
||||
value: i === 0 ? 0 : Infinity,
|
||||
type: F64,
|
||||
loc,
|
||||
}),
|
||||
);
|
||||
// subarray is a VIEW (TypedArray.prototype.subarray aliases), and
|
||||
// Buffer's slice() is subarray's deprecated Node alias — resolved by
|
||||
// where the member is declared, the toString discipline below. Only
|
||||
@@ -796,7 +806,8 @@ export function lowerBytesMethodCall(
|
||||
lowerer.noLowering(`.${name} with ${nArgs} arguments`, call);
|
||||
}
|
||||
const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
|
||||
const args = [lowerer.lowerExprExpecting(call.arguments[0]!, F64)];
|
||||
// ToIndex(undefined) is ToIndex(NaN): a missing offset reads offset 0.
|
||||
const args = [lowerToNumberArgument(lowerer, call.arguments[0]!)];
|
||||
if (nArgs === 2) args.push(lowerer.lowerExprExpecting(call.arguments[1]!, BOOL));
|
||||
return { kind: "bytesIntrinsic", method: dvGetter.method, receiver, args, type: F64, loc };
|
||||
}
|
||||
@@ -829,9 +840,11 @@ export function lowerBytesMethodCall(
|
||||
lowerer.noLowering(`.${name} with ${nArgs} arguments`, call);
|
||||
}
|
||||
const receiver = lowerer.lowerExprExpecting(access.expression, receiverIr);
|
||||
// The offset (ToIndex) and the value (ToNumber) treat a missing value
|
||||
// as NaN.
|
||||
const args = [
|
||||
lowerer.lowerExprExpecting(call.arguments[0]!, F64),
|
||||
lowerer.lowerExprExpecting(call.arguments[1]!, F64),
|
||||
lowerToNumberArgument(lowerer, call.arguments[0]!),
|
||||
lowerToNumberArgument(lowerer, call.arguments[1]!),
|
||||
];
|
||||
if (nArgs === 3) args.push(lowerer.lowerExprExpecting(call.arguments[2]!, BOOL));
|
||||
return { kind: "bytesIntrinsic", method: dvSetter.method, receiver, args, type: VOID, loc };
|
||||
|
||||
@@ -107,8 +107,10 @@ import { lowerProcStreamMethodCall } from "./builtins/process.js";
|
||||
import { lowerReflectCall } from "./builtins/reflection.js";
|
||||
import {
|
||||
lowerAbsenceProbe,
|
||||
lowerOptionalNumber,
|
||||
lowerPromiseAllTupleCall,
|
||||
lowerPromiseRejectCall,
|
||||
lowerToNumberArgument,
|
||||
stringWrapperToString,
|
||||
symbolFieldInfo,
|
||||
templateRawTextOf,
|
||||
@@ -9532,7 +9534,8 @@ export function lowerStaticNumberGlobal(
|
||||
);
|
||||
}
|
||||
if (name === "isNaN") {
|
||||
const x = lowerer.lowerExprExpecting(args[0]!, F64);
|
||||
// The global's ToNumber turns a missing value into NaN.
|
||||
const x = lowerToNumberArgument(lowerer, args[0]!);
|
||||
return { kind: "libCall", fn: "num.isNaN", args: [x], type: BOOL, loc };
|
||||
}
|
||||
const radix: IrExpr = args[1]
|
||||
@@ -9662,6 +9665,17 @@ export function lowerStaticNumberGlobal(
|
||||
if (name === "isFinite" && probed?.type.kind === "f64") {
|
||||
return { kind: "libCall", fn: "number.isFinite", args: [probed], type: BOOL, loc };
|
||||
}
|
||||
// A number the checker trusts but storage may hold missing (an
|
||||
// out-of-range read): the global's ToNumber makes undefined NaN.
|
||||
if (
|
||||
name === "isFinite" &&
|
||||
probed?.type.kind === "union" &&
|
||||
lowerer.armTag(probed.type.unionId, UNDEFINED_T) >= 0 &&
|
||||
lowerer.stripUndefinedArm(probed.type).kind === "f64"
|
||||
) {
|
||||
const x = lowerOptionalNumber(lowerer, probed, loc, args[0]);
|
||||
return { kind: "libCall", fn: "number.isFinite", args: [x], type: BOOL, loc };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -363,7 +363,14 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
}
|
||||
lowerExprDepth++;
|
||||
try {
|
||||
const lowered = lowerExprInner(lowerer, expr);
|
||||
let lowered = lowerExprInner(lowerer, expr);
|
||||
const nanCoded = lowerer.nanCodedReads;
|
||||
if (nanCoded) {
|
||||
if (lowered.type.kind === "f64" && nanCoded.reboxes(expr))
|
||||
lowered = reboxNanCoded(lowerer, lowered, locOf(expr));
|
||||
else if (lowered.type.kind === "union" && nanCoded.convertsSource(expr))
|
||||
lowered = lowerOptionalNumber(lowerer, lowered, locOf(expr));
|
||||
}
|
||||
if (lowered.type.kind === "void" && (lowerer.typeOf(expr).flags & ts.TypeFlags.Never) !== 0)
|
||||
lowerer.neverValued.add(lowered);
|
||||
return lowered;
|
||||
@@ -372,6 +379,28 @@ export function lowerExpr(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
}
|
||||
}
|
||||
|
||||
/** `number | undefined` from a NaN-coded double whose slot never holds a
|
||||
* genuine NaN (nan-coded-reads.ts): NaN is exactly undefined. */
|
||||
function reboxNanCoded(lowerer: Lowerer, value: IrExpr, loc: SrcLoc): IrExpr {
|
||||
const optional = lowerer.withUndefinedArm(F64);
|
||||
const stmts: IrStmt[] = [];
|
||||
let stable = value;
|
||||
if (value.kind !== "varRef") {
|
||||
const tmp = lowerer.declareHiddenLocal("%nanCoded", F64);
|
||||
stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc });
|
||||
stable = varRef(tmp.id, F64, loc);
|
||||
}
|
||||
const result: IrExpr = {
|
||||
kind: "ternary",
|
||||
cond: { kind: "libCall", fn: "num.isNaN", args: [stable], type: BOOL, loc },
|
||||
then: lowerer.wrappedUndefined(optional, loc)!,
|
||||
else_: lowerer.coerceToExpected(stable, optional),
|
||||
type: optional,
|
||||
loc,
|
||||
};
|
||||
return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: optional, loc };
|
||||
}
|
||||
|
||||
function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
const loc = locOf(expr);
|
||||
|
||||
@@ -2576,6 +2605,10 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
|
||||
lowerer.isRuntimeOptionalField(handled.shapeId, handled.field)
|
||||
)
|
||||
return handled;
|
||||
// A class field assigned a missing read: the checker narrows this
|
||||
// read to the assigned value's bare type, but the stored undefined
|
||||
// arm is real (`obj.n = bytes[i]; obj.n` is undefined in Node).
|
||||
if (runtimeOptionalClassField(lowerer, handled, expr)) return handled;
|
||||
return lowerer.maybeNarrow(handled, expr);
|
||||
}
|
||||
// `globalThis.<name>` that no lowering above claimed
|
||||
@@ -6548,6 +6581,33 @@ export function lowerOptionalNumber(
|
||||
: { kind: "seqExpr", stmts: prefix, result: value, type: F64, loc };
|
||||
}
|
||||
|
||||
/** A number argument of a builtin that converts it with ToNumber (Math
|
||||
* functions, String.fromCharCode, the global isNaN/isFinite, DataView
|
||||
* offsets and values). ToNumber(undefined) is NaN, so a missing value — an
|
||||
* out-of-range read, a local or field holding one, or a hole — converts to
|
||||
* NaN instead of trusting the checker's number type. */
|
||||
export function lowerToNumberArgument(lowerer: Lowerer, node: ts.Expression): IrExpr {
|
||||
let inner = node;
|
||||
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
|
||||
const loc = locOf(inner);
|
||||
const isOptionalNumber = (value: IrExpr): boolean =>
|
||||
value.type.kind === "union" &&
|
||||
lowerer.armTag(value.type.unionId, UNDEFINED_T) >= 0 &&
|
||||
lowerer.stripUndefinedArm(value.type).kind === "f64";
|
||||
const optional = ts.isIdentifier(inner)
|
||||
? runtimeOptionalStorageOperand(lowerer, inner)
|
||||
: ts.isElementAccessExpression(inner)
|
||||
? lowerAbsenceProbe(lowerer, inner)
|
||||
: null;
|
||||
if (optional && isOptionalNumber(optional))
|
||||
return lowerOptionalNumber(lowerer, optional, loc, inner);
|
||||
// A conditional builds each branch for the destination.
|
||||
if (ts.isConditionalExpression(inner)) return lowerer.lowerExprExpecting(node, F64);
|
||||
const value = lowerer.lowerExpr(node);
|
||||
if (isOptionalNumber(value)) return lowerOptionalNumber(lowerer, value, loc, inner);
|
||||
return lowerer.coerceInto(node, value, F64);
|
||||
}
|
||||
|
||||
/** `a[i]` reads. Only f64 indices into array receivers are modeled; JS
|
||||
* string-key element access (`a["length"]`) and string indexing (`s[0]`
|
||||
* typechecks against the lib's index signature; use .charAt) stay out. */
|
||||
@@ -6858,7 +6918,12 @@ export function lowerElementAccess(lowerer: Lowerer, expr: ts.ElementAccessExpre
|
||||
// `t[i]!` asserts presence: the numeric element read (NaN when
|
||||
// invalid). Every other read answers undefined for an invalid index,
|
||||
// like Node.
|
||||
if (ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) {
|
||||
// A read whose every consumer treats undefined like NaN (see
|
||||
// nan-coded-reads.ts) is the same plain numeric read.
|
||||
if (
|
||||
(ts.isNonNullExpression(expr.parent) && expr.parent.expression === expr) ||
|
||||
lowerer.nanCodedReads?.isNumericRead(expr) === true
|
||||
) {
|
||||
return {
|
||||
kind: "bytesIntrinsic",
|
||||
method: "get",
|
||||
@@ -7651,6 +7716,24 @@ export function runtimeOptionalStorageOperand(
|
||||
return optional.value;
|
||||
}
|
||||
|
||||
/** A `T | undefined` class field read that the checker narrowed to `T`
|
||||
* where the field was assigned a value that may be missing (an unchecked
|
||||
* indexed read). TypeScript's assignment narrowing is not a runtime fact
|
||||
* there, so the read keeps the stored union instead of extracting `T`. */
|
||||
function runtimeOptionalClassField(
|
||||
lowerer: Lowerer,
|
||||
value: IrExpr,
|
||||
node: ts.PropertyAccessExpression,
|
||||
): boolean {
|
||||
if (value.kind !== "fieldGet" || value.type.kind !== "union") return false;
|
||||
if (lowerer.armTag(value.type.unionId, UNDEFINED_T) < 0) return false;
|
||||
if ((lowerer.unions.get(value.type.unionId)?.arms.length ?? 0) !== 2) return false;
|
||||
const symbol = lowerer.checker.getSymbolAtLocation(node.name);
|
||||
if (!symbol || !lowerer.runtimeOptionalAssignedSymbols.has(symbol)) return false;
|
||||
const narrowed = lowerer.mapTypeOf(lowerer.typeOf(node));
|
||||
return narrowed !== null && typeEquals(narrowed, lowerer.stripUndefinedArm(value.type));
|
||||
}
|
||||
|
||||
/** A presence-test operand: an absence-aware element or field read, an
|
||||
* identifier's runtime-optional storage, or the ordinary lowering. */
|
||||
export function lowerPresenceOperand(lowerer: Lowerer, node: ts.Expression): IrExpr {
|
||||
@@ -9760,6 +9843,65 @@ export function lowerCompoundValueToTarget(
|
||||
lowerer.unsupported("SC1043", expr);
|
||||
}
|
||||
|
||||
/** Equality of two NaN-coded values (nan-coded-reads.ts), each of which may
|
||||
* stand for undefined and never holds a genuine NaN: Node's undefined equals
|
||||
* undefined, so two NaNs compare equal here. Operands evaluate once, in
|
||||
* order. */
|
||||
function lowerNanCodedEquality(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr | null {
|
||||
const loc = locOf(expr);
|
||||
const left = lowerer.lowerExpr(expr.left);
|
||||
const right = lowerer.lowerExpr(expr.right);
|
||||
if (left.type.kind !== "f64" || right.type.kind !== "f64") {
|
||||
throw new InternalCompilerError("NaN-coded equality operand is not a plain number");
|
||||
}
|
||||
const stmts: IrStmt[] = [];
|
||||
const stable = (value: IrExpr, name: string): IrExpr => {
|
||||
if (value.kind === "varRef" || value.kind === "numLit") return value;
|
||||
const tmp = lowerer.declareHiddenLocal(name, F64);
|
||||
stmts.push({ kind: "varDecl", localId: tmp.id, init: value, loc });
|
||||
return varRef(tmp.id, F64, loc);
|
||||
};
|
||||
const a = stable(left, "%eqLeft");
|
||||
const b = stable(right, "%eqRight");
|
||||
const isNaN = (value: IrExpr): IrExpr => ({
|
||||
kind: "libCall",
|
||||
fn: "num.isNaN",
|
||||
args: [value],
|
||||
type: BOOL,
|
||||
loc,
|
||||
});
|
||||
const bothUndefined: IrExpr = {
|
||||
kind: "logical",
|
||||
op: "&&",
|
||||
left: isNaN(a),
|
||||
right: isNaN(b),
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
const op = expr.operatorToken.kind;
|
||||
const negated =
|
||||
op === ts.SyntaxKind.ExclamationEqualsEqualsToken ||
|
||||
op === ts.SyntaxKind.ExclamationEqualsToken;
|
||||
const result: IrExpr = negated
|
||||
? {
|
||||
kind: "logical",
|
||||
op: "&&",
|
||||
left: { kind: "bin", op: "!==", left: a, right: b, type: BOOL, loc },
|
||||
right: { kind: "unary", op: "!", operand: bothUndefined, type: BOOL, loc },
|
||||
type: BOOL,
|
||||
loc,
|
||||
}
|
||||
: {
|
||||
kind: "logical",
|
||||
op: "||",
|
||||
left: { kind: "bin", op: "===", left: a, right: b, type: BOOL, loc },
|
||||
right: bothUndefined,
|
||||
type: BOOL,
|
||||
loc,
|
||||
};
|
||||
return stmts.length === 0 ? result : { kind: "seqExpr", stmts, result, type: BOOL, loc };
|
||||
}
|
||||
|
||||
export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr {
|
||||
const loc = locOf(expr);
|
||||
const op = expr.operatorToken.kind;
|
||||
@@ -9767,6 +9909,11 @@ export function lowerBinary(lowerer: Lowerer, expr: ts.BinaryExpression): IrExpr
|
||||
const cacheHas = lowerRequireCacheHas(lowerer, expr);
|
||||
if (cacheHas) return cacheHas;
|
||||
|
||||
if (lowerer.nanCodedReads?.needsUndefinedEquality(expr)) {
|
||||
const undefinedEquality = lowerNanCodedEquality(lowerer, expr);
|
||||
if (undefinedEquality) return undefinedEquality;
|
||||
}
|
||||
|
||||
if (
|
||||
op === ts.SyntaxKind.EqualsToken ||
|
||||
(op >= ts.SyntaxKind.FirstCompoundAssignment && op <= ts.SyntaxKind.LastCompoundAssignment)
|
||||
@@ -14372,6 +14519,15 @@ function classFieldTarget(
|
||||
const lowerObjectReceiver = (): IrExpr => {
|
||||
let obj = represented;
|
||||
if (obj.type.kind === "union") {
|
||||
// A receiver that may hold a missing value (a field assigned an
|
||||
// unchecked read): Node's member-read TypeError for the unit arm.
|
||||
const present = lowerer.runtimeOptionalPropertyReceiver(
|
||||
receiverNode,
|
||||
obj,
|
||||
receiverType,
|
||||
fieldName,
|
||||
);
|
||||
if (present) return present;
|
||||
const helper = lowerer.narrowedArmHelper(obj.type.unionId, receiverType, locOf(receiverNode));
|
||||
obj = helper
|
||||
? lowerer.checkedNarrowCall(helper, obj, receiverType, locOf(receiverNode))
|
||||
|
||||
@@ -5,11 +5,7 @@ import { InternalCompilerError } from "../../errors.js";
|
||||
* surface (Math and number/string methods under --dynamic), and the npm
|
||||
* package boundary fences for node_modules-declared symbols. */
|
||||
import * as ts from "../ts7/adapter.js";
|
||||
import {
|
||||
lowerAbsenceProbe,
|
||||
lowerOptionalNumber,
|
||||
runtimeOptionalStorageOperand,
|
||||
} from "./lower-exprs.js";
|
||||
import { lowerToNumberArgument } from "./lower-exprs.js";
|
||||
import type { Lowerer } from "./lowerer.js";
|
||||
import {
|
||||
arrayOf,
|
||||
@@ -3401,23 +3397,10 @@ export function lowerIslandMethodCall(
|
||||
// Static numeric Math calls precede the island path. The scalar methods
|
||||
// use their declared arity; min/max and hypot accept their variadic forms.
|
||||
const staticMath = isMath ? own(STATIC_MATH_FNS, name) : undefined;
|
||||
// A missing element (a hole bound by a loop, a local holding a missing
|
||||
// read, or the read itself) converts to NaN, ToNumber's answer for
|
||||
// undefined, instead of trusting the checker's number type.
|
||||
const mathArg = (a: ts.Expression): IrExpr => {
|
||||
let inner = a;
|
||||
while (ts.isParenthesizedExpression(inner)) inner = inner.expression;
|
||||
const optional = ts.isIdentifier(inner)
|
||||
? runtimeOptionalStorageOperand(lowerer, inner)
|
||||
: ts.isElementAccessExpression(inner)
|
||||
? lowerAbsenceProbe(lowerer, inner)
|
||||
: null;
|
||||
return optional &&
|
||||
optional.type.kind === "union" &&
|
||||
lowerer.stripUndefinedArm(optional.type).kind === "f64"
|
||||
? lowerOptionalNumber(lowerer, optional, loc, inner)
|
||||
: lowerer.lowerExprExpecting(a, F64);
|
||||
};
|
||||
// A missing element (a hole bound by a loop, a local or field holding a
|
||||
// missing read, or the read itself) converts to NaN, ToNumber's answer
|
||||
// for undefined, instead of trusting the checker's number type.
|
||||
const mathArg = (a: ts.Expression): IrExpr => lowerToNumberArgument(lowerer, a);
|
||||
if (staticMath && name === "hypot") {
|
||||
const elems = call.arguments.map((a) =>
|
||||
ts.isSpreadElement(a) ? lowerer.lowerExprExpecting(a.expression, arrayOf(F64)) : mathArg(a),
|
||||
|
||||
@@ -4901,6 +4901,18 @@ export function lowerVarDecl(
|
||||
init.type.kind === "func"
|
||||
)
|
||||
type = init.type;
|
||||
// A TypeScript lambda whose closure returns the runtime result (a missing
|
||||
// read, a `??` default of another type) instead of the checker's bare
|
||||
// return type: the binding keeps the checker's parameters and takes the
|
||||
// closure's result.
|
||||
if (
|
||||
!decl.type &&
|
||||
type?.kind === "func" &&
|
||||
init.type.kind === "func" &&
|
||||
(ts.isArrowFunction(decl.initializer) || ts.isFunctionExpression(decl.initializer)) &&
|
||||
lowerer.runtimeOptionalFunctionReturns.has(decl.initializer)
|
||||
)
|
||||
type = { ...type, ret: init.type.ret };
|
||||
if (
|
||||
lowerer.implicitParamTypes !== null &&
|
||||
init.type.kind === "dyn" &&
|
||||
|
||||
@@ -76,6 +76,7 @@ import { ArrayElementStates } from "./runtime-optional-elements.js";
|
||||
import { indexReadInBounds } from "./runtime-optional-bounds.js";
|
||||
import { ArrayOwnership } from "./runtime-optional-ownership.js";
|
||||
import { StringIndexBounds } from "./string-index-bounds.js";
|
||||
import { NanCodedReads } from "./nan-coded-reads.js";
|
||||
import { RuntimeOptionalLocals } from "./runtime-optional-locals.js";
|
||||
import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js";
|
||||
import { UnregisteredClassTypes } from "./unregistered-class-types.js";
|
||||
@@ -1642,6 +1643,10 @@ export class Lowerer {
|
||||
/** Per-symbol result of the never-reassigned file scan
|
||||
* (bindingNeverReassigned — object-literal generic-method receivers). */
|
||||
readonly neverReassignedCache = new Map<ts.Symbol, boolean>();
|
||||
/** Typed-array element reads whose every consumer treats undefined like
|
||||
* NaN, which lower to plain doubles (see nan-coded-reads.ts). Settled by
|
||||
* analyzeRuntimeOptionalArrayReads before any body lowers. */
|
||||
nanCodedReads: NanCodedReads | null = null;
|
||||
private stringIndexBoundsCache: StringIndexBounds | null = null;
|
||||
/** Proofs that a string element read names an existing code unit. The
|
||||
* indexed-read analysis and expression lowering share one instance, so a
|
||||
@@ -3899,6 +3904,38 @@ export class Lowerer {
|
||||
}
|
||||
}
|
||||
}
|
||||
const mappedOrNull = (node: ts.Node): IrType | null =>
|
||||
panicSafe(() => this.mapTypeOf(this.typeOf(node)), null);
|
||||
const nanCoded = new NanCodedReads(
|
||||
{
|
||||
symbolOf,
|
||||
declarationOf: (symbol) => this.checker.valueDeclarationOf(symbol),
|
||||
bytesElemOf: (receiver) => {
|
||||
const t = mappedOrNull(receiver);
|
||||
return t?.kind === "bytes" ? t.elem : null;
|
||||
},
|
||||
isNumber: (node) => mappedOrNull(node)?.kind === "f64",
|
||||
isString: (node) => mappedOrNull(node)?.kind === "string",
|
||||
hasLength: (node) => {
|
||||
const kind = mappedOrNull(node)?.kind;
|
||||
return kind === "array" || kind === "bytes";
|
||||
},
|
||||
isStdlibGlobal: (node, name) => this.isStdlibGlobal(node, name),
|
||||
paramIsNumber: (fn, index) => {
|
||||
const param = signatureBySymbol.get(fn)?.params[index];
|
||||
return (
|
||||
param !== undefined &&
|
||||
param.type.kind === "f64" &&
|
||||
(param.mode === "required" || param.mode === "omittable")
|
||||
);
|
||||
},
|
||||
returnIsNumber: (fn) => signatureBySymbol.get(fn)?.returnType.kind === "f64",
|
||||
methodOverridden: (fn) => (familyBySymbol.get(fn)?.length ?? 1) > 1,
|
||||
},
|
||||
sourceFiles,
|
||||
);
|
||||
nanCoded.analyze();
|
||||
this.nanCodedReads = nanCoded;
|
||||
const peel = (node: ts.Expression): ts.Expression => {
|
||||
let e = node;
|
||||
while (
|
||||
@@ -4010,8 +4047,10 @@ export class Lowerer {
|
||||
if (!ts.isElementAccessExpression(e)) return false;
|
||||
const kind = this.mapTypeOf(this.typeOf(e.expression))?.kind;
|
||||
if (kind === "string") return !this.stringIndexBounds.inBounds(e);
|
||||
// Typed-array reads answer undefined for an invalid index too.
|
||||
return kind === "array" || kind === "bytes";
|
||||
// Typed-array reads answer undefined for an invalid index too, unless
|
||||
// every consumer treats undefined like NaN.
|
||||
if (kind === "bytes") return !nanCoded.isNumericRead(e);
|
||||
return kind === "array";
|
||||
};
|
||||
const isDynamicObjectEntryRead = (node: ts.Expression): boolean => {
|
||||
const read = peel(node);
|
||||
@@ -4099,6 +4138,11 @@ export class Lowerer {
|
||||
) >= 0
|
||||
)
|
||||
return true;
|
||||
// A NaN-coded value that reaches a consumer able to observe undefined
|
||||
// is rebuilt as the ordinary optional value there; every other read
|
||||
// of a NaN-coded slot is a plain number.
|
||||
if (nanCoded.reboxes(e)) return true;
|
||||
if (nanCoded.readsNanCodedSlot(e)) return false;
|
||||
if (ts.isIdentifier(e)) {
|
||||
const symbol = symbolOf(e);
|
||||
return (
|
||||
@@ -4184,6 +4228,23 @@ export class Lowerer {
|
||||
if (!symbol) return false;
|
||||
return noteFieldSymbol(symbol, name);
|
||||
};
|
||||
/** An instance field of a TypeScript class whose declared storage is
|
||||
* exactly `T | undefined`: an assignment from a missing read stores the
|
||||
* undefined arm while the checker narrows later reads to `T`. */
|
||||
const optionalInstanceField = (symbol: ts.Symbol): boolean => {
|
||||
const decl = this.checker.valueDeclarationOf(symbol);
|
||||
if (!decl || !ts.isPropertyDeclaration(decl) || !ts.isIdentifier(decl.name)) return false;
|
||||
if (ts.getCombinedModifierFlags(decl) & ts.ModifierFlags.Static) return false;
|
||||
const sf = decl.getSourceFile();
|
||||
if (isJsSourceFile(sf) || sf.isDeclarationFile) return false;
|
||||
const declared = typeOrNull(decl.name);
|
||||
const mapped = declared ? this.mapTypeOf(declared) : null;
|
||||
if (mapped?.kind !== "union" || this.armTag(mapped.unionId, UNDEFINED_T) < 0) return false;
|
||||
const arms = this.unions.get(mapped.unionId)?.arms ?? [];
|
||||
return (
|
||||
arms.length === 2 && arms.every((arm) => arm.kind === "undefinedT" || !isUnitType(arm))
|
||||
);
|
||||
};
|
||||
const noteFieldSymbol = (symbol: ts.Symbol, name: string): boolean => {
|
||||
const set = optionalFields.get(symbol) ?? new Set<string>();
|
||||
const before = set.size;
|
||||
@@ -4314,12 +4375,29 @@ export class Lowerer {
|
||||
return found;
|
||||
};
|
||||
const jsLambdas: ts.FunctionLikeDeclaration[] = [];
|
||||
// TypeScript lambdas keep the checker's inferred return type, which
|
||||
// spells a missing read as its bare element type and `read ?? "text"`
|
||||
// as a number. Their closures return the runtime result instead.
|
||||
const tsLambdas: ts.FunctionLikeDeclaration[] = [];
|
||||
for (const sf of sourceFiles) {
|
||||
if (!isJsSourceFile(sf)) continue;
|
||||
const js = isJsSourceFile(sf);
|
||||
if (!js && sf.isDeclarationFile) continue;
|
||||
ts.walkPreorder(sf, (node) => {
|
||||
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node)) jsLambdas.push(node);
|
||||
if (ts.isArrowFunction(node) || ts.isFunctionExpression(node))
|
||||
(js ? jsLambdas : tsLambdas).push(node);
|
||||
});
|
||||
}
|
||||
/** The primitive result union of a lambda's returns (see
|
||||
* optionalPrimitiveResultType), as declared functions record it. */
|
||||
const lambdaArithmeticReturn = (fn: ts.FunctionLikeDeclaration): IrType | null => {
|
||||
if (fn.body && !ts.isBlock(fn.body))
|
||||
return optionalPrimitiveResultType(fn.body as ts.Expression);
|
||||
for (const expression of returnsOf(fn)) {
|
||||
const result = optionalPrimitiveResultType(expression);
|
||||
if (result) return result;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
for (const [symbol, signature] of signatureBySymbol) {
|
||||
const declaration = functionDeclBySymbol.get(symbol);
|
||||
if (!declaration || !isJsSourceFile(declaration.getSourceFile())) continue;
|
||||
@@ -5010,6 +5088,13 @@ export class Lowerer {
|
||||
optionalSymbols.add(field);
|
||||
provenance?.note("static-field", node.left, node.right);
|
||||
changed = true;
|
||||
} else if (field && !optionalSymbols.has(field) && optionalInstanceField(field)) {
|
||||
// The checker narrows the field to the assigned read's bare
|
||||
// type, but the stored value can be undefined: reads keep the
|
||||
// stored union (runtimeOptionalClassField).
|
||||
optionalSymbols.add(field);
|
||||
provenance?.note("assigned-field", node.left, node.right);
|
||||
changed = true;
|
||||
} else if (ts.isIdentifier(node.left.expression)) {
|
||||
const symbol = symbolOf(node.left.expression);
|
||||
if (symbol && noteFieldSymbol(symbol, node.left.name.text)) {
|
||||
@@ -5344,6 +5429,54 @@ export class Lowerer {
|
||||
for (const sf of sourceFiles) if (scanFile(sf)) changed = true;
|
||||
for (const [symbol, decl] of functionDeclBySymbol)
|
||||
if (scanReturns(symbol, decl)) changed = true;
|
||||
for (const fn of tsLambdas) {
|
||||
// An annotated return is the author's contract, as for parameters.
|
||||
if (fn.type) continue;
|
||||
const mapped = this.mapTypeOf(this.typeOf(fn));
|
||||
if (mapped?.kind !== "func") continue;
|
||||
const previous = this.runtimeOptionalFunctionReturnType(fn, mapped.ret);
|
||||
// Async, generator and void bodies keep their own result shapes.
|
||||
if (
|
||||
previous.kind === "promise" ||
|
||||
previous.kind === "generator" ||
|
||||
previous.kind === "void"
|
||||
)
|
||||
continue;
|
||||
const arithmetic = lambdaArithmeticReturn(fn);
|
||||
const next =
|
||||
arithmetic &&
|
||||
(previous.kind === "f64" || previous.kind === "string" || previous.kind === "bool")
|
||||
? arithmetic
|
||||
: bodyReturnsOptional(fn)
|
||||
? this.runtimeOptionalType(previous)
|
||||
: previous;
|
||||
if (!typeEquals(previous, next)) {
|
||||
this.runtimeOptionalFunctionReturns.set(fn, next);
|
||||
provenance?.note("lambda-return", fn, fn.body);
|
||||
changed = true;
|
||||
}
|
||||
// Calls through the lambda's own binding (`const get = () => xs[i];
|
||||
// get()`) produce the same result as calls of a declared function.
|
||||
const binding = fn.parent;
|
||||
if (
|
||||
next !== previous &&
|
||||
ts.isVariableDeclaration(binding) &&
|
||||
binding.initializer === fn &&
|
||||
!binding.type &&
|
||||
ts.isIdentifier(binding.name)
|
||||
) {
|
||||
const symbol = symbolOf(binding.name);
|
||||
if (symbol && next.kind === "union" && this.armTag(next.unionId, UNDEFINED_T) >= 0) {
|
||||
if (!optionalReturns.has(symbol)) {
|
||||
optionalReturns.add(symbol);
|
||||
changed = true;
|
||||
}
|
||||
} else if (symbol && !arithmeticReturns.has(symbol)) {
|
||||
arithmeticReturns.set(symbol, next);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fn of jsLambdas) {
|
||||
if (!bodyReturnsOptional(fn)) continue;
|
||||
const mapped = this.mapTypeOf(this.typeOf(fn));
|
||||
@@ -5377,6 +5510,21 @@ export class Lowerer {
|
||||
}
|
||||
}
|
||||
}
|
||||
// NaN-coded slots convert optional values on entry instead of holding
|
||||
// the union, so promotion never applies to them.
|
||||
for (const symbol of [...optionalSymbols])
|
||||
if (nanCoded.nanCodedBinding(symbol)) optionalSymbols.delete(symbol);
|
||||
for (const symbol of [...optionalReturns])
|
||||
if (nanCoded.nanCodedReturn(symbol)) optionalReturns.delete(symbol);
|
||||
for (const [symbol, params] of optionalParams) {
|
||||
const decl = functionDeclBySymbol.get(symbol);
|
||||
for (const index of [...params]) {
|
||||
const name = decl?.parameters[index]?.name;
|
||||
const param = name && ts.isIdentifier(name) ? symbolOf(name) : null;
|
||||
if (param && nanCoded.nanCodedBinding(param)) params.delete(index);
|
||||
}
|
||||
if (params.size === 0) optionalParams.delete(symbol);
|
||||
}
|
||||
for (const [site, present] of presentSites) if (present) this.presentElementCalls.add(site);
|
||||
for (const [loop, present] of presentLoops) if (present) this.presentElementLoops.add(loop);
|
||||
const globalsById = new Map(this.globalsList.map((global) => [global.id, global]));
|
||||
@@ -5439,11 +5587,11 @@ export class Lowerer {
|
||||
const symbol = symbolOf(node.name);
|
||||
if (!symbol) return;
|
||||
if (
|
||||
isJsSourceFile(sf) &&
|
||||
!node.type &&
|
||||
!hasJsTypeAnnotation(node) &&
|
||||
node.initializer &&
|
||||
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer))
|
||||
(ts.isArrowFunction(node.initializer) || ts.isFunctionExpression(node.initializer)) &&
|
||||
(isJsSourceFile(sf) || this.runtimeOptionalFunctionReturns.has(node.initializer))
|
||||
) {
|
||||
const global = this.globalsBySymbol.get(symbol);
|
||||
if (global?.type.kind === "func")
|
||||
@@ -9544,14 +9692,32 @@ export class Lowerer {
|
||||
/* ── scoping and captures ─────────────────────────────────────────── */
|
||||
|
||||
declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal {
|
||||
return declareContextLocal(
|
||||
const symbol = this.checker.getSymbolAtLocation(nameNode);
|
||||
const local = declareContextLocal(
|
||||
this.ctx,
|
||||
name,
|
||||
type,
|
||||
mutable,
|
||||
this.checker.getSymbolAtLocation(nameNode),
|
||||
symbol,
|
||||
bindingSource(nameNode),
|
||||
);
|
||||
// A binding declared `T | undefined` that receives a missing read: the
|
||||
// checker narrows it to `T` after the assignment, but the stored value
|
||||
// can be undefined. Track it like widened storage so bare reads keep
|
||||
// the union until a guard proves presence.
|
||||
if (
|
||||
symbol &&
|
||||
this.runtimeOptionalAssignedSymbols.has(symbol) &&
|
||||
type.kind === "union" &&
|
||||
this.armTag(type.unionId, UNDEFINED_T) >= 0 &&
|
||||
this.unions.get(type.unionId)?.arms.length === 2 &&
|
||||
!isUnitType(this.stripUndefinedArm(type))
|
||||
) {
|
||||
const root = this.runtimeOptionalRootOf(local);
|
||||
this.runtimeOptionalStorageLocals.add(root);
|
||||
this.runtimeOptionalLocals.add(root);
|
||||
}
|
||||
return local;
|
||||
}
|
||||
|
||||
/** A never-returning call in a value position of type `expected`: the
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -80,3 +80,35 @@ test("a guarded read forwarded to a call is bounded when nothing earlier can cha
|
||||
});
|
||||
expect(results).toEqual([true, true, false, false, true]);
|
||||
});
|
||||
|
||||
test("an if condition reads like a statement, and a same-length fact extends the guard", () => {
|
||||
const file = parseSourceFile(
|
||||
"same-length.ts",
|
||||
`declare function use(...values: number[]): boolean;
|
||||
function f(xs: Uint32Array, ys: Uint32Array, zs: Uint32Array) {
|
||||
for (let i = 0; i < xs.length; i++) if (use(xs[i], ys[i], zs[i])) return;
|
||||
for (let i = 0; i < xs.length; i++) if (use(xs[i], use(), ys[i])) return;
|
||||
}`,
|
||||
"ts",
|
||||
);
|
||||
const results: boolean[] = [];
|
||||
const facts = {
|
||||
conditions: true,
|
||||
sameLength: (array: ts.Expression, other: ts.Expression) =>
|
||||
ts.isIdentifier(array) &&
|
||||
ts.isIdentifier(other) &&
|
||||
[array.text, other.text].sort().join() === "xs,ys",
|
||||
};
|
||||
ts.walkPreorder(file, (node) => {
|
||||
if (ts.isElementAccessExpression(node)) results.push(indexReadInBounds(node, facts));
|
||||
return undefined;
|
||||
});
|
||||
expect(results).toEqual([true, true, false, true, false]);
|
||||
// Without the opt-in, a read in an `if` condition keeps its old answer.
|
||||
const plain: boolean[] = [];
|
||||
ts.walkPreorder(file, (node) => {
|
||||
if (ts.isElementAccessExpression(node)) plain.push(indexReadInBounds(node));
|
||||
return undefined;
|
||||
});
|
||||
expect(plain).toEqual([false, false, false, false, false]);
|
||||
});
|
||||
|
||||
@@ -24,12 +24,28 @@ export function indexStoreInBounds(store: ts.BinaryExpression): boolean {
|
||||
return boundedBefore(parent, { array, index }, [store.right]);
|
||||
}
|
||||
|
||||
/** Facts about the program that a purely syntactic guard cannot see. */
|
||||
export interface BoundsFacts {
|
||||
/** `other` always has the same length as `array` (for example, two
|
||||
* fixed-length typed arrays allocated with the same length), so a guard
|
||||
* `i < other.length` bounds `array[i]` as well. */
|
||||
sameLength?(array: ts.Expression, other: ts.Expression): boolean;
|
||||
/** A read inside an `if` condition is bounded like one in a statement.
|
||||
* Opt-in: a caller that does not also prove the index a non-negative
|
||||
* integer keeps its established decision for such reads. */
|
||||
conditions?: boolean;
|
||||
}
|
||||
|
||||
/** Proves that an element read `xs[i]` (typically `xs[i]!` forwarded to a
|
||||
* call) names an index below the length, by the same dominating guards as
|
||||
* indexStoreInBounds. Everything the enclosing statement evaluates before
|
||||
* the read (a callee, earlier arguments, a left operand) joins the code
|
||||
* that must leave the operands unchanged. */
|
||||
export function indexReadInBounds(read: ts.ElementAccessExpression): boolean {
|
||||
* that must leave the operands unchanged. The proof says nothing about the
|
||||
* sign or integrality of the index. */
|
||||
export function indexReadInBounds(
|
||||
read: ts.ElementAccessExpression,
|
||||
facts: BoundsFacts = {},
|
||||
): boolean {
|
||||
if (read.questionDotToken) return false;
|
||||
const array = peel(read.expression);
|
||||
const index = peel(read.argumentExpression);
|
||||
@@ -63,12 +79,15 @@ export function indexReadInBounds(read: ts.ElementAccessExpression): boolean {
|
||||
parent.parent.declarations[0] === parent &&
|
||||
ts.isVariableStatement(parent.parent.parent)
|
||||
) {
|
||||
return boundedBefore(parent.parent.parent, { array, index }, before);
|
||||
return boundedBefore(parent.parent.parent, { array, index, facts }, before);
|
||||
} else if (
|
||||
(ts.isExpressionStatement(parent) || ts.isReturnStatement(parent)) &&
|
||||
(ts.isExpressionStatement(parent) ||
|
||||
ts.isReturnStatement(parent) ||
|
||||
(facts.conditions === true && ts.isIfStatement(parent))) &&
|
||||
parent.expression === child
|
||||
) {
|
||||
return boundedBefore(parent, { array, index }, before);
|
||||
// An `if` condition runs before its branches, like a statement.
|
||||
return boundedBefore(parent, { array, index, facts }, before);
|
||||
} else return false;
|
||||
child = parent;
|
||||
parent = parent.parent;
|
||||
@@ -138,6 +157,7 @@ function boundedBefore(statement: ts.Statement, operands: Operands, start: ts.No
|
||||
interface Operands {
|
||||
array: ts.Expression;
|
||||
index: ts.Expression;
|
||||
facts?: BoundsFacts;
|
||||
}
|
||||
|
||||
function isFunctionBoundary(node: ts.Node): boolean {
|
||||
@@ -180,26 +200,28 @@ function sameReference(a: ts.Expression, b: ts.Expression): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
function isLengthOf(node: ts.Expression, array: ts.Expression): boolean {
|
||||
function isLengthOf(node: ts.Expression, array: ts.Expression, facts?: BoundsFacts): boolean {
|
||||
const e = peel(node);
|
||||
return (
|
||||
ts.isPropertyAccessExpression(e) &&
|
||||
!e.questionDotToken &&
|
||||
e.name.text === "length" &&
|
||||
sameReference(e.expression, array)
|
||||
(sameReference(e.expression, array) || facts?.sameLength?.(array, e.expression) === true)
|
||||
);
|
||||
}
|
||||
|
||||
/** `condition` holding proves `index < array.length`. */
|
||||
function boundedBy(condition: ts.Expression, { array, index }: Operands): boolean {
|
||||
function boundedBy(condition: ts.Expression, operands: Operands): boolean {
|
||||
const { array, index, facts } = operands;
|
||||
const e = peel(condition);
|
||||
if (!ts.isBinaryExpression(e)) return false;
|
||||
const op = e.operatorToken.kind;
|
||||
if (op === ts.SyntaxKind.AmpersandAmpersandToken)
|
||||
return boundedBy(e.left, { array, index }) || boundedBy(e.right, { array, index });
|
||||
return boundedBy(e.left, operands) || boundedBy(e.right, operands);
|
||||
if (op === ts.SyntaxKind.LessThanToken)
|
||||
return sameReference(e.left, index) && isLengthOf(e.right, array);
|
||||
return sameReference(e.left, index) && isLengthOf(e.right, array, facts);
|
||||
if (op === ts.SyntaxKind.GreaterThanToken)
|
||||
return isLengthOf(e.left, array) && sameReference(e.right, index);
|
||||
return isLengthOf(e.left, array, facts) && sameReference(e.right, index);
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -103,7 +103,8 @@ import {
|
||||
} from "./library/sidecar.js";
|
||||
import { type IrModule, type SrcLoc } from "./ir/ir.js";
|
||||
import { moduleRuntimeFeatures } from "./ir/runtime-features.js";
|
||||
import { serializeModule } from "./ir/serialize.js";
|
||||
import { serializeModuleParts } from "./ir/serialize.js";
|
||||
import { writeTextParts } from "./text-parts.js";
|
||||
import { validateModule } from "./ir/validate.js";
|
||||
import { loadProgram } from "./frontend/program-node.js";
|
||||
import { runFrontend, type FrontendFactory } from "./frontend/pipeline.js";
|
||||
@@ -206,7 +207,13 @@ export {
|
||||
} from "./compat/fetch-profile.js";
|
||||
export { LIB_FN_SIGS } from "./ir/builtin-signatures.js";
|
||||
export { validateModule } from "./ir/validate.js";
|
||||
export { deserializeModule, IR_VERSION, serializeModule } from "./ir/serialize.js";
|
||||
export {
|
||||
deserializeModule,
|
||||
deserializeModuleBytes,
|
||||
IR_VERSION,
|
||||
serializeModule,
|
||||
serializeModuleParts,
|
||||
} from "./ir/serialize.js";
|
||||
export {
|
||||
resolveLibraryFences,
|
||||
type LibraryFenceDecl,
|
||||
@@ -670,7 +677,7 @@ async function prepareExecutableInput(
|
||||
|
||||
if (outputKind === "ir") {
|
||||
await mkdir(dirname(opts.outPath), { recursive: true });
|
||||
await writeFile(opts.outPath, serializeModule(mod, true));
|
||||
await writeTextParts(opts.outPath, serializeModuleParts(mod));
|
||||
return { ok: true, artifact: { kind: "ir", path: opts.outPath } };
|
||||
}
|
||||
|
||||
@@ -692,7 +699,7 @@ async function prepareExecutableInput(
|
||||
}
|
||||
if (outputKind === "llvm") {
|
||||
await mkdir(dirname(opts.outPath), { recursive: true });
|
||||
await writeFile(opts.outPath, llvm);
|
||||
await writeTextParts(opts.outPath, llvm);
|
||||
} else {
|
||||
try {
|
||||
await emitNativeArtifact({
|
||||
@@ -766,7 +773,7 @@ async function prepareExecutableInput(
|
||||
let irPath: string | undefined;
|
||||
if (opts.emitIr) {
|
||||
irPath = defaultSourcePaths.ir;
|
||||
await writeFile(irPath, serializeModule(mod, true));
|
||||
await writeTextParts(irPath, serializeModuleParts(mod));
|
||||
}
|
||||
|
||||
const nativeFeatures = executableNativeFeatures(
|
||||
@@ -1448,7 +1455,7 @@ async function emitSemanticLibraryHit(
|
||||
let irPath: string | undefined;
|
||||
if (opts.emitIr) {
|
||||
irPath = join(opts.outDir, `${stem}.lib.ir.json`);
|
||||
await writeFile(irPath, serializeModule(mod, true));
|
||||
await writeTextParts(irPath, serializeModuleParts(mod));
|
||||
}
|
||||
await compileLibraryNative(profile, llvmPath, archivePath, opts.sanitize ?? false, hit.native);
|
||||
timing("native-archive");
|
||||
@@ -1724,7 +1731,7 @@ async function compileLibraryTracked(
|
||||
? Buffer.byteLength(ll)
|
||||
: ll.reduce((bytes, part) => bytes + Buffer.byteLength(part), 0),
|
||||
});
|
||||
await writeFile(llvmPath, ll);
|
||||
await writeTextParts(llvmPath, ll);
|
||||
timing("llvm-write");
|
||||
} catch (err) {
|
||||
if (!(err instanceof LlvmUnsupportedError)) throw err;
|
||||
@@ -1734,7 +1741,7 @@ async function compileLibraryTracked(
|
||||
let irPath: string | undefined;
|
||||
if (opts.emitIr) {
|
||||
irPath = join(opts.outDir, `${stem}.lib.ir.json`);
|
||||
await writeFile(irPath, serializeModule(mod, true));
|
||||
await writeTextParts(irPath, serializeModuleParts(mod));
|
||||
}
|
||||
|
||||
const nativeFeatures = libraryNativeFeatures(mod, profile.emission);
|
||||
|
||||
@@ -271,6 +271,71 @@ function walkFunction(
|
||||
for (const s of fn.body) stmt(s);
|
||||
}
|
||||
|
||||
/** The class and record field names checked-dynamic code can store into a
|
||||
* live typed-ref capsule (a capsule commit rewrites every other field with
|
||||
* its unchanged snapshot value). `all`: some runtime helper that can store
|
||||
* an arbitrary property receives a dynamic value, or the program has a
|
||||
* dynamic island; otherwise only literal-keyed `dyn.keySet`/`dyn.keyDelete`
|
||||
* name fields, collected in `names`. */
|
||||
export function dynamicFieldStores(mod: IrModule): { all: boolean; names: Set<string> } {
|
||||
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
|
||||
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
|
||||
const dynTypes = new Map<IrType, boolean>();
|
||||
const dynNamed = new Map<string, boolean>();
|
||||
const dynish = (type: IrType): boolean => {
|
||||
if (DYN_KINDS.has(type.kind)) return true;
|
||||
const cached = dynTypes.get(type);
|
||||
if (cached !== undefined) return cached;
|
||||
let result = false;
|
||||
const named =
|
||||
type.kind === "record"
|
||||
? `r:${type.shapeId}`
|
||||
: type.kind === "union"
|
||||
? `u:${type.unionId}`
|
||||
: null;
|
||||
if (named) {
|
||||
const known = dynNamed.get(named);
|
||||
if (known !== undefined) return known;
|
||||
dynNamed.set(named, false);
|
||||
const members =
|
||||
type.kind === "record"
|
||||
? [
|
||||
...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []),
|
||||
...(records.get(type.shapeId)?.indexValue
|
||||
? [records.get(type.shapeId)!.indexValue!]
|
||||
: []),
|
||||
]
|
||||
: type.kind === "union"
|
||||
? (unions.get(type.unionId)?.arms ?? [])
|
||||
: [];
|
||||
result = members.some(dynish);
|
||||
dynNamed.set(named, result);
|
||||
} else everyTypeChild(type, (child) => !(result = dynish(child)));
|
||||
dynTypes.set(type, result);
|
||||
return result;
|
||||
};
|
||||
const dynWritten = new Set<string>();
|
||||
let dynWritesAll = false;
|
||||
for (const fn of mod.functions)
|
||||
walkFunction(fn, {
|
||||
expr: (e) => {
|
||||
if (dynWritesAll) return;
|
||||
if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true;
|
||||
else if (
|
||||
e.kind === "libCall" &&
|
||||
!DYN_READ_ONLY.has(e.fn) &&
|
||||
e.args.some((a) => dynish(a.type))
|
||||
) {
|
||||
const key = e.args[1];
|
||||
if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit")
|
||||
dynWritten.add(key.value);
|
||||
else dynWritesAll = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
return { all: dynWritesAll, names: dynWritten };
|
||||
}
|
||||
|
||||
export function analyzeInt32Slots(mod: IrModule): Int32Slots {
|
||||
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
|
||||
const slots = new Int32Slots(classes);
|
||||
@@ -371,59 +436,7 @@ export function analyzeInt32Slots(mod: IrModule): Int32Slots {
|
||||
// writes every field from the view, which holds the exact snapshot value
|
||||
// of any property no store named. So reaching dynamic code disqualifies
|
||||
// exactly the field names some dynamic store in the program can name.
|
||||
const dynTypes = new Map<IrType, boolean>();
|
||||
const dynNamed = new Map<string, boolean>();
|
||||
const dynish = (type: IrType): boolean => {
|
||||
if (DYN_KINDS.has(type.kind)) return true;
|
||||
const cached = dynTypes.get(type);
|
||||
if (cached !== undefined) return cached;
|
||||
let result = false;
|
||||
const named =
|
||||
type.kind === "record"
|
||||
? `r:${type.shapeId}`
|
||||
: type.kind === "union"
|
||||
? `u:${type.unionId}`
|
||||
: null;
|
||||
if (named) {
|
||||
const known = dynNamed.get(named);
|
||||
if (known !== undefined) return known;
|
||||
dynNamed.set(named, false);
|
||||
const members =
|
||||
type.kind === "record"
|
||||
? [
|
||||
...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []),
|
||||
...(records.get(type.shapeId)?.indexValue
|
||||
? [records.get(type.shapeId)!.indexValue!]
|
||||
: []),
|
||||
]
|
||||
: type.kind === "union"
|
||||
? (unions.get(type.unionId)?.arms ?? [])
|
||||
: [];
|
||||
result = members.some(dynish);
|
||||
dynNamed.set(named, result);
|
||||
} else everyTypeChild(type, (child) => !(result = dynish(child)));
|
||||
dynTypes.set(type, result);
|
||||
return result;
|
||||
};
|
||||
const dynWritten = new Set<string>();
|
||||
let dynWritesAll = false;
|
||||
for (const fn of mod.functions)
|
||||
walkFunction(fn, {
|
||||
expr: (e) => {
|
||||
if (dynWritesAll) return;
|
||||
if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true;
|
||||
else if (
|
||||
e.kind === "libCall" &&
|
||||
!DYN_READ_ONLY.has(e.fn) &&
|
||||
e.args.some((a) => dynish(a.type))
|
||||
) {
|
||||
const key = e.args[1];
|
||||
if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit")
|
||||
dynWritten.add(key.value);
|
||||
else dynWritesAll = true;
|
||||
}
|
||||
},
|
||||
});
|
||||
const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod);
|
||||
for (const fn of mod.functions)
|
||||
walkFunction(fn, {
|
||||
expr: (e) => {
|
||||
|
||||
@@ -42,6 +42,11 @@ export const BYTES_ELEMENT_SIZE: Record<IrBytesElem, number> = {
|
||||
f64: 8,
|
||||
};
|
||||
|
||||
/** Integer element kinds: a valid element read is never NaN. */
|
||||
export function isIntegerBytesElem(elem: IrBytesElem): boolean {
|
||||
return elem !== "f32" && elem !== "f64";
|
||||
}
|
||||
|
||||
export const BYTES_ELEMENT_NAME: Record<IrBytesElem, string> = {
|
||||
u8: "Uint8Array",
|
||||
u8c: "Uint8ClampedArray",
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { F64, VOID, type IrStmt, type IrModule } from "./ir.js";
|
||||
import { IR_VERSION, deserializeModule, serializeModule } from "./serialize.js";
|
||||
import {
|
||||
IR_VERSION,
|
||||
deserializeModule,
|
||||
deserializeModuleBytes,
|
||||
serializeModule,
|
||||
serializeModuleParts,
|
||||
} from "./serialize.js";
|
||||
|
||||
function numbers(values: number[]): IrModule {
|
||||
const loc = { file: "numbers.ts", start: 0, end: 0 };
|
||||
@@ -124,3 +130,71 @@ test("compact artifacts keep JSON's circular-data refusal", () => {
|
||||
expect(() => serializeModule(mod)).toThrow(TypeError);
|
||||
expect(() => serializeModule(mod, true)).toThrow(TypeError);
|
||||
});
|
||||
|
||||
/** A module whose strings contain every byte the function splitter must not
|
||||
* mistake for structure. */
|
||||
function structuralStrings(): IrModule {
|
||||
const mod = numbers([NaN, -0, 1.5, Infinity]);
|
||||
const tricky = 'q"\\"],[{"functions":[]}\\, \n\t😀é\\';
|
||||
mod.sourceFile = tricky + '"functions":[';
|
||||
const base = mod.functions[0]!;
|
||||
mod.functions = [
|
||||
base,
|
||||
{ ...base, name: tricky, loc: { file: tricky, start: -0, end: 2 } },
|
||||
{ ...base, name: "]", body: [] },
|
||||
{ ...base, name: "\\" },
|
||||
];
|
||||
mod.globals = [{ id: "%g.functions", name: "functions", type: F64, mutable: false }];
|
||||
return mod;
|
||||
}
|
||||
|
||||
test("compact parts are exactly the single-root JSON encoding", () => {
|
||||
const mod = structuralStrings();
|
||||
const parts = serializeModuleParts(mod);
|
||||
expect(parts.length).toBe(2 + mod.functions.length * 2 - 1);
|
||||
const sentinels = (_key: string, value: unknown): unknown =>
|
||||
typeof value !== "number"
|
||||
? value
|
||||
: Number.isNaN(value)
|
||||
? { $nonfinite: "nan" }
|
||||
: value === Infinity
|
||||
? { $nonfinite: "inf" }
|
||||
: value === -Infinity
|
||||
? { $nonfinite: "-inf" }
|
||||
: Object.is(value, -0)
|
||||
? { $nonfinite: "-0" }
|
||||
: value;
|
||||
expect(parts.join("")).toBe(JSON.stringify(mod, sentinels));
|
||||
expect(serializeModule(mod, true)).toBe(parts.join(""));
|
||||
});
|
||||
|
||||
test("byte decoding splits functions without being misled by string contents", () => {
|
||||
const mod = structuralStrings();
|
||||
for (const text of [serializeModule(mod, true), serializeModule(mod)]) {
|
||||
const decoded = deserializeModuleBytes(Buffer.from(text, "utf8"));
|
||||
expect(decoded).toEqual(mod);
|
||||
expect(decoded).toEqual(deserializeModule(text));
|
||||
expect(Object.is(decoded.functions[1]!.loc.start, -0)).toBe(true);
|
||||
}
|
||||
mod.functions = [];
|
||||
expect(deserializeModuleBytes(Buffer.from(serializeModule(mod, true)))).toEqual(mod);
|
||||
expect(deserializeModuleBytes(Buffer.from(serializeModule(mod)))).toEqual(mod);
|
||||
});
|
||||
|
||||
test("byte decoding keeps the whole-document errors and version refusal", () => {
|
||||
const text = serializeModule(structuralStrings(), true);
|
||||
for (const broken of [text.slice(0, -1), text.slice(0, text.length - 40), text + "]", "[]", ""]) {
|
||||
let expected: unknown;
|
||||
try {
|
||||
deserializeModule(broken);
|
||||
} catch (error) {
|
||||
expected = error;
|
||||
}
|
||||
expect(expected).toBeInstanceOf(Error);
|
||||
expect(() => deserializeModuleBytes(Buffer.from(broken))).toThrow();
|
||||
}
|
||||
const older = text.replace(`"irVersion":${IR_VERSION}`, `"irVersion":${IR_VERSION - 1}`);
|
||||
expect(() => deserializeModuleBytes(Buffer.from(older))).toThrow("IR version mismatch");
|
||||
const sentinel = text.replace('"nan"', '"future"');
|
||||
expect(() => deserializeModuleBytes(Buffer.from(sentinel))).toThrow("Invalid IR number sentinel");
|
||||
});
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
/* IR ↔ JSON. Numeric literals preserve JavaScript's complete number domain:
|
||||
* JSON's null/zero spellings must not erase NaN, infinities or negative zero. */
|
||||
import type { IrModule } from "./ir.js";
|
||||
* JSON's null/zero spellings must not erase NaN, infinities or negative zero.
|
||||
*
|
||||
* Compiler-scale artifacts are larger than one host string can hold (V8 caps
|
||||
* strings at 2^29 - 24 UTF-16 units, and the self-hosted compiler's IR is
|
||||
* already close to it). The compact encoding is therefore produced as ordered
|
||||
* text parts and decoded from bytes one function at a time; neither direction
|
||||
* materializes the complete document as a single string. */
|
||||
import type { IrFunction, IrModule } from "./ir.js";
|
||||
|
||||
// Version 15 expands the native Error prefix with filesystem metadata.
|
||||
// Older modules embed incompatible subclass offsets and must be rejected.
|
||||
@@ -22,52 +28,65 @@ function ordinaryNumbers(root: unknown): boolean {
|
||||
return visit(root);
|
||||
}
|
||||
|
||||
/** Compiler artifacts use compact JSON to keep large graphs below the host's
|
||||
* string size limit. API consumers can retain the readable default. */
|
||||
export function serializeModule(mod: IrModule, compact = false): string {
|
||||
const replacer = (_key: string, value: unknown): unknown => {
|
||||
if (typeof value !== "number") return value;
|
||||
if (!Number.isFinite(value)) {
|
||||
return { $nonfinite: Number.isNaN(value) ? "nan" : value > 0 ? "inf" : "-inf" };
|
||||
}
|
||||
// JSON.stringify(-0) prints "0", silently losing the sign a numLit's
|
||||
// f64 semantics depend on (String(-0) is "0" but 1/-0 is -Infinity) —
|
||||
// the same sentinel mechanism carries it.
|
||||
if (Object.is(value, -0)) {
|
||||
return { $nonfinite: "-0" };
|
||||
}
|
||||
return value;
|
||||
};
|
||||
if (!compact) return JSON.stringify(mod, replacer, 2);
|
||||
function encodeNumber(_key: string, value: unknown): unknown {
|
||||
if (typeof value !== "number") return value;
|
||||
if (!Number.isFinite(value)) {
|
||||
return { $nonfinite: Number.isNaN(value) ? "nan" : value > 0 ? "inf" : "-inf" };
|
||||
}
|
||||
// JSON.stringify(-0) prints "0", silently losing the sign a numLit's
|
||||
// f64 semantics depend on (String(-0) is "0" but 1/-0 is -Infinity) —
|
||||
// the same sentinel mechanism carries it.
|
||||
if (Object.is(value, -0)) {
|
||||
return { $nonfinite: "-0" };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/** The compact encoding as ordered parts: the module header up to the
|
||||
* function array, one part per function (with separate comma parts), and the
|
||||
* header's remainder. Concatenating the parts yields exactly
|
||||
* `serializeModule(mod, true)`; writers stream them to disk in batches. */
|
||||
export function serializeModuleParts(mod: IrModule): string[] {
|
||||
// A replacer reads through live native views. Serializing the complete
|
||||
// function array through one view refreshes every function capsule for
|
||||
// each element, making large compiler artifacts quadratic. The sentinel
|
||||
// replacer is key-independent, so encode each function as its own root.
|
||||
const header = JSON.stringify({ ...mod, functions: [] }, replacer);
|
||||
const header = JSON.stringify({ ...mod, functions: [] }, encodeNumber);
|
||||
const slot = '"functions":[]';
|
||||
const offset = header.indexOf(slot);
|
||||
const functions = mod.functions
|
||||
.map(
|
||||
(fn) => (ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, replacer)) ?? "null",
|
||||
)
|
||||
.join(",");
|
||||
return (
|
||||
header.slice(0, offset) + '"functions":[' + functions + "]" + header.slice(offset + slot.length)
|
||||
);
|
||||
const parts: string[] = [header.slice(0, offset) + '"functions":['];
|
||||
for (let index = 0; index < mod.functions.length; index++) {
|
||||
const fn = mod.functions[index]!;
|
||||
if (index > 0) parts.push(",");
|
||||
parts.push(
|
||||
(ordinaryNumbers(fn) ? JSON.stringify(fn) : JSON.stringify(fn, encodeNumber)) ?? "null",
|
||||
);
|
||||
}
|
||||
parts.push("]" + header.slice(offset + slot.length));
|
||||
return parts;
|
||||
}
|
||||
|
||||
export function deserializeModule(json: string): IrModule {
|
||||
const mod = JSON.parse(json, (_key, value: unknown) => {
|
||||
if (typeof value === "object" && value !== null && "$nonfinite" in value) {
|
||||
const tag = (value as { $nonfinite: string }).$nonfinite;
|
||||
if (tag === "inf") return Infinity;
|
||||
if (tag === "-inf") return -Infinity;
|
||||
if (tag === "-0") return -0;
|
||||
if (tag === "nan") return NaN;
|
||||
throw new Error(`Invalid IR number sentinel: ${String(tag)}`);
|
||||
}
|
||||
return value;
|
||||
}) as IrModule;
|
||||
/** Compiler artifacts use compact JSON; API consumers can retain the
|
||||
* readable default. Both return one string, so modules whose encoding
|
||||
* exceeds the host's string limit must use `serializeModuleParts`. */
|
||||
export function serializeModule(mod: IrModule, compact = false): string {
|
||||
if (!compact) return JSON.stringify(mod, encodeNumber, 2);
|
||||
return serializeModuleParts(mod).join("");
|
||||
}
|
||||
|
||||
function decodeNumber(_key: string, value: unknown): unknown {
|
||||
if (typeof value === "object" && value !== null && "$nonfinite" in value) {
|
||||
const tag = (value as { $nonfinite: string }).$nonfinite;
|
||||
if (tag === "inf") return Infinity;
|
||||
if (tag === "-inf") return -Infinity;
|
||||
if (tag === "-0") return -0;
|
||||
if (tag === "nan") return NaN;
|
||||
throw new Error(`Invalid IR number sentinel: ${String(tag)}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function checkedVersion(mod: IrModule): IrModule {
|
||||
if (mod.irVersion !== IR_VERSION) {
|
||||
throw new Error(
|
||||
`IR version mismatch: file has ${String(mod.irVersion)}, compiler expects ${IR_VERSION}`,
|
||||
@@ -75,3 +94,150 @@ export function deserializeModule(json: string): IrModule {
|
||||
}
|
||||
return mod;
|
||||
}
|
||||
|
||||
export function deserializeModule(json: string): IrModule {
|
||||
return checkedVersion(JSON.parse(json, decodeNumber) as IrModule);
|
||||
}
|
||||
|
||||
const QUOTE = 0x22;
|
||||
const BACKSLASH = 0x5c;
|
||||
const COMMA = 0x2c;
|
||||
const COLON = 0x3a;
|
||||
const OPEN_BRACKET = 0x5b;
|
||||
const CLOSE_BRACKET = 0x5d;
|
||||
const OPEN_BRACE = 0x7b;
|
||||
const CLOSE_BRACE = 0x7d;
|
||||
const FUNCTIONS_KEY = '"functions"';
|
||||
|
||||
function isJsonSpace(byte: number): boolean {
|
||||
return byte === 0x20 || byte === 0x0a || byte === 0x0d || byte === 0x09;
|
||||
}
|
||||
|
||||
function skipSpace(bytes: Buffer, index: number): number {
|
||||
while (index < bytes.length && isJsonSpace(bytes[index]!)) index++;
|
||||
return index;
|
||||
}
|
||||
|
||||
/** `index` is an opening quote; returns the index after its closing quote. */
|
||||
function skipString(bytes: Buffer, index: number): number {
|
||||
const length = bytes.length;
|
||||
index++;
|
||||
while (index < length) {
|
||||
const byte = bytes[index]!;
|
||||
if (byte === BACKSLASH) index += 2;
|
||||
else if (byte === QUOTE) return index + 1;
|
||||
else index++;
|
||||
}
|
||||
return length;
|
||||
}
|
||||
|
||||
interface FunctionSpans {
|
||||
/** Offsets of the function array's brackets. */
|
||||
open: number;
|
||||
close: number;
|
||||
/** Byte range of each element. */
|
||||
starts: number[];
|
||||
ends: number[];
|
||||
}
|
||||
|
||||
/** Element ranges of the array starting at `open`, or null when the bytes
|
||||
* are not well-formed enough to split (the whole-document parse then reports
|
||||
* the exact syntax error). */
|
||||
function elementSpans(bytes: Buffer, open: number): FunctionSpans | null {
|
||||
const length = bytes.length;
|
||||
const starts: number[] = [];
|
||||
const ends: number[] = [];
|
||||
let index = skipSpace(bytes, open + 1);
|
||||
if (index < length && bytes[index] === CLOSE_BRACKET) return { open, close: index, starts, ends };
|
||||
while (index < length) {
|
||||
const start = index;
|
||||
let depth = 0;
|
||||
while (index < length) {
|
||||
const byte = bytes[index]!;
|
||||
if (byte === QUOTE) {
|
||||
index = skipString(bytes, index);
|
||||
if (depth === 0) break;
|
||||
continue;
|
||||
}
|
||||
if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++;
|
||||
else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) {
|
||||
if (depth === 0) break;
|
||||
depth--;
|
||||
if (depth === 0) {
|
||||
index++;
|
||||
break;
|
||||
}
|
||||
} else if (depth === 0 && (byte === COMMA || isJsonSpace(byte))) break;
|
||||
index++;
|
||||
}
|
||||
if (index === start || index >= length) return null;
|
||||
starts.push(start);
|
||||
ends.push(index);
|
||||
index = skipSpace(bytes, index);
|
||||
if (index >= length) return null;
|
||||
if (bytes[index] === CLOSE_BRACKET) return { open, close: index, starts, ends };
|
||||
if (bytes[index] !== COMMA) return null;
|
||||
index = skipSpace(bytes, index + 1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Locate the root object's `"functions"` array. */
|
||||
function functionSpans(bytes: Buffer): FunctionSpans | null {
|
||||
const length = bytes.length;
|
||||
let depth = 0;
|
||||
let index = 0;
|
||||
while (index < length) {
|
||||
const byte = bytes[index]!;
|
||||
if (byte === QUOTE) {
|
||||
const start = index;
|
||||
index = skipString(bytes, index);
|
||||
if (depth === 1 && index - start === FUNCTIONS_KEY.length) {
|
||||
let key = true;
|
||||
for (let offset = 1; offset < FUNCTIONS_KEY.length - 1; offset++) {
|
||||
if (bytes[start + offset] !== FUNCTIONS_KEY.charCodeAt(offset)) {
|
||||
key = false;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (key) {
|
||||
let next = skipSpace(bytes, index);
|
||||
if (next < length && bytes[next] === COLON) {
|
||||
next = skipSpace(bytes, next + 1);
|
||||
if (next < length && bytes[next] === OPEN_BRACKET) return elementSpans(bytes, next);
|
||||
}
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if (byte === OPEN_BRACE || byte === OPEN_BRACKET) depth++;
|
||||
else if (byte === CLOSE_BRACE || byte === CLOSE_BRACKET) depth--;
|
||||
index++;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Decode a UTF-8 IR artifact (compact or readable) without materializing
|
||||
* it as one string: the header and each function are parsed separately.
|
||||
* Split points are ASCII structural bytes, so every range is valid UTF-8. */
|
||||
export function deserializeModuleBytes(bytes: Buffer): IrModule {
|
||||
const spans = functionSpans(bytes);
|
||||
if (spans === null) return deserializeModule(bytes.toString("utf8"));
|
||||
const mod = checkedVersion(
|
||||
JSON.parse(
|
||||
bytes.toString("utf8", 0, spans.open + 1) + bytes.toString("utf8", spans.close),
|
||||
decodeNumber,
|
||||
) as IrModule,
|
||||
);
|
||||
const functions: IrFunction[] = [];
|
||||
for (let index = 0; index < spans.starts.length; index++) {
|
||||
functions.push(
|
||||
JSON.parse(
|
||||
bytes.toString("utf8", spans.starts[index]!, spans.ends[index]!),
|
||||
decodeNumber,
|
||||
) as IrFunction,
|
||||
);
|
||||
}
|
||||
mod.functions = functions;
|
||||
return mod;
|
||||
}
|
||||
|
||||
@@ -38,6 +38,20 @@ test("cache hits require an intact payload and digest", () => {
|
||||
expect(cache.read("object", key)).toBeNull();
|
||||
});
|
||||
|
||||
test("text parts publish one digest-checked entry and oversized reads are misses", () => {
|
||||
const cache = new NativeCache(directory());
|
||||
const key = contentDigest("parts identity");
|
||||
const parts = ['{"a":', '"\u00e9é😀"', ",", "", '"b":1}'];
|
||||
cache.writeParts("frontend", key, parts);
|
||||
const bytes = cache.read("frontend", key);
|
||||
expect(bytes?.toString("utf8")).toBe(parts.join(""));
|
||||
expect(readFileSync(join(cache.root, "frontend", key + ".sha256"), "utf8").trim()).toBe(
|
||||
contentDigest(parts.join("")),
|
||||
);
|
||||
expect(cache.read("frontend", key, bytes!.length)).toEqual(bytes);
|
||||
expect(cache.read("frontend", key, bytes!.length - 1)).toBeNull();
|
||||
});
|
||||
|
||||
test("a failed cache publication does not fail the build or replace unrelated files", () => {
|
||||
const cache = new NativeCache(directory());
|
||||
const occupied = join(cache.root, "object");
|
||||
|
||||
@@ -11,6 +11,12 @@ import {
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { resolveBuildCacheRoot } from "../backend/cache-root.js";
|
||||
import { writeTextPartsSync } 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
|
||||
* decoded as one string are bounded by it up front. */
|
||||
export const NATIVE_STRING_BYTES = 0x7fffffff;
|
||||
|
||||
export function contentDigest(value: string | Uint8Array): string {
|
||||
const hash = createHash("sha256");
|
||||
@@ -29,9 +35,11 @@ export class NativeCache {
|
||||
mkdirSync(this.root, { recursive: true, mode: 0o700 });
|
||||
}
|
||||
|
||||
read(family: string, key: string): Buffer | null {
|
||||
/** Entries larger than `maxBytes` are misses. */
|
||||
read(family: string, key: string, maxBytes = Infinity): Buffer | null {
|
||||
try {
|
||||
const path = join(this.root, family, key);
|
||||
if (maxBytes < Infinity && statSync(path).size > maxBytes) return null;
|
||||
const bytes = readFileSync(path);
|
||||
if (contentDigest(bytes) !== readFileSync(path + ".sha256", "utf8").trim()) return null;
|
||||
return bytes;
|
||||
@@ -77,6 +85,31 @@ export class NativeCache {
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish text parts without joining them into one string. */
|
||||
writeParts(family: string, key: string, parts: readonly string[]): void {
|
||||
let stage: string | null = null;
|
||||
try {
|
||||
const directory = join(this.root, family);
|
||||
mkdirSync(directory, { recursive: true, mode: 0o700 });
|
||||
stage = mkdtempSync(join(directory, ".write-"));
|
||||
writeTextPartsSync(join(stage, "payload"), parts);
|
||||
const hash = createHash("sha256");
|
||||
for (const part of parts) hash.update(part);
|
||||
writeFileSync(join(stage, "digest"), hash.digest("hex") + "\n");
|
||||
const path = join(directory, key);
|
||||
renameSync(join(stage, "payload"), path);
|
||||
renameSync(join(stage, "digest"), path + ".sha256");
|
||||
} catch {
|
||||
/* Cache failures never change a build's result. */
|
||||
} finally {
|
||||
try {
|
||||
if (stage !== null) rmSync(stage, { recursive: true, force: true });
|
||||
} catch {
|
||||
/* Cleanup is best effort too. */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
prune(): void {
|
||||
try {
|
||||
const configured = Number(process.env["SCRIPTC_CACHE_MAX_MB"] ?? "4096");
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { basename, dirname, join, resolve } from "node:path";
|
||||
import { writeTextPartsSync } from "./text-file.js";
|
||||
import type {
|
||||
AnalyzeOptions,
|
||||
AnalyzeResult,
|
||||
@@ -320,7 +321,7 @@ export class NativeCompiler {
|
||||
const stem = basename(entry).replace(/\.(ts|mts|cts|js|mjs|cjs)$/, "");
|
||||
const llvmPath = join(options.outDir, `${stem}.ll`);
|
||||
if (outputKind === "ir") {
|
||||
writeFileSync(stagedOutput, prepared.ir!);
|
||||
writeTextPartsSync(stagedOutput, prepared.ir!);
|
||||
renameSync(stagedOutput, output);
|
||||
return { ok: true, artifact: { kind: "ir", path: output } };
|
||||
}
|
||||
@@ -464,7 +465,7 @@ export class NativeCompiler {
|
||||
let irPath: string | undefined;
|
||||
if (options.emitIr) {
|
||||
irPath = join(options.outDir, `${stem}.ir.json`);
|
||||
writeFileSync(irPath, prepared.ir!);
|
||||
writeTextPartsSync(irPath, prepared.ir!);
|
||||
}
|
||||
renameSync(stagedOutput, output);
|
||||
if (target.platform === "darwin" && (optimization !== "dev" || options.strip))
|
||||
@@ -614,7 +615,7 @@ export class NativeCompiler {
|
||||
let irPath: string | undefined;
|
||||
if (options.emitIr) {
|
||||
irPath = join(options.outDir, `${stem}.lib.ir.json`);
|
||||
writeFileSync(irPath, prepared.ir!);
|
||||
writeTextPartsSync(irPath, prepared.ir!);
|
||||
}
|
||||
let sidecarPath: string | undefined;
|
||||
if (prepared.sidecarJson !== null) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { nativeCodegenTarget } from "../backend/targets.js";
|
||||
import { runFrontend, type FrontendFactory } from "../frontend/pipeline.js";
|
||||
import { loadProgram } from "../frontend/program-node.js";
|
||||
import { ts7Executable } from "../frontend/ts7/rpc-api.js";
|
||||
import { deserializeModule } from "../ir/serialize.js";
|
||||
import { NativeCache } from "./cache.js";
|
||||
import { prepareNativeExecutable } from "./prepare.js";
|
||||
import type { NativeToolchain } from "./toolchain.js";
|
||||
@@ -55,7 +56,8 @@ function fixture() {
|
||||
loads++;
|
||||
return runFrontend(path, loadProgram, npmStatic, externalTypes);
|
||||
};
|
||||
const prepare = () => prepareNativeExecutable(entry, options, null, toolchain, frontend, cache);
|
||||
const prepare = (extra: { emitIr?: boolean } = {}) =>
|
||||
prepareNativeExecutable(entry, { ...options, ...extra }, null, toolchain, frontend, cache);
|
||||
return { root, entry, dependency, cache, options, prepare, loads: () => loads };
|
||||
}
|
||||
|
||||
@@ -88,3 +90,24 @@ test("structurally invalid cached features cause a rebuild even with a valid dig
|
||||
expect(f.prepare().ok).toBe(true);
|
||||
expect(f.loads()).toBe(2);
|
||||
});
|
||||
|
||||
test("serialized IR travels as bounded parts through the frontend cache", () => {
|
||||
const f = fixture();
|
||||
const first = f.prepare({ emitIr: true });
|
||||
expect(first.ok, JSON.stringify(first)).toBe(true);
|
||||
if (!first.ok) return;
|
||||
expect(Array.isArray(first.ir)).toBe(true);
|
||||
const mod = deserializeModule(first.ir!.join(""));
|
||||
expect(mod.functions.length).toBeGreaterThan(0);
|
||||
const warm = f.prepare({ emitIr: true });
|
||||
expect(f.loads()).toBe(1);
|
||||
expect(warm).toEqual(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.ir).toEqual(first.ir);
|
||||
});
|
||||
|
||||
@@ -25,15 +25,18 @@ import { provenanceSources } from "../frontend/provenance-registry.js";
|
||||
import { prepareLibrary } from "../library/prepare.js";
|
||||
import type { LibraryProfile } from "../library/library-profile.js";
|
||||
import type { IrModule } from "../ir/ir.js";
|
||||
import { deserializeModule, serializeModule } from "../ir/serialize.js";
|
||||
import { contentDigest, type NativeCache } from "./cache.js";
|
||||
import { deserializeModuleBytes, serializeModuleParts } from "../ir/serialize.js";
|
||||
import { contentDigest, NATIVE_STRING_BYTES, type NativeCache } from "./cache.js";
|
||||
import { batchTextParts } from "./text-file.js";
|
||||
import type { NativeToolchain } from "./toolchain.js";
|
||||
import { nativeFileIdentity } from "./file-identity.js";
|
||||
|
||||
export interface NativeExecutableInput {
|
||||
ok: true;
|
||||
llvm: string;
|
||||
ir: string | null;
|
||||
/** Compact serialized IR as bounded parts (`batchTextParts`): compiler-scale
|
||||
* modules exceed one native string. */
|
||||
ir: string[] | null;
|
||||
sidecarJson: string | null;
|
||||
features: NativeLinkFeatures;
|
||||
sources: [string, string][];
|
||||
@@ -42,10 +45,62 @@ export interface NativeExecutableInput {
|
||||
warnings?: ScrDiagnostic[];
|
||||
}
|
||||
|
||||
/** The two large artifacts follow the input metadata as separate members so
|
||||
* an entry can be written as parts instead of one JSON string; the stored
|
||||
* input carries empty placeholders (`""`, `null`) for them. */
|
||||
interface FrontendCacheEntry {
|
||||
schema: "scriptc.native-frontend.v2";
|
||||
schema: "scriptc.native-frontend.v3";
|
||||
probes: FrontendInputSnapshot;
|
||||
input: NativeExecutableInput;
|
||||
llvm: string;
|
||||
ir: string[] | null;
|
||||
}
|
||||
|
||||
function frontendCacheParts(probes: FrontendInputSnapshot, input: NativeExecutableInput): string[] {
|
||||
const metadata = JSON.stringify({
|
||||
schema: "scriptc.native-frontend.v3",
|
||||
probes,
|
||||
input: { ...input, llvm: "", ir: null },
|
||||
});
|
||||
const parts = [metadata.slice(0, metadata.length - 1) + ',"llvm":', JSON.stringify(input.llvm)];
|
||||
if (input.ir === null) parts.push(',"ir":null}');
|
||||
else {
|
||||
parts.push(',"ir":[');
|
||||
for (let index = 0; index < input.ir.length; index++) {
|
||||
if (index > 0) parts.push(",");
|
||||
parts.push(JSON.stringify(input.ir[index]!));
|
||||
}
|
||||
parts.push("]}");
|
||||
}
|
||||
return parts;
|
||||
}
|
||||
|
||||
/** Publish an entry only when a native reader can decode it as one string;
|
||||
* UTF-16 length never exceeds the UTF-8 byte length. */
|
||||
function publishFrontendCache(
|
||||
cache: NativeCache,
|
||||
key: string,
|
||||
probes: FrontendInputSnapshot,
|
||||
input: NativeExecutableInput,
|
||||
): void {
|
||||
const parts = frontendCacheParts(probes, input);
|
||||
let length = 0;
|
||||
for (const part of parts) length += part.length;
|
||||
if (length <= NATIVE_STRING_BYTES) cache.writeParts("frontend", key, parts);
|
||||
}
|
||||
|
||||
function readFrontendCache(
|
||||
cache: NativeCache,
|
||||
key: string,
|
||||
exclusions: FrontendInputExclusions,
|
||||
): NativeExecutableInput | 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))
|
||||
return null;
|
||||
return { ...cached.input, llvm: cached.llvm, ir: cached.ir };
|
||||
}
|
||||
|
||||
function implementationIdentity(toolchain: NativeToolchain): string {
|
||||
@@ -106,7 +161,7 @@ function validCachedInput(value: unknown): value is FrontendCacheEntry {
|
||||
const entry = value as Partial<FrontendCacheEntry>;
|
||||
const input = entry.input;
|
||||
if (
|
||||
entry.schema !== "scriptc.native-frontend.v2" ||
|
||||
entry.schema !== "scriptc.native-frontend.v3" ||
|
||||
!validFrontendInputSnapshot(entry.probes) ||
|
||||
input === undefined ||
|
||||
input === null ||
|
||||
@@ -115,8 +170,9 @@ function validCachedInput(value: unknown): value is FrontendCacheEntry {
|
||||
return false;
|
||||
return (
|
||||
input.ok === true &&
|
||||
typeof input.llvm === "string" &&
|
||||
(input.ir === null || typeof input.ir === "string") &&
|
||||
typeof entry.llvm === "string" &&
|
||||
(entry.ir === null ||
|
||||
(Array.isArray(entry.ir) && entry.ir.every((part) => typeof part === "string"))) &&
|
||||
(input.sidecarJson === null || typeof input.sidecarJson === "string") &&
|
||||
validFeatures(input.features) &&
|
||||
Array.isArray(input.sources) &&
|
||||
@@ -144,7 +200,7 @@ function preparedFromIr(
|
||||
): { ok: true; mod: IrModule; sourceTexts: Map<string, string>; warnings: ScrDiagnostic[] } {
|
||||
if ((options.outputKind ?? "exe") !== "exe" || options.strip !== true)
|
||||
throw new Error(`${BOOTSTRAP_IR_INPUT} requires a stripped executable build`);
|
||||
const mod = deserializeModule(readFileSync(path, "utf8"));
|
||||
const mod = deserializeModuleBytes(readFileSync(path));
|
||||
if (resolve(mod.sourceFile) !== resolve(entry))
|
||||
throw new Error(`${BOOTSTRAP_IR_INPUT} was lowered from a different entry`);
|
||||
return { ok: true, mod, sourceTexts: new Map(), warnings: [] };
|
||||
@@ -194,14 +250,10 @@ export function prepareNativeExecutable(
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
}),
|
||||
);
|
||||
const bytes = cache.read("frontend", key);
|
||||
if (bytes !== null) {
|
||||
const cached: unknown = JSON.parse(bytes.toString("utf8"));
|
||||
if (validCachedInput(cached) && frontendInputsStillMatch(cached.probes, exclusions)) {
|
||||
const input: NativeExecutableInput = cached.input;
|
||||
timing("frontend-cache-hit");
|
||||
return input;
|
||||
}
|
||||
const input = readFrontendCache(cache, key, exclusions);
|
||||
if (input !== null) {
|
||||
timing("frontend-cache-hit");
|
||||
return input;
|
||||
}
|
||||
} catch {
|
||||
key = null;
|
||||
@@ -217,7 +269,10 @@ export function prepareNativeExecutable(
|
||||
);
|
||||
if (!prepared.ok) return prepared;
|
||||
if (irInput !== "") timing("ir-input");
|
||||
const ir = outputKind === "ir" || options.emitIr ? serializeModule(prepared.mod, true) : null;
|
||||
const ir =
|
||||
outputKind === "ir" || options.emitIr
|
||||
? batchTextParts(serializeModuleParts(prepared.mod))
|
||||
: null;
|
||||
timing("ir-serialize");
|
||||
const llvm =
|
||||
outputKind === "ir"
|
||||
@@ -251,8 +306,7 @@ export function prepareNativeExecutable(
|
||||
probes.stable &&
|
||||
frontendInputsStillMatch(probes, exclusions)
|
||||
) {
|
||||
const saved: FrontendCacheEntry = { schema: "scriptc.native-frontend.v2", probes, input };
|
||||
cache.write("frontend", key, JSON.stringify(saved));
|
||||
publishFrontendCache(cache, key, probes, input);
|
||||
}
|
||||
timing("frontend-cache-publish");
|
||||
return input;
|
||||
@@ -307,14 +361,10 @@ export function prepareNativeLibrary(
|
||||
.sort(([a], [b]) => a.localeCompare(b)),
|
||||
}),
|
||||
);
|
||||
const bytes = cache.read("frontend", key);
|
||||
if (bytes !== null) {
|
||||
const cached: unknown = JSON.parse(bytes.toString("utf8"));
|
||||
if (validCachedInput(cached) && frontendInputsStillMatch(cached.probes, exclusions)) {
|
||||
const input: NativeExecutableInput = cached.input;
|
||||
timing("frontend-cache-hit");
|
||||
return input;
|
||||
}
|
||||
const input = readFrontendCache(cache, key, exclusions);
|
||||
if (input !== null) {
|
||||
timing("frontend-cache-hit");
|
||||
return input;
|
||||
}
|
||||
} catch {
|
||||
key = null;
|
||||
@@ -350,7 +400,7 @@ export function prepareNativeLibrary(
|
||||
const input: NativeExecutableInput = {
|
||||
ok: true,
|
||||
llvm,
|
||||
ir: options.emitIr ? serializeModule(prepared.mod, true) : null,
|
||||
ir: options.emitIr ? batchTextParts(serializeModuleParts(prepared.mod)) : null,
|
||||
sidecarJson: prepared.sidecarJson,
|
||||
features: executableLinkFeatures(prepared.mod, false),
|
||||
sources: [...prepared.sourceTexts],
|
||||
@@ -363,8 +413,7 @@ export function prepareNativeLibrary(
|
||||
probes.stable &&
|
||||
frontendInputsStillMatch(probes, exclusions)
|
||||
) {
|
||||
const saved: FrontendCacheEntry = { schema: "scriptc.native-frontend.v2", probes, input };
|
||||
cache.write("frontend", key, JSON.stringify(saved));
|
||||
publishFrontendCache(cache, key, probes, input);
|
||||
}
|
||||
timing("frontend-cache-publish");
|
||||
return input;
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import { closeSync, openSync, writeSync } from "node:fs";
|
||||
|
||||
/** Native compiler strings stop at 2^31 - 1 UTF-8 bytes, and exceeding that
|
||||
* traps instead of throwing. Large artifacts therefore travel as bounded
|
||||
* parts; this is the part size that batching aims for. */
|
||||
export const TEXT_BATCH_LENGTH = 16 * 1024 * 1024;
|
||||
|
||||
/** Join consecutive small parts into batches of about TEXT_BATCH_LENGTH
|
||||
* UTF-16 units, so callers hold and write few, still-bounded strings. */
|
||||
export function batchTextParts(parts: readonly string[]): string[] {
|
||||
const batches: string[] = [];
|
||||
let batch: string[] = [];
|
||||
let length = 0;
|
||||
for (const part of parts) {
|
||||
batch.push(part);
|
||||
length += part.length;
|
||||
if (length >= TEXT_BATCH_LENGTH) {
|
||||
batches.push(batch.join(""));
|
||||
batch = [];
|
||||
length = 0;
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) batches.push(batch.join(""));
|
||||
return batches;
|
||||
}
|
||||
|
||||
/** Write text parts to `path` in order without joining them first. Each part
|
||||
* is one write; pass batched parts (`batchTextParts`) for many small ones. */
|
||||
export function writeTextPartsSync(path: string, parts: readonly string[]): void {
|
||||
const fd = openSync(path, "w");
|
||||
try {
|
||||
for (const part of parts) {
|
||||
const bytes = Buffer.from(part, "utf8");
|
||||
for (let offset = 0; offset < bytes.length;) {
|
||||
offset += writeSync(fd, bytes, offset, bytes.length - offset);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
closeSync(fd);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { open } from "node:fs/promises";
|
||||
|
||||
/** Writes compiler text artifacts (LLVM, serialized IR) in large batches.
|
||||
* Programs past the engine's string limit arrive as many parts, and writing
|
||||
* each part separately is several times slower than writing the same bytes in
|
||||
* a few large chunks. Batches stay far below the limit. */
|
||||
export async function writeTextParts(
|
||||
path: string,
|
||||
text: string | readonly string[],
|
||||
mode = 0o666,
|
||||
): Promise<void> {
|
||||
const file = await open(path, "w", mode);
|
||||
try {
|
||||
const write = async (chunk: string): Promise<void> => {
|
||||
const bytes = Buffer.from(chunk, "utf8");
|
||||
for (let offset = 0; offset < bytes.length;) {
|
||||
const { bytesWritten } = await file.write(bytes, offset, bytes.length - offset);
|
||||
offset += bytesWritten;
|
||||
}
|
||||
};
|
||||
if (typeof text === "string") return await write(text);
|
||||
let batch: string[] = [];
|
||||
let length = 0;
|
||||
for (const part of text) {
|
||||
batch.push(part);
|
||||
length += part.length;
|
||||
if (length >= 16 * 1024 * 1024) {
|
||||
await write(batch.join(""));
|
||||
batch = [];
|
||||
length = 0;
|
||||
}
|
||||
}
|
||||
if (batch.length > 0) await write(batch.join(""));
|
||||
} finally {
|
||||
await file.close();
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,8 @@ test("numeric byte pipelines retain checked fallbacks and use field widths on bo
|
||||
expect(wide).toContain("phi i64");
|
||||
expect(wide).toContain("@llvm.bswap.i64");
|
||||
const offsets = body(ll, "offsets");
|
||||
expect(offsets).toContain("@llvm.trunc.f64");
|
||||
// Truncation is the intrinsic, or inline on an x86-64 host (trunc.ts).
|
||||
expect(offsets).toMatch(/@llvm\.trunc\.f64|@llvm\.copysign\.f64/);
|
||||
expect(offsets).toContain("@scr_dataview_set");
|
||||
expect(offsets).toContain("@scr_dataview_get");
|
||||
expect(offsets).toMatch(/icmp ule i(?:32|64)/);
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
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, validateModule } from "../src/index.js";
|
||||
import type { IrFunction, IrModule } from "../src/ir/ir.js";
|
||||
|
||||
async function lower(source: string): Promise<IrModule> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-nan-coded-"));
|
||||
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" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const mod = deserializeModule(await readFile(outPath, "utf8"));
|
||||
expect(validateModule(mod)).toEqual([]);
|
||||
return mod;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function fn(mod: IrModule, name: string): IrFunction {
|
||||
const found = mod.functions.find((f) => f.name === name || f.name.endsWith(`.${name}`));
|
||||
if (!found)
|
||||
throw new Error(`no function ${name}: ${mod.functions.map((f) => f.name).join(", ")}`);
|
||||
return found;
|
||||
}
|
||||
|
||||
function calls(value: unknown): string[] {
|
||||
const out: string[] = [];
|
||||
const visit = (node: unknown): void => {
|
||||
if (!node || typeof node !== "object") return;
|
||||
if (Array.isArray(node)) return node.forEach(visit);
|
||||
const n = node as { kind?: string; callee?: string; fn?: string };
|
||||
if (n.kind === "call" && n.callee) out.push(n.callee);
|
||||
if (n.kind === "libCall" && n.fn) out.push(n.fn);
|
||||
Object.values(node).forEach(visit);
|
||||
};
|
||||
visit(value);
|
||||
return out;
|
||||
}
|
||||
|
||||
const scanner = `
|
||||
class Cursor {
|
||||
pos = 0;
|
||||
readonly chars: Uint16Array;
|
||||
readonly end: number;
|
||||
constructor(chars: Uint16Array) {
|
||||
this.chars = chars;
|
||||
this.end = chars.length;
|
||||
}
|
||||
char(): number {
|
||||
return this.pos < this.end ? this.chars[this.pos] : -1;
|
||||
}
|
||||
}
|
||||
function isSpace(ch: number): boolean {
|
||||
return ch === 32 || ch === 9;
|
||||
}
|
||||
function sameAt(a: Uint16Array, b: Uint16Array, i: number): boolean {
|
||||
return a[i] === b[i];
|
||||
}
|
||||
function show(chars: Uint16Array, i: number): string {
|
||||
return String(chars[i]);
|
||||
}
|
||||
const chars = new Uint16Array([32, 97]);
|
||||
const cursor = new Cursor(chars);
|
||||
let spaces = 0;
|
||||
for (; cursor.pos <= cursor.end; cursor.pos++) if (isSpace(cursor.char())) spaces++;
|
||||
console.log(spaces, sameAt(chars, chars, 9), show(chars, 9));
|
||||
`;
|
||||
|
||||
test("reads whose consumers treat undefined like NaN lower to plain numbers", async () => {
|
||||
const mod = await lower(scanner);
|
||||
expect(fn(mod, "isSpace").params[0]!.type).toEqual({ kind: "f64" });
|
||||
expect(fn(mod, "char").returnType).toEqual({ kind: "f64" });
|
||||
expect(calls(fn(mod, "char").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(false);
|
||||
// Two missing elements are equal: the equality also tests for two NaNs.
|
||||
const same = calls(fn(mod, "sameAt").body);
|
||||
expect(same.some((c) => c.startsWith("%bytes.idxOr"))).toBe(false);
|
||||
expect(same).toContain("num.isNaN");
|
||||
// String() observes undefined: the ordinary optional read remains.
|
||||
expect(calls(fn(mod, "show").body).some((c) => c.startsWith("%bytes.idxOr"))).toBe(true);
|
||||
});
|
||||
|
||||
const relation = `
|
||||
class Relation {
|
||||
private readonly regular = new Int32Array(4);
|
||||
assignable(source: number, target: number): boolean {
|
||||
if (source === target) return true;
|
||||
return this.related(source, target);
|
||||
}
|
||||
related(inputSource: number, inputTarget: number): boolean {
|
||||
const regularSource = this.regular[inputSource];
|
||||
const sourceId = regularSource >= 0 ? regularSource : inputSource;
|
||||
return this.simple(sourceId, inputTarget);
|
||||
}
|
||||
simple(sourceId: number, targetId: number): boolean {
|
||||
return sourceId === targetId;
|
||||
}
|
||||
}
|
||||
function probe(a: number, b: number): boolean {
|
||||
return a === b;
|
||||
}
|
||||
function escaped(a: number): boolean {
|
||||
return String(a) === "0";
|
||||
}
|
||||
function grab(t: Uint32Array): number {
|
||||
return t.length;
|
||||
}
|
||||
const relation = new Relation();
|
||||
const nan = process.argv.length / 0 - process.argv.length / 0;
|
||||
const ids = [1, nan, 3];
|
||||
const sources = new Uint32Array(ids.length);
|
||||
const targets = new Uint32Array(ids.length);
|
||||
const shorter = new Uint32Array(ids.length - 1);
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
sources[i] = ids[i]!;
|
||||
targets[i] = ids[i]!;
|
||||
relation.assignable(ids[i]!, nan);
|
||||
probe(ids[i]!, nan);
|
||||
escaped(nan);
|
||||
}
|
||||
function run(): number {
|
||||
const xs = new Uint32Array(ids.length);
|
||||
const ys = new Uint32Array(ids.length);
|
||||
let hits = 0;
|
||||
for (let i = 0; i < xs.length; i++) if (relation.assignable(xs[i], ys[i])) hits++;
|
||||
for (let i = 0; i < xs.length; i++) if (probe(xs[i], shorter[i])) hits++;
|
||||
const zs = new Uint32Array(ids.length);
|
||||
grab(zs);
|
||||
for (let i = 0; i < zs.length; i++) if (escaped(zs[i])) hits++;
|
||||
return hits;
|
||||
}
|
||||
console.log(run(), sources.length, targets.length);
|
||||
`;
|
||||
|
||||
test("proven-present reads and guarded occurrences keep numeric parameters plain", async () => {
|
||||
const mod = await lower(relation);
|
||||
const f64 = { kind: "f64" };
|
||||
// Parallel same-length typed arrays read under `i < xs.length`.
|
||||
expect(
|
||||
fn(mod, "assignable")
|
||||
.params.slice(1)
|
||||
.map((p) => p.type),
|
||||
).toEqual([f64, f64]);
|
||||
// `regularSource >= 0 ? regularSource : ...` holds a number in its true arm.
|
||||
expect(
|
||||
fn(mod, "simple")
|
||||
.params.slice(1)
|
||||
.map((p) => p.type),
|
||||
).toEqual([f64, f64]);
|
||||
// `shorter` is not as long as `xs`: its read may be undefined.
|
||||
expect(fn(mod, "probe").params[1]!.type.kind).toBe("union");
|
||||
// Code holding `zs` could detach its buffer: no proof.
|
||||
expect(fn(mod, "escaped").params[0]!.type.kind).toBe("union");
|
||||
});
|
||||
@@ -0,0 +1,45 @@
|
||||
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, validateModule } from "../src/index.js";
|
||||
import { emitLlvmModule } from "../src/backend/llvm/emitter.js";
|
||||
import type { IrModule } from "../src/ir/ir.js";
|
||||
|
||||
async function lower(source: string): Promise<IrModule> {
|
||||
const dir = await mkdtemp(join(tmpdir(), "scriptc-trunc-"));
|
||||
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" });
|
||||
if (!result.ok)
|
||||
throw new Error(result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"));
|
||||
const mod = deserializeModule(await readFile(outPath, "utf8"));
|
||||
expect(validateModule(mod)).toEqual([]);
|
||||
return mod;
|
||||
} finally {
|
||||
await rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
test("Math.trunc is inline on x86-64 and the intrinsic elsewhere", async () => {
|
||||
const mod = await lower(
|
||||
"const x: number = process.argv.length + 0.5;\nconsole.log(Math.trunc(x), Math.trunc(-x));\n",
|
||||
);
|
||||
const x86 = emitLlvmModule(mod, { targetTriple: "x86_64-unknown-linux-gnu", pointerBits: 64 });
|
||||
expect(x86).not.toContain("@llvm.trunc.f64(double");
|
||||
expect(x86).toContain("@llvm.copysign.f64");
|
||||
const arm = emitLlvmModule(mod, { targetTriple: "arm64-apple-darwin", pointerBits: 64 });
|
||||
expect(arm).toContain("call double @llvm.trunc.f64(double");
|
||||
});
|
||||
|
||||
test("integer typed-array reads decide presence without a truncation", async () => {
|
||||
const mod = await lower(
|
||||
"const t = new Uint16Array(4);\nconst i: number = process.argv.length + 0.5;\nconsole.log(t[i], t[1]);\n",
|
||||
);
|
||||
const helper = mod.functions.find((f) => f.name.startsWith("%bytes.idxOr"));
|
||||
expect(helper).toBeDefined();
|
||||
expect(JSON.stringify(helper!.body)).not.toContain("math.trunc");
|
||||
expect(JSON.stringify(helper!.body)).toContain("num.isNaN");
|
||||
});
|
||||
@@ -15754,6 +15754,60 @@
|
||||
"<repo>/tests/corpus/4652-stale-closure-binding-narrowing.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/native-worker-arena-reuse.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/native-worker-arena-reuse.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/4598-map-set-clear-reuse.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4598-map-set-clear-reuse.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/missing-read-lambda-results.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/missing-read-lambda-results.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/missing-read-narrowed-storage.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/missing-read-narrowed-storage.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/missing-read-number-conversions.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/missing-read-number-conversions.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/typed-array-nan-coded-reads.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/typed-array-nan-coded-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/typed-array-present-reads.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/typed-array-present-reads.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/4601-cycle-holders.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4601-cycle-holders.ts"
|
||||
],
|
||||
"diags": []
|
||||
},
|
||||
"<repo>/tests/corpus/4602-constructor-escape-order.ts": {
|
||||
"order": [
|
||||
"<repo>/tests/corpus/4602-constructor-escape-order.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,9 +6,10 @@
|
||||
* and the out-of-line entry points the LLVM backend calls for acyclic
|
||||
* records and class instances.
|
||||
*
|
||||
* Freed blocks are recycled within their class and never returned to the
|
||||
* operating system; the reservation itself costs address space only, and
|
||||
* pages are committed as the bump pointers first touch them. */
|
||||
* Freed blocks are recycled within their class; the reservation itself
|
||||
* costs address space only, and pages are committed as the bump pointers
|
||||
* first touch them. Only an exiting worker thread returns pages, those
|
||||
* holding nothing but free blocks (scr_sa_trim, Linux). */
|
||||
#include "scr_runtime.h"
|
||||
|
||||
/* Defined in every build: the emitted fast paths read it (see the layout
|
||||
@@ -26,9 +27,73 @@ ScrSaState scr_sa = {0};
|
||||
#if SCR_SMALL_ALLOC
|
||||
#include <sys/mman.h>
|
||||
|
||||
/* Page policy. Slice pages are committed by first touch, which on 4 KiB
|
||||
* pages is one fault (and page clear) per 4 KiB the bump pointers cross.
|
||||
* Transparent huge pages fault 2 MiB at a time and shrink the TLB working
|
||||
* set, but make the whole 2 MiB page around a bump pointer resident at once,
|
||||
* so every class slice in use (per arena, in worker executables) would cost
|
||||
* up to 2 MiB more than it holds. A slice therefore starts on small pages
|
||||
* and moves the rest of its range to huge pages once it has carved
|
||||
* SCR_SA_HUGE_AFTER bytes: only slices that already hold that much pay the
|
||||
* partial last huge page. The reservation is huge-page aligned so the
|
||||
* switch point and every slice start on a huge-page boundary. */
|
||||
#if defined(__linux__) && defined(MADV_HUGEPAGE) && defined(MADV_NOHUGEPAGE)
|
||||
#define SCR_SA_THP 1
|
||||
#define SCR_SA_HUGE_PAGE ((size_t)2 << 20)
|
||||
#define SCR_SA_HUGE_AFTER ((size_t)8 << 20)
|
||||
#endif
|
||||
|
||||
/* Maps span bytes of address space for slices (huge-page aligned where the
|
||||
* policy above applies), or NULL. */
|
||||
static void *scr_sa_map(size_t span) {
|
||||
int flags = MAP_PRIVATE | MAP_ANON;
|
||||
#ifdef MAP_NORESERVE
|
||||
flags |= MAP_NORESERVE;
|
||||
#endif
|
||||
#ifdef SCR_SA_THP
|
||||
size_t pad = SCR_SA_HUGE_PAGE;
|
||||
#else
|
||||
size_t pad = 0;
|
||||
#endif
|
||||
char *p = mmap(NULL, span + pad, PROT_READ | PROT_WRITE, flags, -1, 0);
|
||||
if (p == MAP_FAILED) return NULL;
|
||||
#ifdef SCR_SA_THP
|
||||
char *a = (char *)(((uintptr_t)p + pad - 1) & ~(uintptr_t)(pad - 1));
|
||||
if (a > p) (void)munmap(p, (size_t)(a - p));
|
||||
(void)munmap(a + span, (size_t)(p + pad - a)); /* a < p + pad */
|
||||
p = a;
|
||||
(void)madvise(p, span, MADV_NOHUGEPAGE);
|
||||
#endif
|
||||
return p;
|
||||
}
|
||||
|
||||
/* The next bump limit of a class slice [start, end) whose limit is lim
|
||||
* (< end): its small-page prefix ends at SCR_SA_HUGE_AFTER, where the rest
|
||||
* of the slice switches to huge pages. */
|
||||
static char *scr_sa_extend(char *start, char *lim, char *end) {
|
||||
#ifdef SCR_SA_THP
|
||||
if ((size_t)(lim - start) < SCR_SA_HUGE_AFTER && (size_t)(end - start) > SCR_SA_HUGE_AFTER)
|
||||
return start + SCR_SA_HUGE_AFTER;
|
||||
if (lim > start) (void)madvise(lim, (size_t)(end - lim), MADV_HUGEPAGE);
|
||||
#else
|
||||
(void)start;
|
||||
(void)lim;
|
||||
#endif
|
||||
return end;
|
||||
}
|
||||
|
||||
#ifdef SCR_WORKERS
|
||||
#include <pthread.h>
|
||||
#include <stdatomic.h>
|
||||
#if defined(__linux__) && defined(MADV_DONTNEED)
|
||||
#define SCR_SA_TRIM 1
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
#include <unistd.h>
|
||||
#if defined(__GLIBC__)
|
||||
#include <malloc.h>
|
||||
#endif
|
||||
#endif
|
||||
|
||||
/* Per-thread arenas (contract in scr_runtime.h). The region is reserved
|
||||
* once; arena i spans [region + i * arena_span, + arena_span) and is split
|
||||
@@ -55,15 +120,8 @@ static bool scr_sa_reserve_region(void) {
|
||||
for (unsigned shift = 30; shift >= 22; shift -= 2) {
|
||||
size_t arena = (size_t)SCR_SA_NCLASS << shift;
|
||||
size_t span = arena * SCR_SA_ARENAS;
|
||||
int flags = MAP_PRIVATE | MAP_ANON;
|
||||
#ifdef MAP_NORESERVE
|
||||
flags |= MAP_NORESERVE;
|
||||
#endif
|
||||
void *p = mmap(NULL, span, PROT_READ | PROT_WRITE, flags, -1, 0);
|
||||
if (p == MAP_FAILED) continue;
|
||||
#if defined(__linux__) && defined(MADV_NOHUGEPAGE)
|
||||
(void)madvise(p, span, MADV_NOHUGEPAGE);
|
||||
#endif
|
||||
void *p = scr_sa_map(span);
|
||||
if (!p) continue;
|
||||
scr_sa_arena_span = arena;
|
||||
scr_sa_region_shift = shift;
|
||||
atomic_store_explicit(&scr_sa_region, (uintptr_t)p, memory_order_release);
|
||||
@@ -75,6 +133,13 @@ static bool scr_sa_reserve_region(void) {
|
||||
return atomic_load_explicit(&scr_sa_region, memory_order_acquire) != 0;
|
||||
}
|
||||
|
||||
#ifdef SCR_SA_TRIM
|
||||
/* The arena's carved bytes when the current thread claimed it (see
|
||||
* scr_sa_trim). */
|
||||
static size_t scr_sa_claim_carved[SCR_SA_ARENAS];
|
||||
static size_t scr_sa_carved(void);
|
||||
#endif
|
||||
|
||||
/* Moves every block other threads freed into this arena's free lists. */
|
||||
static void scr_sa_drain_remote(void) {
|
||||
ScrSaBlock *b = atomic_exchange_explicit(&scr_sa_remote[scr_sa_arena], NULL, memory_order_acquire);
|
||||
@@ -113,14 +178,287 @@ static bool scr_sa_reserve(void) {
|
||||
scr_sa.shift = scr_sa_region_shift;
|
||||
}
|
||||
scr_sa_drain_remote();
|
||||
#ifdef SCR_SA_TRIM
|
||||
scr_sa_claim_carved[i] = scr_sa_carved();
|
||||
#endif
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#ifdef SCR_SA_TRIM
|
||||
/* Returning an exited thread's free memory (Linux). When a script thread
|
||||
* exits, every page of its arena that holds only free blocks goes back to
|
||||
* the system (MADV_DONTNEED, after which it reads as zero), so other
|
||||
* threads can use that memory while the arena is parked. The free blocks on
|
||||
* the kept pages are relinked in address order; the returned runs of pages
|
||||
* become zeroed carve regions (`ScrSaHoles`), which the next thread to claim
|
||||
* the arena carves before the slice's own frontier: the bump fast paths
|
||||
* already rely on carved memory being zero. A thread that carved less than
|
||||
* SCR_SA_TRIM_MIN, or whose arena holds less than that in free blocks,
|
||||
* leaves the arena as it is. */
|
||||
#define SCR_SA_TRIM_MIN ((size_t)16 << 20)
|
||||
typedef struct {
|
||||
char *lo, *hi;
|
||||
} ScrSaRun;
|
||||
typedef struct {
|
||||
ScrSaRun *runs; /* zeroed regions left to carve, carved from the end */
|
||||
size_t n, cap;
|
||||
char *bump, *lim; /* the frontier while a region is carved (lim != NULL) */
|
||||
} ScrSaHoles;
|
||||
static ScrSaHoles scr_sa_holes[SCR_SA_ARENAS][SCR_SA_NCLASS];
|
||||
|
||||
/* Bytes carved from the calling thread's slices: up to each frontier. */
|
||||
static size_t scr_sa_carved(void) {
|
||||
size_t carved = 0;
|
||||
for (unsigned c = 0; c < SCR_SA_NCLASS; c++) {
|
||||
if (!scr_sa.lim[c]) continue;
|
||||
const ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c];
|
||||
char *top = h->lim ? h->bump : scr_sa.bump[c];
|
||||
carved += (size_t)(top - ((char *)scr_sa.base + ((size_t)c << scr_sa.shift)));
|
||||
}
|
||||
return carved;
|
||||
}
|
||||
|
||||
/* For the slow path of class c, whose bump room is below sz: moves the
|
||||
* bump pointer to the next zeroed region, or back to the frontier. */
|
||||
static void scr_sa_next_hole(unsigned c, size_t sz) {
|
||||
ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c];
|
||||
if ((size_t)(scr_sa.lim[c] - scr_sa.bump[c]) >= sz || (!h->n && !h->lim)) return;
|
||||
if (h->n) {
|
||||
if (!h->lim) {
|
||||
h->bump = scr_sa.bump[c];
|
||||
h->lim = scr_sa.lim[c];
|
||||
}
|
||||
ScrSaRun r = h->runs[--h->n];
|
||||
scr_sa.bump[c] = r.lo;
|
||||
scr_sa.lim[c] = r.hi;
|
||||
} else {
|
||||
scr_sa.bump[c] = h->bump;
|
||||
scr_sa.lim[c] = h->lim;
|
||||
h->bump = h->lim = NULL;
|
||||
}
|
||||
}
|
||||
|
||||
static bool scr_sa_push_run(ScrSaHoles *h, char *lo, char *hi) {
|
||||
if (h->n == h->cap) {
|
||||
size_t cap = h->cap ? 2 * h->cap : 64;
|
||||
ScrSaRun *runs = realloc(h->runs, cap * sizeof *runs);
|
||||
if (!runs) return false;
|
||||
h->runs = runs;
|
||||
h->cap = cap;
|
||||
}
|
||||
h->runs[h->n++] = (ScrSaRun){lo, hi};
|
||||
return true;
|
||||
}
|
||||
|
||||
/* With SCR_SA_STATS=1: carved, free and returned bytes of the parking thread. */
|
||||
static _Thread_local size_t scr_sa_trim_stats[3];
|
||||
|
||||
/* Returns the pages of class c that hold only free blocks (see above). */
|
||||
static void scr_sa_trim_class(unsigned c, size_t page) {
|
||||
ScrSaHoles *h = &scr_sa_holes[scr_sa_arena][c];
|
||||
size_t sz = (size_t)(c + 1) * SCR_SA_STEP;
|
||||
char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift);
|
||||
/* Close the region being carved: its rest is still zero. */
|
||||
if (h->lim) {
|
||||
char *lo = scr_sa.bump[c], *hi = scr_sa.lim[c];
|
||||
scr_sa.bump[c] = h->bump;
|
||||
scr_sa.lim[c] = h->lim;
|
||||
h->bump = h->lim = NULL;
|
||||
if ((size_t)(hi - lo) >= sz && !scr_sa_push_run(h, lo, hi)) return;
|
||||
}
|
||||
char *top = scr_sa.bump[c];
|
||||
size_t nblocks = (size_t)(top - start) / sz;
|
||||
if (!nblocks) return;
|
||||
size_t words = (nblocks + 63) / 64;
|
||||
uint64_t *bits = mmap(NULL, words * 8, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANON, -1, 0);
|
||||
if (bits == MAP_FAILED) return;
|
||||
size_t nfree = 0;
|
||||
for (ScrSaBlock *b = scr_sa.free[c]; b; b = b->next) {
|
||||
size_t k = (size_t)((char *)b - start) / sz;
|
||||
bits[k / 64] |= (uint64_t)1 << (k % 64);
|
||||
nfree++;
|
||||
}
|
||||
/* Uncarved zeroed regions count as free; they are rebuilt below. */
|
||||
for (size_t r = 0; r < h->n; r++) {
|
||||
for (char *q = h->runs[r].lo; q + sz <= h->runs[r].hi; q += sz) {
|
||||
size_t k = (size_t)(q - start) / sz;
|
||||
bits[k / 64] |= (uint64_t)1 << (k % 64);
|
||||
}
|
||||
}
|
||||
scr_sa_trim_stats[0] += nblocks * sz;
|
||||
scr_sa_trim_stats[1] += nfree * sz;
|
||||
#define SCR_SA_BIT(k) ((bits[(k) / 64] >> ((k) % 64)) & 1)
|
||||
/* Whole pages below the frontier whose blocks are all free. */
|
||||
uintptr_t first = ((uintptr_t)start + page - 1) & ~(uintptr_t)(page - 1);
|
||||
uintptr_t last = (uintptr_t)top & ~(uintptr_t)(page - 1);
|
||||
h->n = 0;
|
||||
char *run = NULL;
|
||||
for (uintptr_t pg = first; pg <= last; pg += page) {
|
||||
bool empty = false;
|
||||
if (pg < last) {
|
||||
size_t k0 = (size_t)((char *)pg - start) / sz;
|
||||
size_t k1 = (size_t)((char *)pg + page - 1 - start) / sz;
|
||||
if (k1 >= nblocks) k1 = nblocks - 1;
|
||||
empty = true;
|
||||
for (size_t k = k0; k <= k1 && empty; k++) empty = SCR_SA_BIT(k);
|
||||
}
|
||||
if (empty && !run) run = (char *)pg;
|
||||
if (!empty && run) {
|
||||
(void)madvise(run, (size_t)((char *)pg - run), MADV_DONTNEED);
|
||||
scr_sa_trim_stats[2] += (size_t)((char *)pg - run);
|
||||
/* Blocks wholly inside the returned pages carve from it later. */
|
||||
char *lo = start + ((size_t)(run - start) + sz - 1) / sz * sz;
|
||||
char *hi = start + ((size_t)((char *)pg - start) / sz) * sz;
|
||||
if (hi > lo) (void)scr_sa_push_run(h, lo, hi);
|
||||
for (char *q = lo; q < hi; q += sz) {
|
||||
size_t k = (size_t)(q - start) / sz;
|
||||
bits[k / 64] &= ~((uint64_t)1 << (k % 64));
|
||||
}
|
||||
/* A block starting in the returned pages but ending on a kept one is
|
||||
* dropped: relinking it would fault its page back in. */
|
||||
if (hi < (char *)pg && hi >= run) {
|
||||
size_t k = (size_t)(hi - start) / sz;
|
||||
bits[k / 64] &= ~((uint64_t)1 << (k % 64));
|
||||
}
|
||||
run = NULL;
|
||||
}
|
||||
}
|
||||
#undef SCR_SA_BIT
|
||||
/* The remaining free blocks, in address order. */
|
||||
ScrSaBlock *head = NULL, **tail = &head;
|
||||
for (size_t w = 0; w < words; w++) {
|
||||
for (uint64_t m = bits[w]; m; m &= m - 1) {
|
||||
size_t k = w * 64 + (size_t)__builtin_ctzll(m);
|
||||
ScrSaBlock *b = (ScrSaBlock *)(start + k * sz);
|
||||
*tail = b;
|
||||
tail = &b->next;
|
||||
}
|
||||
}
|
||||
*tail = NULL;
|
||||
scr_sa.free[c] = head;
|
||||
munmap(bits, words * 8);
|
||||
/* The next owner carves the returned regions before the frontier. */
|
||||
if (h->n) {
|
||||
h->bump = scr_sa.bump[c];
|
||||
h->lim = scr_sa.lim[c];
|
||||
ScrSaRun r = h->runs[--h->n];
|
||||
scr_sa.bump[c] = r.lo;
|
||||
scr_sa.lim[c] = r.hi;
|
||||
}
|
||||
}
|
||||
|
||||
/* Whether the calling thread carved at least SCR_SA_TRIM_MIN since it
|
||||
* claimed its arena: a short-lived thread reusing the arena's free blocks
|
||||
* leaves them for the next one. */
|
||||
static bool scr_sa_trim_worth(void) {
|
||||
return scr_sa_carved() - scr_sa_claim_carved[scr_sa_arena] >= SCR_SA_TRIM_MIN;
|
||||
}
|
||||
|
||||
/* Returns the free pages of the calling thread's arena (see above). */
|
||||
static void scr_sa_trim(void) {
|
||||
scr_sa_drain_remote();
|
||||
size_t free_bytes = 0;
|
||||
for (unsigned c = 0; c < SCR_SA_NCLASS; c++) {
|
||||
size_t n = 0;
|
||||
for (ScrSaBlock *b = scr_sa.free[c]; b && n * (c + 1) * SCR_SA_STEP < SCR_SA_TRIM_MIN;
|
||||
b = b->next)
|
||||
n++;
|
||||
free_bytes += n * (c + 1) * SCR_SA_STEP;
|
||||
if (free_bytes >= SCR_SA_TRIM_MIN) break;
|
||||
}
|
||||
const char *stats = getenv("SCR_SA_STATS");
|
||||
const char *off = getenv("SCR_SA_TRIM");
|
||||
bool trim = free_bytes >= SCR_SA_TRIM_MIN && !(off && off[0] == '0');
|
||||
memset(scr_sa_trim_stats, 0, sizeof scr_sa_trim_stats);
|
||||
if (!trim && stats && stats[0] == '1')
|
||||
for (unsigned c = 0; c < SCR_SA_NCLASS; c++) {
|
||||
if (!scr_sa.lim[c]) continue;
|
||||
size_t sz = (size_t)(c + 1) * SCR_SA_STEP;
|
||||
char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift);
|
||||
scr_sa_trim_stats[0] += (size_t)(scr_sa.bump[c] - start);
|
||||
for (ScrSaBlock *b = scr_sa.free[c]; b; b = b->next) scr_sa_trim_stats[1] += sz;
|
||||
}
|
||||
if (trim) {
|
||||
long page = sysconf(_SC_PAGESIZE);
|
||||
for (unsigned c = 0; c < SCR_SA_NCLASS; c++)
|
||||
if (scr_sa.lim[c]) scr_sa_trim_class(c, page > 0 ? (size_t)page : 4096);
|
||||
}
|
||||
#if defined(__GLIBC__)
|
||||
/* Large blocks: the system allocator's free pages too. */
|
||||
if (trim) (void)malloc_trim(0);
|
||||
#endif
|
||||
if (stats && stats[0] == '1')
|
||||
fprintf(stderr, "scr_sa: park arena %d trim %d carved %zu MB free %zu MB returned %zu MB\n",
|
||||
scr_sa_arena, (int)trim, scr_sa_trim_stats[0] >> 20, scr_sa_trim_stats[1] >> 20,
|
||||
scr_sa_trim_stats[2] >> 20);
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef SCR_SA_TRIM
|
||||
/* Trims run on a detached helper thread: the parent of a worker joins it
|
||||
* when it handles the worker's exit, which must not wait for the trim (at
|
||||
* the end of a program every worker exits at once, and the process ends
|
||||
* right after). The helper takes over the exiting thread's claim on the
|
||||
* arena, so no thread claims it during the trim. */
|
||||
static _Atomic(unsigned) scr_sa_trimming; /* helpers running */
|
||||
|
||||
static void scr_sa_release(unsigned i) {
|
||||
scr_sa_parked[i] = scr_sa;
|
||||
memset(&scr_sa, 0, sizeof scr_sa);
|
||||
scr_sa_arena = -1;
|
||||
scr_sa_refused = true;
|
||||
atomic_fetch_and_explicit(&scr_sa_claimed, ~(1u << i), memory_order_release);
|
||||
}
|
||||
|
||||
static void *scr_sa_trim_main(void *arg) {
|
||||
unsigned i = (unsigned)(uintptr_t)arg;
|
||||
scr_sa_arena = (int)i;
|
||||
scr_sa = scr_sa_parked[i];
|
||||
scr_sa_trim();
|
||||
scr_sa_release(i);
|
||||
atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_release);
|
||||
return NULL;
|
||||
}
|
||||
|
||||
void scr_sa_wait_trims(void) {
|
||||
while (atomic_load_explicit(&scr_sa_trimming, memory_order_acquire)) {
|
||||
struct timespec pause = {0, 1000000};
|
||||
nanosleep(&pause, NULL);
|
||||
}
|
||||
}
|
||||
#else
|
||||
void scr_sa_wait_trims(void) {}
|
||||
#endif
|
||||
|
||||
void scr_sa_thread_park(void) {
|
||||
if (scr_sa_arena < 0) return;
|
||||
unsigned i = (unsigned)scr_sa_arena;
|
||||
#ifdef SCR_SA_TRIM
|
||||
if (scr_sa_trim_worth()) {
|
||||
scr_sa_parked[i] = scr_sa;
|
||||
atomic_fetch_add_explicit(&scr_sa_trimming, 1, memory_order_relaxed);
|
||||
pthread_attr_t attr;
|
||||
pthread_t helper;
|
||||
bool started = pthread_attr_init(&attr) == 0;
|
||||
if (started) {
|
||||
started = pthread_attr_setdetachstate(&attr, PTHREAD_CREATE_DETACHED) == 0 &&
|
||||
pthread_create(&helper, &attr, scr_sa_trim_main, (void *)(uintptr_t)i) == 0;
|
||||
pthread_attr_destroy(&attr);
|
||||
}
|
||||
if (started) {
|
||||
/* Later frees on this thread take the foreign path into the arena's
|
||||
* remote list; the helper releases the claim. */
|
||||
memset(&scr_sa, 0, sizeof scr_sa);
|
||||
scr_sa_arena = -1;
|
||||
scr_sa_refused = true;
|
||||
return;
|
||||
}
|
||||
atomic_fetch_sub_explicit(&scr_sa_trimming, 1, memory_order_relaxed);
|
||||
scr_sa_trim();
|
||||
}
|
||||
#endif
|
||||
scr_sa_parked[i] = scr_sa;
|
||||
/* Later frees on this thread (thread-exit destructors) take the foreign
|
||||
* path into the parked arena's remote list. */
|
||||
@@ -161,17 +499,8 @@ static bool scr_sa_refused;
|
||||
static bool scr_sa_reserve(void) {
|
||||
for (unsigned shift = 30; shift >= 24; shift -= 2) {
|
||||
size_t span = (size_t)SCR_SA_NCLASS << shift;
|
||||
int flags = MAP_PRIVATE | MAP_ANON;
|
||||
#ifdef MAP_NORESERVE
|
||||
flags |= MAP_NORESERVE;
|
||||
#endif
|
||||
void *p = mmap(NULL, span, PROT_READ | PROT_WRITE, flags, -1, 0);
|
||||
if (p == MAP_FAILED) continue;
|
||||
#if defined(__linux__) && defined(MADV_NOHUGEPAGE)
|
||||
/* Each class touches its own slice; transparent huge pages would make
|
||||
* every touched slice a 2 MiB resident minimum. */
|
||||
(void)madvise(p, span, MADV_NOHUGEPAGE);
|
||||
#endif
|
||||
void *p = scr_sa_map(span);
|
||||
if (!p) continue;
|
||||
scr_sa.base = (uintptr_t)p;
|
||||
scr_sa.shift = shift;
|
||||
scr_sa.span = span;
|
||||
@@ -198,19 +527,24 @@ __attribute__((cold, noinline)) void *scr_sa_slow(size_t n, bool zero) {
|
||||
if (zero) memset(b, 0, n);
|
||||
return b;
|
||||
}
|
||||
#endif
|
||||
/* Carve from the slice, opening it or raising its limit as needed;
|
||||
* fresh pages are zero. */
|
||||
size_t sz = (size_t)(c + 1) * SCR_SA_STEP;
|
||||
char *p = scr_sa.bump[c];
|
||||
if (scr_sa.lim[c] && (size_t)(scr_sa.lim[c] - p) >= sz) {
|
||||
#ifdef SCR_SA_TRIM
|
||||
if (scr_sa.lim[c]) scr_sa_next_hole(c, sz);
|
||||
#endif
|
||||
char *start = (char *)scr_sa.base + ((size_t)c << scr_sa.shift);
|
||||
char *end = start + ((size_t)1 << scr_sa.shift);
|
||||
char *p = scr_sa.lim[c] ? scr_sa.bump[c] : start;
|
||||
char *lim = scr_sa.lim[c] ? scr_sa.lim[c] : start;
|
||||
while ((size_t)(lim - p) < sz && lim < end) lim = scr_sa_extend(start, lim, end);
|
||||
scr_sa.lim[c] = lim;
|
||||
if ((size_t)(lim - p) >= sz) {
|
||||
scr_sa.bump[c] = p + sz;
|
||||
return p;
|
||||
}
|
||||
#endif
|
||||
if (!scr_sa.lim[c]) { /* open the class slice; fresh pages are zero */
|
||||
char *p = (char *)scr_sa.base + ((size_t)c << scr_sa.shift);
|
||||
scr_sa.lim[c] = p + ((size_t)1 << scr_sa.shift);
|
||||
scr_sa.bump[c] = p + (size_t)(c + 1) * SCR_SA_STEP;
|
||||
return p;
|
||||
}
|
||||
scr_sa.bump[c] = p;
|
||||
}
|
||||
/* Large or zero-sized, an exhausted slice, or no reservation. */
|
||||
return zero ? calloc(1, n) : malloc(n);
|
||||
@@ -238,6 +572,7 @@ void *scr_mem_realloc(void *p, size_t n) {
|
||||
void *scr_mem_realloc(void *p, size_t n) { return realloc(p, n); }
|
||||
#ifdef SCR_WORKERS
|
||||
void scr_sa_thread_park(void) {}
|
||||
void scr_sa_wait_trims(void) {}
|
||||
void scr_sa_free_foreign(void *p) { free(p); }
|
||||
#endif
|
||||
#endif
|
||||
|
||||
@@ -98,6 +98,64 @@ static void check_churn(void) {
|
||||
}
|
||||
}
|
||||
|
||||
#if SCR_SMALL_ALLOC
|
||||
#include <string.h>
|
||||
#ifdef __linux__
|
||||
/* The VmFlags line of the mapping that contains p, or "". */
|
||||
static void vm_flags(const void *p, char *out, size_t cap) {
|
||||
out[0] = '\0';
|
||||
FILE *f = fopen("/proc/self/smaps", "r");
|
||||
assert(f);
|
||||
char line[512];
|
||||
bool inside = false;
|
||||
while (fgets(line, sizeof line, f)) {
|
||||
unsigned long lo, hi;
|
||||
if (sscanf(line, "%lx-%lx ", &lo, &hi) == 2 && strchr(line, '-') < strchr(line, ' ')) {
|
||||
inside = (uintptr_t)p >= lo && (uintptr_t)p < hi;
|
||||
} else if (inside && !strncmp(line, "VmFlags:", 8)) {
|
||||
snprintf(out, cap, "%s", line + 8);
|
||||
break;
|
||||
}
|
||||
}
|
||||
fclose(f);
|
||||
}
|
||||
#endif
|
||||
|
||||
/* A class slice carves contiguously from its start, across the point where
|
||||
* its limit is raised; on Linux the slices are huge-page aligned, the first
|
||||
* 8 MiB of a slice stay on small pages and the rest may use huge pages. */
|
||||
static void check_slice_growth(void) {
|
||||
enum { SZ = 496, AFTER = 8 << 20 };
|
||||
unsigned char *first = scr_mem_alloc(SZ), *prev = first;
|
||||
assert(owned(first));
|
||||
uintptr_t start = scr_sa.base + ((uintptr_t)((SZ - 1) / SCR_SA_STEP) << scr_sa.shift);
|
||||
assert((uintptr_t)first == start); /* a class nothing else has touched */
|
||||
size_t blocks = AFTER / SZ + 64;
|
||||
for (size_t i = 1; i < blocks; i++) {
|
||||
unsigned char *p = scr_mem_alloc(SZ);
|
||||
assert(p == prev + SZ);
|
||||
p[0] = (unsigned char)i; /* commit the pages */
|
||||
prev = p;
|
||||
}
|
||||
#ifdef __linux__
|
||||
assert((scr_sa.base & ((2u << 20) - 1)) == 0);
|
||||
FILE *thp = fopen("/sys/kernel/mm/transparent_hugepage/enabled", "r");
|
||||
if (thp) { /* a kernel without THP refuses both advices */
|
||||
fclose(thp);
|
||||
char flags[256];
|
||||
vm_flags(first, flags, sizeof flags);
|
||||
assert(strstr(flags, " nh") && !strstr(flags, " hg"));
|
||||
vm_flags((void *)(start + AFTER), flags, sizeof flags);
|
||||
assert(strstr(flags, " hg") && !strstr(flags, " nh"));
|
||||
}
|
||||
#endif
|
||||
/* The blocks recycle through the free list like any others. */
|
||||
for (size_t i = blocks; i-- > 0;) scr_mem_free(first + i * SZ);
|
||||
assert(scr_mem_alloc(SZ) == first);
|
||||
scr_mem_free(first);
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC
|
||||
#include <pthread.h>
|
||||
|
||||
@@ -159,13 +217,104 @@ static void check_threads(void) {
|
||||
}
|
||||
#endif
|
||||
|
||||
#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC && defined(__linux__)
|
||||
#include <sys/mman.h>
|
||||
#include <unistd.h>
|
||||
|
||||
/* An exiting thread returns the pages that hold only free blocks; the next
|
||||
* thread to claim the arena carves the returned range again, zeroed, before
|
||||
* growing the slice. */
|
||||
enum { TRIM_BLOCKS = 1 << 20, TRIM_KEEP = 4096, TRIM_SIZE = 48 };
|
||||
static unsigned char **trim_blocks;
|
||||
static unsigned char *trim_kept[TRIM_BLOCKS / TRIM_KEEP];
|
||||
static uintptr_t trim_lo, trim_hi;
|
||||
static size_t trim_outside;
|
||||
|
||||
static size_t resident_pages(uintptr_t lo, uintptr_t hi) {
|
||||
size_t page = (size_t)sysconf(_SC_PAGESIZE);
|
||||
lo &= ~(uintptr_t)(page - 1);
|
||||
size_t n = (hi - lo + page - 1) / page, count = 0;
|
||||
unsigned char *vec = malloc(n);
|
||||
assert(vec && mincore((void *)lo, n * page, vec) == 0);
|
||||
for (size_t i = 0; i < n; i++) count += vec[i] & 1;
|
||||
free(vec);
|
||||
return count;
|
||||
}
|
||||
|
||||
static void *trim_allocate(void *arg) {
|
||||
(void)arg;
|
||||
trim_lo = UINTPTR_MAX;
|
||||
trim_hi = 0;
|
||||
for (size_t i = 0; i < TRIM_BLOCKS; i++) {
|
||||
trim_blocks[i] = scr_mem_alloc(TRIM_SIZE);
|
||||
assert(trim_blocks[i] && owned(trim_blocks[i]));
|
||||
fill(trim_blocks[i], TRIM_SIZE, (unsigned char)i);
|
||||
if ((uintptr_t)trim_blocks[i] < trim_lo) trim_lo = (uintptr_t)trim_blocks[i];
|
||||
if ((uintptr_t)trim_blocks[i] + TRIM_SIZE > trim_hi) trim_hi = (uintptr_t)trim_blocks[i] + TRIM_SIZE;
|
||||
}
|
||||
for (size_t i = 0; i < TRIM_BLOCKS; i++) {
|
||||
if (i % TRIM_KEEP == 0) trim_kept[i / TRIM_KEEP] = trim_blocks[i];
|
||||
else scr_mem_free(trim_blocks[i]);
|
||||
}
|
||||
scr_sa_thread_park();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void *trim_reuse(void *arg) {
|
||||
(void)arg;
|
||||
trim_outside = 0;
|
||||
for (size_t i = 0; i < TRIM_BLOCKS; i++) {
|
||||
unsigned char *p = scr_mem_calloc(TRIM_SIZE);
|
||||
assert(p && owned(p));
|
||||
for (size_t k = 0; k < TRIM_SIZE; k++) assert(p[k] == 0);
|
||||
fill(p, TRIM_SIZE, (unsigned char)i);
|
||||
if ((uintptr_t)p < trim_lo || (uintptr_t)p >= trim_hi) trim_outside++;
|
||||
trim_blocks[i] = p;
|
||||
}
|
||||
for (size_t i = 0; i < TRIM_BLOCKS; i++) scr_mem_free(trim_blocks[i]);
|
||||
scr_sa_thread_park();
|
||||
return NULL;
|
||||
}
|
||||
|
||||
static void check_trim(void) {
|
||||
trim_blocks = malloc(TRIM_BLOCKS * sizeof *trim_blocks);
|
||||
assert(trim_blocks);
|
||||
pthread_t t;
|
||||
assert(pthread_create(&t, NULL, trim_allocate, NULL) == 0);
|
||||
assert(pthread_join(t, NULL) == 0);
|
||||
scr_sa_wait_trims(); /* a helper thread returns the pages */
|
||||
/* One page per kept block stays, give or take the pages at run ends. */
|
||||
size_t total = (trim_hi - trim_lo) / (size_t)sysconf(_SC_PAGESIZE);
|
||||
size_t resident = resident_pages(trim_lo, trim_hi);
|
||||
assert(resident < total / 8);
|
||||
for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++)
|
||||
verify(trim_kept[k], TRIM_SIZE, (unsigned char)(k * TRIM_KEEP));
|
||||
assert(pthread_create(&t, NULL, trim_reuse, NULL) == 0);
|
||||
assert(pthread_join(t, NULL) == 0);
|
||||
scr_sa_wait_trims();
|
||||
/* Nearly every block came from the returned range: the slice did not grow. */
|
||||
assert(trim_outside < 4 * TRIM_BLOCKS / TRIM_KEEP + 256);
|
||||
for (size_t k = 0; k < TRIM_BLOCKS / TRIM_KEEP; k++) {
|
||||
verify(trim_kept[k], TRIM_SIZE, (unsigned char)(k * TRIM_KEEP));
|
||||
scr_mem_free(trim_kept[k]); /* remote free into the parked arena */
|
||||
}
|
||||
free(trim_blocks);
|
||||
}
|
||||
#endif
|
||||
|
||||
int main(void) {
|
||||
#if SCR_SMALL_ALLOC
|
||||
check_slice_growth(); /* first: its class must be untouched */
|
||||
#endif
|
||||
check_alignment_and_zeroing();
|
||||
check_realloc();
|
||||
check_system_pointers();
|
||||
check_churn();
|
||||
#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC
|
||||
check_threads();
|
||||
#ifdef __linux__
|
||||
check_trim();
|
||||
#endif
|
||||
printf("allocator checks passed: size-class workers\n");
|
||||
#else
|
||||
printf("allocator checks passed: %s\n", SCR_SMALL_ALLOC ? "size-class" : "system");
|
||||
|
||||
@@ -26,6 +26,9 @@ test.each([
|
||||
"-O1",
|
||||
"-Wall",
|
||||
"-Wextra",
|
||||
// Linux runtime packs compile with _GNU_SOURCE, which exposes the
|
||||
// madvise advice the page policy uses; build the checks the same way.
|
||||
...(process.platform === "linux" ? ["-D_GNU_SOURCE"] : []),
|
||||
...sanitize,
|
||||
join(import.meta.dirname, "scr_alloc.test.c"),
|
||||
join(import.meta.dirname, "scr_alloc.c"),
|
||||
|
||||
@@ -67,7 +67,8 @@
|
||||
* whose condition holds:
|
||||
* full, growth the live cycle-headered heap is a fraction past its size
|
||||
* after the last full pass. The fraction starts at 1/4 and
|
||||
* is scheduled by YIELD: it widens by doublings (up to 1)
|
||||
* is scheduled by YIELD: it widens by doublings (up to the
|
||||
* growth cap, 16 unless SCR_CYCLE_GROWTH_CAP lowers it)
|
||||
* as growth-triggered full passes reclaim fewer OLD objects
|
||||
* relative to the growth that triggered them, and narrows
|
||||
* back as soon as a pass finds old garbage again (see
|
||||
@@ -111,10 +112,11 @@
|
||||
* pass walks every buffer without skipping any generation, so it leaves no
|
||||
* cyclic garbage behind except what its own teardowns release for the next
|
||||
* pass. Between two full passes the cycle-headered heap can grow by at most
|
||||
* the old level's fraction, so it never exceeds twice its size after the
|
||||
* last full pass (or the growth floor); the mature level bounds younger
|
||||
* garbage the same way between mature passes. That cap is reached only after
|
||||
* passes have stopped finding garbage. While they find it, a level widens its
|
||||
* the old level's fraction, so it never exceeds 17 times its size after the
|
||||
* last full pass (or the growth floor), or twice it with
|
||||
* SCR_CYCLE_GROWTH_CAP=1; the mature level bounds younger garbage the same
|
||||
* way between mature passes. That cap is reached only after passes have
|
||||
* stopped finding garbage. While they find it, a level widens its
|
||||
* fraction only in inverse proportion to its yield, so if the yield holds,
|
||||
* the garbage floating by its next pass stays below an eighth of the heap
|
||||
* once widened, inside the quarter the base fraction allows.
|
||||
@@ -344,15 +346,43 @@ static SCR_TL size_t scr_cyc_live_after[SCR_CYC_NGENS];
|
||||
|
||||
/* Per level, doublings of the growth fraction (see scr_cyc_adapt_growth),
|
||||
* and the run of growth-triggered passes at that level whose yield asked for
|
||||
* a wider fraction than the current one. Capped at a fraction of 1: the heap
|
||||
* may at most double past its size after the last pass at a level before
|
||||
* the next. Only the mature and old entries are used. */
|
||||
#define SCR_CYC_GROWTH_MAX_SHIFT 2
|
||||
* a wider fraction than the current one. Only the mature and old entries are
|
||||
* used.
|
||||
*
|
||||
* The fraction is capped at 16 (shift 6): after passes at a level stop
|
||||
* finding garbage, the heap may grow to 17 times its size after the last
|
||||
* pass there before the next. A lower cap bounds dead cyclic garbage more
|
||||
* tightly, but a large live heap that keeps growing is then walked in full
|
||||
* each time it grows by the cap, and on such heaps (a compiler checking a
|
||||
* large program, say) those walks dominate collection time. The cap trades
|
||||
* that walking for memory only when passes find nothing: while they find
|
||||
* garbage, the yield keeps the fraction narrow. SCR_CYCLE_GROWTH_CAP lowers
|
||||
* the cap for memory-sensitive programs: a power of two from 1 to 16, with
|
||||
* other positive values rounded down to a power of two and larger ones read
|
||||
* as 16. At 1 the heap may at most double past its size after the last pass
|
||||
* at a level before the next. */
|
||||
#define SCR_CYC_GROWTH_MAX_SHIFT 6
|
||||
#ifndef SCR_WORKERS
|
||||
static SCR_TL unsigned char scr_cyc_growth_shift[SCR_CYC_NGENS];
|
||||
static SCR_TL unsigned char scr_cyc_growth_streak[SCR_CYC_NGENS];
|
||||
#endif
|
||||
|
||||
/* Shift of the configured growth cap: 2 + log2(cap), since the base
|
||||
* fraction is 1/4. */
|
||||
static unsigned scr_cyc_growth_max_shift(void) {
|
||||
static SCR_TL unsigned char cached = 0;
|
||||
if (cached == 0) {
|
||||
const char *env = scr_getenv("SCR_CYCLE_GROWTH_CAP");
|
||||
long v = env ? strtol(env, NULL, 10) : 0;
|
||||
unsigned shift = SCR_CYC_GROWTH_MAX_SHIFT;
|
||||
if (v > 0)
|
||||
for (shift = 2; shift < SCR_CYC_GROWTH_MAX_SHIFT && (2L << (shift - 2)) <= v;)
|
||||
shift++;
|
||||
cached = (unsigned char)shift;
|
||||
}
|
||||
return cached;
|
||||
}
|
||||
|
||||
/* Doublings of each generation's backlog threshold earned by consecutive
|
||||
* passes that found almost nothing to free (see scr_cyc_backlog_threshold).
|
||||
* Only the mature and old entries are used. */
|
||||
@@ -467,9 +497,8 @@ static size_t scr_cyc_backlog_threshold(unsigned gen) {
|
||||
* it still younger, and a single miss says little. */
|
||||
static void scr_cyc_adapt_growth(unsigned gen, size_t growth,
|
||||
size_t reclaimed) {
|
||||
unsigned want = 0;
|
||||
while (want < SCR_CYC_GROWTH_MAX_SHIFT
|
||||
&& reclaimed * SCR_CYC_GROWTH_DIV < (growth >> want))
|
||||
unsigned want = 0, max = scr_cyc_growth_max_shift();
|
||||
while (want < max && reclaimed * SCR_CYC_GROWTH_DIV < (growth >> want))
|
||||
want++;
|
||||
if (want <= scr_cyc_growth_shift[gen]) {
|
||||
scr_cyc_growth_shift[gen] = (unsigned char)want;
|
||||
|
||||
+111
-20
@@ -518,15 +518,51 @@ static __attribute__((noinline)) void scr_map_leave_dense(ScrMap *m) {
|
||||
}
|
||||
|
||||
/* Drop tombstones, preserving insertion order. Only legal when no iteration
|
||||
* is active (the forEach desugar's indices would shift). */
|
||||
static void scr_map_compact(ScrMap *m) {
|
||||
size_t w = 0;
|
||||
* is active (the forEach desugar's indices would shift).
|
||||
*
|
||||
* Index tables are sized by the most entries a map has held, so a map that
|
||||
* shrank keeps rebuilding (and clearing) a table sized for its peak. A
|
||||
* bucket table more than eight times what the survivors need shrinks to
|
||||
* twice that, leaving room to double before it grows again. With `reindex`
|
||||
* the direct index also follows the surviving keys: it shrinks to cover the
|
||||
* largest one, and keys that became sparse move back to hashing (at twice
|
||||
* the spread that admits them, so churn cannot flip modes). The append path
|
||||
* passes false: it holds a slot of the current direct index. */
|
||||
static void scr_map_compact(ScrMap *m, bool reindex) {
|
||||
size_t w = 0, top = 0;
|
||||
bool dense = m->ndense != 0;
|
||||
for (size_t r = 0; r < m->nentries; r++) {
|
||||
if (m->entries[r].hash) m->entries[w++] = m->entries[r];
|
||||
if (!m->entries[r].hash) continue;
|
||||
if (dense) {
|
||||
size_t k = (size_t)scr_map_slot_to_f64(m->entries[r].key);
|
||||
if (k > top) top = k;
|
||||
}
|
||||
m->entries[w++] = m->entries[r];
|
||||
}
|
||||
m->nentries = w;
|
||||
if (m->nbuckets > 0) scr_map_rebuild_buckets(m, m->nbuckets);
|
||||
else if (m->ndense) scr_map_fill_dense(m);
|
||||
if (m->nbuckets > 0) {
|
||||
size_t want = 16;
|
||||
while (want < 2 * (w + 1)) want *= 2;
|
||||
scr_map_rebuild_buckets(m, m->nbuckets / 8 > want ? 2 * want : m->nbuckets);
|
||||
return;
|
||||
}
|
||||
if (!dense) return;
|
||||
if (reindex) {
|
||||
if (w == 0) {
|
||||
free(m->dense);
|
||||
m->dense = NULL;
|
||||
m->ndense = 0;
|
||||
return;
|
||||
}
|
||||
size_t limit = scr_map_dense_limit(w);
|
||||
if (top / 2 >= limit) {
|
||||
scr_map_leave_dense(m);
|
||||
return;
|
||||
}
|
||||
size_t need = scr_map_dense_size(top);
|
||||
if (need < m->ndense) scr_map_dense_alloc(m, need);
|
||||
}
|
||||
scr_map_fill_dense(m);
|
||||
}
|
||||
|
||||
static void scr_map_grow_entries(ScrMap *m) {
|
||||
@@ -548,7 +584,7 @@ static void scr_map_reserve_append(ScrMap *m) {
|
||||
if (m->ndense) {
|
||||
/* Directly indexed: the table is sized by keys, not entry count. */
|
||||
if (m->nentries < m->ecap) return;
|
||||
if (m->iter_depth == 0 && m->nlive <= m->nentries / 2) scr_map_compact(m);
|
||||
if (m->iter_depth == 0 && m->nentries > 0 && m->nlive <= m->nentries / 2) scr_map_compact(m, false);
|
||||
if (m->nentries == m->ecap) scr_map_grow_entries(m);
|
||||
return;
|
||||
}
|
||||
@@ -557,7 +593,7 @@ static void scr_map_reserve_append(ScrMap *m) {
|
||||
m->nbuckets >= 2 * (m->nentries + 1))) return;
|
||||
if (m->iter_depth == 0 && m->nentries > 0 &&
|
||||
(m->nbuckets == 0 ? m->nlive < m->nentries : m->nlive <= m->nentries / 2)) {
|
||||
scr_map_compact(m);
|
||||
scr_map_compact(m, false);
|
||||
}
|
||||
if (m->nentries == m->ecap) scr_map_grow_entries(m);
|
||||
if (m->nbuckets == 0 && m->nentries < SCR_MAP_LINEAR_LIMIT) return;
|
||||
@@ -701,10 +737,29 @@ void scr_map_trace_v(void *m, ScrTraceVisit visit, void *ctx) {
|
||||
|
||||
double scr_map_size(const ScrMap *m) { return (double)m->nlive; }
|
||||
|
||||
/* Storage clear() keeps regardless of how many entries it removed. */
|
||||
#define SCR_MAP_CLEAR_KEEP 256
|
||||
|
||||
/* clear() costs time proportional to the entries it walks (live entries
|
||||
* and tombstones appended since the last reset), never to the peak size of
|
||||
* the index tables:
|
||||
* - the direct index is zero exactly at the absent keys (deletes clear
|
||||
* their slots), so only the live entries' slots are reset;
|
||||
* - the bucket table is reset only while it is proportional to the
|
||||
* entries that pointed into it;
|
||||
* - storage far larger than the entries just removed is freed, returning
|
||||
* the map to its initial empty state the way V8 reallocates a cleared
|
||||
* table. Regrowth is amortized against the insertions that need it.
|
||||
* A map without entries has empty tables already: clearing it does no
|
||||
* work and keeps its storage for the next fill. */
|
||||
void scr_map_clear(ScrMap *m) {
|
||||
for (size_t e = 0; e < m->nentries; e++) {
|
||||
size_t walked = m->nentries;
|
||||
if (walked == 0) return;
|
||||
uint32_t *dense = m->ndense ? m->dense : NULL;
|
||||
for (size_t e = 0; e < walked; e++) {
|
||||
if (!m->entries[e].hash) continue;
|
||||
m->entries[e].hash = 0;
|
||||
if (dense) dense[(size_t)scr_map_slot_to_f64(m->entries[e].key)] = 0;
|
||||
scr_map_release_key(m, m->entries[e].key);
|
||||
scr_map_release_val(m, m->entries[e].val);
|
||||
}
|
||||
@@ -714,12 +769,29 @@ void scr_map_clear(ScrMap *m) {
|
||||
* entries so its indices stay stable — entries added by the callback
|
||||
* after the clear append past them and ARE visited (Node-exact). */
|
||||
m->nentries = 0;
|
||||
if (m->ecap > SCR_MAP_CLEAR_KEEP && m->ecap / 8 > walked) {
|
||||
free(m->entries);
|
||||
m->entries = NULL;
|
||||
m->ecap = 0;
|
||||
}
|
||||
}
|
||||
/* Without either index, a map is a linear scan; dead entries kept for an
|
||||
* active iteration are skipped by every later rebuild. */
|
||||
if (m->ndense > SCR_MAP_CLEAR_KEEP && m->ndense / 32 > walked) {
|
||||
free(m->dense);
|
||||
m->dense = NULL;
|
||||
m->ndense = 0;
|
||||
}
|
||||
if (m->ndense) memset(m->dense, 0, m->ndense * sizeof *m->dense);
|
||||
if (m->nbuckets) {
|
||||
bool compact = scr_map_compact_buckets(m->nbuckets);
|
||||
memset(m->buckets, compact ? 0 : 0xff,
|
||||
m->nbuckets * (compact ? sizeof(uint32_t) : sizeof(size_t)));
|
||||
if (m->nbuckets > SCR_MAP_CLEAR_KEEP && m->nbuckets / 8 > walked) {
|
||||
free(m->buckets);
|
||||
m->buckets = NULL;
|
||||
m->nbuckets = 0;
|
||||
} else {
|
||||
bool compact = scr_map_compact_buckets(m->nbuckets);
|
||||
memset(m->buckets, compact ? 0 : 0xff,
|
||||
m->nbuckets * (compact ? sizeof(uint32_t) : sizeof(size_t)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -733,13 +805,26 @@ bool scr_map_has_str(const ScrMap *m, const ScrStr *key) {
|
||||
return scr_map_find_str(m, key) != SCR_MAP_EMPTY;
|
||||
}
|
||||
|
||||
/* Tombstones beyond this share of the entries are compacted on delete, so
|
||||
* iteration and later rebuilds cost O(live) rather than O(peak). V8 shrinks
|
||||
* an ordered hash table at the same occupancy. Small maps keep theirs. */
|
||||
#define SCR_MAP_SHRINK_MIN 64
|
||||
|
||||
static bool scr_map_delete_found(ScrMap *m, size_t e) {
|
||||
if (e == SCR_MAP_EMPTY) return false;
|
||||
uint64_t key = m->entries[e].key, val = m->entries[e].val;
|
||||
m->entries[e].hash = 0; /* bucket slot stays: probe chains intact */
|
||||
m->nlive--;
|
||||
if (m->ndense) m->dense[(size_t)scr_map_slot_to_f64(m->entries[e].key)] = 0;
|
||||
scr_map_release_key(m, m->entries[e].key);
|
||||
scr_map_release_val(m, m->entries[e].val);
|
||||
if (m->ndense) m->dense[(size_t)scr_map_slot_to_f64(key)] = 0;
|
||||
/* Each such compaction follows deletes of three quarters of the entries,
|
||||
* so it is amortized constant per delete. It runs before the releases:
|
||||
* they may drop the last reference to this map. */
|
||||
if (__builtin_expect(m->iter_depth == 0 && m->nentries >= SCR_MAP_SHRINK_MIN &&
|
||||
m->nlive < m->nentries / 4, 0)) {
|
||||
scr_map_compact(m, true);
|
||||
}
|
||||
scr_map_release_key(m, key);
|
||||
scr_map_release_val(m, val);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -1166,9 +1251,14 @@ ScrMap *scr_map_clone(const ScrMap *source, bool keys_only) {
|
||||
out->entries = malloc(count * sizeof(ScrMapEntry));
|
||||
if (!out->entries) scr_map_oom();
|
||||
out->ecap = count;
|
||||
size_t top = 0;
|
||||
for (size_t i = 0; i < source->nentries; i++) {
|
||||
const ScrMapEntry *entry = &source->entries[i];
|
||||
if (!entry->hash) continue;
|
||||
if (source->ndense) {
|
||||
size_t k = (size_t)scr_map_slot_to_f64(entry->key);
|
||||
if (k > top) top = k;
|
||||
}
|
||||
ScrMapEntry *copy = &out->entries[out->nentries++];
|
||||
*copy = *entry;
|
||||
if (out->key_kind == SCR_MAP_KEY_STR) scr_str_retain(scr_map_slot_to_ptr(copy->key));
|
||||
@@ -1177,9 +1267,10 @@ ScrMap *scr_map_clone(const ScrMap *source, bool keys_only) {
|
||||
else if (out->val_kind == SCR_MAP_VAL_REF) out->val_retain(scr_map_slot_to_ptr(copy->val));
|
||||
}
|
||||
out->nlive = count;
|
||||
if (source->ndense) {
|
||||
/* Same keys, so the same table size; entry indices are compacted. */
|
||||
scr_map_dense_alloc(out, source->ndense);
|
||||
/* Size the copy's direct index for its own keys, not for the source's
|
||||
* peak; keys that are sparse for their count hash instead. */
|
||||
if (source->ndense && top / 2 < scr_map_dense_limit(count)) {
|
||||
scr_map_dense_alloc(out, scr_map_dense_size(top));
|
||||
scr_map_fill_dense(out);
|
||||
return out;
|
||||
}
|
||||
@@ -1245,7 +1336,7 @@ void scr_map_iter_exit(ScrMap *m) {
|
||||
* active they are safe to drop now (bounds memory under forEach-heavy
|
||||
* add/delete workloads). */
|
||||
if (m->iter_depth == 0 && m->nlive <= m->nentries / 2 && m->nentries >= 16) {
|
||||
scr_map_compact(m);
|
||||
scr_map_compact(m, true);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <time.h>
|
||||
|
||||
#define MODEL_CAP 20000
|
||||
|
||||
@@ -227,10 +228,253 @@ static void modes(void) {
|
||||
scr_map_release(set);
|
||||
}
|
||||
|
||||
/* clear() and delete must cost time proportional to the entries they
|
||||
* touch, not to the largest size the map ever had. Each case grows the
|
||||
* index tables to about a million slots, then times many small rounds
|
||||
* against the same rounds on a fresh map: a clear that resets the whole
|
||||
* table (megabytes per call), or iteration over the tombstones of the old
|
||||
* peak, is hundreds of times slower and exceeds the bound early. The bound
|
||||
* is relative, so machine speed and sanitizers do not matter. */
|
||||
#define BIG_KEYS (1 << 20)
|
||||
#define SMALL_ROUNDS 50000
|
||||
|
||||
typedef void (*Round)(ScrMap *map, size_t round, void *ctx);
|
||||
|
||||
static double cpu_seconds(void) {
|
||||
return (double)clock() / CLOCKS_PER_SEC;
|
||||
}
|
||||
|
||||
/* CPU seconds for SMALL_ROUNDS rounds; stops early once over budget. */
|
||||
static double timed_rounds(ScrMap *map, Round round, void *ctx, double budget) {
|
||||
double start = cpu_seconds();
|
||||
for (size_t r = 0; r < SMALL_ROUNDS; r++) {
|
||||
round(map, r, ctx);
|
||||
if (r % 1024 == 1023 && cpu_seconds() - start > budget) break;
|
||||
}
|
||||
return cpu_seconds() - start;
|
||||
}
|
||||
|
||||
static void expect_proportional(const char *what, ScrMap *fresh, ScrMap *grown, Round round, void *ctx) {
|
||||
double base = timed_rounds(fresh, round, ctx, INFINITY);
|
||||
double budget = 20 * base + 0.05;
|
||||
double spent = timed_rounds(grown, round, ctx, budget);
|
||||
if (spent > budget) {
|
||||
fprintf(stderr, "%s: cost follows the peak size (%.3f s, fresh map %.3f s)\n", what, spent, base);
|
||||
exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* keys: 0 directly indexed integers, 1 sparse integers (hashed). */
|
||||
static double cost_key(unsigned keys, size_t i) {
|
||||
return keys == 0 ? (double)i : (double)i * 1000003.0;
|
||||
}
|
||||
|
||||
/* clear() an empty map, add up to six keys, clear() again. */
|
||||
static void number_round(ScrMap *map, size_t round, void *ctx) {
|
||||
unsigned keys = *(unsigned *)ctx;
|
||||
scr_map_clear(map);
|
||||
size_t n = round % 7;
|
||||
for (size_t i = 0; i < n; i++) scr_map_set_f64_f64(map, cost_key(keys, round + i), (double)i);
|
||||
assert(scr_map_size(map) == (double)n);
|
||||
scr_map_clear(map);
|
||||
}
|
||||
|
||||
static void clear_cost_numbers(unsigned keys) {
|
||||
ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, cost_key(keys, i), (double)i);
|
||||
assert(keys == 0 ? map->ndense >= BIG_KEYS : map->nbuckets >= 2 * BIG_KEYS);
|
||||
scr_map_clear(map);
|
||||
assert(scr_map_size(map) == 0 && scr_map_iter_count(map) == 0 && !scr_map_has_f64(map, cost_key(keys, 1)));
|
||||
ScrMap *fresh = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
expect_proportional(keys == 0 ? "dense clear" : "hashed clear", fresh, map, number_round, &keys);
|
||||
scr_map_release(fresh);
|
||||
/* Storage sized for the old peak is gone; the map is reusable. */
|
||||
assert(map->ndense <= 256 && map->nbuckets <= 256 && map->ecap <= 256);
|
||||
for (size_t i = 0; i < 100; i++) scr_map_set_f64_f64(map, cost_key(keys, 99 - i), (double)i);
|
||||
size_t j = 0;
|
||||
for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) {
|
||||
if (!scr_map_iter_live(map, (double)i)) continue;
|
||||
assert(scr_map_iter_key_f64(map, (double)i) == cost_key(keys, 99 - j));
|
||||
assert(scr_map_iter_val_f64(map, (double)i) == (double)j);
|
||||
j++;
|
||||
}
|
||||
assert(j == 100 && !scr_map_has_f64(map, cost_key(keys, 100)));
|
||||
scr_map_release(map);
|
||||
}
|
||||
|
||||
static ScrStr *key_str(size_t i) {
|
||||
char buf[32];
|
||||
int len = snprintf(buf, sizeof buf, "k%zu", i);
|
||||
return scr_str_new(buf, (size_t)len);
|
||||
}
|
||||
|
||||
enum { STRING_KEYS = 1 << 18 };
|
||||
|
||||
static void string_round(ScrMap *map, size_t round, void *ctx) {
|
||||
ScrStr **keys = ctx;
|
||||
scr_map_clear(map);
|
||||
size_t n = round % 7;
|
||||
for (size_t i = 0; i < n; i++) scr_map_set_str_f64(map, keys[(round + i) % STRING_KEYS], (double)i);
|
||||
assert(scr_map_size(map) == (double)n);
|
||||
scr_map_clear(map);
|
||||
}
|
||||
|
||||
static void clear_cost_strings(void) {
|
||||
ScrStr **keys = malloc(STRING_KEYS * sizeof *keys);
|
||||
assert(keys);
|
||||
for (size_t i = 0; i < STRING_KEYS; i++) keys[i] = key_str(i);
|
||||
ScrMap *map = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t i = 0; i < STRING_KEYS; i++) scr_map_set_str_f64(map, keys[i], (double)i);
|
||||
assert(map->nbuckets >= 2 * STRING_KEYS);
|
||||
scr_map_clear(map);
|
||||
ScrMap *fresh = scr_map_new(SCR_MAP_KEY_STR, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
expect_proportional("string clear", fresh, map, string_round, keys);
|
||||
scr_map_release(fresh);
|
||||
assert(map->nbuckets <= 256 && map->ecap <= 256);
|
||||
for (size_t i = 0; i < 10; i++) scr_map_set_str_f64(map, keys[i], (double)i);
|
||||
for (size_t i = 0; i < 20; i++) assert(scr_map_has_str(map, keys[i]) == (i < 10));
|
||||
scr_map_release(map);
|
||||
for (size_t i = 0; i < STRING_KEYS; i++) scr_str_release(keys[i]);
|
||||
free(keys);
|
||||
}
|
||||
|
||||
/* Walk the entries the way the forEach desugar does. */
|
||||
static void iterate_round(ScrMap *map, size_t round, void *ctx) {
|
||||
(void)round;
|
||||
size_t count = 0;
|
||||
for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) count += scr_map_iter_live(map, (double)i);
|
||||
assert(count == *(size_t *)ctx);
|
||||
}
|
||||
|
||||
/* Deleting almost everything compacts the entries and index tables, so
|
||||
* iteration costs O(live), and keys left far apart move back to hashing
|
||||
* instead of keeping a huge direct index. */
|
||||
static void delete_shrinks(void) {
|
||||
ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
ScrMap *fresh = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, (double)i, (double)i);
|
||||
for (size_t i = 0; i + 10 < BIG_KEYS; i++) assert(scr_map_delete_f64(map, (double)i));
|
||||
for (size_t i = BIG_KEYS - 10; i < BIG_KEYS; i++) scr_map_set_f64_f64(fresh, (double)i, (double)i);
|
||||
assert(scr_map_size(map) == 10);
|
||||
size_t live = 10;
|
||||
expect_proportional("iteration after deletes", fresh, map, iterate_round, &live);
|
||||
assert(scr_map_iter_count(map) <= 4 * 10 + 64);
|
||||
assert(!map->ndense); /* ten keys near 2^20: hashed */
|
||||
for (size_t i = BIG_KEYS - 10; i < BIG_KEYS; i++) assert(scr_map_has_f64(map, (double)i));
|
||||
assert(!scr_map_has_f64(map, 0) && !scr_map_has_f64(map, BIG_KEYS - 11));
|
||||
scr_map_release(fresh);
|
||||
scr_map_release(map);
|
||||
|
||||
/* Small survivors keep a direct index sized for them. */
|
||||
map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t i = 0; i < BIG_KEYS; i++) scr_map_set_f64_f64(map, (double)i, (double)i);
|
||||
for (size_t i = BIG_KEYS; i-- > 20;) assert(scr_map_delete_f64(map, (double)i));
|
||||
assert(scr_map_size(map) == 20 && map->ndense && map->ndense <= 64);
|
||||
for (size_t i = 0; i < 40; i++) assert(scr_map_has_f64(map, (double)i) == (i < 20));
|
||||
scr_map_release(map);
|
||||
|
||||
/* A copy indexes its own keys, not the source's peak (here a quarter of
|
||||
* the entries survive, too many to compact the source yet). */
|
||||
map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t i = 0; i < 4096; i++) scr_map_set_f64_f64(map, (double)i, (double)i);
|
||||
for (size_t i = 1024; i < 4096; i++) assert(scr_map_delete_f64(map, (double)i));
|
||||
assert(map->ndense == 4096 && scr_map_iter_count(map) == 4096);
|
||||
ScrMap *copy = scr_map_clone(map, false);
|
||||
assert(copy->ndense == 1024 && scr_map_size(copy) == 1024);
|
||||
double out;
|
||||
for (size_t i = 0; i < 1100; i++) {
|
||||
assert(scr_map_get_f64_f64(copy, (double)i, &out) == (i < 1024));
|
||||
if (i < 1024) assert(out == (double)i);
|
||||
}
|
||||
scr_map_release(copy);
|
||||
scr_map_release(map);
|
||||
}
|
||||
|
||||
/* Grow-and-drain cycles against the ordered model: the map repeatedly
|
||||
* grows large and is drained by random deletes, deletes in insertion
|
||||
* order, clear, or deletes during an iteration that also appends, so
|
||||
* compaction on delete, index shrinking, the move back to hashing and
|
||||
* clear's storage release all run under the Map contract. */
|
||||
static double drain_key(unsigned flavor, size_t cycle, size_t i) {
|
||||
switch (flavor) {
|
||||
case 0: return (double)(next() % 4000); /* dense */
|
||||
case 1: return (double)(cycle * 3000 + i); /* a moving dense window */
|
||||
default: return (double)(next() % 64) * 1000003.0 + (double)(next() % 3); /* hashed */
|
||||
}
|
||||
}
|
||||
|
||||
static void fuzz_drain(unsigned flavor) {
|
||||
static Model model;
|
||||
model.n = 0;
|
||||
ScrMap *map = scr_map_new(SCR_MAP_KEY_F64, SCR_MAP_VAL_F64, NULL, NULL, NULL);
|
||||
for (size_t cycle = 0; cycle < 16; cycle++) {
|
||||
size_t grow = 1 + next() % 3000;
|
||||
for (size_t i = 0; i < grow; i++) {
|
||||
double k = drain_key(flavor, cycle, i), v = (double)(next() % 1000);
|
||||
scr_map_set_f64_f64(map, k, v);
|
||||
model_set(&model, k, v);
|
||||
}
|
||||
check_same(map, &model);
|
||||
size_t keep = next() % 40;
|
||||
switch (next() % 4) {
|
||||
case 0:
|
||||
while (model.n > keep) {
|
||||
double k = model.key[next() % model.n];
|
||||
assert(scr_map_delete_f64(map, k) && model_delete(&model, k));
|
||||
}
|
||||
break;
|
||||
case 1:
|
||||
while (model.n > keep) {
|
||||
double k = model.key[0];
|
||||
assert(scr_map_delete_f64(map, k) && model_delete(&model, k));
|
||||
}
|
||||
break;
|
||||
case 2:
|
||||
scr_map_clear(map);
|
||||
model.n = 0;
|
||||
if (next() % 2) scr_map_clear(map);
|
||||
break;
|
||||
default: {
|
||||
/* Delete the visited entry with probability 3/4 and sometimes append
|
||||
* a fresh key, which the same iteration must still visit. */
|
||||
size_t cursor = 0, added = 0;
|
||||
scr_map_iter_enter(map);
|
||||
for (size_t i = 0; i < (size_t)scr_map_iter_count(map); i++) {
|
||||
if (!scr_map_iter_live(map, (double)i)) continue;
|
||||
double k = scr_map_iter_key_f64(map, (double)i);
|
||||
assert(cursor < model.n && same_value_zero(k, model.key[cursor]));
|
||||
if (added < 200 && next() % 8 == 0) {
|
||||
double fresh = 1e7 + (double)(cycle * 1000 + added++);
|
||||
scr_map_set_f64_f64(map, fresh, 1);
|
||||
model_set(&model, fresh, 1);
|
||||
}
|
||||
if (next() % 4) {
|
||||
assert(scr_map_delete_f64(map, k) && model_delete(&model, k));
|
||||
} else {
|
||||
cursor++;
|
||||
}
|
||||
}
|
||||
assert(cursor == model.n);
|
||||
scr_map_iter_exit(map);
|
||||
break;
|
||||
}
|
||||
}
|
||||
check_same(map, &model);
|
||||
for (size_t i = 0; i < 64; i++) probe(map, &model, drain_key(flavor, cycle, i));
|
||||
for (size_t i = 0; i < model.n; i++) probe(map, &model, model.key[i]);
|
||||
}
|
||||
scr_map_release(map);
|
||||
}
|
||||
|
||||
int main(void) {
|
||||
modes();
|
||||
iterate_while_mutating();
|
||||
for (unsigned flavor = 0; flavor < 5; flavor++) fuzz(flavor);
|
||||
clear_cost_numbers(0);
|
||||
clear_cost_numbers(1);
|
||||
clear_cost_strings();
|
||||
delete_shrinks();
|
||||
for (unsigned flavor = 0; flavor < 3; flavor++) fuzz_drain(flavor);
|
||||
puts("number-key maps passed");
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -465,12 +465,14 @@ static ScrMessage *scr_message_try_publish(const ScrDyn *value) {
|
||||
#ifdef SCR_RC_AUDIT
|
||||
scr_str_live_forget();
|
||||
#endif
|
||||
scr_lsan_immortal(text);
|
||||
} else if (d->rc != SIZE_MAX) {
|
||||
scr_cyc_on_dead(d); /* leave the candidate and tenured buffers */
|
||||
d->rc = SIZE_MAX;
|
||||
#ifdef SCR_RC_AUDIT
|
||||
scr_dyn_live_forget();
|
||||
#endif
|
||||
scr_lsan_immortal(d);
|
||||
}
|
||||
}
|
||||
message = scr_message_alloc(1, sizeof(*message));
|
||||
|
||||
@@ -282,6 +282,7 @@ void scr_publish(void *root, ScrPubFn fn) {
|
||||
#ifdef SCR_RC_AUDIT
|
||||
scr_pub_audit_forget(ctx.log[i].kind);
|
||||
#endif
|
||||
scr_lsan_immortal(ctx.log[i].obj);
|
||||
}
|
||||
if (trace) {
|
||||
uint64_t t2 = scr_hrtime_ns();
|
||||
|
||||
@@ -374,6 +374,9 @@ char **scr_worker_argv(void);
|
||||
* exit (before the RC audit), event-loop quiescence, and the per-generation
|
||||
* triggers.
|
||||
* SCR_CYCLE_THRESHOLD pins the nursery trigger to a fixed candidate count.
|
||||
* SCR_CYCLE_GROWTH_CAP lowers how far past its size the heap may grow
|
||||
* between passes at an older level that find nothing (16 times its size by
|
||||
* default; see scr_cycle.c).
|
||||
* There is no concurrent or incremental collection.
|
||||
*
|
||||
* Contract for trace/teardown pairs (the compiler emits them for shapes,
|
||||
@@ -473,8 +476,9 @@ void scr_cyc_free(void *obj); /* frees the block, header included */
|
||||
* of SCR_SA_ARENAS equal slices of a single process-wide reservation, so
|
||||
* the owning arena of any block is address arithmetic. A thread claims an
|
||||
* arena on its first allocation and parks it (free lists and bump pointers
|
||||
* intact, memory never unmapped) when it exits; the next thread to claim
|
||||
* it continues from there. Script objects stay on their thread, so a free
|
||||
* intact) when it exits; the next thread to claim it continues from there.
|
||||
* On Linux an exiting thread that carved much first returns the pages of
|
||||
* its arena that hold only free blocks (scr_alloc.c). Script objects stay on their thread, so a free
|
||||
* normally hits the caller's own arena inline. A block freed by another
|
||||
* thread goes to the owning arena's lock-free remote list, which the owner
|
||||
* drains on its slow path; published (immortal) objects are never freed,
|
||||
@@ -574,6 +578,8 @@ _Static_assert(offsetof(ScrSaState, span) == 8 && offsetof(ScrSaState, shift) ==
|
||||
void scr_sa_free_foreign(void *p);
|
||||
/* The calling script thread is done with its arena (worker exit). */
|
||||
void scr_sa_thread_park(void);
|
||||
/* Waits until the arenas of exited threads are trimmed (tests). */
|
||||
void scr_sa_wait_trims(void);
|
||||
#define SCR_SA_FREE_FOREIGN(p) scr_sa_free_foreign(p)
|
||||
#else
|
||||
#define SCR_SA_FREE_FOREIGN(p) free(p)
|
||||
@@ -2091,6 +2097,26 @@ void scr_map_live_forget(void);
|
||||
void scr_union_live_forget(void);
|
||||
#endif
|
||||
|
||||
/* For the same reason LeakSanitizer must not report a published object
|
||||
* whose last reference is dropped (scr_message.c, scr_publish.c). Ignored
|
||||
* chunks are leak-scan roots, so everything an immortal object reaches is
|
||||
* covered and the verdict on the rest of the heap is unchanged. Weak: ASan
|
||||
* runtimes without a leak checker leave it NULL; other builds compile the
|
||||
* hook away. */
|
||||
#if defined(__has_feature)
|
||||
#if __has_feature(address_sanitizer)
|
||||
#define SCR_LSAN_IMMORTAL 1
|
||||
void __lsan_ignore_object(const void *p) __attribute__((weak));
|
||||
#endif
|
||||
#endif
|
||||
static inline void scr_lsan_immortal(const void *obj) {
|
||||
#ifdef SCR_LSAN_IMMORTAL
|
||||
if (__lsan_ignore_object) __lsan_ignore_object(obj);
|
||||
#else
|
||||
(void)obj;
|
||||
#endif
|
||||
}
|
||||
|
||||
/* ── closures ───────────────────────────────────────────────────────
|
||||
* A closure is a function pointer plus the boxes of its captured bindings.
|
||||
* A box is one shared, refcounted variable cell: every function that
|
||||
|
||||
@@ -7,7 +7,7 @@ import { expect, test } from "vitest";
|
||||
const execFileAsync = promisify(execFile);
|
||||
const testDir = import.meta.dirname;
|
||||
|
||||
test("cycle traversal preserves counts, deep graphs and configured thresholds", async () => {
|
||||
test("cycle traversal preserves counts, deep graphs, configured thresholds and growth caps", async () => {
|
||||
const buildDir = join(testDir, "build");
|
||||
await mkdir(buildDir, { recursive: true });
|
||||
const bin = join(buildDir, "test_cycle");
|
||||
@@ -29,16 +29,22 @@ test("cycle traversal preserves counts, deep graphs and configured thresholds",
|
||||
UBSAN_OPTIONS: "halt_on_error=1",
|
||||
};
|
||||
delete baseEnv.SCR_CYCLE_THRESHOLD;
|
||||
delete baseEnv.SCR_CYCLE_GROWTH_CAP;
|
||||
|
||||
for (const [configured, expected] of [
|
||||
[undefined, "256"],
|
||||
["1", "1"],
|
||||
["2", "2"],
|
||||
["7", "7"],
|
||||
for (const [threshold, cap, expected] of [
|
||||
[undefined, undefined, "threshold=256 growth_cap=16"],
|
||||
["1", undefined, "threshold=1 growth_cap=16"],
|
||||
["2", undefined, "threshold=2 growth_cap=16"],
|
||||
["7", undefined, "threshold=7 growth_cap=16"],
|
||||
[undefined, "1", "threshold=256 growth_cap=1"],
|
||||
["1", "1", "threshold=1 growth_cap=1"],
|
||||
[undefined, "3", "threshold=256 growth_cap=2"],
|
||||
[undefined, "64", "threshold=256 growth_cap=16"],
|
||||
] as const) {
|
||||
const env = { ...baseEnv };
|
||||
if (configured !== undefined) env.SCR_CYCLE_THRESHOLD = configured;
|
||||
if (threshold !== undefined) env.SCR_CYCLE_THRESHOLD = threshold;
|
||||
if (cap !== undefined) env.SCR_CYCLE_GROWTH_CAP = cap;
|
||||
const run = await execFileAsync(bin, [], { env });
|
||||
expect(run.stdout).toBe(`cycle collection checks passed: threshold=${expected}\n`);
|
||||
expect(run.stdout).toBe(`cycle collection checks passed: ${expected}\n`);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -20,6 +20,18 @@ static size_t configured_nursery_threshold(void) {
|
||||
return value > 0 ? (size_t)value : 256;
|
||||
}
|
||||
|
||||
/* How many times its size after the last full pass the heap may grow before
|
||||
* the next once passes stop finding garbage: SCR_CYCLE_GROWTH_CAP rounded
|
||||
* down to a power of two from 1 to 16, and 16 when unset. */
|
||||
static size_t configured_growth_cap(void) {
|
||||
const char *env = getenv("SCR_CYCLE_GROWTH_CAP");
|
||||
long value = env ? strtol(env, NULL, 10) : 0;
|
||||
if (value <= 0) return 16;
|
||||
size_t cap = 1;
|
||||
while (cap < 16 && (long)(cap * 2) <= value) cap *= 2;
|
||||
return cap;
|
||||
}
|
||||
|
||||
static void check(bool condition, const char *message) {
|
||||
if (condition) return;
|
||||
fprintf(stderr, "cycle test failed: %s\n", message);
|
||||
@@ -359,20 +371,16 @@ static Node *grow_chain(Node *head, size_t count) {
|
||||
return head;
|
||||
}
|
||||
|
||||
/* A dead ring aged into the old generation, with no outside owner left. */
|
||||
static void make_dead_old_ring(size_t count) {
|
||||
Node *ring = make_ring(count);
|
||||
age_to_old(ring);
|
||||
release_live(ring);
|
||||
}
|
||||
|
||||
/* Full passes over a growing retained structure that find nothing widen the
|
||||
* growth fraction, so the structure is not re-walked at every quarter of its
|
||||
* growth; old garbage still goes before the heap has doubled past its size
|
||||
* after the last full pass, and garbage found in proportion to the growth
|
||||
* narrows the schedule again. */
|
||||
* growth. How far the fraction may widen, and how a productive pass narrows
|
||||
* it, are pinned on a heap at the growth floor by
|
||||
* check_growth_cap_bounds_floating_garbage: on a structure this size the
|
||||
* default cap lets a dead cycle wait through millions of allocations. Leaves
|
||||
* the fraction widened, so it runs after the tests that assume the base
|
||||
* schedule. */
|
||||
static void check_growth_schedule_follows_yield(void) {
|
||||
enum { START = 4096, GROW = 64 * START, RING = 64 };
|
||||
enum { START = 4096, GROW = 64 * START };
|
||||
size_t before = freed;
|
||||
Node *head = grow_chain(make_leaf(), START);
|
||||
scr_collect_cycles(); /* a baseline the bounds below are measured from */
|
||||
@@ -385,42 +393,6 @@ static void check_growth_schedule_follows_yield(void) {
|
||||
check(traced - walked_before <= 7 * (size_t)GROW,
|
||||
"a growing retained structure was re-walked at every fraction of growth");
|
||||
|
||||
/* Widened to the cap: a dead old ring still goes within one doubling. */
|
||||
make_dead_old_ring(RING);
|
||||
size_t live = scr_cyc_live;
|
||||
size_t grown = 0;
|
||||
while (freed < before + RING && grown <= 2 * live) {
|
||||
head = grow_chain(head, 1);
|
||||
grown++;
|
||||
}
|
||||
check(freed == before + RING, "old garbage was never reclaimed after widening");
|
||||
/* A pass waits for the release trigger, so allow one nursery's worth. */
|
||||
check(grown <= live + configured_nursery_threshold(),
|
||||
"old garbage outlived the doubled growth bound");
|
||||
|
||||
/* Old garbage in proportion to the growth narrows the fraction back. */
|
||||
size_t big = scr_cyc_live / 2;
|
||||
before = freed;
|
||||
make_dead_old_ring(big);
|
||||
live = scr_cyc_live;
|
||||
grown = 0;
|
||||
while (freed < before + big && grown <= 2 * live) {
|
||||
head = grow_chain(head, 1);
|
||||
grown++;
|
||||
}
|
||||
check(freed == before + big, "large old garbage was never reclaimed");
|
||||
before = freed;
|
||||
make_dead_old_ring(RING);
|
||||
live = scr_cyc_live;
|
||||
grown = 0;
|
||||
while (freed < before + RING && grown <= 2 * live) {
|
||||
head = grow_chain(head, 1);
|
||||
grown++;
|
||||
}
|
||||
check(freed == before + RING, "old garbage was never reclaimed after narrowing");
|
||||
check(grown <= live / 4 + configured_nursery_threshold(),
|
||||
"a productive pass did not narrow the growth fraction");
|
||||
|
||||
/* Close the chain into one dead cycle: the collector reclaims it whole. */
|
||||
Node *tail = head;
|
||||
while (tail->next) tail = tail->next;
|
||||
@@ -433,6 +405,81 @@ static void check_growth_schedule_follows_yield(void) {
|
||||
check(freed == before + length, "the closed chain was not reclaimed whole");
|
||||
}
|
||||
|
||||
/* Grow one dead cycle until a full pass reclaims it, and return the live
|
||||
* heap at the release that ran that pass. The cycle's only old member is
|
||||
* also its only candidate; the young nodes appended to it are never
|
||||
* buffered, so only a full pass walks or frees them, and that pass reclaims
|
||||
* one old object against all of the growth, plus a dead old ring of
|
||||
* `old_garbage` objects when that is nonzero. Dead one-node rings keep the
|
||||
* release path running restricted passes meanwhile. The explicit sweeps in
|
||||
* age_to_old leave a live heap below the growth floor, so the full pass is
|
||||
* due once the heap grows past the floor by the old level's fraction. */
|
||||
static size_t grow_dead_cycle_until_full_pass(size_t old_garbage) {
|
||||
enum { FLOOR = 4096 };
|
||||
size_t limit = FLOOR * 18 + 4 * configured_nursery_threshold();
|
||||
Node *garbage = NULL;
|
||||
if (old_garbage) {
|
||||
garbage = make_ring(old_garbage);
|
||||
age_to_old(garbage);
|
||||
}
|
||||
Node *old = make_leaf();
|
||||
age_to_old(old);
|
||||
check(scr_cyc_live < FLOOR, "live heap did not start below the growth floor");
|
||||
if (garbage) release_live(garbage); /* last outside owner gone */
|
||||
Node *first = make_leaf(); /* owned by old->other */
|
||||
first->next = old;
|
||||
old->rc++;
|
||||
old->other = first;
|
||||
release_live(old); /* external owner gone: a dead cycle with one old root */
|
||||
size_t old_before = scr_cyc_old_freed;
|
||||
for (size_t appended = 1; appended < limit; appended++) {
|
||||
Node *node = make_leaf(); /* takes over old's reference to the newest */
|
||||
node->next = old->other;
|
||||
old->other = node;
|
||||
Node *ring = make_ring(1);
|
||||
size_t live = scr_cyc_live;
|
||||
release_live(ring);
|
||||
if (scr_cyc_old_freed != old_before) {
|
||||
check(scr_cyc_old_freed == old_before + old_garbage + 1
|
||||
&& scr_cyc_live < FLOOR / 2,
|
||||
"the full pass did not reclaim the dead old garbage whole");
|
||||
return live;
|
||||
}
|
||||
}
|
||||
check(false, "a dead cycle through the old generation outlived the growth cap");
|
||||
return 0;
|
||||
}
|
||||
|
||||
/* Passes that keep finding nothing widen the old level's fraction one
|
||||
* doubling at a time up to the configured cap and no further: once there, a
|
||||
* dead cycle goes exactly when the heap has grown the cap past the floor,
|
||||
* not one doubling earlier (a narrower cap) and not later (a wider or
|
||||
* missing one). A pass that then finds old garbage narrows the fraction at
|
||||
* once. Runs last: it leaves the fraction narrowed but not at the base. */
|
||||
static void check_growth_cap_bounds_floating_garbage(void) {
|
||||
enum { FLOOR = 4096 };
|
||||
size_t cap = configured_growth_cap();
|
||||
/* Passes wait for the release trigger, and dead rings float until the
|
||||
* next restricted pass, so allow two nursery's worth. */
|
||||
size_t slack = 2 * configured_nursery_threshold() + 2;
|
||||
size_t rounds = 2; /* two passes asking to widen before the first doubling */
|
||||
for (size_t c = cap; c >= 1; c /= 2) rounds++; /* one per doubling up to cap */
|
||||
for (size_t i = 0; i < rounds; i++) grow_dead_cycle_until_full_pass(0);
|
||||
size_t due = FLOOR + FLOOR * cap;
|
||||
size_t live = grow_dead_cycle_until_full_pass(0);
|
||||
check(live > due, "the growth fraction stopped widening below the cap");
|
||||
check(live <= due + slack, "the growth fraction widened past the cap");
|
||||
|
||||
/* At any cap, old garbage of an eighth of the floor or more is a yield
|
||||
* that asks for at least one doubling less (this ring asks for two below
|
||||
* a cap of 2 or more), and narrowing does not wait for a second pass. */
|
||||
live = grow_dead_cycle_until_full_pass(FLOOR / 2 - 64);
|
||||
check(live > due && live <= due + slack, "a capped pass came early or late");
|
||||
live = grow_dead_cycle_until_full_pass(0);
|
||||
check(live <= FLOOR + FLOOR * cap / 2 + slack,
|
||||
"a pass that found old garbage did not narrow the growth fraction");
|
||||
}
|
||||
|
||||
static void check_deep_ring(void) {
|
||||
enum { DEPTH = 100000 };
|
||||
size_t before = freed;
|
||||
@@ -604,7 +651,6 @@ static void check_deferred_white_restoration(void) {
|
||||
|
||||
int main(void) {
|
||||
check_old_garbage_reclaimed_while_growing();
|
||||
check_growth_schedule_follows_yield();
|
||||
check_older_rings_into_old();
|
||||
check_cycle_spanning_mature_and_old();
|
||||
check_unproductive_backlog_backs_off();
|
||||
@@ -620,7 +666,9 @@ int main(void) {
|
||||
check_cross_generation_edges();
|
||||
check_deep_cross_generation_chain();
|
||||
check_teardown_rebuffers_and_immortals();
|
||||
printf("cycle collection checks passed: threshold=%zu\n",
|
||||
configured_nursery_threshold());
|
||||
check_growth_schedule_follows_yield();
|
||||
check_growth_cap_bounds_floating_garbage();
|
||||
printf("cycle collection checks passed: threshold=%zu growth_cap=%zu\n",
|
||||
configured_nursery_threshold(), configured_growth_cap());
|
||||
return 0;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ sudo env DEBIAN_FRONTEND=noninteractive apt-get install --quiet=2 --yes --no-ins
|
||||
clang-22 \
|
||||
cmake \
|
||||
libclang-rt-22-dev \
|
||||
libpolly-22-dev \
|
||||
libzstd-dev \
|
||||
llvm-22-dev \
|
||||
ninja-build \
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
// Map and Set clear(), reuse and iteration after clear and after mass
|
||||
// deletion, for number, string and object keys. The sizes cross the
|
||||
// runtime's storage thresholds: maps grow far beyond their later size, so
|
||||
// clear() and delete release or shrink their index tables, number keys move
|
||||
// between the direct index and hashing, and every later operation must
|
||||
// still follow insertion order exactly.
|
||||
|
||||
class Point {
|
||||
readonly x: number;
|
||||
readonly y: number;
|
||||
constructor(x: number, y: number) {
|
||||
this.x = x;
|
||||
this.y = y;
|
||||
}
|
||||
}
|
||||
|
||||
function summarize<K>(label: string, all: K[], show: (k: K) => string): void {
|
||||
const head = all.slice(0, 4).map(show).join(",");
|
||||
const tail = all.slice(-3).map(show).join(",");
|
||||
console.log(`${label}: size=${all.length} head=[${head}] tail=[${tail}]`);
|
||||
}
|
||||
|
||||
// ── number keys: dense table grown large, then cleared and reused ──────────
|
||||
const nums = new Map<number, number>();
|
||||
for (let i = 0; i < 70000; i++) nums.set(i, i * 2);
|
||||
console.log("nums grown", nums.size, nums.get(69999), nums.has(70000));
|
||||
nums.clear();
|
||||
console.log("nums cleared", nums.size, nums.has(0), nums.get(5), [...nums.keys()].length);
|
||||
nums.clear(); // empty clear
|
||||
nums.clear();
|
||||
for (const k of [5, 3, 9, 3, 1000000, 0.5, -0, 7]) nums.set(k, k + 1);
|
||||
summarize("nums reuse", [...nums.keys()], (k) => String(Object.is(k, -0) ? "-0" : k));
|
||||
console.log("nums -0 stored as", Object.is([...nums.keys()][5], -0) ? "-0" : "+0");
|
||||
console.log("nums values", [...nums.values()].join(","));
|
||||
// many small fill/clear rounds after the large one
|
||||
let roundSum = 0;
|
||||
for (let round = 0; round < 2000; round++) {
|
||||
nums.clear();
|
||||
for (let j = 0; j < round % 9; j++) nums.set(round * 10 + j, j);
|
||||
for (const [k, v] of nums) roundSum += k * 3 + v;
|
||||
roundSum += nums.size;
|
||||
}
|
||||
console.log("nums rounds", roundSum, nums.size);
|
||||
|
||||
// ── number keys: mass deletion, then iteration and lookups ────────────────
|
||||
const ids = new Set<number>();
|
||||
for (let i = 0; i < 50000; i++) ids.add(i);
|
||||
for (let i = 0; i < 49990; i++) ids.delete(i); // survivors are far from zero
|
||||
summarize("ids after deletes", [...ids], (k) => String(k));
|
||||
console.log("ids lookups", ids.has(49989), ids.has(49990), ids.has(49999), ids.has(0));
|
||||
ids.add(3);
|
||||
ids.add(49989);
|
||||
summarize("ids re-added", [...ids], (k) => String(k));
|
||||
for (let i = 49999; i >= 20; i--) ids.delete(i); // now small keys only
|
||||
summarize("ids small", [...ids], (k) => String(k));
|
||||
for (let i = 0; i < 100; i++) ids.add(i);
|
||||
summarize("ids regrown", [...ids], (k) => String(k));
|
||||
|
||||
// ── stack-like add/delete like a recursion guard, cleared on every reset ──
|
||||
const guard = new Set<number>();
|
||||
let maybe = 0;
|
||||
function visit(depth: number, id: number): void {
|
||||
if (guard.has(id)) {
|
||||
maybe++;
|
||||
return;
|
||||
}
|
||||
guard.add(id);
|
||||
if (depth > 0) {
|
||||
visit(depth - 1, id * 3 + 1);
|
||||
visit(depth - 1, (id * 7) % 1000);
|
||||
}
|
||||
guard.delete(id);
|
||||
}
|
||||
for (let check = 0; check < 300; check++) {
|
||||
guard.clear();
|
||||
visit(check % 50 === 0 ? 12 : 3, check * 100003);
|
||||
}
|
||||
console.log("guard", maybe, guard.size, [...guard].length);
|
||||
|
||||
// ── string keys ──────────────────────────────────────────────────────────
|
||||
const words = new Map<string, number>();
|
||||
for (let i = 0; i < 20000; i++) words.set(`w${i}`, i);
|
||||
words.clear();
|
||||
console.log("words cleared", words.size, words.has("w1"), words.get("w2"));
|
||||
for (const w of ["delta", "alpha", "charlie", "alpha", "bravo"]) words.set(w, (words.get(w) ?? 0) + 1);
|
||||
summarize("words reuse", [...words.entries()], ([k, v]) => `${k}=${v}`);
|
||||
for (let i = 0; i < 5000; i++) words.set(`x${i}`, i);
|
||||
for (let i = 0; i < 4990; i++) words.delete(`x${i}`);
|
||||
summarize("words after deletes", [...words.keys()], (k) => k);
|
||||
const tags = new Set<string>(["a", "b", "c"]);
|
||||
tags.clear();
|
||||
tags.add("z");
|
||||
tags.add("a");
|
||||
console.log("tags", [...tags].join(","), tags.has("b"));
|
||||
|
||||
// ── object keys ──────────────────────────────────────────────────────────
|
||||
const points: Point[] = [];
|
||||
for (let i = 0; i < 3000; i++) points.push(new Point(i, -i));
|
||||
const owners = new Map<Point, string>();
|
||||
for (const p of points) owners.set(p, `p${p.x}`);
|
||||
owners.clear();
|
||||
console.log("owners cleared", owners.size, owners.has(points[0]!));
|
||||
owners.set(points[10]!, "ten");
|
||||
owners.set(points[2]!, "two");
|
||||
owners.set(points[10]!, "TEN");
|
||||
summarize("owners reuse", [...owners.entries()], ([p, s]) => `${p.x}:${s}`);
|
||||
const seen = new Set<Point>(points);
|
||||
for (let i = 0; i < 2995; i++) seen.delete(points[i]!);
|
||||
summarize("seen after deletes", [...seen], (p) => `${p.x}/${p.y}`);
|
||||
|
||||
// ── clear and delete during iteration (Node's live iteration) ────────────
|
||||
const live = new Map<number, string>();
|
||||
for (let i = 0; i < 400; i++) live.set(i, `v${i}`);
|
||||
const visited: number[] = [];
|
||||
for (const [k] of live) {
|
||||
visited.push(k);
|
||||
if (k === 2) {
|
||||
live.clear();
|
||||
live.set(1000, "after");
|
||||
live.set(1001, "after");
|
||||
}
|
||||
if (k === 1000) live.set(1002, "late");
|
||||
}
|
||||
console.log("live visited", visited.join(","), "size", live.size);
|
||||
|
||||
const churn = new Set<number>();
|
||||
for (let i = 0; i < 300; i++) churn.add(i);
|
||||
let walked = 0;
|
||||
churn.forEach((k) => {
|
||||
walked++;
|
||||
churn.delete(k);
|
||||
if (k < 100) churn.add(k + 1000);
|
||||
});
|
||||
summarize("churn after forEach", [...churn], (k) => String(k));
|
||||
console.log("churn walked", walked);
|
||||
|
||||
const strs = new Map<string, number>();
|
||||
for (let i = 0; i < 500; i++) strs.set(`s${i}`, i);
|
||||
const order: string[] = [];
|
||||
strs.forEach((_v, k) => {
|
||||
order.push(k);
|
||||
if (k === "s1") {
|
||||
strs.clear();
|
||||
strs.set("fresh", 1);
|
||||
}
|
||||
});
|
||||
console.log("strs visited", order.join(","), [...strs.keys()].join(","));
|
||||
@@ -0,0 +1,75 @@
|
||||
// Objects that hold reference cycles without being able to lie on one need
|
||||
// no cycle tracking: nothing reachable from the cycle can lead back to them,
|
||||
// so their references are external counts and they die by reference
|
||||
// counting alone, releasing the cycle to the collector. Types that reach a
|
||||
// closure, which can capture anything, stay tracked. The sanitized lane's
|
||||
// RC audit fails if any cycle below leaks.
|
||||
|
||||
class Ring {
|
||||
peer: Ring | null = null;
|
||||
value: number;
|
||||
constructor(value: number) {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
function ring(value: number): Ring {
|
||||
const a = new Ring(value);
|
||||
const b = new Ring(value + 1);
|
||||
a.peer = b;
|
||||
b.peer = a;
|
||||
return a;
|
||||
}
|
||||
|
||||
// Holds a cycle; no field of Ring can reach a Wrapper.
|
||||
class Wrapper {
|
||||
readonly ring: Ring;
|
||||
readonly label: string;
|
||||
constructor(ring: Ring, label: string) {
|
||||
this.ring = ring;
|
||||
this.label = label;
|
||||
}
|
||||
}
|
||||
|
||||
// Holds wrappers in a mutable array: still not on any cycle.
|
||||
class Shelf {
|
||||
items: Wrapper[] = [];
|
||||
owner: Wrapper | null = null;
|
||||
}
|
||||
|
||||
// Reaches a closure, which can capture the object itself.
|
||||
class Callback {
|
||||
readonly ring: Ring;
|
||||
run: () => number;
|
||||
constructor(ring: Ring) {
|
||||
this.ring = ring;
|
||||
this.run = () => 0;
|
||||
}
|
||||
}
|
||||
|
||||
interface Tagged {
|
||||
ring: Ring;
|
||||
tag: string;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let round = 0; round < 300; round++) {
|
||||
const shelf = new Shelf();
|
||||
for (let i = 0; i < 20; i++) shelf.items.push(new Wrapper(ring(i), `w${i}`));
|
||||
shelf.owner = shelf.items[3]!;
|
||||
for (const w of shelf.items) total += w.ring.peer!.value;
|
||||
const cb = new Callback(ring(round));
|
||||
cb.run = () => cb.ring.value + total;
|
||||
total += cb.run() > 0 ? 1 : 0;
|
||||
const tagged: Tagged = { ring: ring(round), tag: "t" };
|
||||
total += tagged.ring.peer!.peer!.value;
|
||||
const kept: (Wrapper | Ring)[] = [shelf.owner, ring(1)];
|
||||
total += kept.length;
|
||||
}
|
||||
console.log(total);
|
||||
|
||||
// Release the last holder of a long-lived cycle late.
|
||||
let late: Wrapper | null = new Wrapper(ring(1000), "late");
|
||||
const peerValue = late.ring.peer!.value;
|
||||
late = null;
|
||||
console.log(peerValue, late === null);
|
||||
@@ -0,0 +1,66 @@
|
||||
// A constructor that lets `this` escape only does so from a certain
|
||||
// statement on. Fields it stores before that point are still construction-
|
||||
// only: no other code could see the object yet, so they keep their value and
|
||||
// cannot close a cycle. Stores from the escaping statement on are ordinary
|
||||
// writes: they may close cycles (the sanitized lane's RC audit fails if one
|
||||
// leaks) and their values may be replaced while other code reads them.
|
||||
|
||||
class Item {
|
||||
readonly name: string;
|
||||
constructor(name: string) {
|
||||
this.name = name;
|
||||
}
|
||||
}
|
||||
|
||||
const registry: Node[] = [];
|
||||
|
||||
class Node {
|
||||
readonly item: Item;
|
||||
readonly id: number;
|
||||
peer: Node | null;
|
||||
late: Item | null;
|
||||
constructor(id: number, item: Item) {
|
||||
this.id = id;
|
||||
this.item = item;
|
||||
this.peer = null;
|
||||
registry.push(this);
|
||||
// After the escape: these stores can see (and close cycles with)
|
||||
// objects created after this one.
|
||||
this.peer = registry.length > 1 ? registry[registry.length - 2]! : null;
|
||||
this.late = new Item(`late${id}`);
|
||||
}
|
||||
}
|
||||
|
||||
class Linked extends Node {
|
||||
back: Node | null;
|
||||
constructor(id: number) {
|
||||
super(id, new Item(`linked${id}`));
|
||||
// The base constructor already let `this` escape.
|
||||
this.back = registry[0] ?? null;
|
||||
if (this.back !== null) this.back.peer = this;
|
||||
}
|
||||
}
|
||||
|
||||
function useAfter(item: Item, drop: () => void): string {
|
||||
drop();
|
||||
const noise: Item[] = [];
|
||||
for (let i = 0; i < 64; i++) noise.push(new Item(`noise${i}`));
|
||||
return `${item.name}/${noise.length}`;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let round = 0; round < 200; round++) {
|
||||
const a = new Node(round, new Item(`a${round}`));
|
||||
const b = new Linked(round + 1);
|
||||
// Pre-escape field of a local owner, borrowed while every other
|
||||
// reference goes away.
|
||||
total += useAfter(a.item, () => {
|
||||
registry.length = 0;
|
||||
}).length;
|
||||
// A post-escape field replaced while it is being read.
|
||||
total += useAfter(b.late!, () => {
|
||||
b.late = new Item("replaced");
|
||||
}).length;
|
||||
total += (a.peer === null ? 0 : 1) + (b.back === a ? 1 : 0) + (a.peer === b ? 1 : 0);
|
||||
}
|
||||
console.log(total, registry.length);
|
||||
@@ -0,0 +1,74 @@
|
||||
// A missing read (an out-of-range typed-array or array read the checker
|
||||
// types as `number`) defaulted with `??` or `||` to a string, or returned
|
||||
// as is, gives a lambda a result its inferred return type does not spell:
|
||||
// TypeScript types `read ?? "text"` and `() => xs[i]` as number. The
|
||||
// closure returns the runtime value, through bindings, callbacks taking
|
||||
// `unknown`, calls of the binding, and the enclosing function's return.
|
||||
const base: number = process.argv.length;
|
||||
const chars = new Uint16Array(4);
|
||||
chars[0] = 108;
|
||||
const nums: number[] = [7];
|
||||
const far = base + 100;
|
||||
const a = chars[far];
|
||||
const c0 = chars[0];
|
||||
|
||||
// The scanbox probe table: every arrow is typed `() => number` by
|
||||
// inference and stored in a `() => unknown` slot.
|
||||
const probes: [string, () => unknown][] = [
|
||||
["plus", () => a + 1],
|
||||
["or", () => a | 0],
|
||||
["max", () => Math.max(a, 1)],
|
||||
["fcc", () => JSON.stringify(String.fromCharCode(c0, a))],
|
||||
["and", () => c0 && 5],
|
||||
["or2", () => chars[far] || 7],
|
||||
["nullish", () => a ?? "dflt"],
|
||||
["nullish present", () => c0 ?? "dflt"],
|
||||
["tern", () => (a ? "yes" : "no")],
|
||||
["bang", () => !a],
|
||||
["concat", () => a + "s"],
|
||||
["missing", () => nums[far]],
|
||||
];
|
||||
for (const [name, f] of probes) {
|
||||
try {
|
||||
console.log(name, f());
|
||||
} catch (e) {
|
||||
console.log(name, "threw", (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
function take(f: () => unknown): unknown {
|
||||
return f();
|
||||
}
|
||||
console.log("take nullish", take(() => a ?? "d1"));
|
||||
console.log("take missing", take(() => nums[far]));
|
||||
|
||||
const bound = () => a ?? "d2";
|
||||
console.log("bound", bound());
|
||||
const block = () => {
|
||||
return a ?? "d3";
|
||||
};
|
||||
console.log("block", block());
|
||||
const expression = function () {
|
||||
return a || "d4";
|
||||
};
|
||||
console.log("expression", expression());
|
||||
const read = () => nums[far];
|
||||
console.log("read", read(), read() === undefined);
|
||||
|
||||
function viaBinding(i: number): number {
|
||||
const get = () => nums[i];
|
||||
return get();
|
||||
}
|
||||
console.log("via binding", viaBinding(far), viaBinding(0));
|
||||
|
||||
function declared() {
|
||||
return a ?? "d5";
|
||||
}
|
||||
console.log("declared", declared());
|
||||
|
||||
class Lexer {
|
||||
peek(i: number) {
|
||||
return chars[i] ?? "eof";
|
||||
}
|
||||
}
|
||||
console.log("method", new Lexer().peek(far), new Lexer().peek(0));
|
||||
@@ -0,0 +1,88 @@
|
||||
// A `T | undefined` slot assigned a missing read (an out-of-range
|
||||
// typed-array or array read the checker types as `T`) holds undefined,
|
||||
// even though TypeScript narrows later reads to `T`. Reads keep the stored
|
||||
// value: printing shows undefined, arithmetic gives NaN, and a member read
|
||||
// throws Node's TypeError. Class fields (through `obj.f` and `this.f`) and
|
||||
// locals both follow it; a presence guard still narrows.
|
||||
const base: number = process.argv.length;
|
||||
const chars = new Uint16Array([5, 6]);
|
||||
const nums: number[] = [7];
|
||||
const far = base + 100;
|
||||
|
||||
function twice(n: number): number {
|
||||
return n * 2;
|
||||
}
|
||||
|
||||
class Cursor {
|
||||
token: number | undefined = 0;
|
||||
peek(i: number): number {
|
||||
return chars[i];
|
||||
}
|
||||
advance(i: number): string {
|
||||
this.token = chars[i];
|
||||
return `${this.token} ${this.token === undefined} ${this.token + 1}`;
|
||||
}
|
||||
}
|
||||
|
||||
function fields(i: number, label: string): void {
|
||||
const cursor = new Cursor();
|
||||
cursor.token = chars[i];
|
||||
console.log(label, "direct", cursor.token, cursor.token === undefined);
|
||||
console.log(label, "ops", cursor.token + 1, -cursor.token, cursor.token < 3, `${cursor.token}`);
|
||||
console.log(label, "defaults", cursor.token ?? "none", cursor.token || "falsy");
|
||||
console.log(label, "call", twice(cursor.token), [cursor.token]);
|
||||
cursor.token = cursor.peek(i);
|
||||
console.log(label, "method", cursor.token, typeof cursor.token);
|
||||
cursor.token = nums[i];
|
||||
console.log(label, "array", cursor.token, String(cursor.token));
|
||||
console.log(label, "this", cursor.advance(i));
|
||||
if (cursor.token !== undefined) console.log(label, "guarded", cursor.token * 2);
|
||||
else console.log(label, "guarded absent");
|
||||
}
|
||||
fields(far, "missing");
|
||||
fields(0, "present");
|
||||
|
||||
function locals(i: number, label: string): void {
|
||||
let code: number | undefined = 0;
|
||||
code = chars[i];
|
||||
console.log(label, "local", code, code + 1, `${code}`, [code], twice(code));
|
||||
let late: number | undefined;
|
||||
late = nums[i];
|
||||
console.log(label, "late", late, late === undefined, late ?? "none");
|
||||
let branch: number | undefined = 1;
|
||||
if (base > 0) branch = chars[i];
|
||||
console.log(label, "branch", branch, typeof branch);
|
||||
if (code !== undefined) console.log(label, "local guarded", code * 3);
|
||||
const viaExpr = (code = chars[i]);
|
||||
console.log(label, "assign expr", viaExpr, code);
|
||||
}
|
||||
locals(far, "missing");
|
||||
locals(1, "present");
|
||||
|
||||
class Node2 {
|
||||
v: number;
|
||||
constructor(v: number) {
|
||||
this.v = v;
|
||||
}
|
||||
}
|
||||
const pool: Node2[] = [new Node2(4)];
|
||||
|
||||
class Holder {
|
||||
node: Node2 | undefined = undefined;
|
||||
}
|
||||
|
||||
function objects(i: number, label: string): void {
|
||||
const holder = new Holder();
|
||||
holder.node = pool[i];
|
||||
console.log(label, "object field", holder.node === undefined);
|
||||
try {
|
||||
console.log(label, "object member", holder.node.v);
|
||||
} catch (e) {
|
||||
console.log(label, "object member threw", (e as Error).name, (e as Error).message);
|
||||
}
|
||||
let current: Node2 | undefined = new Node2(1);
|
||||
current = pool[i];
|
||||
console.log(label, "object local", current === undefined);
|
||||
}
|
||||
objects(far, "missing");
|
||||
objects(0, "present");
|
||||
@@ -0,0 +1,95 @@
|
||||
// Builtins that convert a number argument with ToNumber see a missing
|
||||
// read (an out-of-range typed-array or array read the checker types as
|
||||
// `number`) as NaN, ToNumber's answer for undefined: String.fromCharCode
|
||||
// yields code unit 0, fromCodePoint throws a RangeError, Math answers NaN,
|
||||
// isNaN is true. The missing value reaches them from a module binding, a
|
||||
// local, a class field and a record field.
|
||||
const base: number = process.argv.length;
|
||||
const chars = new Uint16Array(4);
|
||||
chars[0] = 108;
|
||||
chars[1] = 66;
|
||||
const nums: number[] = [7];
|
||||
const far = base + 100;
|
||||
|
||||
const missing = chars[far];
|
||||
const present = chars[1];
|
||||
const missingNum = nums[far];
|
||||
|
||||
function show(label: string, value: unknown): void {
|
||||
console.log(label, typeof value === "string" ? JSON.stringify(value) : value);
|
||||
}
|
||||
|
||||
function attempt(label: string, run: () => unknown): void {
|
||||
try {
|
||||
show(label, run());
|
||||
} catch (e) {
|
||||
console.log(label, "threw", (e as Error).name, (e as Error).message);
|
||||
}
|
||||
}
|
||||
|
||||
// String.fromCharCode / fromCodePoint.
|
||||
show("fcc module", String.fromCharCode(chars[0], missing));
|
||||
show("fcc single", String.fromCharCode(missing));
|
||||
show("fcc present", String.fromCharCode(chars[0], present));
|
||||
show("fcc array", String.fromCharCode(missingNum, 65));
|
||||
attempt("fcp module", () => String.fromCodePoint(missing));
|
||||
show("fcp present", String.fromCodePoint(present));
|
||||
|
||||
// Math functions.
|
||||
show("abs", Math.abs(missing));
|
||||
show("floor", Math.floor(missingNum));
|
||||
show("max", Math.max(missing, 1));
|
||||
show("min3", Math.min(1, 2, missing));
|
||||
show("imul", Math.imul(missing, 3));
|
||||
show("pow", Math.pow(present, 2));
|
||||
show("hypot", Math.hypot(missing, 3));
|
||||
|
||||
// The global number predicates convert; the Number statics do not.
|
||||
show("isNaN", isNaN(missing));
|
||||
show("isNaN present", isNaN(present));
|
||||
show("isFinite", isFinite(missing));
|
||||
show("isFinite present", isFinite(present));
|
||||
show("Number.isNaN", Number.isNaN(missing));
|
||||
show("Number.isFinite", Number.isFinite(missing));
|
||||
|
||||
// DataView offsets (ToIndex) and values (ToNumber).
|
||||
const view = new DataView(new ArrayBuffer(4));
|
||||
view.setUint8(1, 9);
|
||||
show("dv get", view.getUint8(missing));
|
||||
view.setUint8(missing, 5);
|
||||
show("dv set offset", view.getUint8(0));
|
||||
view.setUint8(2, missing);
|
||||
show("dv set value", view.getUint8(2));
|
||||
|
||||
// Typed-array ranges: a missing start is 0, a missing end is the length.
|
||||
show("slice start", chars.slice(missing).length);
|
||||
show("slice end", chars.slice(1, missing).length);
|
||||
show("subarray start", chars.subarray(missing).length);
|
||||
show("subarray end", chars.subarray(2, missing).length);
|
||||
|
||||
// The same reads held in a local, a class field and a record field.
|
||||
function local(i: number, label: string): void {
|
||||
let code: number | undefined = 0;
|
||||
code = chars[i];
|
||||
show(`local fcc ${label}`, String.fromCharCode(97, code));
|
||||
show(`local max ${label}`, Math.max(code, 1));
|
||||
show(`local isNaN ${label}`, isNaN(code));
|
||||
}
|
||||
local(far, "missing");
|
||||
local(1, "present");
|
||||
|
||||
class Cursor {
|
||||
code: number | undefined = 0;
|
||||
}
|
||||
const cursor = new Cursor();
|
||||
cursor.code = chars[far];
|
||||
show("field fcc", String.fromCharCode(97, cursor.code));
|
||||
show("field abs", Math.abs(cursor.code));
|
||||
cursor.code = chars[1];
|
||||
show("field fcc present", String.fromCharCode(97, cursor.code));
|
||||
|
||||
const record: { code: number | undefined } = { code: 0 };
|
||||
record.code = chars[far];
|
||||
show("record fcc", String.fromCharCode(97, record.code));
|
||||
show("record max", Math.max(record.code, 1));
|
||||
show("record isNaN", isNaN(record.code));
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Worker, isMainThread, parentPort, workerData } from "node:worker_threads";
|
||||
|
||||
// Workers that hold and then drop tens of megabytes of small objects, and later workers that
|
||||
// allocate as much again once the first ones have exited (in scriptc on Linux, the first ones
|
||||
// return those pages when they exit and the later ones reuse them).
|
||||
type Item = { value: number; next: Item | undefined; tags: number[]; label: string };
|
||||
|
||||
if (isMainThread) {
|
||||
for (let wave = 0; wave < 2; wave++) {
|
||||
const results = await Promise.all(
|
||||
[1, 2, 3].map(
|
||||
(k) =>
|
||||
new Promise<number>((resolve, reject) => {
|
||||
let result = -1;
|
||||
const worker = new Worker(new URL(import.meta.url), { workerData: wave * 10 + k });
|
||||
worker.once("message", (value: unknown) => {
|
||||
result = value as number;
|
||||
});
|
||||
worker.once("error", reject);
|
||||
worker.once("exit", () => resolve(result));
|
||||
}),
|
||||
),
|
||||
);
|
||||
console.log("wave", wave, results.join(" "));
|
||||
}
|
||||
} else {
|
||||
const round = workerData as number;
|
||||
let items: Item[] = [];
|
||||
for (let i = 0; i < 300_000; i++)
|
||||
items.push({ value: i * round, next: undefined, tags: [i & 7, round, i % 13], label: "item" + (i % 97) });
|
||||
// A few items survive the rest, linked together.
|
||||
const kept: Item[] = [];
|
||||
for (let i = 0; i < items.length; i += 4096) kept.push(items[i]!);
|
||||
for (let i = 1; i < kept.length; i++) kept[i - 1]!.next = kept[i];
|
||||
let sum = 0;
|
||||
for (const item of items) sum = (sum + item.value + item.tags[2]! + item.label.length) % 1_000_003;
|
||||
items = [];
|
||||
let walk = 0;
|
||||
for (let item: Item | undefined = kept[0]; item !== undefined; item = item.next)
|
||||
walk = (walk + item.tags[0]! + item.tags[1]! + item.label.length) % 1_000_003;
|
||||
// Fresh objects after the drop.
|
||||
const fresh: Item[] = [];
|
||||
for (let i = 0; i < 100_000; i++) fresh.push({ value: i, next: undefined, tags: [], label: "" });
|
||||
let empty = 0;
|
||||
for (const item of fresh) if (item.next === undefined && item.tags.length === 0 && item.label === "") empty++;
|
||||
parentPort!.postMessage((sum + walk + empty) % 1_000_003);
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
// Typed-array element reads flowing through bindings, parameters and returns
|
||||
// whose consumers cannot tell undefined from NaN, next to consumers that can.
|
||||
// An index that is not an integer in [0, length) reads undefined, like Node;
|
||||
// the compiler may carry such reads as plain numbers only where that is
|
||||
// unobservable.
|
||||
const base: number = process.argv.length; // 2 under both runtimes
|
||||
const text = "let x = 1; /* note */ y\u{1F600}";
|
||||
const chars = new Uint16Array(text.length);
|
||||
for (let i = 0; i < text.length; i++) chars[i] = text.charCodeAt(i);
|
||||
|
||||
const far = base + 100;
|
||||
const negative = -base;
|
||||
const fraction = base + 0.5;
|
||||
const nan = base / 0 - base / 0;
|
||||
const negativeZero = -0 * base;
|
||||
|
||||
class Cursor {
|
||||
pos = 0;
|
||||
readonly end: number;
|
||||
token: number | undefined = 0;
|
||||
readonly data: Uint16Array;
|
||||
constructor(data: Uint16Array) {
|
||||
this.data = data;
|
||||
this.end = data.length;
|
||||
}
|
||||
char(): number {
|
||||
return this.pos < this.end ? this.data[this.pos] : -1;
|
||||
}
|
||||
peek(offset: number): number {
|
||||
return this.data[this.pos + offset];
|
||||
}
|
||||
codePointAt(pos: number): number {
|
||||
const ch = this.data[pos];
|
||||
if (ch >= 0xd800 && ch <= 0xdbff) {
|
||||
const low = this.data[pos + 1];
|
||||
if (low >= 0xdc00 && low <= 0xdfff) return (ch - 0xd800) * 0x400 + (low - 0xdc00) + 0x10000;
|
||||
}
|
||||
return ch;
|
||||
}
|
||||
}
|
||||
|
||||
function isLetter(ch: number): boolean {
|
||||
return (ch >= 97 && ch <= 122) || (ch >= 65 && ch <= 90);
|
||||
}
|
||||
|
||||
function isSpace(ch: number): boolean {
|
||||
return ch === 32 || ch === 9;
|
||||
}
|
||||
|
||||
function kind(ch: number): string {
|
||||
switch (ch) {
|
||||
case 32:
|
||||
return "space";
|
||||
case 61:
|
||||
return "equals";
|
||||
case -1:
|
||||
return "eof";
|
||||
default:
|
||||
return isLetter(ch) ? "letter" : "other";
|
||||
}
|
||||
}
|
||||
|
||||
function describe(ch: number): string {
|
||||
// String() observes undefined: the read is rebuilt as `number | undefined`.
|
||||
return ch === 120 ? "x" : String(ch);
|
||||
}
|
||||
|
||||
function skipComment(data: Uint16Array, pos: number, end: number): number {
|
||||
const view = data.subarray(pos, end);
|
||||
for (let i = 0; i < view.length; i++) {
|
||||
const c = view[i];
|
||||
if (c === 42 || c === 10 || c > 127) return pos + i;
|
||||
}
|
||||
return end;
|
||||
}
|
||||
|
||||
const cursor = new Cursor(chars);
|
||||
const kinds: string[] = [];
|
||||
for (; cursor.pos <= cursor.end; cursor.pos++) kinds.push(kind(cursor.char()));
|
||||
console.log("kinds", kinds.join(","));
|
||||
cursor.pos = 0;
|
||||
console.log("peek", cursor.peek(1), cursor.peek(far), cursor.peek(negative), cursor.peek(fraction));
|
||||
console.log("peek kinds", kind(cursor.peek(2)), kind(cursor.peek(far)), isSpace(cursor.peek(far)));
|
||||
console.log("describe", describe(chars[4]), describe(chars[0]), describe(chars[far]), describe(chars[nan]));
|
||||
console.log("code points", cursor.codePointAt(text.length - 2), cursor.codePointAt(text.length - 1), cursor.codePointAt(far));
|
||||
console.log("skip", skipComment(chars, 13, chars.length), skipComment(chars, 0, 3));
|
||||
|
||||
// Index conversions: NaN, fractions, negatives and -0.
|
||||
const reads = [chars[nan], chars[fraction], chars[negative], chars[negativeZero], chars[far], chars[-0]];
|
||||
console.log("index forms", reads.join("|"), reads.map((r) => r === undefined).join(","));
|
||||
let present = 0;
|
||||
for (const index of [0, negativeZero, 1.5, -1, nan, Infinity, -Infinity, 2 ** 32, chars.length - 1, chars.length]) {
|
||||
const c = chars[index];
|
||||
if (c > 0) present++;
|
||||
}
|
||||
console.log("present", present);
|
||||
|
||||
function bindings(): void {
|
||||
// Equality of two reads: undefined equals undefined.
|
||||
const a = chars[far];
|
||||
const b = chars[far + 1];
|
||||
const c0 = chars[0];
|
||||
console.log("equal missing", a === b, a !== b, a == b, a === c0, c0 === chars[0], chars[far] === chars[negative]);
|
||||
console.log("equal mixed", a === 108, c0 === 108, c0 !== 108, a !== 108, a == null, a === undefined);
|
||||
console.log("relational", a < 1, a >= 0, c0 > 100, chars[far] <= chars[far]);
|
||||
console.log("arithmetic", a + 1, c0 + 1, a | 0, c0 >> 1, -a, ~a, Math.max(a, 1));
|
||||
console.log("truthy", a ? "yes" : "no", !a, c0 && 5, chars[far] || 7, a ?? -1);
|
||||
console.log("typeof", typeof a, typeof c0, `${a}/${c0}`, [a, c0], JSON.stringify({ a, c0 }));
|
||||
|
||||
// Stored and returned values keep undefined.
|
||||
cursor.token = cursor.peek(far);
|
||||
console.log("field", cursor.token === undefined);
|
||||
cursor.token = cursor.char();
|
||||
console.log("field", cursor.token);
|
||||
const kept: (number | undefined)[] = [];
|
||||
kept.push(cursor.peek(far), cursor.peek(0));
|
||||
console.log("pushed", kept, kept.indexOf(undefined));
|
||||
function passThrough(value: unknown): string {
|
||||
return value === undefined ? "undefined" : typeof value;
|
||||
}
|
||||
console.log("unknown", passThrough(chars[far]), passThrough(chars[1]));
|
||||
|
||||
// A binding that may hold a genuine NaN next to a missing read.
|
||||
const floats = new Float64Array([nan, 1.5]);
|
||||
const f0 = floats[0];
|
||||
const fMissing = floats[far];
|
||||
console.log("floats", f0 === fMissing, f0, fMissing, Number.isNaN(f0), fMissing === undefined);
|
||||
let counter = chars[far];
|
||||
counter++;
|
||||
console.log("incremented", counter, counter === chars[far], Number.isNaN(counter));
|
||||
|
||||
}
|
||||
bindings();
|
||||
|
||||
// Only NaN-insensitive consumers: the reads stay plain numbers, and equality
|
||||
// of two missing reads is still true.
|
||||
function equalities(offset: number): string {
|
||||
const p = chars[far + offset];
|
||||
const q = chars[negative - offset];
|
||||
const r = chars[offset];
|
||||
const s = chars[offset + 1];
|
||||
const flags = [p === q, p !== q, p == q, p != q, p === r, r === s, r !== s, p < r, r > p];
|
||||
let score = 0;
|
||||
if (p === q) score += 1;
|
||||
if (q !== r) score += 2;
|
||||
if (r === chars[offset]) score += 4;
|
||||
if (p === chars[fraction]) score += 8;
|
||||
switch (p) {
|
||||
case 0:
|
||||
score += 100;
|
||||
break;
|
||||
default:
|
||||
score += 16;
|
||||
}
|
||||
return flags.join(",") + " " + score + " " + JSON.stringify(String.fromCharCode(p, r, Math.max(q, s)));
|
||||
}
|
||||
console.log("equalities", equalities(0), equalities(1));
|
||||
|
||||
// Views: a subarray answers undefined past its own length even when the
|
||||
// underlying buffer continues.
|
||||
const view = chars.subarray(2, 5);
|
||||
console.log("view", view.length, view[0], view[2], view[3], view[far], view[3] === chars[far]);
|
||||
let hash = 0x811c9dc5 | 0;
|
||||
for (let i = 0; i <= view.length; i++) hash = Math.imul(hash ^ view[i], 0x01000193);
|
||||
console.log("hash", hash >>> 0);
|
||||
@@ -0,0 +1,127 @@
|
||||
// Typed-array reads proven to name an element (a non-negative counter under
|
||||
// an `i < t.length` guard, including the length of a same-length parallel
|
||||
// array), and NaN-coded reads under a comparison that excludes undefined,
|
||||
// are plain numbers. The neighbors those proofs must not cover still read
|
||||
// undefined for an index outside [0, length), like Node.
|
||||
const base: number = process.argv.length; // 2 under both runtimes
|
||||
const nan = base / 0 - base / 0;
|
||||
|
||||
class Relation {
|
||||
private readonly regular: Int32Array;
|
||||
calls = 0;
|
||||
constructor(size: number) {
|
||||
this.regular = new Int32Array(size);
|
||||
for (let i = 0; i < size; i++) this.regular[i] = i % 3 === 0 ? -1 : size - i;
|
||||
}
|
||||
assignable(source: number, target: number): boolean {
|
||||
if (source === target) return true;
|
||||
this.calls++;
|
||||
return this.related(Number(source), Number(target)) !== 0;
|
||||
}
|
||||
private related(inputSource: number, inputTarget: number): number {
|
||||
if (inputSource === inputTarget) return 1;
|
||||
// Out of range or NaN: undefined, so the comparison picks the input.
|
||||
const regularSource = this.regular[inputSource];
|
||||
const regularTarget = this.regular[inputTarget];
|
||||
const sourceId = regularSource >= 0 ? regularSource : inputSource;
|
||||
const targetId = regularTarget >= 0 ? regularTarget : inputTarget;
|
||||
if (sourceId === targetId) return 1;
|
||||
return this.simple(sourceId, targetId) ? 1 : 0;
|
||||
}
|
||||
private simple(sourceId: number, targetId: number): boolean {
|
||||
return sourceId < targetId || sourceId !== sourceId || targetId !== targetId;
|
||||
}
|
||||
}
|
||||
|
||||
interface Query {
|
||||
source: number;
|
||||
target: number;
|
||||
}
|
||||
|
||||
function check(queries: Query[]): void {
|
||||
const relation = new Relation(8);
|
||||
const results: boolean[] = [];
|
||||
const sources = new Uint32Array(queries.length);
|
||||
const targets = new Uint32Array(queries.length);
|
||||
for (let i = 0; i < queries.length; i++) {
|
||||
const query = queries[i];
|
||||
sources[i] = query.source;
|
||||
targets[i] = query.target;
|
||||
results.push(relation.assignable(query.source, query.target));
|
||||
}
|
||||
let checksum = 0;
|
||||
for (let round = 0; round < 3; round++) {
|
||||
for (let i = 0; i < sources.length; i++) {
|
||||
if (relation.assignable(sources[i], targets[i])) checksum++;
|
||||
}
|
||||
}
|
||||
console.log("results", results.join(","));
|
||||
console.log("checksum", checksum, "calls", relation.calls);
|
||||
const lanes: number[] = [];
|
||||
for (let i = 0; i < sources.length; i++) lanes.push(sources[i], targets[i]);
|
||||
console.log("lanes", lanes.join(","));
|
||||
}
|
||||
|
||||
check([
|
||||
{ source: 1, target: 1 },
|
||||
{ source: 2, target: 5 },
|
||||
{ source: 3, target: 0 },
|
||||
{ source: 7, target: 4 },
|
||||
{ source: nan, target: nan },
|
||||
{ source: nan, target: 2 },
|
||||
{ source: 12, target: 3 },
|
||||
{ source: -1, target: 6 },
|
||||
{ source: 2.5, target: 1 },
|
||||
]);
|
||||
|
||||
function show(label: string, value: number): string {
|
||||
return `${label}=${typeof value}:${String(value)}`;
|
||||
}
|
||||
|
||||
function equal(a: number, b: number): boolean {
|
||||
return a === b;
|
||||
}
|
||||
|
||||
// Neighbors: every read below may name a missing element.
|
||||
function neighbors(n: number): void {
|
||||
const long = new Uint16Array(n);
|
||||
const short = new Uint16Array(n - 1);
|
||||
for (let i = 0; i < n; i++) long[i] = 10 + i;
|
||||
for (let i = 0; i < n - 1; i++) short[i] = 20 + i;
|
||||
const out: string[] = [];
|
||||
// Inclusive bound: the last read is past the end.
|
||||
for (let i = 0; i <= long.length; i++) out.push(show("le" + i, long[i]));
|
||||
// A shorter parallel array is not bounded by the longer one's length.
|
||||
for (let i = 0; i < long.length; i++) out.push(show("par" + i, short[i]));
|
||||
// A counter that goes down reaches -1.
|
||||
for (let i = 1; i >= -1; i--) out.push(show("down" + i, long[i]));
|
||||
// A counter that a closure also writes.
|
||||
let k = 0;
|
||||
const skip = (): void => {
|
||||
k += 2;
|
||||
};
|
||||
for (; k < long.length; k++) {
|
||||
skip();
|
||||
out.push(show("skip" + k, long[k]));
|
||||
}
|
||||
// A fractional step is not an integer index.
|
||||
let f = 0;
|
||||
for (; f < long.length; f += 1.5) out.push(show("frac" + f, long[f]));
|
||||
console.log(out.join(" "));
|
||||
// Undefined equals undefined even through parameters.
|
||||
const missing = long[n + 3];
|
||||
const alsoMissing = short[n + 3];
|
||||
console.log("equal", equal(missing, alsoMissing), equal(long[0], long[0]), equal(missing, nan));
|
||||
// A guarded occurrence is a number; the unguarded one is still undefined.
|
||||
const regular = long[n + 1];
|
||||
const picked = regular >= 0 ? regular : -7;
|
||||
console.log("guard", picked, show("raw", regular), regular > 3 ? show("in", regular) : "none");
|
||||
let moved = long[0];
|
||||
if (moved >= 0) {
|
||||
moved = long[n + 5];
|
||||
console.log("moved", show("m", moved));
|
||||
}
|
||||
}
|
||||
|
||||
neighbors(4);
|
||||
neighbors(base);
|
||||
+41
@@ -0,0 +1,41 @@
|
||||
import { publish } from "@scriptc/threads";
|
||||
|
||||
// Published objects are immortal: retains and releases skip them and
|
||||
// nothing frees them, so dropping the last reference to one is not a leak.
|
||||
// Every graph below becomes unreachable once its function returns.
|
||||
|
||||
class Item {
|
||||
value: number;
|
||||
label: string;
|
||||
next: Item | null;
|
||||
self: Item | undefined;
|
||||
constructor(value: number, label: string, next: Item | null) {
|
||||
this.value = value;
|
||||
this.label = label;
|
||||
this.next = next;
|
||||
}
|
||||
}
|
||||
|
||||
// `this` is published before its constructor returns.
|
||||
class SelfPublishing {
|
||||
a: number;
|
||||
constructor(a: number) {
|
||||
this.a = a;
|
||||
publish(this);
|
||||
}
|
||||
}
|
||||
|
||||
function dropTyped(): number {
|
||||
let head: Item | null = null;
|
||||
for (let i = 0; i < 100; i++) head = new Item(i, `item-${i}`, head);
|
||||
head!.self = head!;
|
||||
publish(head);
|
||||
const labels = publish([`first-${head!.value}`, `second-${head!.next!.value}`]);
|
||||
const index = publish(new Map<string, Item>([[`key-${head!.value}`, head!]]));
|
||||
const own = new SelfPublishing(labels.length);
|
||||
return head!.value + labels.length + index.size + own.a;
|
||||
}
|
||||
|
||||
let total = 0;
|
||||
for (let round = 0; round < 3; round++) total += dropTyped();
|
||||
console.log(total);
|
||||
+16
-3
@@ -1,9 +1,19 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { deserializeModule, serializeModule } from "../../../packages/compiler/src/ir/serialize.js";
|
||||
import {
|
||||
deserializeModule,
|
||||
deserializeModuleBytes,
|
||||
serializeModule,
|
||||
serializeModuleParts,
|
||||
} from "../../../packages/compiler/src/ir/serialize.js";
|
||||
import { validateModule } from "../../../packages/compiler/src/ir/validate.js";
|
||||
|
||||
try {
|
||||
const mod = deserializeModule(readFileSync(process.argv[2]!, "utf8"));
|
||||
// "bytes" exercises the compiler-scale path: per-function decoding and
|
||||
// compact parts, as the native compiler uses for its own IR.
|
||||
const bytes = process.argv[3] === "bytes";
|
||||
const mod = bytes
|
||||
? deserializeModuleBytes(readFileSync(process.argv[2]!))
|
||||
: deserializeModule(readFileSync(process.argv[2]!, "utf8"));
|
||||
const errors = validateModule(mod);
|
||||
if (errors.length > 0) {
|
||||
console.log(JSON.stringify(errors));
|
||||
@@ -15,7 +25,10 @@ try {
|
||||
const statement = mod.functions[0]!.body[0];
|
||||
if (statement?.kind === "exprStmt" && statement.expr.kind === "numLit") statement.expr.value = NaN;
|
||||
}
|
||||
console.log(serializeModule(mod, process.argv[3] === "compact"));
|
||||
if (bytes) {
|
||||
for (const part of serializeModuleParts(mod)) process.stdout.write(part);
|
||||
process.stdout.write("\n");
|
||||
} else console.log(serializeModule(mod, process.argv[3] === "compact"));
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof Error) console.log(error.name + ": " + error.message);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { expect, test } from "vitest";
|
||||
import { occurrences, withoutFirstLine, withoutRange } from "./large-artifacts.js";
|
||||
|
||||
test("byte-level artifact edits match their string equivalents", () => {
|
||||
const text =
|
||||
"x declare void @scr_runtime_abi_v3()\ndeclare void @scr_runtime_abi_vX()\n" +
|
||||
"declare void @scr_runtime_abi_v12()\n call void @scr_runtime_abi_v12()\n" +
|
||||
"é\n call void @scr_runtime_abi_v12()\n";
|
||||
const bytes = Buffer.from(text);
|
||||
const declaration = /^declare void @scr_runtime_abi_v\d+\(\)\n/m;
|
||||
const call = /^ {2}call void @scr_runtime_abi_v\d+\(\)\n/m;
|
||||
const edited = withoutFirstLine(
|
||||
withoutFirstLine(
|
||||
bytes,
|
||||
"declare void @scr_runtime_abi_v",
|
||||
/^declare void @scr_runtime_abi_v\d+\(\)\n$/,
|
||||
),
|
||||
" call void @scr_runtime_abi_v",
|
||||
/^ {2}call void @scr_runtime_abi_v\d+\(\)\n$/,
|
||||
);
|
||||
expect(edited.toString()).toBe(text.replace(declaration, "").replace(call, ""));
|
||||
expect(withoutFirstLine(bytes, "missing", /^missing\n$/)).toBe(bytes);
|
||||
|
||||
const line = " call void @scr_runtime_abi_v12()\n";
|
||||
const found = occurrences(bytes, line);
|
||||
expect(found).toHaveLength(text.split(line).length - 1);
|
||||
expect(withoutRange(bytes, found[0]!, Buffer.byteLength(line)).toString()).toBe(
|
||||
text.replace(line, ""),
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,31 @@
|
||||
/** Byte-level helpers for compiler-scale text artifacts (the self-hosted
|
||||
* compiler's LLVM and IR). They avoid decoding a complete artifact into one
|
||||
* JavaScript string, which V8 caps at 2^29 - 24 UTF-16 units. */
|
||||
|
||||
/** Offsets of every occurrence of `needle`. */
|
||||
export function occurrences(bytes: Buffer, needle: string): number[] {
|
||||
const found: number[] = [];
|
||||
const width = Buffer.byteLength(needle);
|
||||
for (let at = bytes.indexOf(needle); at >= 0; at = bytes.indexOf(needle, at + width))
|
||||
found.push(at);
|
||||
return found;
|
||||
}
|
||||
|
||||
/** `bytes` without `length` bytes at `start`. */
|
||||
export function withoutRange(bytes: Buffer, start: number, length: number): Buffer {
|
||||
return Buffer.concat([bytes.subarray(0, start), bytes.subarray(start + length)]);
|
||||
}
|
||||
|
||||
/** Remove the first complete line that starts with `prefix` and, including
|
||||
* its newline, matches `line` (anchored), like `text.replace(/^...\n/m, "")`. */
|
||||
export function withoutFirstLine(bytes: Buffer, prefix: string, line: RegExp): Buffer {
|
||||
for (let from = 0; ;) {
|
||||
const at = bytes.indexOf(prefix, from);
|
||||
if (at < 0) return bytes;
|
||||
const newline = bytes.indexOf(10, at);
|
||||
const stop = newline < 0 ? bytes.length : newline + 1;
|
||||
if ((at === 0 || bytes[at - 1] === 10) && line.test(bytes.toString("utf8", at, stop)))
|
||||
return withoutRange(bytes, at, stop - at);
|
||||
from = at + 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/* Published objects (@scriptc/threads publish() and the zero-copy delivery
|
||||
* of deeply frozen plain-data graphs) are immortal by design: nothing frees
|
||||
* them, and the RC audit stops counting them. LeakSanitizer must agree, so
|
||||
* the runtime marks every published object as an intentional leak-scan
|
||||
* root. The sanitized lanes otherwise run with detect_leaks=0, so this pins
|
||||
* the contract with LeakSanitizer explicitly on: programs that drop their
|
||||
* last reference to a published graph exit cleanly. Linux only (Apple's
|
||||
* ASan has no LeakSanitizer); runs in the SCRIPTC_SAN=1 lane. */
|
||||
import { execFile } from "node:child_process";
|
||||
import { createHash } from "node:crypto";
|
||||
import { mkdirSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { compile } from "@scriptc/compiler";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const repoRoot = join(import.meta.dirname, "../..");
|
||||
const cacheDir = join(repoRoot, "node_modules/.cache/scriptc-tests");
|
||||
const sanitize = process.env["SCRIPTC_SAN"] === "1";
|
||||
|
||||
const programs = [
|
||||
"tests/fixtures/publish-leaks/dropped.ts",
|
||||
// Zero-copy delivery of deeply frozen plain-data graphs (scr_message.c).
|
||||
"tests/corpus/native-worker-published-graphs.ts",
|
||||
// Typed graphs published on both threads, including a refused publish.
|
||||
"tests/corpus/native-worker-publish-classes.ts",
|
||||
];
|
||||
|
||||
describe.skipIf(!sanitize || process.platform !== "linux")(
|
||||
"published objects under LeakSanitizer",
|
||||
() => {
|
||||
test.each(programs)("%s reports no leaks", { timeout: 300_000 }, async (program) => {
|
||||
const file = join(repoRoot, program);
|
||||
const key = createHash("sha256")
|
||||
.update(file)
|
||||
.update(readFileSync(file))
|
||||
.update("san")
|
||||
.digest("hex")
|
||||
.slice(0, 16);
|
||||
const outDir = join(cacheDir, `publish-leaks-${key}`);
|
||||
mkdirSync(outDir, { recursive: true });
|
||||
const result = await compile(file, {
|
||||
outPath: join(outDir, "program"),
|
||||
outDir,
|
||||
sanitize: true,
|
||||
backend: "llvm",
|
||||
});
|
||||
if (!result.ok) {
|
||||
throw new Error(
|
||||
"program failed to compile:\n" +
|
||||
result.diagnostics.map((d) => `${d.code}: ${d.message}`).join("\n"),
|
||||
);
|
||||
}
|
||||
const run = await execFileAsync(result.binaryPath, [], {
|
||||
env: { ...process.env, ASAN_OPTIONS: "detect_leaks=1" },
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
}).catch((error: { code?: number; stdout?: string; stderr?: string }) => error);
|
||||
expect(run.stderr).toBe("");
|
||||
expect("code" in run ? run.code : 0).toBe(0);
|
||||
const expected = await execFileAsync(process.execPath, [file], {
|
||||
env: { ...process.env, SCRIPTC_TEST_ENV: "from-harness" },
|
||||
maxBuffer: 16 * 1024 * 1024,
|
||||
});
|
||||
expect(run.stdout).toBe(expected.stdout);
|
||||
});
|
||||
},
|
||||
);
|
||||
@@ -244,10 +244,12 @@ export function backendAnalysisCases(): BackendAnalysisCase[] {
|
||||
{ id: "recursive", arms: [record("cycle"), { kind: "nullT" }] },
|
||||
{ id: "acyclic", arms: [record("chain"), F64] },
|
||||
];
|
||||
// `outer` holds the cycle but nothing reaches back to it, so only the
|
||||
// cycle's own component is traced (cycle-analysis.ts).
|
||||
cases.push({
|
||||
name: "shape and union fixed point",
|
||||
module: graph,
|
||||
shapes: ["record:outer", "record:cycle"],
|
||||
shapes: ["record:cycle"],
|
||||
unions: ["recursive"],
|
||||
});
|
||||
const hierarchy = module();
|
||||
@@ -343,6 +345,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] {
|
||||
});
|
||||
// Constructor-only references are proven acyclic (cycle-analysis.ts), so
|
||||
// the cyclic branch closes through an array edge, which is always mutable.
|
||||
// `outer` only holds that cycle, so it stays untraced like the leaf.
|
||||
const branch = module();
|
||||
branch.records = [
|
||||
{ id: "leaf", fields: [{ name: "value", type: F64 }] },
|
||||
@@ -358,7 +361,7 @@ export function backendAnalysisCases(): BackendAnalysisCase[] {
|
||||
cases.push({
|
||||
name: "removing a leaf preserves another cyclic branch",
|
||||
module: branch,
|
||||
shapes: ["record:cycle", "record:outer"],
|
||||
shapes: ["record:cycle"],
|
||||
});
|
||||
|
||||
const tableId = "%g.table";
|
||||
|
||||
@@ -24,6 +24,7 @@ import {
|
||||
nativeBootstrapPlan,
|
||||
runNativeBootstrapChecks,
|
||||
} from "../../scripts/ci-native-bootstrap.mjs";
|
||||
import { withoutFirstLine } from "./large-artifacts.js";
|
||||
import { bootstrapStep } from "./self-hosting-timing.js";
|
||||
|
||||
const root = join(import.meta.dirname, "../..");
|
||||
@@ -435,17 +436,20 @@ test(
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { isDeepStrictEqual } from 'node:util';
|
||||
import { deserializeModule, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)};
|
||||
import { deserializeModuleBytes, validateModule } from ${JSON.stringify(pathToFileURL(join(root, "packages/compiler/src/index.ts")).href)};
|
||||
import { occurrences, withoutRange } from ${JSON.stringify(pathToFileURL(join(root, "tests/harness/large-artifacts.ts")).href)};
|
||||
// Both artifacts are compared as bytes and per-function documents:
|
||||
// either can exceed V8's maximum string length.
|
||||
if (${ir}) {
|
||||
const expected = deserializeModule(readFileSync(process.argv[1], 'utf8'));
|
||||
const actual = deserializeModule(readFileSync(process.argv[2], 'utf8'));
|
||||
const expected = deserializeModuleBytes(readFileSync(process.argv[1]));
|
||||
const actual = deserializeModuleBytes(readFileSync(process.argv[2]));
|
||||
assert.ok(actual.functions.length > 1000);
|
||||
assert.deepEqual(validateModule(actual), []);
|
||||
assert.ok(isDeepStrictEqual(actual, expected), 'native self-lowering must match the Node seed');
|
||||
}
|
||||
if (${llvm}) {
|
||||
const seedLlvm = readFileSync(process.argv[3], 'utf8');
|
||||
let nativeLlvm = readFileSync(process.argv[4], 'utf8');
|
||||
const seedLlvm = readFileSync(process.argv[3]);
|
||||
let nativeLlvm = readFileSync(process.argv[4]);
|
||||
if (${sanitize}) {
|
||||
// The Node sanitizer builds runtime sources directly; the native
|
||||
// executable path also emits the runtime ABI check. Pin that
|
||||
@@ -453,12 +457,13 @@ test(
|
||||
const declaration = 'declare void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n';
|
||||
const call = ' call void @' + ${JSON.stringify(RUNTIME_ABI_MARKER)} + '()\\n';
|
||||
for (const line of [declaration, call]) {
|
||||
assert.equal(seedLlvm.split(line).length, 1);
|
||||
assert.equal(nativeLlvm.split(line).length, 2);
|
||||
nativeLlvm = nativeLlvm.replace(line, '');
|
||||
assert.equal(occurrences(seedLlvm, line).length, 0);
|
||||
const found = occurrences(nativeLlvm, line);
|
||||
assert.equal(found.length, 1);
|
||||
nativeLlvm = withoutRange(nativeLlvm, found[0], Buffer.byteLength(line));
|
||||
}
|
||||
}
|
||||
assert.ok(seedLlvm === nativeLlvm, 'native LLVM emission must match the Node seed');
|
||||
assert.ok(seedLlvm.equals(nativeLlvm), 'native LLVM emission must match the Node seed');
|
||||
}
|
||||
`,
|
||||
seedIr,
|
||||
@@ -506,12 +511,18 @@ test(
|
||||
);
|
||||
// Executable output adds a runtime ABI check; textual LLVM emission
|
||||
// omits it. Compare all other output without changing either mode.
|
||||
const withoutAbiCheck = (path: string): string =>
|
||||
readFileSync(path, "utf8")
|
||||
.replace(/^declare void @scr_runtime_abi_v\d+\(\)\n/m, "")
|
||||
.replace(/^ call void @scr_runtime_abi_v\d+\(\)\n/m, "");
|
||||
const withoutAbiCheck = (path: string): Buffer =>
|
||||
withoutFirstLine(
|
||||
withoutFirstLine(
|
||||
readFileSync(path),
|
||||
"declare void @scr_runtime_abi_v",
|
||||
/^declare void @scr_runtime_abi_v\d+\(\)\n$/,
|
||||
),
|
||||
" call void @scr_runtime_abi_v",
|
||||
/^ call void @scr_runtime_abi_v\d+\(\)\n$/,
|
||||
);
|
||||
expect(
|
||||
withoutAbiCheck(seedLlvm) === withoutAbiCheck(rebuiltLlvm),
|
||||
withoutAbiCheck(seedLlvm).equals(withoutAbiCheck(rebuiltLlvm)),
|
||||
"native compiler generations must emit identical LLVM",
|
||||
).toBe(true);
|
||||
};
|
||||
|
||||
@@ -178,6 +178,26 @@ for (const backend of ["llvm"] as const) {
|
||||
expect(deserializeModule(many.native.stdout.toString())).toEqual(
|
||||
deserializeModule(many.oracle.stdout.toString()),
|
||||
);
|
||||
// Byte decoding and compact parts, from both encodings.
|
||||
for (const input of [serializeModule(manyFunctions), serializeModule(manyFunctions, true)]) {
|
||||
const parts = run(input, ["bytes"]);
|
||||
expect(parts.native.status, parts.native.stdout.toString()).toBe(0);
|
||||
expect(parts.native.stdout.toString().trimEnd()).not.toContain("\n");
|
||||
expect(deserializeModule(parts.native.stdout.toString())).toEqual(manyFunctions);
|
||||
expect(deserializeModule(parts.native.stdout.toString())).toEqual(
|
||||
deserializeModule(parts.oracle.stdout.toString()),
|
||||
);
|
||||
}
|
||||
const truncated = run(serializeModule(manyFunctions, true).slice(0, -1), ["bytes"]);
|
||||
expect(truncated.native.status).toBe(1);
|
||||
expect(truncated.oracle.status).toBe(1);
|
||||
const olderBytes = run(
|
||||
serializeModule({ ...manyFunctions, irVersion: IR_VERSION - 1 }, true),
|
||||
["bytes"],
|
||||
);
|
||||
expect(olderBytes.native.status).toBe(1);
|
||||
expect(olderBytes.native.stdout).toEqual(olderBytes.oracle.stdout);
|
||||
expect(olderBytes.native.stdout.toString()).toContain("IR version mismatch");
|
||||
const print = numeric.functions[0]!.body[1]!;
|
||||
expect(print.kind).toBe("exprStmt");
|
||||
if (print.kind !== "exprStmt" || print.expr.kind !== "intrinsic")
|
||||
|
||||
Reference in New Issue
Block a user