diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index eb5277533..64abc94a8 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -54,7 +54,7 @@ fib.s
Different output kinds accumulate in `.scriptc/`; rebuilding a kind updates its file.
-The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v12 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime.
+The IR and LLVM source outputs require only Node. On supported macOS, Linux, and Windows hosts, assembly and object outputs use the matching native helper installed with scriptc and do not invoke an external compiler, archiver, linker, or SDK. The object is a relocatable program object with undefined scr_* runtime symbols and a required scr_runtime_abi_v13 marker, not a standalone library. External consumption is experimental and requires the exact runtime version reported by --print=native-link-info. That option still writes the object, performs no link, and prints a versioned JSON recipe with the target, main entry, installed precompiled runtime pack, FFI inputs, and system libraries. It never reports private scriptc cache paths. See Native Program Objects for complete C-driver and direct-linker examples. --emit=exe is the default. LLVM builds emit the program object through the helper and link precompiled runtime objects; runtime development with AddressSanitizer compiles an instrumented C runtime.
## scriptc run
diff --git a/docs/content/docs/how-it-works.mdx b/docs/content/docs/how-it-works.mdx
index f0d5efabb..5fcd664a8 100644
--- a/docs/content/docs/how-it-works.mdx
+++ b/docs/content/docs/how-it-works.mdx
@@ -68,7 +68,7 @@ Without `-o`, artifacts are written to `.scriptc/` next to the input. Different
### External linking
-Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v12` checks runtime compatibility at link time.
+Program objects define `main` and leave selected `scr_*` runtime functions undefined. A strong reference to `scr_runtime_abi_v13` checks runtime compatibility at link time.
`--print=native-link-info` emits the program object and a JSON recipe containing the runtime pack and link order for external builds. This object ABI is experimental and requires an exact runtime version. See [Native Program Objects](/docs/native-objects) for examples.
diff --git a/docs/content/docs/native-objects.mdx b/docs/content/docs/native-objects.mdx
index 609065160..caa5baefd 100644
--- a/docs/content/docs/native-objects.mdx
+++ b/docs/content/docs/native-objects.mdx
@@ -8,7 +8,7 @@ title: "Native Program Objects"
The external object ABI is **experimental**. Its `scr_*` function and data surface may change before 1.0, so consumers must use the exact runtime-pack version reported by the same compiler installation. This is stricter than semver compatibility.
-The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v12`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
+The object intentionally leaves its selected runtime symbols undefined. It also holds a strong reference to `scr_runtime_abi_v13`, which the matching runtime defines. Linking an object against a runtime with another ABI marker fails at link time with the missing versioned symbol; it cannot become a latent runtime incompatibility.
## Machine-readable link information
diff --git a/examples/native-object/README.md b/examples/native-object/README.md
index 486612a0e..3086e995c 100644
--- a/examples/native-object/README.md
+++ b/examples/native-object/README.md
@@ -15,4 +15,4 @@ $ ./app-ld
`cc` uses the C compiler as a linker driver. `ld` links the same precompiled runtime objects directly with the selected Apple SDK. The object defines `main`; it is a complete program object, not a library to load into another process. Use `scriptc build --lib --profile ...` for a host-callable static library.
-The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v12`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI.
+The external object ABI is experimental. Always consume the runtime pack at the exact `runtime_pack.version` reported by the same scriptc installation. The object requires `scr_runtime_abi_v13`, so a mismatched runtime fails during the link instead of starting with an incompatible ABI.
diff --git a/packages/cli/README.md b/packages/cli/README.md
index b4876f9b0..a8dfbc798 100644
--- a/packages/cli/README.md
+++ b/packages/cli/README.md
@@ -34,7 +34,7 @@ Builds use a bounded persistent cache. Unchanged source can reuse the validated
- `scriptc run ` — compile and run
- `scriptc coverage ` — what compiles statically, and why the rest doesn't
-`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v12` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
+`scriptc build app.ts --emit=ir|llvm|asm|obj` selects serialized typed IR, textual LLVM IR, target assembly, or a relocatable program object as the one primary artifact. `--emit=exe` is the default. Assembly/object emission uses the matching helper on supported macOS, Linux, and Windows hosts (and produces WASI artifacts when that target is selected). Objects retain undefined `scr_*` runtime references plus the `scr_runtime_abi_v13` compatibility marker; they are not library archives. External consumption is experimental and requires the exact matching runtime. `scriptc build app.ts --print=native-link-info -o app.o` prints the versioned JSON target/runtime/link recipe without performing a link. `--emit=asm|obj --sanitize` is rejected until ASan pipeline parity is available.
For embedder-hosted modules that are not installed npm packages, coverage can map an exact bare specifier to a local declaration with repeatable `--external-types ` options. This is analysis-only: the types unblock application measurement, while runtime module uses remain reported as blockers.
diff --git a/packages/cli/test/native-link-info.test.ts b/packages/cli/test/native-link-info.test.ts
index ad7791c66..4e1616a8c 100644
--- a/packages/cli/test/native-link-info.test.ts
+++ b/packages/cli/test/native-link-info.test.ts
@@ -72,7 +72,7 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
minimum_os: "14.0",
},
program: { object, entry_symbol: "main" },
- runtime_abi: { version: 12, marker: "scr_runtime_abi_v12" },
+ runtime_abi: { version: 13, marker: "scr_runtime_abi_v13" },
runtime_pack: {
kind: "precompiled",
package: "@scriptc/runtime-darwin-arm64",
@@ -165,6 +165,6 @@ describe.runIf(supported)("macOS arm64 native link info", () => {
() => null,
(failure: { stderr?: string }) => failure,
);
- expect(error?.stderr).toContain("scr_runtime_abi_v12");
+ expect(error?.stderr).toContain("scr_runtime_abi_v13");
});
});
diff --git a/packages/compiler/src/backend/cycle-mutability.ts b/packages/compiler/src/backend/cycle-mutability.ts
index 67196efd7..e5a183db5 100644
--- a/packages/compiler/src/backend/cycle-mutability.ts
+++ b/packages/compiler/src/backend/cycle-mutability.ts
@@ -292,14 +292,11 @@ export function constructorThisAliases(fn: IrFunction, thisId: string): Set {
- 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 === "fieldSet" && isThisRef(s.obj)) 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);
diff --git a/packages/compiler/src/backend/immutability.test.ts b/packages/compiler/src/backend/immutability.test.ts
new file mode 100644
index 000000000..98ecf91ef
--- /dev/null
+++ b/packages/compiler/src/backend/immutability.test.ts
@@ -0,0 +1,172 @@
+import { expect, test } from "vitest";
+import {
+ DYN,
+ F64,
+ STRING,
+ VOID,
+ type IrExpr,
+ type IrModule,
+ type IrStmt,
+ type IrType,
+} from "../ir/ir.js";
+import { computeConstructionFacts, computeImmutableFields, stableGlobals } from "./immutability.js";
+
+const loc = { file: "immutability.ts", start: 0, end: 1 };
+const obj = (className: string): IrType => ({ kind: "object", className });
+const value = (type: IrType): IrExpr => ({ kind: "varRef", localId: "v", type, loc });
+const thisRef = (className: string): IrExpr => ({
+ kind: "varRef",
+ localId: "this.0",
+ type: obj(className),
+ loc,
+});
+const setThis = (className: string, field: string): IrStmt => ({
+ kind: "fieldSet",
+ obj: thisRef(className),
+ className,
+ field,
+ value: value(F64),
+ loc,
+});
+const call = (callee: string, args: IrExpr[]): IrStmt => ({
+ kind: "exprStmt",
+ expr: { kind: "call", callee, args, type: VOID, loc },
+ loc,
+});
+function ctor(className: string, body: IrStmt[]): IrModule["functions"][number] {
+ return {
+ name: `%${className}.constructor`,
+ params: [{ localId: "this.0", name: "this", type: obj(className) }],
+ locals: [],
+ returnType: VOID,
+ body,
+ loc,
+ };
+}
+function module(
+ classes: NonNullable,
+ functions: IrModule["functions"],
+): IrModule {
+ return {
+ irVersion: 15,
+ sourceFile: loc.file,
+ entry: "main",
+ classes,
+ functions: [
+ { name: "main", locals: [], params: [], returnType: VOID, body: [], loc },
+ ...functions,
+ ],
+ };
+}
+const fields = (...names: string[]) => names.map((name) => ({ name, type: F64 }));
+
+/** The fields of `className` whose constructor stores are private. */
+function privateFields(mod: IrModule, className: string): string[] {
+ const facts = computeConstructionFacts(mod);
+ const fn = mod.functions.find((f) => f.name === `%${className}.constructor`)!;
+ return fn.body
+ .filter((s): s is Extract => s.kind === "fieldSet")
+ .filter((s) => facts.privateStores.has(s))
+ .map((s) => s.field);
+}
+
+test("constructor stores before this escapes are private", () => {
+ const mod = module(
+ [{ name: "P", fields: fields("a", "b"), loc }],
+ [ctor("P", [setThis("P", "a"), call("register", [thisRef("P")]), setThis("P", "b")])],
+ );
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+});
+
+test("storing this into its own field ends private construction", () => {
+ // A later read of the field yields `this` again, so any use of it
+ // (publishing it, handing it to a worker) would expose the object.
+ const self: IrStmt = { ...setThis("P", "self"), value: thisRef("P") } as IrStmt;
+ const mod = module(
+ [{ name: "P", fields: [...fields("a", "b"), { name: "self", type: obj("P") }], loc }],
+ [ctor("P", [setThis("P", "a"), self, setThis("P", "b")])],
+ );
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+});
+
+test("a base constructor's escape makes every subclass store public", () => {
+ const superCall = call("%Base.constructor", [
+ { kind: "upcast", value: thisRef("Sub"), type: obj("Base"), loc },
+ ]);
+ const mod = module(
+ [
+ { name: "Base", fields: fields("a"), loc },
+ { name: "Sub", base: "Base", fields: fields("a", "b"), loc },
+ ],
+ [
+ ctor("Base", [setThis("Base", "a"), call("register", [thisRef("Base")])]),
+ ctor("Sub", [superCall, setThis("Sub", "b")]),
+ ],
+ );
+ expect(privateFields(mod, "Base")).toEqual(["a"]);
+ expect(privateFields(mod, "Sub")).toEqual([]);
+});
+
+test("a constructor called on an existing object keeps every guard", () => {
+ const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
+ expect(privateFields(mod, "P")).toEqual(["a"]);
+ mod.functions[0]!.body.push(call("%P.constructor", [value(obj("P"))]));
+ expect(privateFields(mod, "P")).toEqual([]);
+});
+
+test("library builds keep every guard", () => {
+ const mod = module([{ name: "P", fields: fields("a"), loc }], [ctor("P", [setThis("P", "a")])]);
+ mod.lib = {} as NonNullable;
+ expect(privateFields(mod, "P")).toEqual([]);
+});
+
+test("construction-only fields exclude later writes and dynamic stores", () => {
+ const mod = module(
+ [{ name: "P", fields: fields("a", "b"), loc }],
+ [ctor("P", [setThis("P", "a"), setThis("P", "b")])],
+ );
+ mod.functions[0]!.body.push(
+ { kind: "fieldSet", obj: value(obj("P")), className: "P", field: "b", value: value(F64), loc },
+ { kind: "exprStmt", expr: { kind: "dynFrom", value: value(obj("P")), type: DYN, loc }, loc },
+ );
+ const facts = computeImmutableFields(mod)!;
+ expect(facts.classField("P", "a")).toBe(true);
+ expect(facts.classField("P", "b")).toBe(false);
+ // A runtime store into a dynamic value can reach P through its capsule.
+ mod.functions[0]!.body.push({
+ kind: "exprStmt",
+ expr: {
+ kind: "libCall",
+ fn: "dyn.keySet",
+ args: [value(DYN), value(STRING), value(DYN)],
+ type: VOID,
+ loc,
+ },
+ loc,
+ });
+ expect(computeImmutableFields(mod)!.classField("P", "a")).toBe(false);
+});
+
+test("module constants are stable once assigned exactly once", () => {
+ const mod = module([], []);
+ mod.globals = [
+ { id: "%g.k", name: "k", type: STRING, mutable: false },
+ { id: "%g.m", name: "m", type: STRING, mutable: true },
+ { id: "%g.t", name: "t", type: STRING, mutable: false, tdz: true },
+ { id: "%g.twice", name: "twice", type: STRING, mutable: false },
+ ];
+ const assign = (localId: string): IrStmt => ({
+ kind: "assign",
+ localId,
+ value: value(STRING),
+ loc,
+ });
+ mod.functions[0]!.body.push(
+ assign("%g.k"),
+ assign("%g.m"),
+ assign("%g.t"),
+ assign("%g.twice"),
+ assign("%g.twice"),
+ );
+ expect([...stableGlobals(mod)]).toEqual(["%g.k"]);
+});
diff --git a/packages/compiler/src/backend/immutability.ts b/packages/compiler/src/backend/immutability.ts
new file mode 100644
index 000000000..a0b792496
--- /dev/null
+++ b/packages/compiler/src/backend/immutability.ts
@@ -0,0 +1,214 @@
+/* Static immutability facts the backend uses to drop run-time immutability
+ * checks (see computeConstructionFacts). Pure functions of the IR module. */
+import type { IrExpr, IrModule, IrStmt } from "../ir/ir.js";
+import { everyExprChild, everyStmtChild } from "../ir/traverse.js";
+import {
+ computeCycleMutability,
+ constructorEscapePoint,
+ constructorThisAliases,
+ fieldKey,
+ type CycleMutability,
+} from "./cycle-mutability.js";
+
+/** Fields that are never written once their object can be observed outside
+ * its own construction (cycle-mutability.ts derives the facts from every
+ * store in the IR, including typed-ref commits from checked-dynamic code,
+ * and disables them for library builds and dynamic islands).
+ *
+ * Outside constructors, a reference read from such a field stays valid for
+ * as long as its owner object is alive: nothing can replace it and release
+ * the old value. Constructors themselves may still write their own
+ * `this`'s fields, so the facts do not apply while one runs. */
+export class ImmutableFields {
+ constructor(
+ private readonly mutability: CycleMutability,
+ private readonly unitOfClass: (className: string) => string,
+ ) {}
+
+ /** `obj.field` on a class instance is construction-only. */
+ classField(className: string, field: string): boolean {
+ return this.unit(this.unitOfClass(className), field);
+ }
+
+ /** `obj.field` on a record is never written after the literal. */
+ recordField(shapeId: string, field: string): boolean {
+ return this.unit(`record:${shapeId}`, field);
+ }
+
+ private unit(unit: string, field: string): boolean {
+ return (
+ !this.mutability.all &&
+ !this.mutability.units.has(unit) &&
+ !this.mutability.fields.has(fieldKey(unit, field))
+ );
+ }
+}
+
+export function computeImmutableFields(mod: IrModule): ImmutableFields | null {
+ if (mod.lib !== undefined) return null;
+ const baseOf = new Map((mod.classes ?? []).map((c) => [c.name, c.base ?? null] as const));
+ const roots = new Map();
+ const rootOf = (name: string): string => {
+ const known = roots.get(name);
+ if (known !== undefined) return known;
+ let cur = name;
+ for (let base = baseOf.get(cur); base !== null && base !== undefined; base = baseOf.get(cur))
+ cur = base;
+ roots.set(name, cur);
+ return cur;
+ };
+ const unitOfClass = (name: string): string => `object:${rootOf(name)}`;
+ const mutability = computeCycleMutability(mod, unitOfClass);
+ return mutability.all ? null : new ImmutableFields(mutability, unitOfClass);
+}
+
+/** Stores that provably target an object no other code can reach yet.
+ *
+ * A published object (@scriptc/threads `publish`) rejects every write, so
+ * the emitter guards each store into a type the program may publish. A
+ * store into the constructor's own `this` cannot target a published object
+ * when nothing outside the construction can hold `this` at that point:
+ *
+ * - the constructor runs on a fresh object: it is only reached through
+ * `new` (or a construct thunk) for its class or a subclass, never by a
+ * direct call on an existing object (`%C.constructor` called anywhere but
+ * as the direct super call of a subclass constructor disqualifies C and
+ * every ancestor, which that call reaches with the same receiver);
+ * - no ancestor constructor, which runs before this body continues, lets
+ * `this` escape, and the store lies in a top-level statement before the
+ * first one in which this constructor lets it escape
+ * (cycle-mutability's constructorEscapePoint: `this`, or a constant alias
+ * of it, may only be a field-store target, a field-read receiver or the
+ * direct base constructor's receiver; storing it anywhere, including
+ * into one of its own fields whose later reads would hand it out again,
+ * and closures and class references capturing it count as escapes).
+ * A subclass constructor's code only runs after this body returns.
+ *
+ * Library builds keep every guard: their constructors can be invoked by an
+ * embedder in ways the module does not show. */
+export interface ConstructionFacts {
+ /** `fieldSet` statements whose target is a still-private `this`. */
+ privateStores: Set;
+}
+
+export function computeConstructionFacts(mod: IrModule): ConstructionFacts {
+ const facts: ConstructionFacts = { privateStores: new Set() };
+ if (mod.lib !== undefined) return facts;
+ const classes = new Map((mod.classes ?? []).map((c) => [c.name, c] as const));
+ const ctorClass = new Map([...classes.keys()].map((name) => [`%${name}.constructor`, name]));
+ const fnByName = new Map(mod.functions.map((fn) => [fn.name, fn] as const));
+ const thisOf = (name: string): string | null =>
+ fnByName.get(`%${name}.constructor`)?.params[0]?.localId ?? null;
+ const isThisRef = (e: IrExpr, thisId: string | null): boolean => {
+ if (thisId === null) return false;
+ if (e.kind === "varRef") return e.localId === thisId;
+ if (e.kind === "upcast") return isThisRef(e.value, thisId);
+ if (e.kind === "seqExpr") return isThisRef(e.result, thisId);
+ return false;
+ };
+
+ // Constructors reached with a receiver that may not be fresh.
+ const reused = new Set();
+ const markReused = (name: string): void => {
+ for (let c: string | undefined = name; c !== undefined && classes.has(c);) {
+ if (reused.has(c)) break;
+ reused.add(c);
+ c = classes.get(c)!.base;
+ }
+ };
+ for (const fn of mod.functions) {
+ const own = ctorClass.get(fn.name);
+ const thisId = own !== undefined ? thisOf(own) : null;
+ const baseCtor =
+ own !== undefined && classes.get(own)!.base !== undefined
+ ? `%${classes.get(own)!.base}.constructor`
+ : null;
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "call") {
+ const target = ctorClass.get(e.callee);
+ if (
+ target !== undefined &&
+ !(e.callee === baseCtor && e.args.length > 0 && isThisRef(e.args[0]!, thisId))
+ )
+ markReused(target);
+ } else if (e.kind === "closure") {
+ const target = ctorClass.get(e.fnName);
+ if (target !== undefined) markReused(target);
+ }
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => everyStmtChild(s, expr, stmt);
+ fn.body.every(stmt);
+ }
+
+ // The first escaping statement of each constructor (body length: none).
+ // A class without a constructor function of its own (runtime roots) is
+ // treated as escaping at once.
+ const points = new Map();
+ const escapePoint = (name: string): number => {
+ const known = points.get(name);
+ if (known !== undefined) return known;
+ const fn = fnByName.get(`%${name}.constructor`);
+ const base = classes.get(name)?.base;
+ const thisId = thisOf(name);
+ const point =
+ fn === undefined || thisId === null || classes.get(name)?.runtime === true
+ ? 0
+ : constructorEscapePoint(fn, thisId, base !== undefined ? `%${base}.constructor` : null);
+ points.set(name, point);
+ return point;
+ };
+ const ancestorEscapes = (name: string): boolean => {
+ for (let c = classes.get(name)?.base; c !== undefined; c = classes.get(c)?.base) {
+ const fn = fnByName.get(`%${c}.constructor`);
+ if (!classes.has(c) || fn === undefined || escapePoint(c) < fn.body.length) return true;
+ }
+ return false;
+ };
+
+ for (const [name] of classes) {
+ const fn = fnByName.get(`%${name}.constructor`);
+ if (fn === undefined || reused.has(name) || ancestorEscapes(name)) continue;
+ const point = escapePoint(name);
+ if (point === 0) continue;
+ const aliases = constructorThisAliases(fn, thisOf(name)!);
+ const isOwn = (e: IrExpr): boolean =>
+ (e.kind === "varRef" && aliases.has(e.localId)) ||
+ (e.kind === "upcast" && isOwn(e.value)) ||
+ (e.kind === "seqExpr" && isOwn(e.result));
+ const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "fieldSet" && isOwn(s.obj)) facts.privateStores.add(s);
+ return everyStmtChild(s, expr, stmt);
+ };
+ for (let i = 0; i < point; i++) stmt(fn.body[i]!);
+ }
+ return facts;
+}
+
+/** Module constants whose value never changes once initialized: immutable,
+ * not TDZ-checked (every read follows the initialization), no import-cycle
+ * init flag, and at most one assignment in the whole module. Such a global
+ * owns its value for the rest of the program, so a read of it can be
+ * borrowed across any later operand or call. */
+export function stableGlobals(mod: IrModule): Set {
+ const candidates = new Set(
+ (mod.globals ?? [])
+ .filter((g) => !g.mutable && g.tdz !== true && g.initFlag === undefined)
+ .map((g) => g.id),
+ );
+ const assigned = new Set();
+ const expr = (e: IrExpr): boolean => {
+ if (e.kind === "assignExpr" && candidates.has(e.localId)) candidates.delete(e.localId);
+ return everyExprChild(e, expr, stmt);
+ };
+ const stmt = (s: IrStmt): boolean => {
+ if (s.kind === "assign" && candidates.has(s.localId)) {
+ if (assigned.has(s.localId)) candidates.delete(s.localId);
+ assigned.add(s.localId);
+ }
+ return everyStmtChild(s, expr, stmt);
+ };
+ for (const fn of mod.functions) fn.body.every(stmt);
+ return candidates;
+}
diff --git a/packages/compiler/src/backend/llvm/borrowed-inputs.ts b/packages/compiler/src/backend/llvm/borrowed-inputs.ts
index af921a105..16839afd6 100644
--- a/packages/compiler/src/backend/llvm/borrowed-inputs.ts
+++ b/packages/compiler/src/backend/llvm/borrowed-inputs.ts
@@ -1,4 +1,5 @@
import type { IrExpr } from "../../ir/ir.js";
+import type { Writes } from "./reference-writes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
/** For a runtime input whose ABI borrows rather than consumes ownership.
@@ -14,18 +15,33 @@ export function emitBorrowedInput(host: LlvmEmitterContext, value: IrExpr): LlVa
* while every operation preserves existing owners. Compute suffix facts
* once, keeping left-to-right evaluation and the owning fallback for each
* argument independently. The consumer must borrow all inputs and preserve
- * reference edges itself; mutating runtime operations use emitBorrowedInput. */
+ * reference edges itself; mutating runtime operations use emitBorrowedInput.
+ * With `consumerWrites` (the locations the consumer may overwrite), a
+ * projection also borrows when neither the consumer nor any later operand
+ * can write a location its read depends on (ReferenceWrites). */
export function borrowableInputs(
host: LlvmEmitterContext,
inputs: readonly IrExpr[],
consumerPreserves = true,
+ consumerWrites?: Writes,
): boolean[] {
const result: boolean[] = new Array(inputs.length);
let preserves = consumerPreserves;
+ const writes = host.referenceWrites;
+ let suffix: Writes =
+ consumerWrites !== undefined
+ ? consumerWrites
+ : consumerPreserves
+ ? null
+ : (writes?.heapAll ?? null);
for (let i = inputs.length - 1; i >= 0; i--) {
const value = inputs[i]!;
- result[i] = host.canBorrowCallArgument(value) || (preserves && host.canBorrowReceiver(value));
+ result[i] =
+ host.canBorrowCallArgument(value) ||
+ (preserves && host.canBorrowReceiver(value)) ||
+ (writes !== null && host.canBorrowReceiver(value) && host.unwrittenRead(value, suffix));
preserves = preserves && host.referenceEffects.preserves(value);
+ if (writes !== null) suffix = writes.union([suffix, writes.expr(value)]);
}
return result;
}
@@ -36,3 +52,21 @@ export function emitBorrowedInputs(host: LlvmEmitterContext, inputs: readonly Ir
borrowed[index] ? host.emitReadReceiver(value) : host.emitExpr(value),
);
}
+
+/** The writes after each input: the consumer's plus every later input's
+ * (null entries: nothing; an empty array without ReferenceWrites). */
+export function inputSuffixWrites(
+ host: LlvmEmitterContext,
+ inputs: readonly IrExpr[],
+ consumerWrites: Writes,
+): Writes[] {
+ const writes = host.referenceWrites;
+ if (writes === null) return [];
+ const out: Writes[] = new Array(inputs.length);
+ let suffix = consumerWrites;
+ for (let i = inputs.length - 1; i >= 0; i--) {
+ out[i] = suffix;
+ suffix = writes.union([suffix, writes.expr(inputs[i]!)]);
+ }
+ return out;
+}
diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts
index 25a735356..c409fb87b 100644
--- a/packages/compiler/src/backend/llvm/common.ts
+++ b/packages/compiler/src/backend/llvm/common.ts
@@ -9,6 +9,20 @@ import type {
} from "../../ir/ir.js";
import type { LlvmEmitterContext } from "./expr-context.js";
+/** Field numbers of %ScrArr, which mirrors scr_runtime.h's ScrArr field for
+ * field (its static assertions pin `cyc` and `data`). */
+export const SCR_ARR_FIELD = {
+ rc: 0,
+ len: 1,
+ cap: 2,
+ elem: 3,
+ cyc: 4,
+ ops: 5,
+ data: 6,
+ present: 7,
+ ext: 8,
+} as const;
+
/** Lines that decide whether the double `index` is an integer in
* `[0, length)`, where `length` is an unsigned `sizeType` count, and give
* that integer as an i64. `%{ok}` is the answer; `%{wide}` is meaningful
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 4ff7b1b16..8101008a5 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.test.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.test.ts
@@ -77,7 +77,17 @@ test.each([32, 64] as const)("strict scalar reads check dense storage inline (%i
test("reference reads retain inline and keep a cold +1 runtime fallback", () => {
const read: IrExpr = { kind: "arrayGet", arr: ref("s", words), index, type: STRING, loc };
- const llvm = emitLlvmModule(module("word", STRING, [{ kind: "return", value: read, loc }]));
+ // An element removal in the body keeps the read from being lent as a
+ // borrowed return: the result must own its reference.
+ const truncate: IrStmt = {
+ kind: "arraySetLength",
+ arr: ref("s", words),
+ length: { kind: "numLit", value: 1, type: F64, loc },
+ loc,
+ };
+ const llvm = emitLlvmModule(
+ module("word", STRING, [truncate, { kind: "return", value: read, loc }]),
+ );
const fn = body(llvm, "word");
expect(fn).toMatch(/icmp ne ptr .*, null/);
expect(fn).toContain("@scr_str_retain_v(");
diff --git a/packages/compiler/src/backend/llvm/dense-array-access.ts b/packages/compiler/src/backend/llvm/dense-array-access.ts
index 34428db8b..dd0c20588 100644
--- a/packages/compiler/src/backend/llvm/dense-array-access.ts
+++ b/packages/compiler/src/backend/llvm/dense-array-access.ts
@@ -8,9 +8,10 @@
* `undefined`, sparse indices, noncanonical numeric properties (negative,
* fractional, NaN, infinite, 2^32-1), and out-of-range reads branch to the
* unchanged runtime call, which is marked cold. The ScrArr field indices
- * match the C layout in scr_runtime.h: len 1, cap 2, data 7, present 8. */
+ * (SCR_ARR_FIELD) match the C layout in scr_runtime.h. */
import { isRefCounted, type IrExpr, type IrType } from "../../ir/ir.js";
+import { SCR_ARR_FIELD } from "./common.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { exactInteger, widenInteger } from "./integer-values.js";
@@ -85,10 +86,14 @@ function emitDenseGuard(
data = B.tmp(),
statesPtr = B.tmp(),
states = B.tmp();
- B.line(`${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 7`);
+ B.line(
+ `${dataPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.data}`,
+ );
host.markMemoryPointer(dataPtr, "array:header");
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
- B.line(`${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 8`);
+ B.line(
+ `${statesPtr} = getelementptr inbounds %ScrArr, ptr ${array}, i32 0, i32 ${SCR_ARR_FIELD.present}`,
+ );
host.markMemoryPointer(statesPtr, "array:header");
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
const widen = (value: string): string => {
@@ -389,9 +394,9 @@ export function emitDenseArrayPush(
join = B.newLabel("arr.push.join");
B.condBr(room, fast, slow);
B.startBlock(fast);
- const dataPtr = header(7),
+ const dataPtr = header(SCR_ARR_FIELD.data),
data = B.tmp(),
- statesPtr = header(8),
+ statesPtr = header(SCR_ARR_FIELD.present),
states = B.tmp();
B.line(`${data} = load ptr, ptr ${dataPtr}${host.fieldAliasAttachment(dataPtr)}`);
B.line(`${states} = load ptr, ptr ${statesPtr}${host.fieldAliasAttachment(statesPtr)}`);
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index b474068cc..56b18e2b4 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -1,6 +1,6 @@
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
-import { BYTES_ELEM_NUM, f64Lit } from "./common.js";
+import { BYTES_ELEM_NUM, SCR_ARR_FIELD, f64Lit } from "./common.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { InternalCompilerError } from "../../errors.js";
/* ScrDyn helpers for the LLVM backend: per-type match predicates
@@ -1199,8 +1199,8 @@ export class LlDyn {
: [
{ index: 1, type: host.sizeType, name: "length" },
{ index: 2, type: host.sizeType, name: "capacity" },
- { index: 7, type: "ptr" as const, name: "data" },
- { index: 8, type: "ptr" as const, name: "presence" },
+ { index: SCR_ARR_FIELD.data, type: "ptr" as const, name: "data" },
+ { index: SCR_ARR_FIELD.present, type: "ptr" as const, name: "presence" },
];
members.forEach((member) => {
const cachedPtr = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/emitter.test.ts b/packages/compiler/src/backend/llvm/emitter.test.ts
index 958df8cbe..8b8c166ca 100644
--- a/packages/compiler/src/backend/llvm/emitter.test.ts
+++ b/packages/compiler/src/backend/llvm/emitter.test.ts
@@ -99,6 +99,26 @@ test("separate LLVM parts preserve complete module bytes in native, WASI, and de
}
});
+test("the shared indexed-read helpers carry an inline hint", () => {
+ const helper = (name: string): IrFunction => ({
+ name,
+ params: [],
+ returnType: F64,
+ locals: [],
+ body: [{ kind: "return", value: { kind: "numLit", value: 1, type: F64, loc }, loc }],
+ loc,
+ });
+ const module = moduleFor([]);
+ const llvm = emitLlvmModule({
+ ...module,
+ functions: [...module.functions, helper("%arr.idxOr.0"), helper("plain")],
+ });
+ const define = (name: string): string =>
+ llvm.split("\n").find((line) => line.startsWith("define") && line.endsWith(`{ ; ${name}`))!;
+ expect(define("%arr.idxOr.0")).toMatch(/\) inlinehint #0 \{/);
+ expect(define("plain")).not.toContain("inlinehint");
+});
+
test("throwing-call count does not multiply identical scope cleanup", () => {
for (const boxed of [false, true]) {
const small = emitLlvmModule(moduleFor(Array.from({ length: 4 }, call), { boxed }));
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index 67b393a5e..9f405c3bb 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -99,6 +99,7 @@ import { scalarizeNumericRecords } from "../../ir/scalar-records.js";
import { specializeNumericCalls } from "../../ir/numeric-call-specialization.js";
import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse.js";
import { analyzeIntegerRanges, INT32_RANGE, type IntegerRanges } from "../../ir/integer-ranges.js";
+import { unshareNodes } from "../../ir/unshare.js";
import { analyzeInt32Slots, type Int32Slots } from "../../ir/int32-slots.js";
import { analyzeFinalFields, constructorClass, type FinalFields } from "../../ir/final-fields.js";
import { foldConstantGlobals } from "../../ir/constant-globals.js";
@@ -159,10 +160,11 @@ import {
type MapReadLifetimes,
} from "./map-read-lifetimes.js";
import { emitBorrowedFieldSequence, guardedValue } from "./borrowed-receivers.js";
-import { emitBorrowedInput } from "./borrowed-inputs.js";
-import { analyzeWalks, findWalkBorrows } from "./walk-borrows.js";
+import { borrowableInputs, emitBorrowedInput } from "./borrowed-inputs.js";
+import { analyzeWalks, findWalkBorrows, type WalkBorrowHost } from "./walk-borrows.js";
import { findReboundParameters } from "./rebound-parameters.js";
import { ReferenceEffects } from "./reference-effects.js";
+import { ReferenceWrites, type Writes } from "./reference-writes.js";
import { LlvmDebugInfo } from "./debug-info.js";
import { StackCallbacks } from "./stack-callbacks.js";
import { findLoopArrayBorrows } from "./loop-array-borrows.js";
@@ -197,6 +199,7 @@ import { emitBorrowedResultCall, emitCallExpr } from "./expr-calls.js";
import { emitDynamicExpr } from "./expr-dynamic.js";
import { emitIntrinsicExpr, emitSerializationExpr, emitAsyncExpr } from "./expr-async.js";
import { PublishWalkers } from "./publish.js";
+import { computeImmutableFields, stableGlobals, type ImmutableFields } from "../immutability.js";
import { computePublishedTypes, type PublishedTypes } from "../publication.js";
import { emitJsInteropExpr, emitExpr } from "./expr-dispatch.js";
import {
@@ -301,7 +304,7 @@ import { LlWalkers } from "./walkers.js";
import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js";
import { emitNullablePresent, emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { literalCapWord, literalCapWord32 } from "./string-key-hash.js";
-import { NUMBER_MAP_ENTRY_DECL } from "./map-number-lookup.js";
+import { NUMBER_MAP_ENTRY_DECL, STRING_MAP_ENTRY_DECL } from "./map-number-lookup.js";
import { STRING_READ_HELPERS } from "./string-reads.js";
import {
emitConcatInputs,
@@ -407,7 +410,10 @@ export interface LlvmTargetOptions {
detachedExit?: boolean;
}
-export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}): string {
+export function emitLlvmModule(input: IrModule, options: LlvmTargetOptions = {}): string {
+ // Facts are keyed by node identity: emit the same module whether it was
+ // lowered in memory or read back from its serialized form.
+ const mod = unshareNodes(input);
// Keep source storage intact for debugger inspection in dev builds.
return new LlEmitter(
options.debugSources === undefined
@@ -419,9 +425,10 @@ export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}):
/** Keep large translation units below the host's single-string limit. */
export function emitLlvmModuleSource(
- mod: IrModule,
+ input: IrModule,
options: LlvmTargetOptions = {},
): string | readonly string[] {
+ const mod = unshareNodes(input);
const parts = new LlEmitter(
options.debugSources === undefined
? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction))
@@ -598,6 +605,12 @@ export class LlEmitter {
* fiberless-async analysis scans it for suspension entry points. */
private readonly fnDefText = new Map();
readonly referenceEffects: ReferenceEffects;
+ /** Field-sensitive reference writes (null in debug builds). */
+ referenceWrites: ReferenceWrites | null = null;
+ private readonly borrowedReturnKeyCache = new Map();
+ /** The statement list being emitted and the current index, for blocks
+ * whose bindings end with the list (emitBlock and function bodies). */
+ private stmtCursor: { stmts: IrStmt[]; index: number } | null = null;
readonly optionalArrayReads: OptionalArrayReads;
readonly checkedNarrows: CheckedNarrows;
callArrayReads = new Map();
@@ -641,6 +654,9 @@ export class LlEmitter {
readonly ffiHasRetainedCallback: boolean;
private readonly ffiHasForeignCallback: boolean;
private readonly globalTypes = new Map();
+ /** Module constants that keep one value once initialized
+ * (immutability.ts stableGlobals). */
+ private stableGlobals: ReadonlySet = new Set();
private readonly tdzGlobals = new Map();
readonly constantNumericTables: ReadonlyMap;
/** May-throw analysis (computeMayThrow): pending
@@ -656,6 +672,13 @@ export class LlEmitter {
/** Static types whose values can be published (@scriptc/threads): their
* stores carry the frozen-object guard. Null without publish calls. */
readonly publishedTypes: PublishedTypes | null;
+ /** Construction-only class and record fields (immutability.ts); null
+ * where the facts are disabled. */
+ private readonly immutableFields: ImmutableFields | null;
+ /** Constructor function names: their bodies may still write their own
+ * `this`'s construction-only fields. */
+ private readonly constructorFunctions: ReadonlySet;
+ private inConstructor = false;
/** Method names with at least one may-throw implementation — the
* virtualCall pending check's key. */
readonly mayThrowMethods = new Set();
@@ -888,6 +911,8 @@ export class LlEmitter {
this.workerEntryPolls = mt.workerEntryPolls ?? null;
this.stackChecks = mt.stackChecks ?? null;
this.publishedTypes = computePublishedTypes(mod);
+ this.immutableFields = computeImmutableFields(mod);
+ this.constructorFunctions = new Set((mod.classes ?? []).map((c) => `%${c.name}.constructor`));
this.indirectMayThrow = mt.indirect || mod.workers === true;
for (const cls of mod.classes ?? []) {
for (const m of cls.methods ?? []) {
@@ -911,6 +936,15 @@ export class LlEmitter {
(call) => this.optionalArrayReads.get(call) !== null,
mod.classes ?? [],
);
+ if (this.debug === null)
+ this.referenceWrites = new ReferenceWrites(
+ this.fnByName,
+ mod.classes ?? [],
+ (call) => this.optionalArrayReads.get(call) !== null,
+ new Set((mod.globals ?? []).map((g) => g.id)),
+ { unions: mod.unions, records: mod.records },
+ this.immutableFields,
+ );
const walkParameters = new Map>();
if (this.debug === null) {
const host = {
@@ -918,8 +952,9 @@ export class LlEmitter {
borrowsWithoutOwning: (e: IrExpr) => this.borrowsWithoutOwning(e),
};
for (const fn of this.fnByName.values()) {
- if (!this.referenceEffects.functions.has(fn.name)) continue;
- const walks = findWalkBorrows(fn, host);
+ const preserving = this.referenceEffects.functions.has(fn.name);
+ if (!preserving && this.referenceWrites === null) continue;
+ const walks = findWalkBorrows(fn, preserving ? host : this.unwrittenWalkHost(fn, host));
if (walks.size === 0) continue;
this.walkBorrowsByFunction.set(fn.name, walks);
const indexes = new Set();
@@ -1027,6 +1062,7 @@ export class LlEmitter {
this.globalTypes.set(g.id, g.type);
if (g.tdz) this.tdzGlobals.set(g.id, g.name);
}
+ this.stableGlobals = stableGlobals(mod);
// Runtime error, EventEmitter, and stream classes have known layouts.
// Subclasses embed the ScrEmitter/ScrStream prefixes from classes.ts.
const classes = mod.classes ?? [];
@@ -1885,12 +1921,14 @@ export class LlEmitter {
`%ScrFfiTable = type { ptr, ${this.sizeType}, ${this.sizeType}, ptr, i8, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ptr }`,
`%ScrRegex = type { ${this.sizeType}, ptr, ptr, ptr, double, ptr, ptr }`,
// ScrArr mirrors scr_runtime.h field-for-field: { rc, len, cap, elem,
- // elem_retain, elem_release, elem_trace, data, present, ext }, where
- // ext points at the out-of-line sparse, property and metadata storage
- // (NULL until first used). Live dynamic stream commits swap its dense,
- // presence and out-of-line storage while preserving the target
- // object's identity.
- `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, ptr, ptr, ptr, ptr, ptr, ptr }`,
+ // cyc, ops, data, present, ext } (field numbers in SCR_ARR_FIELD),
+ // where ops points at the element type's constant RC descriptor
+ // (%ScrArrOps, SCR_ELEM_REF only) and ext at the out-of-line sparse,
+ // property and metadata storage (NULL until first used). Live dynamic
+ // stream commits swap its dense, presence and out-of-line storage while
+ // preserving the target object's identity.
+ `%ScrArr = type { ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr }`,
+ `%ScrArrOps = type { ptr, ptr, ptr }`,
// The ScrMap prefix { rc, key_kind, val_kind, val_retain, val_release,
// val_trace, key_retain, key_release, key_trace } — the inline RC
// fast paths test the two trace slots (shapes.ts).
@@ -1903,9 +1941,13 @@ export class LlEmitter {
...(this.decls.has(NUMBER_MAP_ENTRY_DECL)
? [
`%ScrMapIx = type { ${this.sizeType}, i32, i32, ptr, ptr, ptr, ptr, ptr, ptr, ${this.sizeType}, ${this.sizeType}, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ${this.sizeType}, ptr, ptr, ptr, ${this.sizeType} }`,
- `%ScrMapEntry = type { i64, i64, i64 }`,
]
: []),
+ // String-keyed reference reads load the value slot of the entry
+ // scr_map_entry_str returns (map-number-lookup.ts).
+ ...(this.decls.has(NUMBER_MAP_ENTRY_DECL) || this.decls.has(STRING_MAP_ENTRY_DECL)
+ ? [`%ScrMapEntry = type { i64, i64, i64 }`]
+ : []),
// The runtime error prefix { rc, vt, name, message, code, cause } and the
// class-object shape { rc, pre, post, ctor, name } — field reads on
// builtin errors and classval loads GEP through these.
@@ -2071,7 +2113,7 @@ export class LlEmitter {
out.push(
`@${inst.sym}_data = internal constant [${n} x ptr] [ ${inst.slots.map((s) => `ptr ${s}`).join(", ")} ]`,
`@${inst.sym}_present = internal constant [${n} x i8] ${present}`,
- `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, ptr null, ptr null, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`,
+ `@${inst.sym} = internal global %ScrArr { ${this.sizeType} -1, ${this.sizeType} ${n}, ${this.sizeType} ${n}, i32 2, i32 0, ptr null, ptr @${inst.sym}_data, ptr @${inst.sym}_present, ptr null }`,
);
}
if (this.templateStringsInstances.size > 0) out.push(``);
@@ -5093,6 +5135,7 @@ export class LlEmitter {
const numericFn = withInitializerBindings(fn, initializerBindings);
this.numericLocals = new Map(numericFn.locals.map((l) => [l.id, l]));
this.borrowedParameters.clear();
+ this.referenceWrites?.enter(fn);
this.reboundOwnerSlots.clear();
this.projectedParameters.clear();
const projectedParameterIndexes = this.callLifetimes.parameters.get(fn.name);
@@ -5133,6 +5176,7 @@ export class LlEmitter {
this.callLifetimes,
this.referenceEffects,
this.optionalArrayReads,
+ this.referenceWrites,
)
: new Set();
this.localArrayReads = findLocalArrayReads(
@@ -5173,6 +5217,7 @@ export class LlEmitter {
this.currentReturnType = fn.returnType;
this.currentGenerator = fn.generator ?? null;
this.currentBorrowedReturn = this.borrowedReturns.has(fn.name);
+ this.inConstructor = this.constructorFunctions.has(fn.name);
this.currentWasiCoro = null;
this.logArgSlots = 0;
@@ -5339,7 +5384,7 @@ export class LlEmitter {
}
if (this.stackChecks?.has(fn.name)) this.emitStackCheck();
if (this.workerEntryPolls?.has(fn.name)) this.emitPendingCheck(undefined, true);
- this.emitStmts(fn.body);
+ this.emitStmts(fn.body, true);
// Implicit exit of a void function: release the function scope unless
// the body already terminated its final block (return, or a throw
// whose unwind released everything down to depth 0).
@@ -5403,7 +5448,13 @@ export class LlEmitter {
// Lifted functions receive their closure first (the callValue ABI).
if (fn.captures !== undefined) params.unshift("ptr %sc_env");
const ret = this.llType(fn.returnType);
- const attrs = coro !== null ? "#1" : FN_ATTRS;
+ let attrs = coro !== null ? "#1" : FN_ATTRS;
+ // The shared indexed-read helper (lowerSafeIndexRead) is a dense bounds
+ // test, a state test and a load behind cold runtime fallbacks, called
+ // from every `a[i]` of one element type. Without the hint LLVM keeps the
+ // hot instances out of line once they have many callers: every read pays
+ // a call, and an integer index converts to a double and back across it.
+ if (fn.name.startsWith("%arr.idxOr.")) attrs = `inlinehint ${attrs}`;
const debug = this.debugScope === null ? "" : ` !dbg ${this.debugScope}`;
const symbol = borrowed ? mangleBorrowedFunction(fn.name) : mangleFunction(fn.name);
const body = `define internal ${ret} @${symbol}(${params.join(", ")}) ${attrs}${debug} { ; ${fn.name}\n${B.render()}\n}`;
@@ -5508,11 +5559,17 @@ export class LlEmitter {
this.canBorrowCallArgument(value.then) &&
this.canBorrowCallArgument(value.else_)
);
+ // A construction-only field of a stable owner keeps its value until the
+ // owner dies: no later operand or callee can replace and release it.
+ if ((value.kind === "fieldGet" || value.kind === "recordGet") && this.immutableRead(value))
+ return this.canBorrowCallArgument(value.obj);
// An optional chain's bound receiver stays unchanged until its body
// finishes (see emitReadReceiver).
if (value.kind === "chainRecv") return this.chainSlots.has(value.id);
if (value.kind !== "varRef") return false;
const binding = this.binding(value.localId);
+ // A module constant owns its value for the rest of the program.
+ if (binding.kind === "global") return this.stableGlobals.has(value.localId);
return (
binding.kind === "local" &&
binding.local !== undefined &&
@@ -5525,15 +5582,213 @@ export class LlEmitter {
);
}
+ /** A plain-pointer read of a construction-only field outside any
+ * constructor (ImmutableFields). */
+ private immutableRead(e: ExprOf<"fieldGet" | "recordGet">): boolean {
+ if (this.inConstructor || this.immutableFields === null) return false;
+ const t = e.type.kind;
+ if (t !== "object" && t !== "record" && t !== "array" && t !== "string") return false;
+ return e.kind === "fieldGet"
+ ? this.immutableFields.classField(e.className, e.field)
+ : this.immutableFields.recordField(e.shapeId, e.field);
+ }
+
+ /** A projection read whose locations nothing writes while a borrowed
+ * read of it is in use (ReferenceWrites), given the writes of that
+ * extent. The root binding itself must stay alive across it too, which
+ * its own location (`l:`, `g:`, `bx`) covers. */
+ unwrittenRead(e: IrExpr, writes: Writes): boolean {
+ const rw = this.referenceWrites;
+ if (rw === null) return false;
+ const keys = rw.readKeys(e, (call) => this.borrowedReturnKeys(call.callee));
+ return keys !== null && !rw.conflicts(keys, writes);
+ }
+
+ /** A stable binding initialized by a projection can borrow it when the
+ * rest of its block (its whole lifetime) writes none of the projection's
+ * locations: the source edge then keeps the value alive, and uses that
+ * escape still retain their own copies. */
+ private unwrittenAlias(s: IrStmt & { kind: "varDecl" }): boolean {
+ const cursor = this.stmtCursor;
+ const rw = this.referenceWrites;
+ if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s)
+ return false;
+ if (
+ !this.plainReference(s.init.type) ||
+ !this.canBorrowReceiver(s.init) ||
+ !this.unownedWalk(s.init)
+ )
+ return false;
+ const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]);
+ return this.unwrittenRead(s.init, writes);
+ }
+
+ /** An optional element read into a projection-only local can borrow the
+ * element when the rest of the block writes no array element and none of
+ * the locations keeping the array alive (which must itself be read
+ * without a frame owner). */
+ private unwrittenLocalArrayRead(s: IrStmt & { kind: "varDecl" }, read: LocalArrayRead): boolean {
+ const cursor = this.stmtCursor;
+ const rw = this.referenceWrites;
+ if (rw === null || s.init === null || cursor === null || cursor.stmts[cursor.index] !== s)
+ return false;
+ if (!this.unownedWalk(read.array)) return false;
+ const keys = rw.readKeys(read.array);
+ if (keys === null || rw.expr(read.index) !== null) return false;
+ const writes = rw.union([rw.expr(s.init), rw.stmts(cursor.stmts.slice(cursor.index + 1))]);
+ return !rw.conflicts(["a", ...keys], writes);
+ }
+
+ /** An optional element read passed to a projection-only parameter can
+ * borrow the element when the callee and later arguments (`writes`)
+ * write no array element and nothing keeping the array alive. */
+ unwrittenCallArrayRead(read: LocalArrayRead, writes: Writes): boolean {
+ const rw = this.referenceWrites;
+ if (rw === null) return false;
+ const keys = rw.readKeys(read.array);
+ if (keys === null || rw.expr(read.index) !== null) return false;
+ return !rw.conflicts(["a", ...keys], writes);
+ }
+
+ /** `return x` of a local whose slot is an owned scope entry of the same
+ * type: the return can take over the local's reference. */
+ private movableReturn(
+ value: IrExpr | null,
+ ): { scope: LlScopeEntry[]; entry: LlScopeEntry; index: number } | null {
+ if (value === null || value.kind !== "varRef" || !isRefCounted(value.type)) return null;
+ if (this.currentBorrowedReturn || this.currentWasiCoro !== null) return null;
+ const binding = this.binding(value.localId);
+ if (binding.kind !== "local" || binding.local === undefined || binding.local.boxed) return null;
+ if (!typeEquals(binding.type, value.type) || !typeEquals(this.currentReturnType, value.type))
+ return null;
+ for (const scope of this.scopes) {
+ const index = scope.findIndex((entry) => entry.slot === binding.slot);
+ if (index < 0) continue;
+ const entry = scope[index]!;
+ if (entry.boxed || entry.lazy || !typeEquals(entry.type, value.type)) return null;
+ return { scope, entry, index };
+ }
+ return null;
+ }
+
+ /** A walk host for a body that may write the heap: each walk must read
+ * only locations the whole body (with its callees) never writes;
+ * rebinding the walk locals themselves releases nothing. */
+ private unwrittenWalkHost(fn: IrFunction, host: WalkBorrowHost): WalkBorrowHost {
+ return {
+ ...host,
+ unwritten: (value, candidates) =>
+ this.referenceWrites?.unwrittenWalk(fn, value, candidates, (call) =>
+ this.borrowedReturnKeys(call.callee),
+ ) ?? false,
+ };
+ }
+
+ /** emitReadReceiver yields a plain pointer for `e` that no statement
+ * frame owns, so it can outlive the statement (walk-borrows' walks, from
+ * any unboxed plain-pointer binding, plus element reads). */
+ private unownedWalk(e: IrExpr): boolean {
+ switch (e.kind) {
+ case "varRef": {
+ const binding = this.binding(e.localId);
+ if (binding.kind === "global") return this.plainReference(binding.type);
+ return (
+ binding.kind === "local" &&
+ binding.local !== undefined &&
+ !binding.local.boxed &&
+ !binding.local.tdz &&
+ this.plainReference(binding.local.type) &&
+ !this.stringSlices.has(e.localId) &&
+ !this.storedSplits.has(e.localId) &&
+ !this.localUnionStorage.has(e.localId)
+ );
+ }
+ case "fieldGet":
+ case "recordGet":
+ return this.borrowsWithoutOwning(e) && this.unownedWalk(e.obj);
+ case "unionNarrow":
+ case "downcast":
+ case "upcast":
+ return this.borrowsWithoutOwning(e) && this.unownedWalk(e.value);
+ case "unionWrap":
+ if (!this.borrowsWithoutOwning(e)) return false;
+ return e.value.kind === "unitLit" || this.unownedWalk(e.value);
+ case "ternary": {
+ const arm = (v: IrExpr): boolean =>
+ (v.kind === "libCall" && v.fn === "error.nodeThrow") || this.unownedWalk(v);
+ return this.borrowsWithoutOwning(e) && arm(e.then) && arm(e.else_);
+ }
+ case "call":
+ return (
+ this.borrowsWithoutOwning(e) &&
+ e.args.every((arg) => !isRefCounted(arg.type) || this.unownedWalk(arg))
+ );
+ case "arrayGet":
+ return (
+ !this.isStackUnionSource(e) &&
+ this.optionalArrayReads.get(e) === null &&
+ this.unownedWalk(e.arr)
+ );
+ case "seqExpr": {
+ const guarded = guardedValue(e);
+ return guarded !== null && this.unownedWalk(guarded);
+ }
+ default:
+ return false;
+ }
+ }
+
+ /** Every location a borrowed-return callee's walks can read (its body's
+ * field, record and element reads, through nested borrowed returns). */
+ private borrowedReturnKeys(callee: string): string[] | null {
+ if (!this.borrowedReturns.has(callee)) return null;
+ const known = this.borrowedReturnKeyCache.get(callee);
+ if (known) return known;
+ const rw = this.referenceWrites!;
+ const keys = new Set();
+ const seen = new Set([callee]);
+ const pending = [callee];
+ while (pending.length > 0) {
+ const fn = this.fnByName.get(pending.pop()!);
+ if (!fn) continue;
+ everyStmtList(fn.body, {
+ stmt: () => true,
+ expr: (e) => {
+ if (e.kind === "fieldGet") keys.add(rw.fieldKey(e.className, e.field));
+ else if (e.kind === "recordGet") {
+ keys.add(`r:${e.field}`);
+ keys.add("r:*");
+ } else if (e.kind === "arrayGet") keys.add("a");
+ else if (e.kind === "call" && this.borrowedReturns.has(e.callee) && !seen.has(e.callee)) {
+ seen.add(e.callee);
+ pending.push(e.callee);
+ }
+ return true;
+ },
+ });
+ }
+ const result = [...keys];
+ this.borrowedReturnKeyCache.set(callee, result);
+ return result;
+ }
+
// ── statements ──────────────────────────────────────────────────────────
- private emitStmts(stmts: IrStmt[]): void {
- for (const s of stmts) {
- // Statements after a terminator are unreachable (dead code after
- // return/break/continue). Skip them so no dropped SSA definition
- // can leak forward.
- if (this.B.isTerminated()) return;
- this.emitStmt(s);
+ private emitStmts(stmts: IrStmt[], scoped = false): void {
+ const saved = this.stmtCursor;
+ const cursor = scoped ? { stmts, index: 0 } : null;
+ try {
+ for (let i = 0; i < stmts.length; i++) {
+ // Statements after a terminator are unreachable (dead code after
+ // return/break/continue). Skip them so no dropped SSA definition
+ // can leak forward.
+ if (this.B.isTerminated()) return;
+ if (cursor) cursor.index = i;
+ this.stmtCursor = cursor;
+ this.emitStmt(stmts[i]!);
+ }
+ } finally {
+ this.stmtCursor = saved;
}
}
@@ -5546,7 +5801,7 @@ export class LlEmitter {
const scope: LlScopeEntry[] = [];
this.scopes.push(scope);
setup?.(scope);
- this.emitStmts(stmts);
+ this.emitStmts(stmts, true);
const ended = endsWithJump(stmts);
this.scopes.pop();
if (!ended) this.releaseScope(scope);
@@ -5610,7 +5865,13 @@ export class LlEmitter {
}
const localRead = this.localArrayReads.get(s.localId);
if (localRead) {
- const owner = emitLocalArrayRead(this, localRead, b.slot);
+ const owner = emitLocalArrayRead(
+ this,
+ !localRead.borrow && this.unwrittenLocalArrayRead(s, localRead)
+ ? { ...localRead, borrow: true }
+ : localRead,
+ b.slot,
+ );
if (owner) this.scopes[this.scopes.length - 1]!.push(owner);
break;
}
@@ -5699,7 +5960,7 @@ export class LlEmitter {
b.local &&
isRefCounted(b.type) &&
this.stableCallBindings.has(s.localId) &&
- this.canBorrowCallArgument(s.init)
+ (this.canBorrowCallArgument(s.init) || this.unwrittenAlias(s))
) {
const v = this.emitReadReceiver(s.init);
B.line(`store ptr ${v.name}, ptr ${b.slot}`);
@@ -5770,8 +6031,27 @@ export class LlEmitter {
// fresh, publishable and never written): its graph becomes
// immortal, so reads skip reference counting and the collector
// never visits it.
- for (const line of this.publishWalkers.emitPublish(v.name, v.type, s.loc))
- B.line(line.trimStart());
+ const lines = this.publishWalkers.emitPublish(v.name, v.type, s.loc);
+ for (const line of lines) B.line(line.trimStart());
+ // Reads of this global skip retains from here on, which is only
+ // sound once the graph really is immortal. Node.js never fails
+ // here, so a refusal means the contract proof was wrong: stop
+ // rather than keep running with a mortal graph read as immortal.
+ if (lines.length > 0) {
+ this.declare(`declare zeroext i1 @scr_exc_pending()`);
+ this.declare(`declare void @scr_trap(ptr)`);
+ const refused = B.tmp();
+ B.line(`${refused} = call zeroext i1 @scr_exc_pending()`);
+ const stop = B.newLabel("immutable.refused");
+ const done = B.newLabel("immutable.published");
+ B.condBr(refused, stop, done);
+ B.startBlock(stop);
+ B.line(
+ `call void @scr_trap(ptr ${this.cstr("scriptc: internal error: an Immutable value could not be published\n")})`,
+ );
+ B.terminate("unreachable");
+ B.startBlock(done);
+ }
}
break;
}
@@ -5889,21 +6169,21 @@ export class LlEmitter {
const obj = this.emitStableReceiver(s.obj, [s.value]);
// (The guard precedes the value here: the nullable store
// evaluates it itself.)
- this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ this.emitStoreGuard(obj.name, s);
this.emitNullableFieldStore(obj.name, s.className, s.field, nullable, s.value);
break;
}
if (s.kind === "fieldSet" && this.int32Slots.isField(s.className, s.field)) {
const obj = this.emitStableReceiver(s.obj, [s.value]);
const v = this.emitExpr(s.value);
- this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ this.emitStoreGuard(obj.name, s);
const { ptr } = this.classFieldPtr(obj.name, s.className, s.field);
this.storeInt32Field(ptr, v, s.value);
break;
}
const obj = this.emitStableReceiver(s.obj, [s.value]);
const v = this.emitExpr(s.value);
- if (s.kind === "fieldSet") this.emitPublishedFieldGuard(obj.name, s.className, s.field);
+ if (s.kind === "fieldSet") this.emitStoreGuard(obj.name, s);
else if (this.publishedTypes?.recordGuarded(s.shapeId) === true)
this.emitPublishedFieldGuard(obj.name, null, s.field);
const { ptr, type } =
@@ -6608,10 +6888,29 @@ export class LlEmitter {
// the actual ret. The parked value owns a synthetic slot-backed
// scope entry during each copy so a throwing finally releases it.
let v: LlValue | null = null;
- if (s.value !== null && this.currentBorrowedReturn) {
+ const moved = this.finallyStack.length === 0 ? this.movableReturn(s.value) : null;
+ if (moved !== null) {
+ // Returning an owned local hands its reference to the caller: no
+ // retain now and no release of the local below.
+ const t = B.tmp();
+ B.line(`${t} = load ptr, ptr ${moved.entry.slot}`);
+ v = { name: t, type: s.value!.type };
+ moved.scope.splice(moved.scope.indexOf(moved.entry), 1);
+ this.releaseForJump(0, 0);
+ moved.scope.splice(moved.index, 0, moved.entry);
+ } else if (s.value !== null && this.currentBorrowedReturn) {
// A borrowed-return body hands back its walk without a reference
- // (it has no try statements, so no finally can intervene).
- v = this.emitReadReceiver(s.value);
+ // (it has no try statements, so no finally can intervene). An
+ // optional element read lends the element its array keeps alive.
+ const read = this.optionalArrayReads.get(s.value);
+ if (read) {
+ const slot = B.slot();
+ B.entryAllocas.push(`${slot} = alloca ptr`);
+ emitLocalArrayRead(this, { ...read, borrow: true }, slot);
+ const t = B.tmp();
+ B.line(`${t} = load ptr, ptr ${slot}`);
+ v = { name: t, type: s.value.type };
+ } else v = this.emitReadReceiver(s.value);
} else if (s.value !== null) {
v = this.emitExpr(s.value);
this.moveTemp(v);
@@ -6643,7 +6942,7 @@ export class LlEmitter {
this.scopes = savedScopes;
this.finallyStack = savedFinally;
this.tryStack = savedTry;
- } else {
+ } else if (moved === null) {
this.releaseForJump(0, 0);
}
if (!B.isTerminated()) {
@@ -6898,7 +7197,7 @@ export class LlEmitter {
const caseLabels = s.cases.map(() => B.newLabel("sw.c"));
const defaultIdx = s.cases.findIndex((c) => c.test === null);
const fallback = defaultIdx >= 0 ? caseLabels[defaultIdx]! : end;
- if (!emitLiteralSwitch(this, disc, s.cases, caseLabels, fallback)) {
+ if (!emitLiteralSwitch(this, disc, s.disc, s.cases, caseLabels, fallback)) {
s.cases.forEach((c, i) => {
if (c.test === null) return;
// Lazy source-order test evaluation (a test after the match never
@@ -7354,6 +7653,13 @@ export class LlEmitter {
];
}
+ /** The frozen-object guard of a class field store, unless the store
+ * provably targets a still-private `this` (PublishedTypes.privateStores). */
+ private emitStoreGuard(obj: string, s: Extract): void {
+ if (this.publishedTypes?.privateStores.has(s) === true) return;
+ this.emitPublishedFieldGuard(obj, s.className, s.field);
+ }
+
/** The frozen-object guard of a class field or record field store:
* Node's "Cannot assign to read only property" TypeError when the target
* is published. `className` null means a record (`#