diff --git a/Dockerfile.sandbox b/Dockerfile.sandbox index f0db1c9e5..597b33445 100644 --- a/Dockerfile.sandbox +++ b/Dockerfile.sandbox @@ -24,6 +24,7 @@ RUN apt-get update \ curl \ git \ libclang-rt-22-dev \ + libpolly-22-dev \ libzstd-dev \ llvm-22-dev \ ninja-build \ diff --git a/packages/compiler/src/backend/cycle-analysis.test.ts b/packages/compiler/src/backend/cycle-analysis.test.ts index 4eac0ed5d..ca1ae760d 100644 --- a/packages/compiler/src/backend/cycle-analysis.test.ts +++ b/packages/compiler/src/backend/cycle-analysis.test.ts @@ -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 }, diff --git a/packages/compiler/src/backend/cycle-analysis.ts b/packages/compiler/src/backend/cycle-analysis.ts index f85e31636..8b6a5b210 100644 --- a/packages/compiler/src/backend/cycle-analysis.ts +++ b/packages/compiler/src/backend/cycle-analysis.ts @@ -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; unions: Set reverse: [], traced: false, }); - const units = new Map(); + // 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(); 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(); + 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; 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; 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; 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(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(); + 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 }; } diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts index cfde93851..67196efd7 100644 --- a/packages/compiler/src/backend/cycle-mutability.ts +++ b/packages/compiler/src/backend/cycle-mutability.ts @@ -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; /** 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(); 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(); + /** 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(); 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 { + const aliases = new Set([thisId]); + const immutable = new Set(fn.locals.filter((l) => !l.mutable).map((l) => l.id)); + const assigned = new Set(); + 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; } diff --git a/packages/compiler/src/backend/llvm/byte-numbers.ts b/packages/compiler/src/backend/llvm/byte-numbers.ts index 07f0142bd..d12ef08b9 100644 --- a/packages/compiler/src/backend/llvm/byte-numbers.ts +++ b/packages/compiler/src/backend/llvm/byte-numbers.ts @@ -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(), diff --git a/packages/compiler/src/backend/llvm/dense-array-access.test.ts b/packages/compiler/src/backend/llvm/dense-array-access.test.ts index f307fde54..4ff7b1b16 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts @@ -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*/); diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts index 88bea73ed..34428db8b 100644 --- a/packages/compiler/src/backend/llvm/dense-array-access.ts +++ b/packages/compiler/src/backend/llvm/dense-array-access.ts @@ -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(); diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 86ac62d8d..40e959f0b 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -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}`); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e7ae6a934..6b1e17293 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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; } diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 2d64ae788..7212f22ce 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -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}`); diff --git a/packages/compiler/src/backend/llvm/expr-containers.ts b/packages/compiler/src/backend/llvm/expr-containers.ts index 6e4886b0b..a3ee2a63d 100644 --- a/packages/compiler/src/backend/llvm/expr-containers.ts +++ b/packages/compiler/src/backend/llvm/expr-containers.ts @@ -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": { diff --git a/packages/compiler/src/backend/llvm/lib-filesystem.ts b/packages/compiler/src/backend/llvm/lib-filesystem.ts index 5a70a55a8..41d93317b 100644 --- a/packages/compiler/src/backend/llvm/lib-filesystem.ts +++ b/packages/compiler/src/backend/llvm/lib-filesystem.ts @@ -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> = { "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(); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 6daeb1cd0..755bea02a 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -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); } diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index fe70d8a1b..323387d79 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -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`, diff --git a/packages/compiler/src/backend/llvm/string-slices.ts b/packages/compiler/src/backend/llvm/string-slices.ts index dd373cfea..07e645376 100644 --- a/packages/compiler/src/backend/llvm/string-slices.ts +++ b/packages/compiler/src/backend/llvm/string-slices.ts @@ -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; diff --git a/packages/compiler/src/backend/llvm/trunc.ts b/packages/compiler/src/backend/llvm/trunc.ts new file mode 100644 index 000000000..6d7b886aa --- /dev/null +++ b/packages/compiler/src/backend/llvm/trunc.ts @@ -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; +} diff --git a/packages/compiler/src/backend/native-codegen.ts b/packages/compiler/src/backend/native-codegen.ts index 6e91560dd..6aab5880a 100644 --- a/packages/compiler/src/backend/native-codegen.ts +++ b/packages/compiler/src/backend/native-codegen.ts @@ -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 { - const file = await open(path, "w", mode); - try { - const write = async (text: string): Promise => { - 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 | null { diff --git a/packages/compiler/src/frontend/lowering/array-values.ts b/packages/compiler/src/frontend/lowering/array-values.ts index 80438dbf0..a682e0401 100644 --- a/packages/compiler/src/frontend/lowering/array-values.ts +++ b/packages/compiler/src/frontend/lowering/array-values.ts @@ -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: [ diff --git a/packages/compiler/src/frontend/lowering/builtins/strings.ts b/packages/compiler/src/frontend/lowering/builtins/strings.ts index 57e650feb..b117c24ff 100644 --- a/packages/compiler/src/frontend/lowering/builtins/strings.ts +++ b/packages/compiler/src/frontend/lowering/builtins/strings.ts @@ -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 }; } diff --git a/packages/compiler/src/frontend/lowering/containers/bytes.ts b/packages/compiler/src/frontend/lowering/containers/bytes.ts index 5b8285f12..255165530 100644 --- a/packages/compiler/src/frontend/lowering/containers/bytes.ts +++ b/packages/compiler/src/frontend/lowering/containers/bytes.ts @@ -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 }; diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts index 66020271a..2a972b8ea 100644 --- a/packages/compiler/src/frontend/lowering/lower-calls.ts +++ b/packages/compiler/src/frontend/lowering/lower-calls.ts @@ -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; } diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts index a7f58bab2..078c13ea9 100644 --- a/packages/compiler/src/frontend/lowering/lower-exprs.ts +++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts @@ -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.` 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)) diff --git a/packages/compiler/src/frontend/lowering/lower-island.ts b/packages/compiler/src/frontend/lowering/lower-island.ts index 5f9301c76..88c2d34df 100644 --- a/packages/compiler/src/frontend/lowering/lower-island.ts +++ b/packages/compiler/src/frontend/lowering/lower-island.ts @@ -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), diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index ab34a1eb5..266a79d81 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -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" && diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index dff05fa46..3351baded 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -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(); + /** 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(); 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 diff --git a/packages/compiler/src/frontend/lowering/nan-coded-reads.ts b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts new file mode 100644 index 000000000..a716cb2bb --- /dev/null +++ b/packages/compiler/src/frontend/lowering/nan-coded-reads.ts @@ -0,0 +1,1406 @@ +import * as ts from "../ts7/adapter.js"; +import { isJsSourceFile } from "../program.js"; +import type { IrBytesElem } from "../../ir/ir.js"; +import { isIntegerBytesElem } from "../../ir/ir.js"; +import { indexReadInBounds } from "./runtime-optional-bounds.js"; + +/* NaN-coded typed-array reads. + * + * In Node a typed-array element read `t[i]` answers undefined when `i` is not + * an integer in [0, length). The checker types the read as `number`, so the + * default lowering returns a heap-boxed `number | undefined` union and widens + * every binding, parameter and return it reaches. + * + * Many consumers cannot tell undefined from NaN: relational and arithmetic + * operators and bitwise operators convert undefined to NaN (or 0, exactly as + * NaN converts), truthiness treats both as false, `Math` functions convert + * them alike, and strict or loose equality with a number that is never + * undefined is false for both. Where every consumer of a read is of this + * kind, the read lowers to a plain double that holds NaN for an invalid + * index: no box, no widening, and no observable difference. + * + * The analysis follows values through local `let`/`const` bindings, + * parameters of directly called functions and methods, and their returns. + * Such a slot is NaN-coded when every value it receives is a NaN-coded value + * or a number that is never undefined, and every use of it is one of the + * consumers above or flows into another NaN-coded slot. Equality between two + * NaN-coded values must answer true when both stand for undefined, so it is + * lowered as `a === b || (a !== a && b !== b)`; that is exact only while + * neither side can also hold a genuine NaN, which the analysis tracks (an + * integer element is never NaN; arithmetic and float elements may be). + * + * Some occurrences are plain numbers outright. A read whose index is a + * non-negative integer counter bounded by a dominating `i < t.length` guard + * (or the length of a same-length, non-escaping typed array) is never + * undefined, so it is not NaN-coded at all. An occurrence of a never-reassigned + * NaN-coded binding under a dominating comparison with a number (`x >= 0 ? + * x : y`) is neither undefined nor NaN there. + * + * Everything else keeps the established `number | undefined` lowering. */ + +export interface NanCodedHost { + symbolOf(node: ts.Node): ts.Symbol | null; + declarationOf(symbol: ts.Symbol): ts.Node | undefined; + /** The element kind of a typed-array receiver, or null. */ + bytesElemOf(receiver: ts.Expression): IrBytesElem | null; + /** The static IR type of the node is a plain number. */ + isNumber(node: ts.Node): boolean; + isString(node: ts.Expression): boolean; + /** The receiver is an array or a typed array (for `.length`). */ + hasLength(node: ts.Expression): boolean; + isStdlibGlobal(node: ts.Expression, name: string): boolean; + /** Parameter `index` of the callable is a plain `number` in its signature. */ + paramIsNumber(fn: ts.Symbol, index: number): boolean; + /** The callable's signature returns a plain `number`. */ + returnIsNumber(fn: ts.Symbol): boolean; + /** The method shares its dispatch slot with another class's method. */ + methodOverridden(fn: ts.Symbol): boolean; +} + +interface ValueInfo { + /** May stand for undefined (NaN-coded). */ + nce: boolean; + /** May also hold a genuine NaN. */ + nan: boolean; +} + +type Slot = BindingSlot | ReturnSlot; + +interface SlotBase { + /** Values written into the slot (initializers, assignments, arguments, + * return expressions); null marks a write of unknown value. */ + sources: (ts.Expression | null)[]; + /** Value occurrences whose consumers must be NaN-insensitive. */ + uses: ts.Expression[]; + /** Compound writes and increments store arithmetic results. */ + arithmeticWrites: boolean; +} + +interface BindingSlot extends SlotBase { + kind: "binding"; + symbol: ts.Symbol; + /** Some reference writes the binding after its declaration. */ + reassigned: boolean; +} + +interface ReturnSlot extends SlotBase { + kind: "return"; + symbol: ts.Symbol; +} + +function peelTransparent(node: ts.Expression): ts.Expression { + let e = node; + while ( + ts.isParenthesizedExpression(e) || + ts.isAsExpression(e) || + ts.isTypeAssertion(e) || + ts.isSatisfiesExpression(e) || + ts.isNonNullExpression(e) + ) + e = e.expression; + return e; +} + +/** The node whose consumer decides how `node`'s value is used. */ +function transparentParent(node: ts.Expression): ts.Expression { + let e: ts.Expression = node; + for (;;) { + const p = e.parent; + if ( + p && + (ts.isParenthesizedExpression(p) || + ts.isAsExpression(p) || + ts.isTypeAssertion(p) || + ts.isSatisfiesExpression(p) || + ts.isNonNullExpression(p)) && + p.expression === e + ) { + e = p; + continue; + } + return e; + } +} + +function isAssignmentOperator(kind: ts.SyntaxKind): boolean { + return kind >= ts.SyntaxKind.FirstAssignment && kind <= ts.SyntaxKind.LastAssignment; +} + +const RELATIONAL = new Set([ + ts.SyntaxKind.LessThanToken, + ts.SyntaxKind.GreaterThanToken, + ts.SyntaxKind.LessThanEqualsToken, + ts.SyntaxKind.GreaterThanEqualsToken, +]); +const EQUALITY = new Set([ + ts.SyntaxKind.EqualsEqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsEqualsToken, + ts.SyntaxKind.EqualsEqualsToken, + ts.SyntaxKind.ExclamationEqualsToken, +]); +/** Results never NaN-free: ToNumber of each operand may be NaN. */ +const ARITHMETIC = new Set([ + ts.SyntaxKind.MinusToken, + ts.SyntaxKind.AsteriskToken, + ts.SyntaxKind.SlashToken, + ts.SyntaxKind.PercentToken, + ts.SyntaxKind.AsteriskAsteriskToken, +]); +/** ToInt32/ToUint32 map undefined and NaN to 0 alike; results are integers. */ +const BITWISE = new Set([ + ts.SyntaxKind.AmpersandToken, + ts.SyntaxKind.BarToken, + ts.SyntaxKind.CaretToken, + ts.SyntaxKind.LessThanLessThanToken, + ts.SyntaxKind.GreaterThanGreaterThanToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanToken, +]); +/** Compound assignments whose operator converts undefined like NaN. */ +const NUMERIC_COMPOUND = new Set([ + ts.SyntaxKind.MinusEqualsToken, + ts.SyntaxKind.AsteriskEqualsToken, + ts.SyntaxKind.SlashEqualsToken, + ts.SyntaxKind.PercentEqualsToken, + ts.SyntaxKind.AsteriskAsteriskEqualsToken, + ts.SyntaxKind.AmpersandEqualsToken, + ts.SyntaxKind.BarEqualsToken, + ts.SyntaxKind.CaretEqualsToken, + ts.SyntaxKind.LessThanLessThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanEqualsToken, + ts.SyntaxKind.GreaterThanGreaterThanGreaterThanEqualsToken, +]); + +/** `Math` functions convert every argument with ToNumber. */ +const MATH_INTEGER_RESULTS = new Set(["imul", "clz32", "sign"]); + +function isLoopOrIf(node: ts.Node): boolean { + return ( + ts.isIfStatement(node) || + ts.isWhileStatement(node) || + ts.isDoStatement(node) || + ts.isForStatement(node) + ); +} + +/** Constructors of fixed-length number typed arrays. */ +const TYPED_ARRAYS = new Set([ + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", +]); + +function nonNegativeIntegerLiteral(node: ts.Expression): boolean { + const e = peelTransparent(node); + if (!ts.isNumericLiteral(e)) return false; + const value = Number(e.text); + return Number.isInteger(value) && value >= 0 && value <= Number.MAX_SAFE_INTEGER; +} + +/** `const a = ..., b = ...;` or two consecutive single declarations. */ +function adjacentDeclarations(a: ts.VariableDeclaration, b: ts.VariableDeclaration): boolean { + if (!ts.isVariableDeclaration(a) || !ts.isVariableDeclaration(b)) return false; + const la = a.parent; + const lb = b.parent; + if (!ts.isVariableDeclarationList(la) || !ts.isVariableDeclarationList(lb)) return false; + if (la === lb) { + const ia = la.declarations.indexOf(a); + const ib = la.declarations.indexOf(b); + return Math.abs(ia - ib) === 1; + } + const sa = la.parent; + const sb = lb.parent; + if (!ts.isVariableStatement(sa) || !ts.isVariableStatement(sb) || sa.parent !== sb.parent) + return false; + if (la.declarations.length !== 1 || lb.declarations.length !== 1) return false; + const list = sa.parent; + if ( + !ts.isBlock(list) && + !ts.isSourceFile(list) && + !ts.isCaseClause(list) && + !ts.isDefaultClause(list) + ) + return false; + const statements = (list as { statements: ts.NodeArray }).statements; + return Math.abs(statements.indexOf(sa) - statements.indexOf(sb)) === 1; +} + +export class NanCodedReads { + /** Typed-array element reads that can lower as NaN-coded doubles. */ + private readonly reads = new Map(); + /** Typed-array element reads proven to name an existing element. */ + private readonly presentReads = new Map(); + private readonly counters = new Map(); + private readonly fixedLengths = new Map(); + private readonly bindings = new Map(); + private readonly notSlots = new Set(); + /** Every slot's value occurrences. */ + private readonly useNodes = new Set(); + private readonly returns = new Map(); + /** Identifiers by text across the analyzed files. */ + private readonly names = new Map(); + private readonly symbolCache = new Map(); + private readonly pneCache = new Map(); + private readonly pneReturnCache = new Map(); + private readonly fnEligibility = new Map(); + private readonly callSites = new Map(); + /** Slots that are NaN-coded after the fixed point. */ + private readonly eligible = new Set(); + private readonly genuineNaN = new Set(); + private readonly numericReads = new Set(); + private analyzed = false; + + constructor( + private readonly host: NanCodedHost, + private readonly files: readonly ts.SourceFile[], + ) {} + + /** The read lowers to a double holding NaN for an invalid index. */ + isNumericRead(read: ts.ElementAccessExpression): boolean { + return this.numericReads.has(read) || this.presentReads.has(read); + } + + /** Both operands of this equality are NaN-coded values that may stand + * for undefined: undefined equals undefined, so equal NaNs compare equal. */ + needsUndefinedEquality(expr: ts.BinaryExpression): boolean { + if (!EQUALITY.has(expr.operatorToken.kind)) return false; + const left = this.loweredInfo(expr.left); + const right = this.loweredInfo(expr.right); + return left.nce && right.nce && !left.nan && !right.nan; + } + + /** Bindings and returns that are NaN-coded (consistency checks). */ + nanCodedBinding(symbol: ts.Symbol): boolean { + const slot = this.bindings.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + nanCodedReturn(symbol: ts.Symbol): boolean { + const slot = this.returns.get(symbol); + return slot !== undefined && this.eligible.has(slot); + } + + get size(): { reads: number; slots: number; present: number } { + return { + reads: this.numericReads.size, + slots: this.eligible.size, + present: this.presentReads.size, + }; + } + + analyze(): void { + if (this.analyzed) return; + this.analyzed = true; + const candidates = new Map(); + for (const sf of this.files) { + if (isJsSourceFile(sf) || sf.isDeclarationFile) continue; + ts.walkPreorder(sf, (node) => { + if (ts.isIdentifier(node)) { + const list = this.names.get(node.text); + if (list) list.push(node); + else this.names.set(node.text, [node]); + return; + } + if (ts.isElementAccessExpression(node) && this.isReadCandidate(node)) { + const elem = this.host.bytesElemOf(node.expression); + if (elem) candidates.set(node, elem); + } + }); + } + // A proven-present read is a plain number; only the rest may be + // undefined. The proof needs every reference, so it runs after the walk. + for (const [read, elem] of candidates) + (this.presentRead(read) ? this.presentReads : this.reads).set(read, elem); + if (this.reads.size === 0) return; + // Forward: the slots a read can reach. + const work: ts.Expression[] = [...this.reads.keys()]; + const reached = new Set(); + while (work.length > 0) { + const value = work.pop()!; + const slot = this.sinkOf(value); + if (!slot || reached.has(slot)) continue; + reached.add(slot); + for (const use of slot.uses) work.push(use); + } + for (const slot of reached) this.eligible.add(slot); + // Backward: drop slots with an unsafe use or source until stable. + for (let changed = true; changed;) { + changed = false; + this.computeGenuineNaN(); + for (const slot of [...this.eligible]) { + if (this.slotOk(slot)) continue; + if (this.removals) this.removals.set(slot, this.removalReason(slot)); + this.eligible.delete(slot); + changed = true; + } + } + this.computeGenuineNaN(); + for (const read of this.reads.keys()) if (this.useSafe(read)) this.numericReads.add(read); + if (process.env["SCRIPTC_DEBUG_NAN_CODED"]) this.debugReport(reached); + } + + private readonly removals: Map | null = process.env["SCRIPTC_DEBUG_NAN_CODED"] + ? new Map() + : null; + + private removalReason(slot: Slot): string { + const badSource = slot.sources.find((s) => s === null || this.sourceInfo(s) === null); + if (badSource !== undefined) + return `source ${badSource ? badSource.getText().slice(0, 60) : ""}`; + const nan = this.genuineNaN.has(slot); + const badUse = slot.uses.find((u) => !this.useSafe(u) && (nan || !this.reboxConsumer(u))); + if (badUse) + return `${nan ? "nan-use" : "use"} ${transparentParent(badUse).parent?.getText().slice(0, 80) ?? "?"}`; + return "?"; + } + + private debugReport(reached: Set): void { + const name = (slot: Slot): string => { + const decl = this.host.declarationOf(slot.symbol); + const sf = decl?.getSourceFile(); + const where = sf && decl ? `${sf.fileName}:${decl.getStart()}` : "?"; + return `${slot.kind} ${slot.symbol.name} @ ${where}`; + }; + for (const slot of reached) { + const ok = this.eligible.has(slot); + const why = ok + ? this.genuineNaN.has(slot) + ? "(nan)" + : `(rebox ${slot.uses.filter((u) => !this.useSafe(u)).length})` + : (this.removals?.get(slot) ?? "?"); + process.stderr.write(`nan-coded ${ok ? "yes" : "no "} ${name(slot)} ${why}\n`); + } + process.stderr.write( + `nan-coded reads ${this.numericReads.size}/${this.reads.size}, present ${this.presentReads.size}, slots ${this.eligible.size}/${reached.size}\n`, + ); + } + + private symbolOf(node: ts.Node): ts.Symbol | null { + // A cached null is an answer: `!` would be a checked extraction in a + // native build of the compiler. + const cached = this.symbolCache.get(node); + if (cached !== undefined) return cached; + const symbol = this.host.symbolOf(node); + this.symbolCache.set(node, symbol); + return symbol; + } + + /** An element read (never a write target) of a TypeScript file. */ + private isReadCandidate(read: ts.ElementAccessExpression): boolean { + if (read.questionDotToken) return false; + let n: ts.Node = read; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + // `t[i]!` asserts presence: it already lowers to the plain numeric read + // and is never optional. + if (!p || ts.isNonNullExpression(p)) return false; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) + return false; + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return false; + if (ts.isDeleteExpression(p)) return false; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isForOfStatement(p) || + ts.isForInStatement(p) + ) + return false; + return true; + } + + // ---- present reads ------------------------------------------------------ + + /** `t[i]` names an existing element: `i` is a non-negative integer + * counter and a dominating guard bounds it by `t.length` (or by the + * length of a typed array that always has the same length). + * + * The guard proof only sees code written between the guard and the read. + * Code it cannot see (a getter, a conversion hook) cannot reach either + * operand: `t` is a fixed-length typed array no other code can reach, and + * only the reading function itself writes `i`. */ + private presentRead(read: ts.ElementAccessExpression): boolean { + const array = peelTransparent(read.expression); + const index = peelTransparent(read.argumentExpression); + if (!ts.isIdentifier(array) || !ts.isIdentifier(index)) return false; + const arraySymbol = this.symbolOf(array); + if (!arraySymbol || !this.fixedLength(arraySymbol)) return false; + const symbol = this.symbolOf(index); + if (!symbol || !this.nonNegativeCounter(symbol)) return false; + const decl = this.host.declarationOf(symbol); + if (!decl || this.containingFunction(decl) !== this.containingFunction(read)) return false; + return indexReadInBounds(read, { + sameLength: (array, other) => this.sameFixedLength(array, other), + conditions: true, + }); + } + + /** A `let`/`const` binding that starts at a non-negative integer literal + * and only ever grows by non-negative integer literals (`i++`, `++i`, + * `i += 2`) or is reset to one (`i = 0`): it always holds a non-negative + * integer. Every write is in the declaring function (no closure writes). */ + private nonNegativeCounter(symbol: ts.Symbol): boolean { + const known = this.counters.get(symbol); + if (known !== undefined) return known; + this.counters.set(symbol, false); + const decl = this.host.declarationOf(symbol); + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !decl.initializer || + !nonNegativeIntegerLiteral(decl.initializer) || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) || + isJsSourceFile(decl.getSourceFile()) + ) + return false; + const name = decl.name; + const owner = this.containingFunction(decl); + for (const id of this.names.get(name.text) ?? []) { + if (id === name || this.symbolOf(id) !== symbol) continue; + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return false; + if (this.writeOf(id) !== "read" && this.containingFunction(id) !== owner) return false; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) { + const k = p.operatorToken.kind; + if (k !== ts.SyntaxKind.EqualsToken && k !== ts.SyntaxKind.PlusEqualsToken) return false; + if (!nonNegativeIntegerLiteral(p.right)) return false; + continue; + } + if (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) { + if (p.operator === ts.SyntaxKind.MinusMinusToken) return false; + continue; + } + // Any other write position (destructuring, for-in/of) is a write of + // an unknown value. + if (this.writeOf(id) !== "read") return false; + } + this.counters.set(symbol, true); + return true; + } + + /** Two typed arrays whose lengths are equal for their whole lifetime. */ + private sameFixedLength(array: ts.Expression, other: ts.Expression): boolean { + const a = peelTransparent(array); + const b = peelTransparent(other); + if (!ts.isIdentifier(a) || !ts.isIdentifier(b)) return false; + const sa = this.symbolOf(a); + const sb = this.symbolOf(b); + if (!sa || !sb || sa === sb) return false; + const na = this.fixedLength(sa); + const nb = this.fixedLength(sb); + if (!na || !nb) return false; + const la = na.arguments![0]!; + const lb = nb.arguments![0]!; + // The same literal length. + const literal = (e: ts.Expression): number | null => { + const x = peelTransparent(e); + return ts.isNumericLiteral(x) ? Number(x.text) : null; + }; + const literalA = literal(la); + if (literalA !== null) return literalA === literal(lb); + // The same `const` binding. + const ia = peelTransparent(la); + const ib = peelTransparent(lb); + if (ts.isIdentifier(ia) && ts.isIdentifier(ib)) { + const symbol = this.symbolOf(ia); + if (!symbol || symbol !== this.symbolOf(ib)) return false; + const decl = this.host.declarationOf(symbol); + return ( + decl !== undefined && + ts.isVariableDeclaration(decl) && + ts.isVariableDeclarationList(decl.parent) && + (decl.parent.flags & ts.NodeFlags.Const) !== 0 && + this.host.isNumber(ia) + ); + } + // `xs.length` of the same `const` array or typed array, read by two + // adjacent declarations: nothing runs between the two reads that could + // change it (allocating a typed array runs no program code). + if ( + ts.isPropertyAccessExpression(ia) && + ts.isPropertyAccessExpression(ib) && + !ia.questionDotToken && + !ib.questionDotToken && + ia.name.text === "length" && + ib.name.text === "length" && + ts.isIdentifier(peelTransparent(ia.expression)) && + ts.isIdentifier(peelTransparent(ib.expression)) && + this.host.hasLength(ia.expression) + ) { + const owner = this.symbolOf(peelTransparent(ia.expression)); + if (!owner || owner !== this.symbolOf(peelTransparent(ib.expression))) return false; + const ownerDecl = this.host.declarationOf(owner); + if ( + !ownerDecl || + !( + (ts.isVariableDeclaration(ownerDecl) && + ts.isVariableDeclarationList(ownerDecl.parent) && + ownerDecl.parent.flags & ts.NodeFlags.Const) || + ts.isParameter(ownerDecl) + ) + ) + return false; + return adjacentDeclarations( + na.parent as ts.VariableDeclaration, + nb.parent as ts.VariableDeclaration, + ); + } + return false; + } + + /** The allocation `new Uint8Array(n)` of a `const` typed array whose every + * reference is an element access or `.length`: nothing can reach its + * buffer to detach it, so its length never changes. */ + private fixedLength(symbol: ts.Symbol): ts.NewExpression | null { + const known = this.fixedLengths.get(symbol); + if (known !== undefined) return known; + this.fixedLengths.set(symbol, null); + const decl = this.host.declarationOf(symbol); + if ( + !decl || + !ts.isVariableDeclaration(decl) || + !ts.isIdentifier(decl.name) || + !decl.initializer || + !ts.isVariableDeclarationList(decl.parent) || + !(decl.parent.flags & ts.NodeFlags.Const) || + // A module binding can escape through an export. + (ts.isVariableStatement(decl.parent.parent) && ts.isSourceFile(decl.parent.parent.parent)) || + isJsSourceFile(decl.getSourceFile()) || + this.host.bytesElemOf(decl.name) === null + ) + return null; + const init = peelTransparent(decl.initializer); + if ( + !ts.isNewExpression(init) || + !ts.isIdentifier(init.expression) || + !TYPED_ARRAYS.has(init.expression.text) || + !this.host.isStdlibGlobal(init.expression, init.expression.text) || + init.typeArguments || + init.arguments?.length !== 1 || + ts.isSpreadElement(init.arguments[0]!) || + !this.host.isNumber(init.arguments[0]!) + ) + return null; + const name = decl.name; + for (const id of this.names.get(name.text) ?? []) { + if (id === name || this.symbolOf(id) !== symbol) continue; + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return null; + if (ts.isElementAccessExpression(p) && p.expression === n) continue; + if ( + ts.isPropertyAccessExpression(p) && + p.expression === n && + p.name.text === "length" && + !( + p.parent && + ts.isBinaryExpression(p.parent) && + p.parent.left === p && + isAssignmentOperator(p.parent.operatorToken.kind) + ) + ) + continue; + return null; + } + this.fixedLengths.set(symbol, init); + return init; + } + + // ---- slots -------------------------------------------------------------- + + /** The function whose `return` statement contains `node`. */ + private containingFunction(node: ts.Node): ts.Node | undefined { + let n = node.parent; + while ( + n && + !ts.isFunctionLike(n) && + !ts.isClassStaticBlockDeclaration(n) && + !ts.isSourceFile(n) + ) + n = n.parent; + return n; + } + + /** The declaration of a directly called function or method, when every + * reference to it is a direct call (no value use, no overrides). */ + private eligibleFunction(symbol: ts.Symbol): ts.FunctionLikeDeclaration | null { + const known = this.fnEligibility.get(symbol); + if (known !== undefined) return known; + this.fnEligibility.set(symbol, null); + const decl = this.host.declarationOf(symbol); + if (!decl || (!ts.isFunctionDeclaration(decl) && !ts.isMethodDeclaration(decl))) return null; + if (!decl.body || decl.asteriskToken || decl.typeParameters || !decl.name) return null; + if (!ts.isIdentifier(decl.name)) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + const flags = ts.getCombinedModifierFlags(decl); + if (flags & (ts.ModifierFlags.Async | ts.ModifierFlags.Abstract | ts.ModifierFlags.Ambient)) + return null; + if (ts.isMethodDeclaration(decl)) { + const cls = decl.parent; + if (!ts.isClassDeclaration(cls) || (cls.heritageClauses?.length ?? 0) > 0) return null; + if (this.host.methodOverridden(symbol)) return null; + } + // Sloppy-mode `arguments` aliases parameters. + let usesArguments = false; + ts.walkPreorder(decl.body, (node) => { + if (ts.isIdentifier(node) && node.text === "arguments") usesArguments = true; + }); + if (usesArguments) return null; + const calls = this.directCalls(symbol, decl.name); + if (!calls) return null; + this.fnEligibility.set(symbol, decl); + return decl; + } + + /** Every call of the function, or null when some reference is not a + * direct, non-optional call. */ + private directCalls(symbol: ts.Symbol, declName: ts.Identifier): ts.CallExpression[] | null { + const known = this.callSites.get(symbol); + if (known !== undefined) return known; + const calls: ts.CallExpression[] = []; + let ok = true; + for (const id of this.names.get(declName.text) ?? []) { + if (id === declName) continue; + // Import and export specifiers only alias the function. + const parent = id.parent; + if ( + parent && + (ts.isImportSpecifier(parent) || ts.isExportSpecifier(parent) || ts.isImportClause(parent)) + ) + continue; + if (this.symbolOf(id) !== symbol) continue; + let callee: ts.Expression = id; + const p = id.parent; + if (p && ts.isPropertyAccessExpression(p) && p.name === id) { + if (p.questionDotToken) ok = false; + callee = p; + } + const call = callee.parent; + if ( + !call || + !ts.isCallExpression(call) || + call.expression !== callee || + call.questionDotToken || + call.arguments.some((a) => ts.isSpreadElement(a)) + ) { + ok = false; + break; + } + calls.push(call); + } + const result = ok ? calls : null; + this.callSites.set(symbol, result); + return result; + } + + private bindingSlot(symbol: ts.Symbol): BindingSlot | null { + const existing = this.bindings.get(symbol); + if (existing) return existing; + if (this.notSlots.has(symbol)) return null; + const slot = this.scanBindingSlot(symbol); + if (slot) this.bindings.set(symbol, slot); + else this.notSlots.add(symbol); + return slot; + } + + private scanBindingSlot(symbol: ts.Symbol): BindingSlot | null { + const decl = this.host.declarationOf(symbol); + if (!decl) return null; + const sf = decl.getSourceFile(); + if (isJsSourceFile(sf) || sf.isDeclarationFile) return null; + let nameNode: ts.Identifier; + const slot: BindingSlot = { + kind: "binding", + symbol, + sources: [], + uses: [], + arithmeticWrites: false, + reassigned: false, + }; + if (ts.isVariableDeclaration(decl)) { + const list = decl.parent; + if ( + !ts.isIdentifier(decl.name) || + !ts.isVariableDeclarationList(list) || + !(list.flags & (ts.NodeFlags.Let | ts.NodeFlags.Const)) || + ts.isForOfStatement(list.parent) || + ts.isForInStatement(list.parent) + ) + return null; + // Module bindings are globals with their own promotion rules. + if (ts.isVariableStatement(list.parent) && ts.isSourceFile(list.parent.parent)) return null; + if (!this.host.isNumber(decl.name)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + } else if (ts.isParameter(decl)) { + if (!ts.isIdentifier(decl.name) || decl.dotDotDotToken || decl.questionToken) return null; + const fn = decl.parent; + if (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) return null; + const fnSymbol = fn.name ? this.symbolOf(fn.name) : null; + if (!fnSymbol || !this.eligibleFunction(fnSymbol)) return null; + const index = fn.parameters.indexOf(decl); + if (index < 0 || !this.host.paramIsNumber(fnSymbol, index)) return null; + nameNode = decl.name; + if (decl.initializer) slot.sources.push(decl.initializer); + for (const call of this.callSites.get(fnSymbol) ?? []) { + const arg = call.arguments[index]; + if (arg) slot.sources.push(arg); + else if (!decl.initializer) slot.sources.push(null); + } + } else return null; + for (const id of this.names.get(nameNode.text) ?? []) { + if (id === nameNode || this.symbolOf(id) !== symbol) continue; + const write = this.writeOf(id); + if (write !== "read") slot.reassigned = true; + if (write === "read") slot.uses.push(id); + else if (write === "arith") { + slot.arithmeticWrites = true; + // The compound operator reads the binding as well. + slot.uses.push(id); + } else if (write === "unknown") slot.sources.push(null); + else slot.sources.push(write); + } + for (const use of slot.uses) this.useNodes.add(use); + return slot; + } + + /** How an identifier reference touches its binding. */ + private writeOf(id: ts.Identifier): "read" | "arith" | "unknown" | ts.Expression { + let n: ts.Node = id; + let p = n.parent; + while (p && ts.isParenthesizedExpression(p)) { + n = p; + p = n.parent; + } + if (!p) return "read"; + if (ts.isBinaryExpression(p) && p.left === n && isAssignmentOperator(p.operatorToken.kind)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right; + if (k === ts.SyntaxKind.PlusEqualsToken) + return this.host.isNumber(p.right) ? "arith" : "unknown"; + return NUMERIC_COMPOUND.has(k) ? "arith" : "unknown"; + } + if ( + (ts.isPrefixUnaryExpression(p) || ts.isPostfixUnaryExpression(p)) && + (p.operator === ts.SyntaxKind.PlusPlusToken || p.operator === ts.SyntaxKind.MinusMinusToken) + ) + return "arith"; + if ( + ts.isArrayLiteralExpression(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + (ts.isPropertyAssignment(p) && p.initializer === n) || + ((ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n) + ) { + // A destructuring target, or an object literal value (a read). + return ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p) + ? this.inDestructuringTarget(p) + ? "unknown" + : "read" + : ts.isForOfStatement(p) || ts.isForInStatement(p) + ? "unknown" + : this.inDestructuringTarget(p) + ? "unknown" + : "read"; + } + return "read"; + } + + private inDestructuringTarget(node: ts.Node): boolean { + let n: ts.Node = node; + let p = n.parent; + while ( + p && + (ts.isArrayLiteralExpression(p) || + ts.isObjectLiteralExpression(p) || + ts.isPropertyAssignment(p) || + ts.isShorthandPropertyAssignment(p) || + ts.isSpreadElement(p) || + ts.isSpreadAssignment(p) || + ts.isParenthesizedExpression(p)) + ) { + n = p; + p = n.parent; + } + if (!p) return false; + if ( + ts.isBinaryExpression(p) && + p.left === n && + p.operatorToken.kind === ts.SyntaxKind.EqualsToken + ) + return true; + return (ts.isForOfStatement(p) || ts.isForInStatement(p)) && p.initializer === n; + } + + private returnSlot(symbol: ts.Symbol): ReturnSlot | null { + const existing = this.returns.get(symbol); + if (existing) return existing; + const decl = this.eligibleFunction(symbol); + if (!decl || !decl.body || !this.host.returnIsNumber(symbol)) return null; + const slot: ReturnSlot = { + kind: "return", + symbol, + sources: [], + uses: [...(this.callSites.get(symbol) ?? [])], + arithmeticWrites: false, + }; + const body = decl.body; + ts.walkPreorder(body, (node) => { + if (!ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + slot.sources.push(node.expression ?? null); + }); + for (const use of slot.uses) this.useNodes.add(use); + this.returns.set(symbol, slot); + return slot; + } + + /** The callee of a call: a function or method symbol. */ + private calleeSymbol(call: ts.CallExpression): ts.Symbol | null { + if (call.questionDotToken) return null; + let callee = call.expression; + while (ts.isParenthesizedExpression(callee)) callee = callee.expression; + if (ts.isIdentifier(callee)) return this.symbolOf(callee); + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) + return this.symbolOf(callee.name); + return null; + } + + /** The slot a value flows into, through transparent wrappers and + * conditional branches; null for a direct consumer. */ + private sinkOf(value: ts.Expression): Slot | null { + const e = transparentParent(value); + const p = e.parent; + if (!p) return null; + if (ts.isConditionalExpression(p) && p.condition !== e) return this.sinkOf(p); + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.CommaToken && p.right === e) return this.sinkOf(p); + if (k === ts.SyntaxKind.EqualsToken && p.right === e) { + const target = peelTransparent(p.left); + if (!ts.isIdentifier(target)) return null; + const symbol = this.symbolOf(target); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + if (ts.isVariableDeclaration(p) && p.initializer === e && ts.isIdentifier(p.name)) { + const symbol = this.symbolOf(p.name); + return symbol ? this.bindingSlot(symbol) : null; + } + if (ts.isReturnStatement(p)) { + const fn = this.containingFunction(p); + if (!fn || (!ts.isFunctionDeclaration(fn) && !ts.isMethodDeclaration(fn)) || !fn.name) + return null; + const symbol = this.symbolOf(fn.name); + return symbol ? this.returnSlot(symbol) : null; + } + if (ts.isCallExpression(p) && p.expression !== e) { + const index = p.arguments.indexOf(e); + const fn = this.calleeSymbol(p); + if (index < 0 || !fn) return null; + const decl = this.eligibleFunction(fn); + const param = decl?.parameters[index]; + if (!param || !ts.isIdentifier(param.name)) return null; + const symbol = this.symbolOf(param.name); + return symbol ? this.bindingSlot(symbol) : null; + } + return null; + } + + /** A slot that never holds a genuine NaN encodes undefined exactly, so + * any consumer can rebuild the union (see `reboxes`); a slot that may + * hold a genuine NaN needs NaN-insensitive consumers throughout. */ + private slotOk(slot: Slot): boolean { + for (const source of slot.sources) + if (source === null || !this.sourceInfo(source)) return false; + const nan = this.genuineNaN.has(slot); + for (const use of slot.uses) + if (!this.useSafe(use) && (nan || !this.reboxConsumer(use))) return false; + return true; + } + + /** The eligible slot whose value this occurrence reads, if any. */ + private slotOfValue(node: ts.Expression): Slot | null { + if (ts.isIdentifier(node)) { + const symbol = this.symbolOf(node); + const slot = symbol ? this.bindings.get(symbol) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + if (ts.isCallExpression(node)) { + const fn = this.calleeSymbol(node); + const slot = fn ? this.returns.get(fn) : undefined; + return slot && this.eligible.has(slot) && this.useNodes.has(node) ? slot : null; + } + return null; + } + + /** This read of a NaN-coded slot reaches a consumer that could observe + * undefined: lowering rebuilds `number | undefined` from the double (NaN + * is undefined, exactly, since the slot never holds a genuine NaN), and + * the optional-read analysis treats the occurrence as optional, so the + * consumer lowers exactly as it does for an ordinary read. */ + reboxes(node: ts.Expression): boolean { + if (!ts.isIdentifier(node) && !ts.isCallExpression(node)) return false; + const slot = this.slotOfValue(node); + return slot !== null && !this.genuineNaN.has(slot) && !this.useSafe(node); + } + + /** A consumer that takes the value whole, so the rebuilt union lowers + * there exactly like an ordinary optional read: an argument, a stored + * or returned value, a literal element, or string concatenation. Any + * other consumer (a member access, `typeof`, `??`, ...) keeps the slot + * on the ordinary optional lowering instead. */ + private reboxConsumer(value: ts.Expression): boolean { + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition !== e && this.reboxConsumer(p); + if (ts.isCallExpression(p) || ts.isNewExpression(p)) return p.expression !== e; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) return true; + if (ts.isArrayLiteralExpression(p) || ts.isTemplateSpan(p)) return true; + if (ts.isPropertyAssignment(p)) return p.initializer === e; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.EqualsToken) return p.right === e; + if (k === ts.SyntaxKind.PlusToken) return true; + if (k === ts.SyntaxKind.CommaToken) return p.right === e && this.reboxConsumer(p); + } + return false; + } + + private computeGenuineNaN(): void { + this.genuineNaN.clear(); + for (let changed = true; changed;) { + changed = false; + for (const slot of this.eligible) { + if (this.genuineNaN.has(slot)) continue; + let nan = slot.arithmeticWrites; + for (const source of slot.sources) { + if (nan) break; + if (source) nan = this.sourceInfo(source)?.nan ?? true; + } + if (nan) { + this.genuineNaN.add(slot); + changed = true; + } + } + } + } + + // ---- values ------------------------------------------------------------- + + /** A value allowed into a NaN-coded slot, or null. */ + private sourceInfo(node: ts.Expression): ValueInfo | null { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.sourceInfo(e.whenTrue); + const b = a && this.sourceInfo(e.whenFalse); + return a && b ? { nce: a.nce || b.nce, nan: a.nan || b.nan } : null; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined) return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + if (ts.isIdentifier(e)) { + if (this.guardedOccurrence(e)) return { nce: false, nan: false }; + const symbol = this.symbolOf(e); + const slot = symbol ? this.bindings.get(symbol) : undefined; + if (slot && this.eligible.has(slot)) return { nce: true, nan: this.genuineNaN.has(slot) }; + } + if (ts.isCallExpression(e)) { + const fn = this.calleeSymbol(e); + const slot = fn ? this.returns.get(fn) : undefined; + if (slot && this.eligible.has(slot) && this.useNodes.has(e)) + return { nce: true, nan: this.genuineNaN.has(slot) }; + } + if (ts.isBinaryExpression(e) && this.nanFreeArithmetic(e, 0)) return { nce: false, nan: false }; + const plain = this.plainNumber(e, 0); + if (plain) return plain; + // Any other number value may be undefined at run time (an optional + // read the default lowering widens): lowering converts it with + // ToNumber on entry (see `convertsSource`), so the slot may then hold + // a genuine NaN. + return this.host.isNumber(e) ? { nce: true, nan: true } : null; + } + + /** The value flows into a NaN-coded slot: lowering converts an optional + * number to NaN-for-undefined on entry. */ + convertsSource(node: ts.Expression): boolean { + const slot = this.sinkOf(node); + return slot !== null && this.eligible.has(slot); + } + + /** The occurrence reads a NaN-coded slot. */ + readsNanCodedSlot(node: ts.Expression): boolean { + return this.slotOfValue(node) !== null; + } + + /** `+`, `-` or `*` over finite literals and integer values proven present + * where they are read: the result is never NaN. A NaN-coded slot that + * never holds a genuine NaN holds integers, finite plain numbers or NaN + * for undefined, and a comparison guarding the read excludes NaN. */ + private nanFreeArithmetic(node: ts.Expression, depth: number): boolean { + if (depth > 6) return false; + const e = peelTransparent(node); + if (ts.isNumericLiteral(e)) + return Number.isFinite(Number(e.text)) && Math.abs(Number(e.text)) < 2 ** 53; + if (ts.isPrefixUnaryExpression(e) && e.operator === ts.SyntaxKind.MinusToken) + return this.nanFreeArithmetic(e.operand, depth + 1); + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.PlusToken || k === ts.SyntaxKind.MinusToken) { + if (!this.host.isNumber(e.left) || !this.host.isNumber(e.right)) return false; + return ( + this.nanFreeArithmetic(e.left, depth + 1) && this.nanFreeArithmetic(e.right, depth + 1) + ); + } + if (k === ts.SyntaxKind.AsteriskToken) + return ( + (ts.isNumericLiteral(peelTransparent(e.left)) || + ts.isNumericLiteral(peelTransparent(e.right))) && + this.nanFreeArithmetic(e.left, depth + 1) && + this.nanFreeArithmetic(e.right, depth + 1) + ); + return false; + } + if (ts.isPropertyAccessExpression(e)) { + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + if (!ts.isIdentifier(e)) return false; + const symbol = this.symbolOf(e); + if (!symbol) return false; + const slot = this.bindings.get(symbol); + if (slot && this.eligible.has(slot)) { + return ( + !this.genuineNaN.has(slot) && + slot.sources.length <= 1 && + !slot.arithmeticWrites && + this.provenPresent(e, symbol) + ); + } + const info = this.plainNumber(e, 0); + return info !== null && !info.nan; + } + + /** An occurrence of a never-reassigned number binding under a dominating + * comparison with a number that is never NaN (`x >= 0 ? x : y`): the + * comparison is false for undefined and for NaN, so here the binding + * holds a plain number that is not NaN, even when the binding itself is + * NaN-coded. */ + private guardedOccurrence(use: ts.Identifier): boolean { + const symbol = this.symbolOf(use); + if (!symbol) return false; + const slot = this.bindingSlot(symbol); + return ( + slot !== null && + !slot.reassigned && + !slot.arithmeticWrites && + this.useNodes.has(use) && + this.provenPresent(use, symbol) + ); + } + + /** A dominating condition compares the never-reassigned binding with a + * number literal, which is false for undefined. */ + private provenPresent(use: ts.Identifier, symbol: ts.Symbol): boolean { + let child: ts.Node = use; + for (let n = use.parent; n && !ts.isFunctionLike(n); child = n, n = n.parent) { + let condition: ts.Expression | undefined; + if (ts.isIfStatement(n) && n.thenStatement === child) condition = n.expression; + else if (ts.isConditionalExpression(n) && n.whenTrue === child) condition = n.condition; + else if ( + ts.isBinaryExpression(n) && + n.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken && + n.right === child + ) + condition = n.left; + if (condition && this.conditionExcludesUndefined(condition, symbol)) return true; + } + return false; + } + + private conditionExcludesUndefined(condition: ts.Expression, symbol: ts.Symbol): boolean { + const e = peelTransparent(condition); + if (!ts.isBinaryExpression(e)) return false; + const k = e.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken) + return ( + this.conditionExcludesUndefined(e.left, symbol) || + this.conditionExcludesUndefined(e.right, symbol) + ); + if (!RELATIONAL.has(k) && k !== ts.SyntaxKind.EqualsEqualsEqualsToken) return false; + const left = peelTransparent(e.left); + const right = peelTransparent(e.right); + const names = (x: ts.Expression): boolean => ts.isIdentifier(x) && this.symbolOf(x) === symbol; + const literal = (x: ts.Expression): boolean => { + const info = this.plainNumber(x, 0); + return info !== null && !info.nan; + }; + return (names(left) && literal(right)) || (names(right) && literal(left)); + } + + /** How the value lowers once the analysis is final: NaN-coded (a numeric + * read or a read of a NaN-coded slot) or not. */ + private loweredInfo(node: ts.Expression): ValueInfo { + const e = peelTransparent(node); + if (ts.isConditionalExpression(e)) { + const a = this.loweredInfo(e.whenTrue); + const b = this.loweredInfo(e.whenFalse); + return { nce: a.nce || b.nce, nan: a.nan || b.nan }; + } + if (ts.isElementAccessExpression(e)) { + const elem = this.reads.get(e); + if (elem !== undefined && this.numericReads.has(e)) + return { nce: true, nan: !isIntegerBytesElem(elem) }; + } + if (ts.isIdentifier(e) && this.guardedOccurrence(e)) return { nce: false, nan: false }; + const slot = this.slotOfValue(e); + if (slot) return { nce: true, nan: this.genuineNaN.has(slot) }; + return { nce: false, nan: true }; + } + + /** A number expression that is never undefined in Node and always lowers + * to a plain double, whatever the optional-read analysis decides. */ + private plainNumber(node: ts.Expression, depth: number): ValueInfo | null { + if (depth > 8) return null; + const cached = this.pneCache.get(node); + if (cached !== undefined) return cached; + const result = this.computePlainNumber(node, depth); + this.pneCache.set(node, result); + return result; + } + + private computePlainNumber(node: ts.Expression, depth: number): ValueInfo | null { + let e = node; + while (ts.isParenthesizedExpression(e) || ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) + e = e.expression; + if (ts.isNumericLiteral(e)) return { nce: false, nan: false }; + if (ts.isElementAccessExpression(e)) { + const elem = this.presentReads.get(e); + return elem === undefined ? null : { nce: false, nan: !isIntegerBytesElem(elem) }; + } + if (ts.isPrefixUnaryExpression(e)) { + if (e.operator === ts.SyntaxKind.TildeToken) return { nce: false, nan: false }; + if (e.operator === ts.SyntaxKind.MinusToken || e.operator === ts.SyntaxKind.PlusToken) + return { nce: false, nan: !ts.isNumericLiteral(e.operand) }; + return null; + } + if (ts.isBinaryExpression(e)) { + const k = e.operatorToken.kind; + if (BITWISE.has(k)) return { nce: false, nan: false }; + if (ARITHMETIC.has(k)) return { nce: false, nan: true }; + if ( + k === ts.SyntaxKind.PlusToken && + this.host.isNumber(e.left) && + this.host.isNumber(e.right) + ) + return { nce: false, nan: true }; + // A number fallback for a nullish (or falsy) left value: never + // undefined. The left value is used as is, so it may be NaN. + if ( + (k === ts.SyntaxKind.QuestionQuestionToken || k === ts.SyntaxKind.BarBarToken) && + this.host.isNumber(e) && + this.plainNumber(e.right, depth + 1) !== null + ) + return { nce: false, nan: true }; + return null; + } + if (ts.isPropertyAccessExpression(e) && !e.questionDotToken) { + const symbol = this.symbolOf(e.name); + if (symbol && symbol.flags & ts.SymbolFlags.EnumMember && this.host.isNumber(e)) + return { nce: false, nan: false }; + if ( + e.name.text === "length" && + (this.host.isString(e.expression) || this.host.hasLength(e.expression)) + ) + return { nce: false, nan: false }; + return null; + } + if (ts.isCallExpression(e) && !e.questionDotToken) { + const callee = e.expression; + if (ts.isPropertyAccessExpression(callee) && !callee.questionDotToken) { + if (this.host.isStdlibGlobal(callee.expression, "Math")) + return { nce: false, nan: !MATH_INTEGER_RESULTS.has(callee.name.text) }; + if (callee.name.text === "charCodeAt" && this.host.isString(callee.expression)) + return { nce: false, nan: true }; + } + const fn = this.calleeSymbol(e); + return fn ? this.plainReturn(fn, depth) : null; + } + if (ts.isIdentifier(e)) { + const symbol = this.symbolOf(e); + const decl = symbol ? this.host.declarationOf(symbol) : undefined; + if ( + decl && + ts.isVariableDeclaration(decl) && + decl.initializer && + ts.isVariableDeclarationList(decl.parent) && + decl.parent.flags & ts.NodeFlags.Const && + !isJsSourceFile(decl.getSourceFile()) + ) + return this.plainNumber(decl.initializer, depth + 1); + return null; + } + return null; + } + + /** A function whose every return value is a plain number. */ + private plainReturn(fn: ts.Symbol, depth: number): ValueInfo | null { + const known = this.pneReturnCache.get(fn); + if (known !== undefined) return known; + this.pneReturnCache.set(fn, null); + const decl = this.eligibleFunction(fn); + if (!decl?.body || !this.host.returnIsNumber(fn)) return null; + let result: ValueInfo | null = { nce: false, nan: false }; + ts.walkPreorder(decl.body, (node) => { + if (!result || !ts.isReturnStatement(node) || this.containingFunction(node) !== decl) return; + const info = node.expression ? this.plainNumber(node.expression, depth + 1) : null; + result = info ? { nce: false, nan: result.nan || info.nan } : null; + }); + this.pneReturnCache.set(fn, result); + return result; + } + + // ---- uses --------------------------------------------------------------- + + /** Whether the value's consumer cannot tell undefined from NaN. */ + private useSafe(value: ts.Expression): boolean { + // A plain number here, whatever consumes it. + if (ts.isIdentifier(value) && this.guardedOccurrence(value)) return true; + const e = transparentParent(value); + const p = e.parent; + if (!p) return false; + if (ts.isConditionalExpression(p)) return p.condition === e || this.useSafe(p); + if (ts.isExpressionStatement(p) || ts.isVoidExpression(p)) return true; + if (isLoopOrIf(p)) { + if (ts.isForStatement(p)) return p.condition === e || p.incrementor === e; + return true; + } + if (ts.isPrefixUnaryExpression(p)) { + return ( + p.operator === ts.SyntaxKind.MinusToken || + p.operator === ts.SyntaxKind.PlusToken || + p.operator === ts.SyntaxKind.TildeToken || + p.operator === ts.SyntaxKind.ExclamationToken + ); + } + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + const other = p.left === e ? p.right : p.left; + if (RELATIONAL.has(k) || ARITHMETIC.has(k) || BITWISE.has(k)) return true; + if (k === ts.SyntaxKind.PlusToken) return this.host.isNumber(other); + if (EQUALITY.has(k)) { + const otherInfo = this.sourceInfo(other); + if (!otherInfo) return false; + if (!otherInfo.nce) return true; + const self = this.sourceInfo(e); + return self !== null && !self.nan && !otherInfo.nan; + } + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + if (k === ts.SyntaxKind.CommaToken) return p.left === e || this.useSafe(p); + if (p.left === e) { + // Compound assignment reads of a binding: arithmetic. + return NUMERIC_COMPOUND.has(k) || k === ts.SyntaxKind.PlusEqualsToken; + } + if (k === ts.SyntaxKind.EqualsToken) { + const slot = this.sinkOf(e); + if (!slot || !this.eligible.has(slot)) return false; + // The assignment's own value flows on when it is used. + const outer = transparentParent(p); + const consumer = outer.parent; + if ( + !consumer || + ts.isExpressionStatement(consumer) || + (ts.isForStatement(consumer) && + (consumer.incrementor === outer || consumer.initializer === outer)) + ) + return true; + return this.useSafe(p); + } + return ( + NUMERIC_COMPOUND.has(k) || + (k === ts.SyntaxKind.PlusEqualsToken && this.host.isNumber(p.left)) + ); + } + if (ts.isPostfixUnaryExpression(p)) return true; + if (ts.isVariableDeclaration(p) || ts.isReturnStatement(p)) { + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + if (ts.isCallExpression(p) && p.expression !== e) { + const callee = p.expression; + if ( + ts.isPropertyAccessExpression(callee) && + !callee.questionDotToken && + !p.questionDotToken && + (this.host.isStdlibGlobal(callee.expression, "Math") || + (callee.name.text === "fromCharCode" && + this.host.isStdlibGlobal(callee.expression, "String"))) + ) + return true; + const slot = this.sinkOf(e); + return slot !== null && this.eligible.has(slot); + } + // A typed-array element read answers undefined for both an undefined + // and a NaN index (neither names an integer index). + if ( + ts.isElementAccessExpression(p) && + p.argumentExpression === e && + this.isReadCandidate(p) && + this.host.bytesElemOf(p.expression) !== null + ) + return true; + if (ts.isSwitchStatement(p)) { + return p.caseBlock.clauses.every( + (clause) => !ts.isCaseClause(clause) || this.plainNumber(clause.expression, 0) !== null, + ); + } + return false; + } + + /** The value of a logical expression is only tested for truthiness. */ + private inConditionContext(node: ts.Expression): boolean { + const e = transparentParent(node); + const p = e.parent; + if (!p) return false; + if (ts.isIfStatement(p) || ts.isWhileStatement(p) || ts.isDoStatement(p)) + return p.expression === e; + if (ts.isForStatement(p)) return p.condition === e; + if (ts.isConditionalExpression(p)) return p.condition === e; + if (ts.isPrefixUnaryExpression(p)) return p.operator === ts.SyntaxKind.ExclamationToken; + if (ts.isExpressionStatement(p)) return true; + if (ts.isBinaryExpression(p)) { + const k = p.operatorToken.kind; + if (k === ts.SyntaxKind.AmpersandAmpersandToken || k === ts.SyntaxKind.BarBarToken) + return this.inConditionContext(p); + } + return false; + } +} diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts index 0b61f46f7..fafacd265 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.test.ts @@ -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]); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts index ccc8740af..3768949ab 100644 --- a/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts +++ b/packages/compiler/src/frontend/lowering/runtime-optional-bounds.ts @@ -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; } diff --git a/packages/compiler/src/index.ts b/packages/compiler/src/index.ts index 0b8d48430..0759a01cb 100644 --- a/packages/compiler/src/index.ts +++ b/packages/compiler/src/index.ts @@ -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); diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index ad790b904..2ef822e81 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -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 } { + 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(); + const dynNamed = new Map(); + 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(); + 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(); - const dynNamed = new Map(); - 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(); - 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) => { diff --git a/packages/compiler/src/ir/ir.ts b/packages/compiler/src/ir/ir.ts index 28ef1f431..f7ac00459 100644 --- a/packages/compiler/src/ir/ir.ts +++ b/packages/compiler/src/ir/ir.ts @@ -42,6 +42,11 @@ export const BYTES_ELEMENT_SIZE: Record = { 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 = { u8: "Uint8Array", u8c: "Uint8ClampedArray", diff --git a/packages/compiler/src/ir/serialize.test.ts b/packages/compiler/src/ir/serialize.test.ts index be933514e..8345ab10a 100644 --- a/packages/compiler/src/ir/serialize.test.ts +++ b/packages/compiler/src/ir/serialize.test.ts @@ -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"); +}); diff --git a/packages/compiler/src/ir/serialize.ts b/packages/compiler/src/ir/serialize.ts index b156300ea..9455191d4 100644 --- a/packages/compiler/src/ir/serialize.ts +++ b/packages/compiler/src/ir/serialize.ts @@ -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; +} diff --git a/packages/compiler/src/native/cache.test.ts b/packages/compiler/src/native/cache.test.ts index d5ac6bc6a..3ffdf2335 100644 --- a/packages/compiler/src/native/cache.test.ts +++ b/packages/compiler/src/native/cache.test.ts @@ -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"); diff --git a/packages/compiler/src/native/cache.ts b/packages/compiler/src/native/cache.ts index 22741a0c4..f81311f52 100644 --- a/packages/compiler/src/native/cache.ts +++ b/packages/compiler/src/native/cache.ts @@ -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"); diff --git a/packages/compiler/src/native/compiler.ts b/packages/compiler/src/native/compiler.ts index 984eb4339..57d2ab4d6 100644 --- a/packages/compiler/src/native/compiler.ts +++ b/packages/compiler/src/native/compiler.ts @@ -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) { diff --git a/packages/compiler/src/native/prepare.test.ts b/packages/compiler/src/native/prepare.test.ts index 4295e88ef..7e22450da 100644 --- a/packages/compiler/src/native/prepare.test.ts +++ b/packages/compiler/src/native/prepare.test.ts @@ -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); +}); diff --git a/packages/compiler/src/native/prepare.ts b/packages/compiler/src/native/prepare.ts index 24f339dae..69f317f28 100644 --- a/packages/compiler/src/native/prepare.ts +++ b/packages/compiler/src/native/prepare.ts @@ -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; 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; 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; diff --git a/packages/compiler/src/native/text-file.ts b/packages/compiler/src/native/text-file.ts new file mode 100644 index 000000000..6f8990339 --- /dev/null +++ b/packages/compiler/src/native/text-file.ts @@ -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); + } +} diff --git a/packages/compiler/src/text-parts.ts b/packages/compiler/src/text-parts.ts new file mode 100644 index 000000000..a4519b0ba --- /dev/null +++ b/packages/compiler/src/text-parts.ts @@ -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 { + const file = await open(path, "w", mode); + try { + const write = async (chunk: string): Promise => { + 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(); + } +} diff --git a/packages/compiler/test/byte-number-emission.test.ts b/packages/compiler/test/byte-number-emission.test.ts index 18c41814e..fc36fd23d 100644 --- a/packages/compiler/test/byte-number-emission.test.ts +++ b/packages/compiler/test/byte-number-emission.test.ts @@ -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)/); diff --git a/packages/compiler/test/nan-coded-reads.test.ts b/packages/compiler/test/nan-coded-reads.test.ts new file mode 100644 index 000000000..8d18b62f4 --- /dev/null +++ b/packages/compiler/test/nan-coded-reads.test.ts @@ -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 { + 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"); +}); diff --git a/packages/compiler/test/trunc-emission.test.ts b/packages/compiler/test/trunc-emission.test.ts new file mode 100644 index 000000000..5f46097e5 --- /dev/null +++ b/packages/compiler/test/trunc-emission.test.ts @@ -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 { + 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"); +}); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 79cc0ac78..0e4c9cdd3 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15754,6 +15754,60 @@ "/tests/corpus/4652-stale-closure-binding-narrowing.ts" ], "diags": [] + }, + "/tests/corpus/native-worker-arena-reuse.ts": { + "order": [ + "/tests/corpus/native-worker-arena-reuse.ts" + ], + "diags": [] + }, + "/tests/corpus/4598-map-set-clear-reuse.ts": { + "order": [ + "/tests/corpus/4598-map-set-clear-reuse.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-lambda-results.ts": { + "order": [ + "/tests/corpus/missing-read-lambda-results.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-narrowed-storage.ts": { + "order": [ + "/tests/corpus/missing-read-narrowed-storage.ts" + ], + "diags": [] + }, + "/tests/corpus/missing-read-number-conversions.ts": { + "order": [ + "/tests/corpus/missing-read-number-conversions.ts" + ], + "diags": [] + }, + "/tests/corpus/typed-array-nan-coded-reads.ts": { + "order": [ + "/tests/corpus/typed-array-nan-coded-reads.ts" + ], + "diags": [] + }, + "/tests/corpus/typed-array-present-reads.ts": { + "order": [ + "/tests/corpus/typed-array-present-reads.ts" + ], + "diags": [] + }, + "/tests/corpus/4601-cycle-holders.ts": { + "order": [ + "/tests/corpus/4601-cycle-holders.ts" + ], + "diags": [] + }, + "/tests/corpus/4602-constructor-escape-order.ts": { + "order": [ + "/tests/corpus/4602-constructor-escape-order.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_alloc.c b/packages/runtime/src/scr_alloc.c index 015236785..32352d9dd 100644 --- a/packages/runtime/src/scr_alloc.c +++ b/packages/runtime/src/scr_alloc.c @@ -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 +/* 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 #include +#if defined(__linux__) && defined(MADV_DONTNEED) +#define SCR_SA_TRIM 1 +#include +#include +#include +#if defined(__GLIBC__) +#include +#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 diff --git a/packages/runtime/src/scr_alloc.test.c b/packages/runtime/src/scr_alloc.test.c index ea4960543..9978c819e 100644 --- a/packages/runtime/src/scr_alloc.test.c +++ b/packages/runtime/src/scr_alloc.test.c @@ -98,6 +98,64 @@ static void check_churn(void) { } } +#if SCR_SMALL_ALLOC +#include +#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 @@ -159,13 +217,104 @@ static void check_threads(void) { } #endif +#if defined(SCR_WORKERS) && SCR_SMALL_ALLOC && defined(__linux__) +#include +#include + +/* 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"); diff --git a/packages/runtime/src/scr_alloc.test.ts b/packages/runtime/src/scr_alloc.test.ts index c3cf5467a..187ffce51 100644 --- a/packages/runtime/src/scr_alloc.test.ts +++ b/packages/runtime/src/scr_alloc.test.ts @@ -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"), diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 1377580e8..3171d258c 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.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; diff --git a/packages/runtime/src/scr_map.c b/packages/runtime/src/scr_map.c index 064204975..804396c3f 100644 --- a/packages/runtime/src/scr_map.c +++ b/packages/runtime/src/scr_map.c @@ -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); } } diff --git a/packages/runtime/src/scr_map.test.c b/packages/runtime/src/scr_map.test.c index d947515bb..6c6e22e01 100644 --- a/packages/runtime/src/scr_map.test.c +++ b/packages/runtime/src/scr_map.test.c @@ -9,6 +9,7 @@ #include #include #include +#include #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; } diff --git a/packages/runtime/src/scr_message.c b/packages/runtime/src/scr_message.c index 655d97219..5b27bfc4e 100644 --- a/packages/runtime/src/scr_message.c +++ b/packages/runtime/src/scr_message.c @@ -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)); diff --git a/packages/runtime/src/scr_publish.c b/packages/runtime/src/scr_publish.c index 819ba3f68..7ef5e50e9 100644 --- a/packages/runtime/src/scr_publish.c +++ b/packages/runtime/src/scr_publish.c @@ -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(); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 363f89245..a44d7d244 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -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 diff --git a/packages/runtime/test/cycle.test.ts b/packages/runtime/test/cycle.test.ts index 9d2ff257d..580986c86 100644 --- a/packages/runtime/test/cycle.test.ts +++ b/packages/runtime/test/cycle.test.ts @@ -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`); } }); diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 041c95caa..4de18b1d6 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -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; } diff --git a/scripts/sandbox-bootstrap.sh b/scripts/sandbox-bootstrap.sh index 54c06c8b1..ac0a4558a 100644 --- a/scripts/sandbox-bootstrap.sh +++ b/scripts/sandbox-bootstrap.sh @@ -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 \ diff --git a/tests/corpus/4598-map-set-clear-reuse.ts b/tests/corpus/4598-map-set-clear-reuse.ts new file mode 100644 index 000000000..0a4095139 --- /dev/null +++ b/tests/corpus/4598-map-set-clear-reuse.ts @@ -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(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(); +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(); +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(); +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(); +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(["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(); +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(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(); +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(); +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(); +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(",")); diff --git a/tests/corpus/4601-cycle-holders.ts b/tests/corpus/4601-cycle-holders.ts new file mode 100644 index 000000000..c6f7b4b36 --- /dev/null +++ b/tests/corpus/4601-cycle-holders.ts @@ -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); diff --git a/tests/corpus/4602-constructor-escape-order.ts b/tests/corpus/4602-constructor-escape-order.ts new file mode 100644 index 000000000..f5307c8dc --- /dev/null +++ b/tests/corpus/4602-constructor-escape-order.ts @@ -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); diff --git a/tests/corpus/missing-read-lambda-results.ts b/tests/corpus/missing-read-lambda-results.ts new file mode 100644 index 000000000..0d9f047d2 --- /dev/null +++ b/tests/corpus/missing-read-lambda-results.ts @@ -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)); diff --git a/tests/corpus/missing-read-narrowed-storage.ts b/tests/corpus/missing-read-narrowed-storage.ts new file mode 100644 index 000000000..97be7214a --- /dev/null +++ b/tests/corpus/missing-read-narrowed-storage.ts @@ -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"); diff --git a/tests/corpus/missing-read-number-conversions.ts b/tests/corpus/missing-read-number-conversions.ts new file mode 100644 index 000000000..abbe50879 --- /dev/null +++ b/tests/corpus/missing-read-number-conversions.ts @@ -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)); diff --git a/tests/corpus/native-worker-arena-reuse.ts b/tests/corpus/native-worker-arena-reuse.ts new file mode 100644 index 000000000..7a4f22e8f --- /dev/null +++ b/tests/corpus/native-worker-arena-reuse.ts @@ -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((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); +} diff --git a/tests/corpus/typed-array-nan-coded-reads.ts b/tests/corpus/typed-array-nan-coded-reads.ts new file mode 100644 index 000000000..02b53cd4f --- /dev/null +++ b/tests/corpus/typed-array-nan-coded-reads.ts @@ -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); diff --git a/tests/corpus/typed-array-present-reads.ts b/tests/corpus/typed-array-present-reads.ts new file mode 100644 index 000000000..da0f02421 --- /dev/null +++ b/tests/corpus/typed-array-present-reads.ts @@ -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); diff --git a/tests/fixtures/publish-leaks/dropped.ts b/tests/fixtures/publish-leaks/dropped.ts new file mode 100644 index 000000000..51178b8ed --- /dev/null +++ b/tests/fixtures/publish-leaks/dropped.ts @@ -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([[`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); diff --git a/tests/fixtures/self-hosting/serialize.ts b/tests/fixtures/self-hosting/serialize.ts index 0146da1a4..4654ea415 100644 --- a/tests/fixtures/self-hosting/serialize.ts +++ b/tests/fixtures/self-hosting/serialize.ts @@ -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); diff --git a/tests/harness/large-artifacts.test.ts b/tests/harness/large-artifacts.test.ts new file mode 100644 index 000000000..207ce80a2 --- /dev/null +++ b/tests/harness/large-artifacts.test.ts @@ -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, ""), + ); +}); diff --git a/tests/harness/large-artifacts.ts b/tests/harness/large-artifacts.ts new file mode 100644 index 000000000..c21dc11ae --- /dev/null +++ b/tests/harness/large-artifacts.ts @@ -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; + } +} diff --git a/tests/harness/publish-leaks.test.ts b/tests/harness/publish-leaks.test.ts new file mode 100644 index 000000000..4ebfbfd28 --- /dev/null +++ b/tests/harness/publish-leaks.test.ts @@ -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); + }); + }, +); diff --git a/tests/harness/self-hosting-backend-cases.ts b/tests/harness/self-hosting-backend-cases.ts index 649c8b3fc..6f89aafd3 100644 --- a/tests/harness/self-hosting-backend-cases.ts +++ b/tests/harness/self-hosting-backend-cases.ts @@ -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"; diff --git a/tests/harness/self-hosting-native-driver.test.ts b/tests/harness/self-hosting-native-driver.test.ts index 87ba5a4c8..b57d0b6cf 100644 --- a/tests/harness/self-hosting-native-driver.test.ts +++ b/tests/harness/self-hosting-native-driver.test.ts @@ -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); }; diff --git a/tests/harness/self-hosting-serialization.test.ts b/tests/harness/self-hosting-serialization.test.ts index 3051c86f1..4139cd648 100644 --- a/tests/harness/self-hosting-serialization.test.ts +++ b/tests/harness/self-hosting-serialization.test.ts @@ -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")