Merge remote-tracking branch 'origin/main' into HEAD

# Conflicts:
#	packages/compiler/test/ts7/baselines/order-parity.json
This commit is contained in:
Malte Ubl
2026-10-11 10:16:59 -07:00
38 changed files with 2048 additions and 155 deletions
@@ -9,6 +9,45 @@ import type {
} from "../../ir/ir.js";
import type { LlvmEmitterContext } from "./expr-context.js";
/** 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
* only when it holds.
*
* A signed round trip replaces the unsigned one (`fptoui`/`uitofp` expand
* to long sequences on x86-64), and an integer compare replaces two float
* compares against the converted length. `freeze` makes an out-of-range or
* NaN conversion an arbitrary integer instead of poison; such an integer
* never converts back to the index, or else is not below the length. Below
* the length (far under 2^53) the round trip is exact, so equality means the
* index is that integer. -0 converts to 0, as ToPropertyKey(-0) is "0". */
export function exactIndexLines(
index: string,
length: string,
sizeType: string,
tmp: () => string,
): { lines: string[]; ok: string; wide: string } {
const raw = tmp(),
wide = tmp(),
back = tmp(),
integral = tmp(),
below = tmp(),
ok = tmp();
const lines = [
`${raw} = fptosi double ${index} to i64`,
`${wide} = freeze i64 ${raw}`,
`${back} = sitofp i64 ${wide} to double`,
`${integral} = fcmp oeq double ${back}, ${index}`,
];
let len64 = length;
if (sizeType !== "i64") {
len64 = tmp();
lines.push(`${len64} = zext ${sizeType} ${length} to i64`);
}
lines.push(`${below} = icmp ult i64 ${wide}, ${len64}`, `${ok} = and i1 ${integral}, ${below}`);
return { lines, ok, wide };
}
/** User-controlled text embedded after an LLVM `;` comment marker. Preserve
* ordinary output byte-for-byte, but encode control and line-separator code
* units so a property name can never inject a line or invalid source byte. */
+19 -1
View File
@@ -1,4 +1,5 @@
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
import { BYTES_ELEM_NUM, f64Lit } from "./common.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import { InternalCompilerError } from "../../errors.js";
@@ -41,6 +42,7 @@ import {
} from "./shapes.js";
import { LlvmUnsupportedError } from "./unsupported.js";
import type { WalkerHost } from "./walkers.js";
import type { LlStreamTypedRefAdapter } from "./expr-context.js";
/** ScrDynKind values (scr_runtime.h). */
/** ScrDynHandleTag numeric values (scr_runtime.h's enum order). */
@@ -83,7 +85,9 @@ export interface DynHost extends WalkerHost {
virtualEntry?(implFn: string): string;
/** The immortal ABSENT field-slot state of an undefined-armed union. */
absentInstanceRef(unionId: string): string;
liveDynRefAdapter(t: IrType): { snapshot: string; commit: string };
/** The shared adapter record: live array capsules read its `arrayOps`, so a
* narrower return type would hand the native compiler a copy without it. */
liveDynRefAdapter(t: IrType): LlStreamTypedRefAdapter;
liveDynUnionRefAdapter(t: IrType & { kind: "union" }): string;
dynPromiseAdapter(t: IrType): string;
isErrorClass(className: string): boolean;
@@ -212,6 +216,7 @@ export class LlDyn {
B.line(
`%result = call ptr ${typedRefConstructor(this.host, type)}(ptr %value, ptr ${rc.retain}, ptr ${rc.release}, ptr ${this.host.cstr(typeId)}, ${this.S} ${Buffer.byteLength(typeId, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
for (const line of bindLiveArrayOps(this.host, adapter, "%result")) B.line(line);
} else {
B.line(`%result = call ptr @${this.toDynHelper(type)}(${ty} %value)`);
}
@@ -3179,6 +3184,19 @@ export class LlDyn {
const lNext = B.newLabel("kg.n");
B.condBr(isTyped, lTyped, lNext);
B.startBlock(lTyped);
// Live arrays answer `length` and own elements from the native array;
// rebuilding the snapshot per read would make index loops quadratic.
host.declare(`declare ptr @scr_dyn_typed_ref_key_get(ptr, ptr)`);
const fast = B.tmp();
const hit = B.tmp();
B.line(`${fast} = call ptr @scr_dyn_typed_ref_key_get(ptr %d, ptr %k)`);
B.line(`${hit} = icmp ne ptr ${fast}, null`);
const lFast = B.newLabel("kg.tr.fast");
const lView = B.newLabel("kg.tr.view");
B.condBr(hit, lFast, lView);
B.startBlock(lFast);
B.terminate(`ret ptr ${fast}`);
B.startBlock(lView);
host.declare(`declare ptr @scr_dyn_typed_ref_materialize(ptr)`);
host.declare(`declare void @scr_dyn_release_v(ptr)`);
const materialized = B.tmp();
+93 -30
View File
@@ -99,6 +99,8 @@ 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 { 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";
import { findIntegerViews } from "./integer-views.js";
import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js";
import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js";
@@ -174,6 +176,7 @@ import { StackCaptures } from "./stack-captures.js";
import {
ABSENT_FIELD_PAYLOAD,
emitFieldAbsentTest,
exactIndexLines,
f64Lit,
ffiNativeTypeLl,
ffiNativeParamLl,
@@ -403,7 +406,7 @@ export function emitLlvmModule(mod: IrModule, options: LlvmTargetOptions = {}):
// Keep source storage intact for debugger inspection in dev builds.
return new LlEmitter(
options.debugSources === undefined
? scalarizeNumericRecords(specializeNumericCalls(mod, mangleFunction))
? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction))
: mod,
options,
).emit();
@@ -416,7 +419,7 @@ export function emitLlvmModuleSource(
): string | readonly string[] {
const parts = new LlEmitter(
options.debugSources === undefined
? scalarizeNumericRecords(specializeNumericCalls(mod, mangleFunction))
? scalarizeNumericRecords(specializeNumericCalls(foldConstantGlobals(mod), mangleFunction))
: mod,
options,
).emitParts();
@@ -733,6 +736,11 @@ export class LlEmitter {
{ slot: string; type: "i32" | "i64"; signed?: boolean; range?: { min: number; max: number } }
>();
private countedLoopsEnabled = false;
/** Whole-program final reference fields (ir/final-fields.ts). */
private finalFields!: FinalFields;
/** The current function may borrow final fields of borrowable objects:
* not a constructor (where they change), not async or a generator. */
private finalFieldBorrows = false;
integerRanges: IntegerRanges = new Map();
private byteWindowsEnabled = true;
private byteWindowEntries: ReturnType<typeof findInitializedByteLoopBindings> = new Map();
@@ -807,6 +815,7 @@ export class LlEmitter {
this.constantNumericTables = findConstantNumericTables(mod);
this.initializerBindings = findInitializerBindings(mod);
this.int32Slots = analyzeInt32Slots(mod);
this.finalFields = analyzeFinalFields(mod);
this.sizeType = options.pointerBits === 32 ? "i32" : "i64";
this.ffiExtendNarrowIntegers =
options.wasi === true || ffiExtendsNarrowIntegers(options.targetTriple);
@@ -1918,25 +1927,18 @@ export class LlEmitter {
const flushedDecls = new Set(this.decls);
for (const table of this.constantNumericTables.values()) {
const n = table.values.length;
let tableTmp = 0;
const tableIndex = exactIndexLines("%i", String(n), "i64", () => `%ix${tableTmp++}`);
out.push(
`@${table.symbol} = private constant [${n} x double] [${table.values.map((v) => `double ${f64Lit(v)}`).join(", ")}]`,
`define internal double @${table.symbol}_get(ptr %a, double %i) alwaysinline #0 {`,
`entry:`,
` %initialized = icmp ne ptr %a, null`,
` %nonnegative = fcmp oge double %i, ${f64Lit(0)}`,
` %below = fcmp olt double %i, ${f64Lit(n)}`,
` %range = and i1 %nonnegative, %below`,
` %safe = and i1 %initialized, %range`,
` br i1 %safe, label %convert, label %fallback`,
`convert:`,
// 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 = sitofp ${this.sizeType} %index to double`,
` %integer = fcmp oeq double %roundtrip, %i`,
` br i1 %integer, label %read, label %fallback`,
...tableIndex.lines.map((line) => ` ${line}`),
` %safe = and i1 %initialized, ${tableIndex.ok}`,
` br i1 %safe, label %read, label %fallback`,
`read:`,
` %slot = getelementptr inbounds [${n} x double], ptr @${table.symbol}, ${this.sizeType} 0, ${this.sizeType} %index`,
` %slot = getelementptr inbounds [${n} x double], ptr @${table.symbol}, i64 0, i64 ${tableIndex.wide}`,
` %value = load double, ptr %slot`,
` ret double %value`,
`fallback:`,
@@ -2761,22 +2763,20 @@ export class LlEmitter {
// keeps the complete SameValueZero semantics.
if (this.decls.has(NUMBER_MAP_ENTRY_DECL)) {
const sz = this.sizeType;
let mapTmp = 0;
const mapIndex = exactIndexLines("%k", "%nd", sz, () => `%ix${mapTmp++}`);
defs.push(
`define internal ptr @sc_map_entry_f64(ptr %m, double %k) ${FN_ATTRS} {`,
`entry:`,
` %ndp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 19`,
` %nd = load ${sz}, ptr %ndp`,
` %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 = sitofp ${sz} %i to double`,
` %int = fcmp oeq double %back, %k`,
` br i1 %int, label %slot, label %miss`,
// A non-integral key inside the table is as absent as one outside it.
...mapIndex.lines.map((line) => ` ${line}`),
` br i1 ${mapIndex.ok}, label %slot, label %outside`,
`slot:`,
sz === "i64"
? ` %i = add i64 ${mapIndex.wide}, 0`
: ` %i = trunc i64 ${mapIndex.wide} to ${sz}`,
` %dp = getelementptr inbounds %ScrMapIx, ptr %m, i32 0, i32 18`,
` %dense = load ptr, ptr %dp`,
` %sp = getelementptr inbounds i32, ptr %dense, ${sz} %i`,
@@ -4957,6 +4957,8 @@ export class LlEmitter {
);
this.integerLoopBindings.clear();
this.countedLoopsEnabled = this.debug === null && !fn.async && !fn.generator;
this.finalFieldBorrows =
!fn.async && !fn.generator && constructorClass(fn.name) === null && this.finalFields.size > 0;
this.byteWindowsEnabled = this.countedLoopsEnabled;
this.byteWindowEntries =
this.byteWindowsEnabled && numericFn.locals.some((l) => l.type.kind === "bytes")
@@ -5326,6 +5328,17 @@ export class LlEmitter {
// stable owner. The throwing arm's typed dummy owns nothing.
if (value.kind === "unionNarrow") return this.canBorrowCallArgument(value.value);
if (value.kind === "libCall") return value.fn === "error.nodeThrow";
// A final field never changes outside constructors: while its object is
// borrowable, so is the field's value. Nullable-pointer fields have no
// box to lend and keep their owning path.
if (
value.kind === "fieldGet" &&
this.finalFieldBorrows &&
isRefCounted(value.type) &&
this.finalFields.isFinal(value.className, value.field) &&
!this.nullableFieldGet(value)
)
return this.canBorrowReceiver(value.obj) && this.canBorrowCallArgument(value.obj);
if (value.kind === "ternary")
return (
this.canBorrowReceiver(value.then) &&
@@ -5333,6 +5346,9 @@ export class LlEmitter {
this.canBorrowCallArgument(value.then) &&
this.canBorrowCallArgument(value.else_)
);
// 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);
return (
@@ -6045,14 +6061,49 @@ export class LlEmitter {
}
let countedJoin: string | null = null;
if (countedLoop?.guarded) {
const bound = this.emitExpr(countedLoop.limit);
const safe = B.tmp();
let safe = "true";
if (countedLoop.limitGuarded) {
const bound = this.emitExpr(countedLoop.limit);
safe = B.tmp();
B.line(
`${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`,
);
}
if (countedLoop.startGuarded) {
// An exact integer of at most 2^53 - 1 in magnitude, and not -0
// (the bit patterns of the value and its round trip agree).
// NaN and infinities fail the ordered range tests.
const start = this.emitExpr(countedLoop.start);
const low = B.tmp(),
high = B.tmp(),
raw = B.tmp(),
wide = B.tmp(),
back = B.tmp(),
bits = B.tmp(),
backBits = B.tmp(),
exact = B.tmp(),
inRange = B.tmp(),
ok = B.tmp();
B.line(`${low} = fcmp oge double ${start.name}, ${f64Lit(-Number.MAX_SAFE_INTEGER)}`);
B.line(`${high} = fcmp ole double ${start.name}, ${f64Lit(Number.MAX_SAFE_INTEGER)}`);
B.line(`${inRange} = and i1 ${low}, ${high}`);
B.line(`${raw} = fptosi double ${start.name} to i64`);
B.line(`${wide} = freeze i64 ${raw}`);
B.line(`${back} = sitofp i64 ${wide} to double`);
B.line(`${bits} = bitcast double ${start.name} to i64`);
B.line(`${backBits} = bitcast double ${back} to i64`);
B.line(`${exact} = icmp eq i64 ${bits}, ${backBits}`);
B.line(`${ok} = and i1 ${inRange}, ${exact}`);
if (safe === "true") safe = ok;
else {
const both = B.tmp();
B.line(`${both} = and i1 ${safe}, ${ok}`);
safe = both;
}
}
const fast = B.newLabel("counted.fast");
const slow = B.newLabel("counted.slow");
countedJoin = B.newLabel("counted.done");
B.line(
`${safe} = fcmp ${countedLoop.step > 0 ? "ole" : "oge"} double ${bound.name}, ${f64Lit(countedLoop.guardLimit)}`,
);
B.condBr(safe, fast, slow);
B.startBlock(slow);
this.countedLoopsEnabled = false;
@@ -7254,6 +7305,16 @@ export class LlEmitter {
this.B.line(`${value} = load ptr, ptr ${binding.slot}`);
return { name: value, type: e.type };
}
if (e.kind === "chainRecv") {
// The chain's bind slot holds its receiver unchanged until the body
// finishes, owned by the slot itself or by the chain's receiver.
const bound = this.chainSlots.get(e.id);
if (!bound)
throw new InternalCompilerError(`llvm emitter bug: chainRecv "${e.id}" outside its chain`);
const value = this.B.tmp();
this.B.line(`${value} = load ${this.llType(bound.type)}, ptr ${bound.name}`);
return { name: value, type: e.type };
}
if (e.kind === "arrayGet" && this.canBorrowReceiver(e)) {
// The array keeps the element alive until the consumer; a missing
// element still traps exactly like the owned read.
@@ -7506,6 +7567,8 @@ export class LlEmitter {
canBorrowReceiver(e: IrExpr): boolean {
switch (e.kind) {
case "chainRecv":
return this.chainSlots.has(e.id);
case "varRef": {
const binding = this.binding(e.localId);
return (
@@ -3,7 +3,7 @@ import { InternalCompilerError } from "../../errors.js";
import { emitBorrowedInput } from "./borrowed-inputs.js";
import { BYTES_ELEMENT_SIZE, F64, type IrBytesElem, type IrExpr } from "../../ir/ir.js";
import type { LlvmEmitterContext, LlValue } from "./expr-context.js";
import { F64_INF, f64Lit } from "./common.js";
import { F64_INF, exactIndexLines, f64Lit } from "./common.js";
import { exactInteger, widenInteger } from "./integer-values.js";
import { byteNumberAccess } from "../../ir/byte-numbers.js";
import { emitByteNumber } from "./byte-numbers.js";
@@ -118,28 +118,12 @@ export function emitBytesIndex(
B.line(`${narrow} = trunc i64 ${wideIndex} to ${host.sizeType}`);
return narrow;
}
const lenF64 = B.tmp();
const nonnegative = B.tmp();
const belowLen = B.tmp();
const inRange = B.tmp();
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}`);
const rangeOk = B.newLabel("bytes.index.range");
const exact = exactIndexLines(index, len, host.sizeType, () => B.tmp());
for (const line of exact.lines) B.line(line);
const invalid = B.newLabel("bytes.index.invalid");
const valid = B.newLabel("bytes.index.valid");
B.condBr(inRange, rangeOk, invalid);
B.startBlock(rangeOk);
const idx = B.tmp();
const roundTrip = B.tmp();
const integral = B.tmp();
B.line(`${idx} = fptoui double ${index} to ${host.sizeType}`);
B.line(`${roundTrip} = sitofp ${host.sizeType} ${idx} to double`);
B.line(`${integral} = fcmp oeq double ${roundTrip}, ${index}`);
B.condBr(integral, valid, invalid);
B.condBr(exact.ok, valid, invalid);
let idx = exact.wide;
B.startBlock(invalid);
if (skipInvalid) B.br(skipInvalid);
@@ -151,6 +135,10 @@ export function emitBytesIndex(
}
B.startBlock(valid);
if (host.sizeType !== "i64") {
idx = B.tmp();
B.line(`${idx} = trunc i64 ${exact.wide} to ${host.sizeType}`);
}
return idx;
}
@@ -197,6 +185,20 @@ export function emitBytesGet(
const local = emitSharedBytesSplit(host, receiver);
const result = B.tmp();
B.line(`${result} = call double @${fn}(ptr ${receiver}, double ${index.name})`);
// Integer elements read as integers in int32/uint32 range, or NaN for a
// missing one: ToUint32 is the integer, or 0. Keeping it lets the join
// carry the local path's integer hint.
let sharedU32: string | undefined;
if (elem !== "f32" && elem !== "f64") {
const missing = B.tmp(),
present = B.tmp(),
wide = B.tmp();
sharedU32 = B.tmp();
B.line(`${missing} = fcmp uno double ${result}, ${result}`);
B.line(`${present} = select i1 ${missing}, double 0.0, double ${result}`);
B.line(`${wide} = fptosi double ${present} to i64`);
B.line(`${sharedU32} = trunc i64 ${wide} to i32`);
}
const shared = B.newLabel("bytes.shared.done");
B.br(shared);
B.startBlock(local);
@@ -211,7 +213,10 @@ export function emitBytesGet(
B.startBlock(join);
const out = B.tmp();
B.line(`${out} = phi double [ ${own.name}, %${owned} ], [ ${result}, %${shared} ]`);
return { name: out, type: F64 };
if (own.uint32 === undefined || sharedU32 === undefined) return { name: out, type: F64 };
const u32 = B.tmp();
B.line(`${u32} = phi i32 [ ${own.uint32}, %${owned} ], [ ${sharedU32}, %${shared} ]`);
return { name: out, type: F64, uint32: u32 };
}
return emitBytesGetLocal(host, elem, receiver, index, expr, inBounds, invalidNaN);
}
@@ -265,9 +270,10 @@ function emitBytesGetLocal(
B.startBlock(done);
const out = B.tmp();
B.line(`${out} = phi double [ ${loaded.name}, %${ok} ], [ ${f64Lit(NaN)}, %${invalid} ]`);
// NaN has no integer value: the integer hint survives only when the read
// is proven valid (the invalid block is then unreachable).
if (loaded.uint32 === undefined || !inBounds) return { name: out, type: F64 };
// The hint is ToUint32 of the value, and ToUint32(NaN) is 0, so it holds
// for an invalid index too. Integer ranges (which would claim more) are
// never derived for NaN-coded reads.
if (loaded.uint32 === undefined) return { name: out, type: F64 };
const u32 = B.tmp();
B.line(`${u32} = phi i32 [ ${loaded.uint32}, %${ok} ], [ 0, %${invalid} ]`);
return { name: out, type: F64, uint32: u32 };
@@ -28,4 +28,6 @@ export type LibCallPrefix = LibCallPrefixOf<IrLibFn>;
export interface LlStreamTypedRefAdapter {
snapshot: string;
commit: string;
/** `@sym` of the ScrDynTypedArrayOps table for live array capsules. */
arrayOps?: string;
}
@@ -282,21 +282,51 @@ export function emitControlExpr(
throw new InternalCompilerError("llvm emitter bug: optChain union arms");
const multiple = def.arms.length - unitTags.length > 1;
const narrowed = multiple ? e.receiver.type : def.arms[narrowIdx]!;
// `owner.field?.member`: a receiver projected from a borrowable owner
// needs no reference of its own while the body cannot remove an edge
// (so nothing can overwrite the projection before the body finishes).
const borrowReceiver =
!multiple &&
isRefCounted(narrowed) &&
host.canBorrowReceiver(e.receiver) &&
host.referenceEffects.preserves(e.body);
// A single present arm is only tested and extracted (+1) before the
// body runs, so a fresh receiver box can stay on the stack.
const r =
!multiple && host.canStackReceiver(e.receiver)
? host.emitReadReceiver(e.receiver)
: host.emitExpr(e.receiver);
const stacked = !borrowReceiver && !multiple && host.canStackReceiver(e.receiver);
let r: LlValue;
const nullableField = borrowReceiver ? host.nullableFieldGet(e.receiver) : null;
if (nullableField !== null && e.receiver.kind === "fieldGet") {
// A nullable-pointer field stores no union: test and peek the
// pointer itself, through a private stack box when the union's own
// representation is boxed (emitUnionProjection's form).
const owner = host.emitReadReceiver(e.receiver.obj);
const { ptr } = host.classFieldPtr(owner.name, e.receiver.className, e.receiver.field);
const p = B.tmp();
B.line(`${p} = load ptr, ptr ${ptr}${host.fieldAliasAttachment(ptr)}`);
r = {
name: host.nullableUnions.get(def.id) ? p : host.stackNullableBox(p, nullableField),
type: e.receiver.type,
};
} else if (borrowReceiver || stacked) r = host.emitReadReceiver(e.receiver);
else r = host.emitExpr(e.receiver);
// The bind slot borrows a ref payload whenever something else keeps it
// alive until the body finishes: the borrowed projection above, or the
// receiver value the statement frame owns (emitExpr). Stack receivers
// may borrow their payload from a container, so they keep their +1.
const borrowBind = !multiple && !stacked && isRefCounted(narrowed);
const bind = B.slot();
B.entryAllocas.push(`${bind} = alloca ${host.llType(narrowed)}`);
B.line(
`store ${host.llType(narrowed)} ${host.llType(narrowed) === "ptr" ? "null" : host.llType(narrowed) === "double" ? f64Lit(0) : "false"}, ptr ${bind}`,
);
host.ownSlot(bind, narrowed);
if (!borrowBind) host.ownSlot(bind, narrowed);
const isUnit = host.tagInSet(r.name, def.id, unitTags);
const extract = (): string =>
multiple ? host.retainValue(r.name, narrowed) : host.unionExtract(r.name, def.id, narrowed);
multiple
? host.retainValue(r.name, narrowed)
: borrowBind
? host.unionPeek(r.name, def.id)
: host.unionExtract(r.name, def.id, narrowed);
if (e.type.kind === "void") {
// Statement form (cb?.()): no result value at all.
const lb = B.newLabel("oc.b");
@@ -2,6 +2,7 @@ import { classMembershipIntervals } from "./classes.js";
import { borrowableInputs, emitBorrowedInput, emitBorrowedInputs } from "./borrowed-inputs.js";
import { preservesDynTest } from "./checked-value-lifetimes.js";
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
/* Focused LLVM expression emission extracted from emitter.ts. */
import { InternalCompilerError } from "../../errors.js";
import { emitNullableIsTag, emitNullablePresent } from "./union-repr.js";
@@ -96,6 +97,7 @@ export function emitDynamicExpr(
B.line(
`${boxed} = call ptr ${typedRefConstructor(host.shapeHost, v.type)}(ptr ${v.name}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
for (const line of bindLiveArrayOps(host.shapeHost, adapter, boxed)) B.line(line);
return host.own({ name: boxed, type: e.type });
}
if (v.type.kind === "func") {
@@ -17,7 +17,15 @@ import { mangleFunction, mangleGlobal, mangleRecordStruct } from "../mangle.js";
import { BlockBuilder } from "./blocks.js";
import { classFieldIndex, classStructSym } from "./classes.js";
import { llvmCommentText } from "./common.js";
import { FN_ATTRS, llFieldType, releaseSym, traceArg, vAdapters } from "./shapes.js";
import {
FN_ATTRS,
elemAccess,
llFieldType,
releaseSym,
traceArg,
vAdapters,
type ShapeHost,
} from "./shapes.js";
import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js";
import type { NullableRefField } from "./nullable-fields.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
@@ -567,6 +575,7 @@ export function liveDynUnionRefAdapter(
B.line(
`${boxed} = call ptr ${typedRefConstructor(host.shapeHost, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`,
);
for (const line of bindLiveArrayOps(host.shapeHost, adapter, boxed)) B.line(line);
B.terminate(`ret ptr ${boxed}`);
});
valueArms.forEach(({ arm }, index) => {
@@ -648,6 +657,7 @@ export function streamTypedRefBoxValue(
B.line(
`${boxed} = call ptr ${typedRefConstructor(host.shapeHost, t)}(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${nested.snapshot}, ptr ${nested.commit})`,
);
for (const line of bindLiveArrayOps(host.shapeHost, nested, boxed)) B.line(line);
return boxed;
}
@@ -972,6 +982,9 @@ export function streamTypedRefMaterializeAdapter(
}
} else if (t.kind === "array") {
const elem = t.elem;
// Published before the element converters: a recursive element type
// can box this same array type while the bodies below are emitted.
adapter.arrayOps = `@${snapshot}_ops`;
host.declare(`declare ptr @scr_dyn_new_arr()`);
host.declare(`declare void @scr_dyn_arr_push(ptr, ptr)`);
host.declare(`declare double @scr_arr_len(ptr)`);
@@ -1033,6 +1046,7 @@ export function streamTypedRefMaterializeAdapter(
host.declare(`declare void @scr_arr_copy_metadata(ptr, ptr)`);
B.line(`call void @scr_arr_copy_metadata(ptr %p, ptr ${out})`);
B.terminate(`ret ptr ${out}`);
liveArrayOps(host, t, snapshot);
} else {
const out = B.tmp();
B.line(`${out} = call ptr @${host.dyn.toDynHelper(t)}(ptr %p)`);
@@ -1048,6 +1062,91 @@ export function streamTypedRefMaterializeAdapter(
return adapter;
}
/** Per-element hooks for a live array capsule (ScrDynTypedArrayOps). The
* runtime answers presence and length from the native array and calls these
* to convert exactly one element, so `xs[i]`, `xs[i] = v`, `i in xs`, and
* iteration on a live array do not rebuild the whole snapshot per access.
* `get` boxes an element exactly like one step of the snapshot loop; `set`
* stores only values the element type accepts and otherwise reports false
* without side effects, leaving the snapshot path to raise its diagnostic. */
function liveArrayOps(
host: LlvmEmitterContext,
t: IrType & { kind: "array" },
snapshot: string,
): void {
const elem = t.elem;
const acc = elemAccess(elem);
const valueTy = acc === "f64" ? "double" : acc === "bool" ? "i1" : "ptr";
const get = `${snapshot}_get`;
const set = `${snapshot}_set`;
const ops = `${snapshot}_ops`;
const G = new BlockBuilder();
const value = G.tmp();
host.declare(
`declare ${acc === "bool" ? "zeroext i1" : valueTy} @scr_arr_get_${acc}(ptr, double)`,
);
G.line(
`${value} = call ${valueTy} @scr_arr_get_${acc}(ptr %p, double %i)${acc === "ref" ? " ; +1" : ""}`,
);
const boxed = host.streamTypedRefBoxValue(G, elem, value);
if (isRefCounted(elem)) G.line(`call void ${releaseSym(host.shapeHost, elem)}(ptr ${value})`);
G.terminate(`ret ptr ${boxed}`);
const S = new BlockBuilder();
const fits = S.tmp();
S.line(`${fits} = call zeroext i1 @${host.dyn.dynMatchHelper(elem)}(ptr %v)`);
const store = S.newLabel("live.array.store");
const refuse = S.newLabel("live.array.refuse");
S.condBr(fits, store, refuse);
S.startBlock(refuse);
S.terminate(`ret i1 false`);
S.startBlock(store);
const next = S.tmp();
S.line(
`${next} = call ${acc === "bool" ? "zeroext " : ""}${valueTy} @${host.dyn.dynCheckHelper(elem)}(ptr %v, ptr null)`,
);
// A conversion that throws (an accessor on a plain object, say) has
// already raised the write's error; report it as handled.
const pending = S.tmp();
for (const line of host.pendingTestLines(pending)) S.line(line.trimStart());
const write = S.newLabel("live.array.write");
const failed = S.newLabel("live.array.failed");
S.condBr(pending, failed, write);
S.startBlock(failed);
S.terminate(`ret i1 true`);
S.startBlock(write);
host.declare(
`declare void @scr_arr_set_${acc}(ptr, double, ${acc === "bool" ? "i1 zeroext" : valueTy})`,
);
S.line(`call void @scr_arr_set_${acc}(ptr %p, double %i, ${valueTy} ${next}) ; takes ownership`);
S.terminate(`ret i1 true`);
host.resolveThunkDefs.push(
`define internal ptr @${get}(ptr %p, double %i) ${FN_ATTRS} { ; live element of ${typeKey(t)}`,
G.render(),
`}`,
``,
`define internal zeroext i1 @${set}(ptr %p, double %i, ptr %v) ${FN_ATTRS} { ; live element store into ${typeKey(t)}`,
S.render(),
`}`,
``,
`@${ops} = internal constant { ptr, ptr } { ptr @${get}, ptr @${set} }`,
``,
);
}
/** Attach the element hooks right after a live array capsule is built. */
export function bindLiveArrayOps(
host: ShapeHost,
adapter: LlStreamTypedRefAdapter,
capsule: string,
): string[] {
if (adapter.arrayOps === undefined) return [];
host.declare(`declare void @scr_dyn_typed_ref_bind_array(ptr, ptr)`);
return [`call void @scr_dyn_typed_ref_bind_array(ptr ${capsule}, ptr ${adapter.arrayOps})`];
}
export function streamFromArrayAdapter(
host: LlvmEmitterContext,
t: IrType & { kind: "array" },
@@ -1139,10 +1238,11 @@ export function streamFromArrayAdapter(
const rc = vAdapters(host.shapeHost, elem);
const keyPtr = host.cstr(key);
let commit: string;
let live: LlStreamTypedRefAdapter | undefined;
if (streamTypedRefEligible(elem)) {
const adapter = host.liveDynRefAdapter(elem);
snapshot = adapter.snapshot;
commit = adapter.commit;
live = host.liveDynRefAdapter(elem);
snapshot = live.snapshot;
commit = live.commit;
} else {
commit = host.streamTypedRefCommitAdapter(elem, snapshot);
host.resolveThunkDefs.push(
@@ -1158,6 +1258,7 @@ export function streamFromArrayAdapter(
B.line(
`${boxed} = call ptr ${typedRefConstructor(host.shapeHost, elem)}(ptr ${value}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${keyPtr}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")}, ptr @${snapshot}, ptr ${commit})`,
);
if (live) for (const line of bindLiveArrayOps(host.shapeHost, live, boxed)) B.line(line);
} else {
boxed = B.tmp();
B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(elem)}(${valueTy} ${value})`);
@@ -12,6 +12,7 @@ import { everyStmtList } from "../../ir/traverse.js";
import { analyzeCallLifetimes, type CallLifetimes } from "./call-lifetimes.js";
import type { LlValue, LlvmEmitterContext } from "./expr-context.js";
import { ReferenceEffects } from "./reference-effects.js";
import { exactIndexLines } from "./common.js";
export interface LocalArrayRead {
type: IrType;
@@ -338,33 +339,29 @@ function emitDenseReferenceArrayRead(
// The dense path uses the existing ScrArr ABI. Sparse indices and
// noncanonical numeric properties retain the runtime lookup semantics.
const capPtr = B.tmp(),
cap = B.tmp(),
capNumber = B.tmp(),
nonnegative = B.tmp(),
belowCap = B.tmp(),
inRange = B.tmp();
cap = B.tmp();
B.line(`${capPtr} = getelementptr inbounds %ScrArr, ptr ${array.name}, i32 0, i32 2`);
host.markMemoryPointer(capPtr, "array:header");
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} = 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}`);
let offset: string, inRange: string;
if (integerIndex) {
offset = integerIndex;
inRange = B.tmp();
B.line(`${inRange} = icmp ult ${host.sizeType} ${integerIndex}, ${cap}`);
} else {
// Capacity bounds the dense slots; the length check below still decides presence.
const exact = exactIndexLines(index.name, cap, host.sizeType, () => B.tmp());
for (const line of exact.lines) B.line(line);
inRange = exact.ok;
offset = exact.wide;
if (host.sizeType !== "i64") {
offset = B.tmp();
B.line(`${offset} = trunc i64 ${exact.wide} to ${host.sizeType}`);
}
}
B.condBr(inRange, range, slow);
B.startBlock(range);
const offset = integerIndex ?? B.tmp(),
roundTrip = B.tmp(),
integral = B.tmp();
if (integerIndex) B.br(dense);
else {
B.line(`${offset} = fptoui double ${index.name} to ${host.sizeType}`);
B.line(`${roundTrip} = sitofp ${host.sizeType} ${offset} to double`);
B.line(`${integral} = fcmp oeq double ${index.name}, ${roundTrip}`);
B.condBr(integral, dense, slow);
}
B.br(dense);
B.startBlock(dense);
const lenPtr = B.tmp(),
len = B.tmp(),
@@ -2,6 +2,7 @@ import { preservesDynTest } from "./checked-value-lifetimes.js";
import { isStableReceiverOperand } from "../../ir/analysis.js";
import {
isRefCounted,
type IrArrIntrinsicMethod,
type IrClassDef,
type IrExpr,
type IrFunction,
@@ -38,6 +39,25 @@ export function preservesRegexInputs(method: string): boolean {
}
}
/** Array operations that never release an element or run user code: reads,
* copies into fresh arrays, and appends (an append retains the new element
* and may move the slot storage, but every element stays referenced).
* Removals (pop, shift, splice), truncation and overwrites are absent. */
const EDGE_PRESERVING_ARRAY_METHODS: ReadonlySet<IrArrIntrinsicMethod> = new Set([
"length",
"getNumber",
"indexEq",
"indexOf",
"includes",
"nextPresent",
"join",
"slice",
"concatSpread",
"toReversed",
"push",
"pushSpread",
]);
function expressionPreservesEdges(
e: IrExpr,
call: (value: Call) => boolean,
@@ -80,6 +100,10 @@ function expressionPreservesEdges(
case "caughtCheck":
// An immortal class object, or a fresh one retaining captured boxes.
case "classRef":
// A chain evaluates its receiver and body (checked as children); its
// bound receiver is a read.
case "optChain":
case "chainRecv":
return true;
// Formatting a primitive allocates a string and runs no user code.
case "toString":
@@ -90,6 +114,10 @@ function expressionPreservesEdges(
);
case "virtualCall":
return virtualCall?.(e) ?? false;
// A module binding's TDZ check reads its initialization flag and may
// throw; it never touches a reference.
case "intrinsic":
return e.name === "module.tdzCheck";
case "dynTest":
return preservesDynTest(e.test);
case "assignExpr":
@@ -109,12 +137,19 @@ function expressionPreservesEdges(
e.fn === "error.new" ||
e.fn === "error.nodeThrow" ||
e.fn.startsWith("math.") ||
// Atomic read-modify-write on an integer typed array (a shared id
// counter): numbers only, no callback.
e.fn === "atomics.op" ||
isStableReceiverOperand(e, "")
);
case "call":
return call(e);
case "arrIntrinsic":
return e.method === "length";
return EDGE_PRESERVING_ARRAY_METHODS.has(e.method);
// A fresh array retains its elements. Spreads keep the conservative
// answer (an iterable spread may run an iterator).
case "arrayLit":
return e.spreads === undefined || e.spreads.length === 0;
case "bytesIntrinsic":
return (
e.method === "get" ||
@@ -11,6 +11,8 @@
* capacity word's low 32 bits on both i64 and i32 layouts.
*/
import { exactIndexLines } from "./common.js";
export const STRING_UTF16_LEN_DECL = "declare double @scr_str_utf16_len(ptr)";
export const STRING_CHAR_CODE_AT_DECL = "declare double @scr_str_char_code_at(ptr, double)";
@@ -55,30 +57,32 @@ export const STRING_READ_HELPERS: readonly StringReadHelper[] = [
},
{
decl: STRING_CHAR_CODE_AT_DECL,
define: (sz, attrs) => [
`define internal double @sc_str_char_code_at(ptr %s, double %i) ${attrs} {`,
`entry:`,
...asciiTest(),
` br i1 %ascii, label %bounds, label %slow`,
`bounds:`,
...loadLength(sz),
` %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`,
` %in = and i1 %lo, %hi`,
` br i1 %in, label %read, label %slow`,
`read:`,
` %k = fptoui double %i to ${sz}`,
` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
` %p = getelementptr inbounds i8, ptr %data, ${sz} %k`,
` %byte = load i8, ptr %p`,
` %unit = uitofp i8 %byte to double`,
` ret double %unit`,
`slow:`,
` %r = call double @scr_str_char_code_at(ptr %s, double %i)`,
` ret double %r`,
`}`,
],
define: (sz, attrs) => {
let n = 0;
const exactIndex = exactIndexLines("%i", "%len", sz, () => `%ix${n++}`);
return [
`define internal double @sc_str_char_code_at(ptr %s, double %i) ${attrs} {`,
`entry:`,
...asciiTest(),
` br i1 %ascii, label %bounds, label %slow`,
`bounds:`,
...loadLength(sz),
...exactIndex.lines.map((line) => ` ${line}`),
` br i1 ${exactIndex.ok}, label %read, label %slow`,
`read:`,
sz === "i64"
? ` %k = add i64 ${exactIndex.wide}, 0`
: ` %k = trunc i64 ${exactIndex.wide} to ${sz}`,
` %data = getelementptr inbounds %ScrStr, ptr %s, i32 1`,
` %p = getelementptr inbounds i8, ptr %data, ${sz} %k`,
` %byte = load i8, ptr %p`,
` %unit = uitofp i8 %byte to double`,
` ret double %unit`,
`slow:`,
` %r = call double @scr_str_char_code_at(ptr %s, double %i)`,
` ret double %r`,
`}`,
];
},
},
];
@@ -0,0 +1,95 @@
import { expect, test } from "vitest";
import { foldConstantGlobals } from "./constant-globals.js";
import { F64, VOID, type IrExpr, type IrFunction, type IrModule, type IrStmt } from "./ir.js";
const loc = { file: "constant-globals.ts", start: 0, end: 0 };
const num = (value: number): IrExpr => ({ loc, kind: "numLit", value, type: F64 });
const ref = (localId: string): IrExpr => ({ loc, kind: "varRef", localId, type: F64 });
const assign = (localId: string, value: IrExpr): IrStmt => ({
loc,
kind: "assign",
localId,
value,
});
const ret = (value: IrExpr): IrStmt => ({ loc, kind: "return", value });
const fn = (name: string, body: IrStmt[]): IrFunction => ({
loc,
name,
params: [],
locals: [],
returnType: name === "%init" ? VOID : F64,
body,
});
const global = (id: string, extra: object = {}) => ({
id,
name: id,
type: F64,
mutable: false,
...extra,
});
test("immutable globals stored once with a literal read as that literal", () => {
const mod: IrModule = {
irVersion: 15,
sourceFile: "constant-globals.ts",
entry: "%init",
functions: [
fn("%init", [
assign("%g.DOT", num(46)),
assign("%g.LATE", num(7)),
assign("%g.TWICE", num(1)),
assign("%g.TWICE", num(2)),
assign("%g.MUT", num(3)),
]),
fn("read", [
ret({
loc,
kind: "bin",
op: "+",
left: ref("%g.DOT"),
right: {
loc,
kind: "bin",
op: "+",
left: ref("%g.LATE"),
right: {
loc,
kind: "bin",
op: "+",
left: ref("%g.TWICE"),
right: ref("%g.MUT"),
type: F64,
},
type: F64,
},
type: F64,
}),
]),
],
classes: [],
records: [],
unions: [],
globals: [
global("%g.DOT"),
global("%g.LATE", { initFlag: "%g.LATE.initialized" }),
global("%g.TWICE"),
global("%g.MUT", { mutable: true }),
],
};
const folded = foldConstantGlobals(mod);
const reads: string[] = [];
const walk = (e: IrExpr): void => {
if (e.kind === "varRef") reads.push(e.localId);
else if (e.kind === "numLit") reads.push(String(e.value));
else if (e.kind === "bin") {
walk(e.left);
walk(e.right);
}
};
const body = folded.functions.find((f) => f.name === "read")!.body[0]!;
if (body.kind === "return" && body.value) walk(body.value);
expect(reads).toEqual(["46", "%g.LATE", "%g.TWICE", "%g.MUT"]);
// The initializer keeps its store; the input module is not modified.
expect(folded.functions[0]).toBe(mod.functions[0]);
expect(mod.functions[1]!.body[0]).not.toBe(body);
});
@@ -0,0 +1,76 @@
import type { IrExpr, IrFunction, IrModule, IrStmt } from "./ir.js";
import { everyStmtList, mapExprChildren, mapStmtChildren } from "./traverse.js";
type Literal = (IrExpr & { kind: "numLit" }) | (IrExpr & { kind: "boolLit" });
/** Module constants such as `const DOT = 0x2e` read as their literal.
*
* A module-level binding is a global that the module initializer stores
* once; every other function loads it (a thread-local load in worker
* programs), which also hides the value from integer ranges, switch
* dispatch and LLVM's constant folding. When the global is immutable, has
* exactly one write in the program and that write stores a number or
* boolean literal, every read sees that literal: reads that could run before
* the store carry the frontend's initialization flag (`initFlag`) or a TDZ
* sentinel, and those globals are left alone. The store stays, so module
* namespace views and debuggers still find the value in the global. Library
* builds keep their globals: a host may observe them across the boundary. */
export function foldConstantGlobals(mod: IrModule): IrModule {
if (mod.lib || !mod.globals?.length) return mod;
const candidates = new Set<string>();
for (const g of mod.globals)
if (
!g.mutable &&
g.tdz !== true &&
g.initFlag === undefined &&
(g.type.kind === "f64" || g.type.kind === "bool")
)
candidates.add(g.id);
if (candidates.size === 0) return mod;
const writes = new Map<string, number>();
const values = new Map<string, Literal>();
const write = (id: string, value: IrExpr | null): void => {
if (!candidates.has(id)) return;
writes.set(id, (writes.get(id) ?? 0) + 1);
if (value && (value.kind === "numLit" || value.kind === "boolLit")) values.set(id, value);
else values.delete(id);
};
for (const fn of mod.functions)
everyStmtList(fn.body, {
// Every node other than a read that names the binding is a write
// (assignments, loop and catch bindings, compound updates); only a
// plain assignment or declaration contributes a value.
stmt: (s) => {
if (s.kind === "assign") write(s.localId, s.value);
else if (s.kind === "varDecl") write(s.localId, s.init);
else if ("localId" in s && typeof s.localId === "string") write(s.localId, null);
return true;
},
expr: (e) => {
if (e.kind !== "varRef" && "localId" in e && typeof e.localId === "string")
write(e.localId, null);
return true;
},
});
const constants = new Map<string, Literal>();
for (const [id, value] of values) if (writes.get(id) === 1) constants.set(id, value);
if (constants.size === 0) return mod;
const reads = (e: IrExpr): boolean => !(e.kind === "varRef" && constants.has(e.localId));
const rewriteExpr = (e: IrExpr): IrExpr => {
if (e.kind === "varRef") {
const value = constants.get(e.localId);
return value ? { ...value, type: e.type, loc: e.loc } : e;
}
return mapExprChildren(e, rewriteExpr, rewriteStmt);
};
const rewriteStmt = (s: IrStmt): IrStmt => mapStmtChildren(s, rewriteExpr, rewriteStmt);
let changed = false;
const functions = mod.functions.map((fn): IrFunction => {
if (everyStmtList(fn.body, { stmt: () => true, expr: reads })) return fn;
changed = true;
return { ...fn, body: fn.body.map(rewriteStmt) };
});
return changed ? { ...mod, functions } : mod;
}
@@ -0,0 +1,129 @@
import { expect, test } from "vitest";
import { analyzeFinalFields, constructorClass } from "./final-fields.js";
import {
BOOL,
STRING,
VOID,
type IrClassDef,
type IrExpr,
type IrFunction,
type IrModule,
type IrStmt,
type IrType,
} from "./ir.js";
const loc = { file: "final-fields.ts", start: 0, end: 0 };
const obj = (className: string): IrType => ({ kind: "object", className });
const ref = (localId: string, type: IrType): IrExpr => ({ loc, kind: "varRef", localId, type });
const str = (value: string): IrExpr => ({ loc, kind: "strLit", value, type: STRING });
const set = (target: IrExpr, className: string, field: string, value: IrExpr): IrStmt => ({
loc,
kind: "fieldSet",
obj: target,
className,
field,
value,
});
const fn = (name: string, self: string | null, body: IrStmt[]): IrFunction => ({
loc,
name,
params: self ? [{ localId: "this", name: "this", type: obj(self) }] : [],
locals: self ? [{ id: "this", name: "this", type: obj(self), mutable: false }] : [],
returnType: VOID,
body,
});
const cls = (name: string, fields: string[], base?: string): IrClassDef => ({
loc,
name,
...(base ? { base } : {}),
fields: fields.map((field) => ({ name: field, type: STRING })),
});
const mod = (classes: IrClassDef[], functions: IrFunction[]): IrModule => ({
irVersion: 15,
sourceFile: "final-fields.ts",
entry: "%main",
classes,
functions: [fn("%main", null, []), ...functions],
records: [],
unions: [],
globals: [],
});
const self = (className: string): IrExpr => ref("this", obj(className));
test("constructor names map to their class", () => {
expect(constructorClass("%%m1.Parser.constructor")).toBe("%m1.Parser");
expect(constructorClass("%%m1.Parser.parse")).toBeNull();
});
test("fields written only through their constructors' this are final", () => {
const finals = analyzeFinalFields(
mod(
[cls("A", ["kept", "moved"]), cls("B", ["kept", "moved", "own"], "A")],
[
fn("%A.constructor", "A", [
set(self("A"), "A", "kept", str("a")),
set(self("A"), "A", "kept", str("twice")),
set(self("A"), "A", "moved", str("a")),
]),
// A subclass constructor initializes its base's field through a guard.
fn("%B.constructor", "B", [
set(
{
loc,
kind: "seqExpr",
stmts: [
{
loc,
kind: "if",
cond: {
loc,
kind: "unary",
op: "!",
operand: ref("superCalled", BOOL),
type: BOOL,
},
then: [
{
loc,
kind: "exprStmt",
expr: {
loc,
kind: "libCall",
fn: "error.nodeThrow",
args: [str("ReferenceError")],
type: VOID,
},
},
],
else_: null,
},
],
result: self("B"),
type: obj("B"),
},
"B",
"own",
str("b"),
),
]),
// Any write elsewhere disqualifies the field family.
fn("%A.reset", "A", [set(self("A"), "A", "moved", str("later"))]),
],
),
);
expect(finals.isFinal("A", "kept")).toBe(true);
expect(finals.isFinal("B", "kept")).toBe(true);
expect(finals.isFinal("B", "own")).toBe(true);
expect(finals.isFinal("A", "moved")).toBe(false);
expect(finals.isFinal("B", "moved")).toBe(false);
expect(finals.reasons.get("A\0moved")).toBe("written in %A.reset");
});
test("constructors writing another object, and library builds, are not final", () => {
const other = fn("%A.constructor", "A", [set(ref("peer", obj("A")), "A", "kept", str("a"))]);
other.locals.push({ id: "peer", name: "peer", type: obj("A"), mutable: false });
expect(analyzeFinalFields(mod([cls("A", ["kept"])], [other])).isFinal("A", "kept")).toBe(false);
const library = mod([cls("A", ["kept"])], []);
library.lib = { profile: "p" } as unknown as NonNullable<IrModule["lib"]>;
expect(analyzeFinalFields(library).size).toBe(0);
});
+177
View File
@@ -0,0 +1,177 @@
import { isRefCounted, type IrClassDef, type IrExpr, type IrModule, type IrStmt } from "./ir.js";
import { dynamicFieldStores } from "./int32-slots.js";
import { everyExprChild, everyStmtChild } from "./traverse.js";
/** Whole-program final reference fields.
*
* A reference-counted class field is final when every write the program can
* make to it is a constructor of its family storing through that
* constructor's own `this`. Outside the constructors such a field never
* changes, so while its object is alive the field keeps its value alive: a
* read of `o.f` may be borrowed for as long as `o` itself is (`this.scanner`
* inside a method, across any call), without a retain and a release.
* Constructors are excluded from that use, because they are where the field
* changes; so are async functions and generators, whose suspensions let
* other code (a constructor that is still running) continue.
*
* Writes the IR does not show disqualify fields up front: library builds
* (instances cross a native boundary), live dynamic views whose stores can
* name the field (see dynamicFieldStores), and classes with runtime-provided
* or generic ancestors. Generated stores only initialize fresh objects or
* tear down dead ones. */
export class FinalFields {
private readonly finals = new Set<string>();
private readonly familyOf = new Map<string, string | null>();
/** Disqualified families and the first reason, for tests. */
readonly reasons = new Map<string, string>();
constructor(private readonly classes: ReadonlyMap<string, IrClassDef> = new Map()) {}
/** The topmost class in the base chain declaring the field, and the field. */
family(className: string, field: string): string | null {
const key = `${className}\0${field}`;
const cached = this.familyOf.get(key);
if (cached !== undefined) return cached;
let owner: IrClassDef | undefined = this.classes.get(className);
let result: string | null = null;
if (owner?.fields.some((f) => f.name === field)) {
for (;;) {
const base: IrClassDef | undefined = owner.base ? this.classes.get(owner.base) : undefined;
if (!base?.fields.some((f) => f.name === field)) break;
owner = base;
}
result = `${owner.name}\0${field}`;
}
this.familyOf.set(key, result);
return result;
}
isFinal(className: string, field: string): boolean {
if (this.finals.size === 0) return false;
const family = this.family(className, field);
return family !== null && this.finals.has(family);
}
get size(): number {
return this.finals.size;
}
/* ── construction (analyzeFinalFields only) ── */
add(family: string): void {
this.finals.add(family);
}
drop(family: string, why: string): void {
if (!this.reasons.has(family)) this.reasons.set(family, why);
this.finals.delete(family);
}
}
const CONSTRUCTOR_SUFFIX = ".constructor";
/** `%C.constructor`'s class name, or null for any other function. */
export function constructorClass(fnName: string): string | null {
return fnName.startsWith("%") && fnName.endsWith(CONSTRUCTOR_SUFFIX)
? fnName.slice(1, -CONSTRUCTOR_SUFFIX.length)
: null;
}
export function analyzeFinalFields(mod: IrModule): FinalFields {
const classes = new Map((mod.classes ?? []).map((c) => [c.name, c]));
const finals = new FinalFields(classes);
if (mod.lib) return finals;
const dynamic = dynamicFieldStores(mod);
if (dynamic.all) return finals;
const genericFamilies = new Set<string>();
for (const cls of classes.values())
if (cls.genericOf !== undefined) {
genericFamilies.add(cls.name);
genericFamilies.add(cls.genericOf);
}
const plainClass = (cls: IrClassDef): boolean => {
for (let c: IrClassDef | undefined = cls; c; c = c.base ? classes.get(c.base) : undefined) {
if (c.runtime || genericFamilies.has(c.name)) return false;
if (c.base && !classes.has(c.base)) return false;
}
return true;
};
const inFamily = (className: string, family: string): boolean => {
const owner = family.slice(0, family.indexOf("\0"));
for (let c = classes.get(className); c; c = c.base ? classes.get(c.base) : undefined)
if (c.name === owner) return true;
return false;
};
for (const cls of classes.values())
for (const field of cls.fields) {
if (!isRefCounted(field.type) || field.name.startsWith("%")) continue;
const family = finals.family(cls.name, field.name)!;
if (finals.reasons.has(family)) continue;
if (!plainClass(cls)) finals.drop(family, "runtime-provided or generic class");
else if (dynamic.names.has(field.name))
finals.drop(family, "dynamic stores can name the field");
else finals.add(family);
}
if (finals.size === 0) return finals;
for (const fn of mod.functions) {
const ctor = constructorClass(fn.name);
const self = ctor !== null ? fn.params[0]?.localId : undefined;
const selfLocal = self !== undefined ? fn.locals.find((l) => l.id === self) : undefined;
// `this` must name the object under construction for the whole body.
const constructing = selfLocal !== undefined && !selfLocal.boxed && !reassigns(fn.body, self!);
const stmt = (s: IrStmt): boolean => {
if (s.kind === "fieldSet") {
const family = finals.family(s.className, s.field);
if (family !== null && finals.isFinal(s.className, s.field)) {
const own =
constructing && ctor !== null && inFamily(ctor, family) && isSelf(s.obj, self!);
if (!own) finals.drop(family, `written in ${fn.name}`);
}
}
return everyStmtChild(s, expr, stmt);
};
const expr = (e: IrExpr): boolean => everyExprChild(e, expr, stmt);
for (const s of fn.body) stmt(s);
}
return finals;
}
/** `this` itself, or behind the derived constructor's guards (`if (!super
* called) throw`), which read no reference and write nothing. */
function isSelf(e: IrExpr, self: string): boolean {
if (e.kind === "varRef") return e.localId === self;
if (e.kind !== "seqExpr") return false;
for (const s of e.stmts) {
if (s.kind !== "if" || s.else_ !== null || s.then.length !== 1) return false;
const only = s.then[0]!;
if (
only.kind !== "exprStmt" ||
only.expr.kind !== "libCall" ||
only.expr.fn !== "error.nodeThrow" ||
!only.expr.args.every((arg) => arg.kind === "numLit" || arg.kind === "strLit") ||
!throwFreeCondition(s.cond)
)
return false;
}
return isSelf(e.result, self);
}
function throwFreeCondition(e: IrExpr): boolean {
if (e.kind === "varRef" || e.kind === "boolLit") return e.type.kind === "bool";
return e.kind === "unary" && throwFreeCondition(e.operand);
}
function reassigns(body: IrStmt[], localId: string): boolean {
let found = false;
const expr = (e: IrExpr): boolean => {
if ((e.kind === "assignExpr" || e.kind === "incDec") && e.localId === localId) found = true;
return !found && everyExprChild(e, expr, stmt);
};
const stmt = (s: IrStmt): boolean => {
if (s.kind === "assign" && s.localId === localId && s.initializes !== true) found = true;
return !found && everyStmtChild(s, expr, stmt);
};
for (const s of body) if (!stmt(s)) break;
return found;
}
+11 -10
View File
@@ -271,15 +271,16 @@ function walkFunction(
for (const s of fn.body) stmt(s);
}
/** The class and record field names checked-dynamic code can store into a
* live typed-ref capsule (a capsule commit rewrites every other field with
* its unchanged snapshot value). `all`: some runtime helper that can store
* an arbitrary property receives a dynamic value, or the program has a
* dynamic island; otherwise only literal-keyed `dyn.keySet`/`dyn.keyDelete`
* name fields, collected in `names`. */
export function dynamicFieldStores(mod: IrModule): { all: boolean; names: Set<string> } {
const records = new Map((mod.records ?? []).map((r) => [r.id, r]));
const unions = new Map((mod.unions ?? []).map((u) => [u.id, u]));
/** The class field names that runtime stores through live dynamic views
* (typed-ref capsules) can write: every name when `all` (islands, jsval
* values, or a dynamic store whose key is not a literal), otherwise the
* literal keys of `dyn.keySet`/`dyn.keyDelete`. A view commits a store back
* into the instance's native field slots, which the IR does not show. */
export function dynamicFieldStores(
mod: IrModule,
records = new Map((mod.records ?? []).map((r) => [r.id, r])),
unions = new Map((mod.unions ?? []).map((u) => [u.id, u])),
): { all: boolean; names: Set<string> } {
const dynTypes = new Map<IrType, boolean>();
const dynNamed = new Map<string, boolean>();
const dynish = (type: IrType): boolean => {
@@ -436,7 +437,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 { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod);
const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions);
for (const fn of mod.functions)
walkFunction(fn, {
expr: (e) => {
@@ -164,3 +164,21 @@ test("versioning is bounded and requires an integer consumer", () => {
expect(matchIntegerCountedForLoop(nested, withLimit)).toBeNull();
expect(matchIntegerCountedForLoop(counted(number(4)), locals)?.guarded).toBe(false);
});
test("an unproven start versions the loop on an exact integer start", () => {
const withStart = new Map(locals);
withStart.set("start", { id: "start", name: "start", type: F64, mutable: false });
const s = counted(number(8));
s.init = { kind: "varDecl", localId: "i", init: ref("start"), loc };
expect(matchIntegerCountedForLoop(s, withStart)).toMatchObject({
guarded: true,
startGuarded: true,
limitGuarded: false,
range: { min: -Number.MAX_SAFE_INTEGER, max: 7 },
});
// The versioned body is duplicated, so the usual size bound applies.
const large = counted(number(8));
large.init = { kind: "varDecl", localId: "i", init: ref("start"), loc };
large.body = Array.from({ length: 81 }, () => large.body[0]!);
expect(matchIntegerCountedForLoop(large, withStart)).toBeNull();
});
+24 -4
View File
@@ -20,6 +20,11 @@ export interface IntegerCountedForLoop {
step: number;
inclusive: boolean;
guarded: boolean;
/** The limit needs the run-time `guardLimit` test (part of `guarded`). */
limitGuarded: boolean;
/** The start has no proven integer range: the versioned loop also tests
* that it is an exact integer (not -0) of at most 2^53 in magnitude. */
startGuarded: boolean;
guardLimit: number;
range: IntegerRange;
}
@@ -104,10 +109,13 @@ export function matchIntegerCountedForLoop(
const local = locals.get(init.localId);
if (local?.type.kind !== "f64" || !local.mutable || local.boxed || local.tdz) return null;
const start = init.init;
const startRange =
let startRange =
start.kind === "numLit" && Number.isSafeInteger(start.value) && !Object.is(start.value, -0)
? { min: start.value, max: start.value }
: ranges.get(start);
// An unproven start is tested at run time, on a versioned loop.
const startGuarded = !startRange && start.kind !== "numLit" && start.type.kind === "f64";
if (startGuarded) startRange = { min: -Number.MAX_SAFE_INTEGER, max: Number.MAX_SAFE_INTEGER };
if (
!startRange ||
startRange.min < -Number.MAX_SAFE_INTEGER ||
@@ -132,9 +140,10 @@ export function matchIntegerCountedForLoop(
const guardLimit = step > 0 ? 2 ** 53 - (inclusive ? 1 : 0) : -(2 ** 53) + (inclusive ? 1 : 0);
const boundRange =
limit.kind === "numLit" ? { min: limit.value, max: limit.value } : ranges.get(limit);
const guarded =
const limitGuarded =
!boundRange || (step > 0 ? !(boundRange.max <= guardLimit) : !(boundRange.min >= guardLimit));
if (guarded && limit.kind === "numLit") return null;
if (limitGuarded && limit.kind === "numLit") return null;
const guarded = limitGuarded || startGuarded;
let useful = false;
let cost = 0;
const counter = (e: IrExpr): boolean =>
@@ -204,7 +213,18 @@ export function matchIntegerCountedForLoop(
),
max: startRange.max,
};
return { localId: local.id, start, limit, step, inclusive, guarded, guardLimit, range };
return {
localId: local.id,
start,
limit,
step,
inclusive,
guarded,
limitGuarded,
startGuarded,
guardLimit,
range,
};
}
/** True when a lowered subtree writes `localId`. Local ids are unique per
@@ -269,15 +269,22 @@ console.log(work());
expect(facts.borrowed.get("relay")).toEqual(new Set([0, 1]));
expect(facts.parameters.has("keep")).toBe(false);
const llvm = emitLlvmModule(mod);
// An append only adds an edge, so `keep` preserves every heap edge, borrows
// every reference parameter and returns one of them: it is a borrowed-return
// function. Its body retains only the element stored into the array; the
// returned parameter goes back +0 (the caller still owns it) and the owned
// adapter retains it before releasing its parameters.
const keep = body(llvm, "sc_bf_keep");
expect(keep.match(/@sc_retain_Item/g)).toHaveLength(2);
expect(keep.match(/@sc_retain_Item/g)).toHaveLength(1);
expect(keep).not.toContain("@scr_arr_retain_v");
expect(keep).not.toContain("@scr_arr_release");
const relay = body(llvm, "sc_bf_relay");
expect(relay).toContain("@sc_bf_keep");
expect(relay).toMatch(/call ptr @sc_bf_keep\([^)]*\) ; borrowed result/);
expect(relay).not.toContain("@sc_retain_Item");
expect(relay).not.toContain("@scr_arr_retain_v");
const owned = body(llvm, "sc_f_keep");
expect(owned.match(/@sc_retain_Item/g)).toHaveLength(1);
expect(owned.indexOf("@sc_retain_Item")).toBeLessThan(owned.indexOf("@sc_release_Item"));
expect(owned).toContain("@sc_release_Item");
expect(owned).toContain("@scr_arr_release");
});
@@ -15802,6 +15802,38 @@
"<repo>/tests/corpus/4660-typed-rest-spread-forwarding.ts"
],
"diags": []
},
"<repo>/tests/corpus/4598-live-array-ref-element-access.ts": {
"order": [
"<repo>/tests/corpus/4598-live-array-ref-element-access.ts"
],
"diags": []
},
"<repo>/tests/corpus/4620-optional-chain-borrowed-receivers.ts": {
"order": [
"<repo>/tests/corpus/4620-optional-chain-borrowed-receivers.ts"
],
"diags": []
},
"<repo>/tests/corpus/4621-edge-preserving-callees/main.ts": {
"order": [
"<repo>/tests/corpus/4621-edge-preserving-callees/compare.ts",
"<repo>/tests/corpus/4621-edge-preserving-callees/items.ts",
"<repo>/tests/corpus/4621-edge-preserving-callees/main.ts"
],
"diags": []
},
"<repo>/tests/corpus/exact-index-round-trip.ts": {
"order": [
"<repo>/tests/corpus/exact-index-round-trip.ts"
],
"diags": []
},
"<repo>/tests/corpus/final-field-borrows.ts": {
"order": [
"<repo>/tests/corpus/final-field-borrows.ts"
],
"diags": []
}
}
}
+24 -10
View File
@@ -347,9 +347,8 @@ static SCR_TL size_t scr_cyc_live_after[SCR_CYC_NGENS];
#endif
/* 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. Only the mature and old entries are
* used.
* and whether the last growth-triggered pass at that level asked for 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
@@ -493,10 +492,23 @@ static size_t scr_cyc_backlog_threshold(unsigned gen) {
*
* Narrowing takes effect at once: a pass that finds garbage again moves the
* fraction straight to what its yield asks for. Widening waits for two
* passes in a row that asked for it and then moves one doubling per pass. A
* program that discards older structures at a steady rate sees its passes
* alternate between finding the garbage just aged into the level and finding
* it still younger, and a single miss says little. */
* passes in a row that asked for it. A program that discards older
* structures at a steady rate sees its passes alternate between finding the
* garbage just aged into the level and finding it still younger, and a
* single miss says little.
*
* How far a qualifying pass widens depends on what the level's passes walk.
* A full pass walks the whole heap, and a heap that grows for a whole run
* gets only a handful of them, so widening a doubling per pass spent the
* largest walks on the way to the cap (a run with seven full passes never
* reached it). The old level therefore widens two doublings per qualifying
* pass, never past what the pass asked for: passes that keep finding
* nothing reach the cap of 16 on the fourth pass instead of the sixth, while
* the heap is still small. A mature pass walks only what was promoted since
* the last one, so its total walking hardly depends on how passes are
* spaced, and the mature level keeps widening a doubling per pass, which
* lets less young garbage float. */
#define SCR_CYC_OLD_WIDEN_STEP 2
static void scr_cyc_adapt_growth(unsigned gen, size_t growth,
size_t reclaimed) {
unsigned want = 0, max = scr_cyc_growth_max_shift();
@@ -505,9 +517,11 @@ static void scr_cyc_adapt_growth(unsigned gen, size_t growth,
if (want <= scr_cyc_growth_shift[gen]) {
scr_cyc_growth_shift[gen] = (unsigned char)want;
scr_cyc_growth_streak[gen] = 0;
} else if (++scr_cyc_growth_streak[gen] >= 2) {
scr_cyc_growth_streak[gen] = 2;
scr_cyc_growth_shift[gen]++;
} else if (scr_cyc_growth_streak[gen]) {
unsigned shift = scr_cyc_growth_shift[gen] + (gen == SCR_CYC_OLD ? SCR_CYC_OLD_WIDEN_STEP : 1);
scr_cyc_growth_shift[gen] = (unsigned char)(shift < want ? shift : want);
} else {
scr_cyc_growth_streak[gen] = 1;
}
}
+46
View File
@@ -720,6 +720,49 @@ static ScrDyn *scr_dyn_invoke_impl(
ScrDyn *recv, const char *method, ScrDyn *const *args, size_t argc,
const char *what, ScrDyn *callback_recv);
/* Live native arrays: the constant-time methods that loops call once per
* element. Each mirrors the checked-array dispatch below (which consults
* neither own properties nor the prototype for these builtins) but touches
* one native element instead of materializing and recommitting the whole
* array. Anything that could observe more — a hole, argument coercion, a
* multi-value push, a value the element type rejects — sets *handled false
* and takes the snapshot path. */
static ScrDyn *dyn_live_array_method(ScrDyn *recv, const char *method, ScrDyn *const *args,
size_t argc, const char *what, bool *handled) {
const ScrDynTypedArrayOps *ops = recv->v.typed_ref.array;
if (!ops) return NULL;
ScrArr *arr = recv->v.typed_ref.ptr;
size_t len = arr->len;
if (dyn_name_is(method, "push") && argc == 1 && ops->set) {
if (!ops->set(arr, (double)len, args[0])) return NULL;
*handled = true;
return scr_exc_pending() ? NULL : scr_dyn_new_num((double)arr->len);
}
if (dyn_name_is(method, "pop")) {
if (len == 0) {
*handled = true;
return scr_dyn_retain(scr_dyn_undefined());
}
ScrDyn *last = scr_dyn_typed_ref_element(recv, len - 1);
if (!last) return NULL;
scr_arr_set_len(arr, (double)(len - 1));
*handled = true;
return last;
}
if (dyn_name_is(method, "at") && (argc == 0 || args[0]->kind == SCR_DYN_NUM || args[0]->kind == SCR_DYN_UNDEF)) {
double index = dyn_index_arg(args, argc, 0, 0, what);
if (index < 0) index += (double)len;
if (index < 0 || index >= (double)len) {
*handled = true;
return scr_dyn_retain(scr_dyn_undefined());
}
ScrDyn *value = scr_dyn_typed_ref_element(recv, (size_t)index);
if (value) *handled = true;
return value;
}
return NULL;
}
ScrDyn *scr_dyn_prepare_method(ScrDyn *recv, const char *method) {
if (recv->kind == SCR_DYN_UNDEF || recv->kind == SCR_DYN_NULL)
return scr_dyn_invoke(recv, method, NULL, 0, method);
@@ -800,6 +843,9 @@ static ScrDyn *scr_dyn_invoke_impl(
* Mutators such as reverse/sort return their receiver, so translate that
* snapshot identity back to the externally visible capsule. */
if (recv->kind == SCR_DYN_TYPED_REF) {
bool handled = false;
ScrDyn *fast = dyn_live_array_method(recv, method, args, argc, what, &handled);
if (handled) return fast;
ScrDyn *materialized = scr_dyn_typed_ref_materialize(recv);
if (scr_exc_pending()) {
scr_dyn_release(materialized);
+99 -1
View File
@@ -2071,14 +2071,16 @@ ScrDyn *scr_dyn_new_typed_ref(
value->v.typed_ref.type_key_len == type_key_len && !memcmp(value->v.typed_ref.type_key, type_key, type_key_len))
return scr_dyn_retain(value);
}
if (type_key_len >= (size_t)1 << 30) scr_trap("scriptc: typed reference key too long\n");
ScrDyn *d = scr_dyn_alloc(SCR_DYN_TYPED_REF);
d->v.typed_ref.ptr = retain(ptr);
d->v.typed_ref.retain = retain;
d->v.typed_ref.release = release;
d->v.typed_ref.type_key = type_key;
d->v.typed_ref.type_key_len = type_key_len;
d->v.typed_ref.type_key_len = (uint32_t)type_key_len;
d->v.typed_ref.materialize = materialize;
d->v.typed_ref.commit = commit;
d->v.typed_ref.array = NULL;
d->v.typed_ref.materialized = NULL;
d->v.typed_ref.casts = NULL;
d->v.typed_ref.traced = false;
@@ -2238,6 +2240,74 @@ void scr_dyn_typed_ref_commit(ScrDyn *d) {
}
}
/* ── live native arrays: per-element access ──────────────────────────────
* Materializing rebuilds the whole array, so doing it for every `xs[i]`,
* `xs.length`, or iteration step makes a loop over a live array quadratic.
* The native array is the source of truth (every refresh rebuilds the view
* from it and keeps only the prototype and symbol properties), so element
* reads, presence, and in-range writes can consult it directly. Anything
* whose answer may come from the prototype chain — holes and out-of-range
* indexes — or needs the snapshot's diagnostics still takes the snapshot. */
void scr_dyn_typed_ref_bind_array(ScrDyn *d, const ScrDynTypedArrayOps *ops) {
if (d && d->kind == SCR_DYN_TYPED_REF) d->v.typed_ref.array = ops;
}
static bool scr_typed_ref_canonical_index(const char *data, size_t len, size_t *out) {
if (len == 0 || len > 10 || (len > 1 && data[0] == '0')) return false;
uint64_t index = 0;
for (size_t i = 0; i < len; i++) {
if (data[i] < '0' || data[i] > '9') return false;
index = index * 10 + (uint64_t)(data[i] - '0');
}
if (index >= 4294967295ULL) return false;
*out = (size_t)index;
return true;
}
static bool scr_typed_ref_is_length(const ScrStr *key) {
return key->len == 6 && memcmp(key->data, "length", 6) == 0;
}
ScrDyn *scr_dyn_typed_ref_element(const ScrDyn *d, size_t index) {
ScrArr *arr = d->v.typed_ref.ptr;
if (index >= arr->len) return NULL;
double state = scr_arr_state(arr, (double)index);
if (state == (double)SCR_ARR_VALUE) return d->v.typed_ref.array->get(arr, (double)index);
if (state == (double)SCR_ARR_UNDEFINED) return scr_dyn_retain(scr_dyn_undefined());
return NULL;
}
ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key) {
if (!d->v.typed_ref.array) return NULL;
if (scr_typed_ref_is_length(key)) return scr_dyn_new_num((double)((ScrArr *)d->v.typed_ref.ptr)->len);
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return NULL;
return scr_dyn_typed_ref_element(d, index);
}
/* An own `length` or element answers `in`/hasOwn without the snapshot. */
static bool scr_typed_ref_array_has_own(const ScrDyn *d, const ScrStr *key) {
if (!d->v.typed_ref.array) return false;
if (scr_typed_ref_is_length(key)) return true;
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false;
ScrArr *arr = d->v.typed_ref.ptr;
return index < arr->len && scr_arr_state(arr, (double)index) != (double)SCR_ARR_HOLE;
}
/* Writes an element in place or appends at `length`. False (nothing done)
* when the value does not fit the element type or the write could grow the
* array by more than one element: the snapshot path owns those outcomes. */
static bool scr_typed_ref_array_set(ScrDyn *d, const ScrStr *key, const ScrDyn *value) {
const ScrDynTypedArrayOps *ops = d->v.typed_ref.array;
if (!ops || !ops->set) return false;
size_t index;
if (!scr_typed_ref_canonical_index(key->data, key->len, &index)) return false;
ScrArr *arr = d->v.typed_ref.ptr;
if (index > arr->len) return false;
return ops->set(arr, (double)index, value);
}
void *scr_dyn_typed_ref_cached_cast(
const ScrDyn *d, const char *type_key, size_t type_key_len) {
for (ScrDynTypedCast *cast = d->v.typed_ref.casts; cast;
@@ -3433,6 +3503,27 @@ static ScrDyn *scr_native_iterator_value(ScrNativeIterator *iterator, bool *done
/* An inherited indexed getter can advance this same iterator recursively.
* Keep the source alive even if that nested call exhausts the iterator. */
if (source) scr_dyn_retain(source);
/* A live native array answers an in-range own element directly. Holes
* (prototype lookups) and completion fall through to the view below. */
if (source && source->kind == SCR_DYN_TYPED_REF && source->v.typed_ref.array &&
iterator->kind == 0 && !iterator->array_like &&
iterator->index < (double)((ScrArr *)source->v.typed_ref.ptr)->len) {
double index = iterator->index;
ScrDyn *value = iterator->selection == 1 ? scr_dyn_new_num(index)
: scr_dyn_typed_ref_element(source, (size_t)index);
if (value) {
if (iterator->selection == 2) {
ScrDyn *entry = scr_dyn_new_arr();
scr_dyn_arr_push(entry, scr_dyn_new_num(index));
scr_dyn_arr_push(entry, value);
value = entry;
}
iterator->index++;
scr_dyn_release(source);
*done_out = false;
return value;
}
}
/* Native array capsules retain the live array, so refresh its view at each
* step rather than iterating a snapshot taken when the iterator opened. */
ScrDyn *view = source && source->kind == SCR_DYN_TYPED_REF ? scr_dyn_typed_ref_materialize(source) : NULL;
@@ -5504,6 +5595,7 @@ bool scr_dyn_has_key(const ScrDyn *v, const ScrStr *key) {
if (v->kind == SCR_DYN_PROXY) return scr_dyn_proxy_has(v, key);
if (v->kind == SCR_DYN_JSVAL) return scr_dyn_isl_fence(v, "'in'");
if (v->kind == SCR_DYN_TYPED_REF) {
if (scr_typed_ref_array_has_own(v, key)) return true;
ScrDyn *materialized = scr_dyn_typed_ref_materialize(v);
bool out = scr_dyn_has_key(materialized, key);
scr_dyn_release(materialized);
@@ -5620,6 +5712,7 @@ void scr_dyn_key_set(ScrDyn *recv, ScrStr *key, ScrDyn *value) {
if (scr_dyn_class_reflection_fence(recv)) return;
if (recv->kind == SCR_DYN_PROXY) { scr_dyn_proxy_set(recv, key, value); return; }
if (recv->kind == SCR_DYN_TYPED_REF) {
if (scr_typed_ref_array_set(recv, key, value)) return;
ScrDyn *materialized = scr_dyn_typed_ref_materialize(recv);
scr_dyn_key_set(materialized, key, value);
if (!scr_exc_pending()) scr_dyn_typed_ref_commit(recv);
@@ -8645,6 +8738,11 @@ static bool scr_dyn_canonical_own_index(const ScrStr *key, size_t length) {
bool scr_dyn_has_own(const ScrDyn *v, const ScrStr *key) {
if (v->kind == SCR_DYN_TYPED_REF) {
/* Own canonical indexes never consult the prototype: a hole is absent. */
size_t index;
if (v->v.typed_ref.array &&
(scr_typed_ref_is_length(key) || scr_typed_ref_canonical_index(key->data, key->len, &index)))
return scr_typed_ref_array_has_own(v, key);
ScrDyn *view = scr_dyn_typed_ref_materialize(v);
if (!view) return false;
bool result = scr_dyn_has_own(view, key);
+32 -3
View File
@@ -4368,6 +4368,20 @@ _Static_assert(sizeof(ScrDynEntry) == (sizeof(void *) == 8 ? 48 : 24),
_Static_assert(offsetof(ScrDynEntry, value) == 2 * sizeof(void *),
"LLVM checked-object readers use the third pointer-sized field");
/* Element access for a live typed reference to a native ScrArr, emitted per
* array type by the compiler. The runtime decides presence and length from
* the native array itself; these hooks only convert one element.
* get: +1 dyn view of the element at a dense index whose state is
* SCR_ARR_VALUE, built exactly as the whole-array snapshot builds it.
* set: stores `value` at index (index <= length) when it fits the element
* type and returns true; returns false with no side effects when it
* does not, so the caller can take the snapshot path and report the
* mismatch with its usual diagnostic. */
typedef struct ScrDynTypedArrayOps {
ScrDyn *(*get)(void *arr, double index);
bool (*set)(void *arr, double index, const ScrDyn *value);
} ScrDynTypedArrayOps;
struct ScrDyn {
size_t rc; /* SIZE_MAX = immortal (unused for dyn; kept per convention) */
ScrDynKind kind;
@@ -4429,17 +4443,21 @@ struct ScrDyn {
void *(*retain)(void *);
void (*release)(void *);
const char *type_key;
size_t type_key_len;
ScrDyn *(*materialize)(void *);
void (*commit)(void *, const ScrDyn *);
/* Live native arrays: per-element access that bypasses the whole-array
* snapshot (scr_dyn_typed_ref_bind_array). NULL for other referents. */
const ScrDynTypedArrayOps *array;
/* Both caches belong to this capsule. ReadableStream canonicalizes
* capsules for repeated source references, so the refreshed dyn view
* and every structurally converted static view keep JS reference
* identity while the source reference remains observable. */
ScrDyn *materialized;
ScrDynTypedCast *casts;
bool traced; /* native referent has a cycle header */
bool observed; /* native release reports this identity's disposal */
/* One word keeps this, the largest payload, at its previous size. */
uint32_t type_key_len : 30;
uint32_t traced : 1; /* native referent has a cycle header */
uint32_t observed : 1; /* native release reports this identity's disposal */
} typed_ref;
/* SCR_DYN_PROMISE: the retained promise. The boundary contract: it
* settles with a dyn payload (SCR_EXC_REF ScrDyn fulfillment or a
@@ -4706,6 +4724,17 @@ void *scr_dyn_typed_ref_unbox(const ScrDyn *d); /* +1 */
ScrDyn *scr_dyn_class_view_unavailable(void *ptr);
ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d); /* +1 */
void scr_dyn_typed_ref_commit(ScrDyn *d);
/* Attach per-element access to a live array capsule (idempotent). Indexed
* reads, `length`, in-range writes, `in`, own-property probes, and array
* iteration then touch one element instead of rebuilding the snapshot. */
void scr_dyn_typed_ref_bind_array(ScrDyn *d, const ScrDynTypedArrayOps *ops);
/* Fast keyed read on a live array capsule: +1 value for `length` and for
* present or explicitly-undefined elements; NULL when the answer needs the
* snapshot (holes, out-of-range, non-index names, or no bound ops). */
ScrDyn *scr_dyn_typed_ref_key_get(const ScrDyn *d, const ScrStr *key);
/* +1 view of an own element of a bound live array capsule; NULL for a hole
* or an index at or past its length. */
ScrDyn *scr_dyn_typed_ref_element(const ScrDyn *d, size_t index);
void *scr_dyn_typed_ref_cached_cast(
const ScrDyn *d, const char *type_key, size_t type_key_len); /* +1/NULL */
void scr_dyn_typed_ref_cache_cast(
+27
View File
@@ -11,6 +11,33 @@
#include <pthread.h>
#endif
#if defined(__linux__)
#include <fcntl.h>
#include <sys/resource.h>
#include <unistd.h>
/* Every worker's mailbox holds descriptors. Once a process passes 64 of
* them, the kernel grows its descriptor table, and while other threads share
* the table it first waits a full RCU grace period (synchronize_rcu in
* expand_fdtable): measured 11-25 ms on a 16-vCPU host, on the thread that
* starts the next worker. Growing the table at load, while the process still
* has one thread, never waits; the table never shrinks. 256 entries leave
* room for the most workers the allocator gives arenas to. */
__attribute__((constructor)) static void scr_worker_reserve_fd_table(void) {
const int top = 255;
struct rlimit limit;
if (getrlimit(RLIMIT_NOFILE, &limit) == 0 && limit.rlim_cur != RLIM_INFINITY && limit.rlim_cur <= (rlim_t)top)
return;
for (int fd = 0; fd <= 2; fd++) {
int copy = fcntl(fd, F_DUPFD_CLOEXEC, top);
if (copy >= 0) {
(void)close(copy);
return;
}
}
}
#endif
struct ScrWorkerThread {
atomic_size_t references;
atomic_bool cancel;
+25
View File
@@ -54,7 +54,32 @@ static int entry(uint32_t root_id) {
return (int)root_id;
}
#if defined(__linux__)
#include <stdlib.h>
#include <string.h>
#include <sys/resource.h>
/* Worker builds grow the descriptor table at load, while the process has one
* thread: growing it later, with threads sharing it, waits a full RCU grace
* period on the thread that opens the next worker's mailbox. */
static void check_fd_table_reserved(void) {
struct rlimit limit;
if (getrlimit(RLIMIT_NOFILE, &limit) != 0 || (limit.rlim_cur != RLIM_INFINITY && limit.rlim_cur <= 255)) return;
FILE *status = fopen("/proc/self/status", "r");
assert(status);
char line[256];
long size = -1;
while (fgets(line, sizeof line, status))
if (strncmp(line, "FDSize:", 7) == 0) size = strtol(line + 7, NULL, 10);
fclose(status);
assert(size >= 256);
}
#endif
int main(void) {
#if defined(__linux__)
check_fd_table_reserved();
#endif
scr_init();
scr_lib_init(0, NULL);
ScrMailbox *mailbox = scr_mailbox_new();
+1
View File
@@ -39,6 +39,7 @@ test("cycle traversal preserves counts, deep graphs, configured thresholds and g
[undefined, "1", "threshold=256 growth_cap=1"],
["1", "1", "threshold=1 growth_cap=1"],
[undefined, "3", "threshold=256 growth_cap=2"],
[undefined, "8", "threshold=256 growth_cap=8"],
[undefined, "64", "threshold=256 growth_cap=16"],
] as const) {
const env = { ...baseEnv };
+24 -12
View File
@@ -454,25 +454,38 @@ static size_t grow_dead_cycle_until_full_pass(size_t old_garbage) {
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. */
/* Passes that keep finding nothing widen the old level's fraction up to the
* configured cap and no further, two doublings per pass once two passes in a
* row have asked: at the cap 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 finds old garbage
* narrows the fraction at once, and a single pass that finds nothing after
* it does not widen it again. From where the ring below narrows it (two
* doublings under any cap of 4 or more, the base fraction under 1 and 2),
* the second pass in a row that finds nothing reaches the cap; widening a
* doubling per pass would not. 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 narrowed = FLOOR + FLOOR * cap / 2 + slack;
size_t due = FLOOR + FLOOR * cap;
/* The previous test left the fraction at the cap. Old garbage narrows it
* (see below); the pass after that is the first to find nothing and must
* not widen it; the second in a row widens it to the cap. */
grow_dead_cycle_until_full_pass(FLOOR / 2 - 64);
size_t live = grow_dead_cycle_until_full_pass(0);
check(live > due, "the growth fraction stopped widening below the cap");
check(live <= narrowed, "a pass that found old garbage did not narrow the growth fraction");
live = grow_dead_cycle_until_full_pass(0);
check(live <= narrowed, "a single pass that found nothing widened the growth fraction");
live = grow_dead_cycle_until_full_pass(0);
check(live > due, "two passes in a row that found nothing did not widen to the cap");
check(live <= due + slack, "the growth fraction widened past the cap");
live = grow_dead_cycle_until_full_pass(0);
check(live > due && live <= due + slack, "the growth fraction left 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
@@ -480,8 +493,7 @@ static void check_growth_cap_bounds_floating_garbage(void) {
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");
check(live <= narrowed, "a pass that found old garbage did not narrow the growth fraction");
}
static void check_deep_ring(void) {
+79
View File
@@ -71,6 +71,84 @@ static void typed_view_failure_tests(void) {
scr_str_release(source);
}
/* Live array capsules with bound element hooks answer per-element queries
* from the native array; only holes, out-of-range reads, and rejected
* values may fall back to materializing the whole array. */
static size_t live_array_materializations;
static ScrDyn *live_array_materialize(void *value) {
live_array_materializations++;
ScrArr *arr = value;
ScrDyn *view = scr_dyn_new_arr();
for (size_t i = 0; i < arr->len; i++) {
if (scr_arr_state(arr, (double)i) == (double)SCR_ARR_VALUE)
scr_dyn_arr_push(view, scr_dyn_new_num(scr_arr_get_f64(arr, (double)i)));
else scr_dyn_arr_push_hole(view);
}
return view;
}
static ScrDyn *live_array_get(void *arr, double index) {
return scr_dyn_new_num(scr_arr_get_f64(arr, index));
}
static bool live_array_set(void *arr, double index, const ScrDyn *value) {
if (value->kind != SCR_DYN_NUM) return false;
scr_arr_set_f64(arr, index, value->v.num);
return true;
}
static const ScrDynTypedArrayOps live_array_ops = { live_array_get, live_array_set };
static void live_array_ref_tests(void) {
ScrArr *arr = scr_arr_new(SCR_ELEM_F64, 4);
for (int i = 0; i < 4; i++) scr_arr_push_f64(arr, i * 10);
scr_arr_delete(arr, 2);
ScrDyn *capsule = scr_dyn_new_typed_ref(arr, scr_arr_retain_v, scr_arr_release_v,
"array<f64>", 10, live_array_materialize, NULL);
scr_dyn_typed_ref_bind_array(capsule, &live_array_ops);
live_array_materializations = 0;
ScrStr *length = S("length"), *one = S("1"), *two = S("2"), *four = S("4"), *five = S("5"), *lead = S("01");
ScrDyn *got = scr_dyn_typed_ref_key_get(capsule, length);
check(got && got->kind == SCR_DYN_NUM && got->v.num == 4, "live array length");
scr_dyn_release(got);
got = scr_dyn_typed_ref_key_get(capsule, one);
check(got && got->kind == SCR_DYN_NUM && got->v.num == 10, "live array element");
scr_dyn_release(got);
check(!scr_dyn_typed_ref_key_get(capsule, two), "live array hole needs the view");
check(!scr_dyn_typed_ref_key_get(capsule, four), "live array past length needs the view");
check(!scr_dyn_typed_ref_key_get(capsule, lead), "noncanonical index needs the view");
check(scr_dyn_has_key(capsule, one) && scr_dyn_has_own(capsule, one) && scr_dyn_has_own(capsule, length),
"live array own presence");
check(!scr_dyn_has_own(capsule, two) && !scr_dyn_has_own(capsule, four), "live array own absence");
check(live_array_materializations == 0, "live array reads never materialize");
ScrDyn *value = scr_dyn_new_num(7);
scr_dyn_key_set(capsule, one, value);
scr_dyn_key_set(capsule, two, value);
scr_dyn_key_set(capsule, four, value);
scr_dyn_release(value);
check(arr->len == 5 && scr_arr_get_f64(arr, 1) == 7 && scr_arr_get_f64(arr, 2) == 7 &&
scr_arr_get_f64(arr, 4) == 7, "live array writes and appends land natively");
check(live_array_materializations == 0, "live array writes never materialize");
ScrDyn *text = scr_dyn_new_str(one);
scr_dyn_key_set(capsule, one, text);
scr_dyn_release(text);
check(live_array_materializations == 1 && scr_arr_get_f64(arr, 1) == 7,
"rejected value takes the snapshot path without a native write");
ScrStr *seven = S("7");
value = scr_dyn_new_num(1);
scr_dyn_key_set(capsule, seven, value);
scr_dyn_release(value);
check(live_array_materializations == 2 && arr->len == 5, "growth past length takes the snapshot path");
check(!scr_dyn_has_key(capsule, five) && live_array_materializations == 3,
"absent index consults the view");
check(!scr_exc_pending(), "live array fallbacks raise nothing without a commit");
scr_str_release(length); scr_str_release(one); scr_str_release(two);
scr_str_release(four); scr_str_release(five); scr_str_release(seven); scr_str_release(lead);
scr_dyn_release(capsule);
scr_arr_release(arr);
}
/* Parse `text`, expect success, return the checked-dynamic tree (+1). */
static ScrDyn *parse_ok(const char *text, const char *name) {
ScrStr *t = S(text);
@@ -560,6 +638,7 @@ int main(void) {
json_callback_tests();
typed_view_failure_tests();
live_array_ref_tests();
printf("%d/%d checks passed\n", checks - failures, checks);
return failures == 0 ? 0 : 1;
@@ -0,0 +1,152 @@
// A static array passed where a dynamically typed array is expected travels
// as a live reference: element reads, length, writes, presence, iteration,
// and push/pop/at act on the original array, and changes made through
// either view are visible through the other. The 100k-element loops pin
// that each access costs one element rather than a copy of the array.
type Slot = object | undefined;
type Loose = any[];
const N = 100_000;
function sumByIndex(xs: Slot[]): number {
let total = 0;
for (let i = 0; i < xs.length; i++) total += xs[i] as unknown as number;
return total;
}
function scale(xs: Loose, factor: number): void {
for (let i = 0; i < xs.length; i++) xs[i] = (xs[i] as number) * factor;
}
function countPresent(xs: Slot[]): number {
let present = 0;
for (let i = 0; i < xs.length; i++) if (i in xs && Object.hasOwn(xs, i)) present++;
return present;
}
function sumByIteration(xs: Slot[]): number {
let total = 0;
for (const value of xs) total += value as unknown as number;
return total;
}
function churn(xs: Loose, count: number): number {
for (let i = 0; i < count; i++) xs.push(i);
let total = 0;
for (let i = 0; i < count; i++) total += (xs.at(-1 - i) as number) - (xs.at(i) as number);
for (let i = 0; i < count; i++) total += xs.pop() as number;
return total;
}
function writeThrough(xs: Slot[], index: number, value: unknown): void {
(xs as unknown[])[index] = value;
}
// Large live arrays: every loop would take minutes if an access rebuilt
// the whole array.
const big: number[] = [];
for (let i = 0; i < N; i++) big.push(i);
const bigView = big as unknown as Slot[];
console.log("sum", sumByIndex(bigView), sumByIteration(bigView));
scale(big, 2);
console.log("scaled", big[0], big[1], big[N - 1], sumByIndex(bigView));
console.log("present", countPresent(bigView));
console.log("churn", churn(big, N), big.length, big[N - 1]);
big.length = 10;
console.log("truncated", bigView.length, bigView[9], bigView[10]);
const floats = new Float64Array(N);
for (let i = 0; i < N; i++) floats[i] = i / 2;
console.log("typed array", sumByIndex(floats as unknown as Slot[]), sumByIteration(floats as unknown as Slot[]));
interface Point { x: number; y: number }
const many: Point[] = [];
for (let i = 0; i < N; i++) many.push({ x: i, y: -i });
const manyView = many as unknown as Slot[];
let nested = 0;
for (let i = 0; i < manyView.length; i++) {
const point = manyView[i] as Point;
point.y = point.x * 2;
nested += point.x;
}
console.log("nested", nested, many[N - 1]!.y, (manyView[N - 1] as Point).y);
// Reads, length, holes, and out-of-range indexes through the view.
const nums: number[] = [1, 2, 3, 4];
const view = nums as unknown as Slot[];
console.log("len", view.length, "first", view[0], "last", view[view.length - 1], "oob", view[view.length], view[-1 as number]);
console.log("in", 0 in view, view.length in view, "length" in view, Object.hasOwn(view, 3), Object.hasOwn(view, 4));
// Writes are visible both ways.
writeThrough(view, 1, 20);
console.log("native sees", nums[1], nums.length);
nums[2] = 30;
console.log("view sees", view[2], view.length);
nums.push(5);
console.log("view after native push", view.length, view[4]);
writeThrough(view, 5, 6);
console.log("append", nums.length, nums[5]);
writeThrough(view, 8, 9);
console.log("gap", nums.length, JSON.stringify(nums), 6 in nums, 7 in view, Object.hasOwn(view, 7));
console.log("hole reads", view[6], view[7]);
nums.length = 3;
console.log("native truncate", view.length, view[3], JSON.stringify(view));
// Holes in the original array.
const holey: number[] = [1, , 3] as number[];
const holeyView = holey as unknown as Slot[];
console.log("holey", holeyView.length, holeyView[1], 1 in holeyView, holeyView[2]);
writeThrough(holeyView, 1, 2);
console.log("filled", JSON.stringify(holey), 1 in holey);
let holeySum = 0;
const sparse: number[] = [1, , 3] as number[];
for (const value of sparse as unknown as Slot[]) holeySum += value === undefined ? 100 : (value as unknown as number);
console.log("holey iteration", holeySum);
// Strings and booleans.
const words: string[] = ["a", "b"];
const wordsView = words as unknown as Slot[];
writeThrough(wordsView, 0, "z");
(wordsView as unknown as Loose).push("c");
console.log("words", words.join(""), wordsView[1], wordsView.length, (wordsView as unknown as Loose).at(-1));
const flags: boolean[] = [true, false];
const flagsView = flags as unknown as Slot[];
writeThrough(flagsView, 1, true);
console.log("flags", flags[1], flagsView[0], (flagsView as unknown as Loose).pop(), flags.length);
// Nested objects keep identity and liveness both ways.
const points: Point[] = [{ x: 1, y: 2 }, { x: 3, y: 4 }];
const pointsView = points as unknown as Slot[];
const p0 = pointsView[0] as Point;
console.log("nested", p0.x, pointsView[0] === pointsView[0], p0 === pointsView[0], (pointsView[1] as Point).y);
p0.x = 10;
console.log("native nested", points[0]!.x);
points[1]!.y = 40;
console.log("view nested", (pointsView[1] as Point).y);
writeThrough(pointsView, 0, { x: 7, y: 8 });
console.log("replaced", points[0]!.x, (pointsView[0] as Point).y, p0.x);
points[1] = { x: 5, y: 6 };
console.log("native replaced", (pointsView[1] as Point).x);
const maybe: (Point | undefined)[] = [{ x: 1, y: 1 }, undefined];
const maybeView = maybe as unknown as Slot[];
writeThrough(maybeView, 1, { x: 2, y: 2 });
writeThrough(maybeView, 0, undefined);
console.log("optional", maybe[0] === undefined, maybe[1]!.x, maybeView[0], (maybeView[1] as Point).y);
// Nested arrays.
const grid: number[][] = [[1, 2], [3, 4]];
const gridView = grid as unknown as Slot[];
const row = gridView[1] as number[];
row[0] = 30;
console.log("grid", grid[1]![0], (gridView[0] as number[]).length, gridView.length);
grid[0]!.push(99);
console.log("grid push", (gridView[0] as number[])[2], (gridView[0] as number[]).length);
// Iteration sees elements appended during the loop.
const growing: number[] = [1, 2, 3];
let total = 0;
for (const value of growing as unknown as Slot[]) {
total += value as unknown as number;
if (total === 1) growing.push(4);
}
console.log("for-of", total);
const keys: string[] = [];
for (const key in growing as unknown as Slot[]) keys.push(key);
console.log("for-in", keys.join(","), JSON.stringify(Object.keys(growing as unknown as Slot[])));
console.log("isArray", Array.isArray(growing as unknown as Slot[]), typeof (growing as unknown as Slot[]));
@@ -0,0 +1,125 @@
// An optional chain binds its receiver once. A receiver projected from a
// stable owner (`owner.field?.member`) is borrowed while the chain's body
// cannot remove a reference edge, and the bound value is borrowed by the body
// whenever the chain's receiver keeps it alive. Bodies that replace or clear
// the receiver's field, or drop the last other reference, must still see the
// value the chain bound first, exactly as Node does.
class Sym {
flags: number;
name: string;
constructor(name: string, flags: number) {
this.name = name;
this.flags = flags;
}
}
class Table {
members: Map<string, Sym> | undefined = undefined;
first: Sym | undefined = undefined;
items: (Sym | undefined)[] = [];
next: Table | undefined = undefined;
onRead: ((s: string) => number) | undefined = undefined;
label(): string {
return `table(${this.members?.size ?? 0})`;
}
}
class Owner {
table: Table | undefined;
constructor(table: Table | undefined) {
this.table = table;
}
}
function lookup(t: Table, name: string): Sym | undefined {
const s = t.members?.get(name);
if (s !== undefined && s.flags !== 0) return s;
return undefined;
}
function deep(o: Owner, name: string): number {
return o.table?.next?.members?.get(name)?.flags ?? -1;
}
let cleared = 0;
function clearAndCount(o: Owner): number {
o.table = undefined;
cleared++;
return cleared;
}
function replaceMembers(t: Table): Map<string, Sym> {
const fresh = new Map<string, Sym>();
fresh.set("fresh", new Sym("fresh", 9));
t.members = fresh;
return fresh;
}
function makeTable(n: number): Table {
const t = new Table();
const m = new Map<string, Sym>();
for (let i = 0; i < n; i++) m.set(`k${i}`, new Sym(`k${i}`, i));
t.members = m;
t.first = new Sym("first", 1);
t.items = [new Sym("a", 1), undefined, new Sym("c", 3)];
return t;
}
const t = makeTable(4);
console.log(lookup(t, "k2")?.name, lookup(t, "k0")?.name, lookup(t, "zz")?.name);
// The field is cleared by the body's argument: the chain still reads the map
// it bound first, and the map stays alive for the whole call.
const o = new Owner(t);
console.log(o.table?.members?.get(`k${clearAndCount(o)}`)?.name, o.table === undefined);
// The body replaces the field the receiver was read from.
const t2 = makeTable(3);
console.log(t2.members?.get(replaceMembers(t2).has("fresh") ? "k1" : "k2")?.name, t2.members?.size);
// The only other reference disappears inside the body.
const holder = new Owner(makeTable(2));
const flags = holder.table?.first?.flags;
const read = holder.table?.items.map((s) => {
holder.table = undefined;
return s?.name ?? "-";
});
console.log(flags, read?.join(","), holder.table === undefined);
// Nested chains over stable owners, including missing links.
const a = makeTable(2);
const b = makeTable(5);
a.next = b;
console.log(deep(new Owner(a), "k4"), deep(new Owner(b), "k4"), deep(new Owner(undefined), "k1"));
// Chains over call results and element reads.
function current(): Table | undefined {
return a;
}
console.log(current()?.members?.get("k1")?.name, a.items[2]?.name, a.items[1]?.name ?? "hole");
// Optional calls through a field that the callback itself clears.
function callRead(t: Table, s: string): number | undefined {
return t.onRead?.(s);
}
const c = makeTable(1);
c.onRead = (s: string): number => {
c.onRead = undefined;
return s.length;
};
console.log(callRead(c, "abc"), callRead(c, "abcd"), c.label());
// Values read through a chain outlive later stores to its receiver.
const d = makeTable(3);
const kept = d.members?.get("k2");
d.members = undefined;
console.log(kept?.name, kept?.flags, lookup(d, "k2"));
// A loop that exercises the borrowed lookup heavily.
let total = 0;
for (let i = 0; i < 2000; i++) {
const s = b.members?.get(`k${i % 7}`);
total += s?.flags ?? 100;
}
console.log(total);
@@ -0,0 +1,44 @@
import { Item, emptyItems } from "./items.ts";
export function compareLists(a: readonly Item[] | undefined, b: readonly Item[] | undefined): number {
const x = a ?? emptyItems;
const y = b ?? emptyItems;
if (x.length !== y.length) return x.length - y.length;
for (let i = 0; i < x.length; i++) {
const r = x[i]!.name < y[i]!.name ? -1 : x[i]!.name > y[i]!.name ? 1 : 0;
if (r !== 0) return r;
}
return 0;
}
/** Appends `count` fillers to `list`, which also owns `keep`, then reads `keep`. */
export function appendMany(list: Item[], keep: Item, count: number, filler: Item): number {
for (let i = 0; i < count; i++) list.push(filler);
return keep.name.length;
}
const ids = new Int32Array(new SharedArrayBuffer(4));
export function labelOf(item: Item): string {
if (item.id === 0) item.id = Atomics.add(ids, 0, 1) + 1;
return `${item.name}#${item.id}`;
}
export function snapshot(list: readonly Item[], extra: Item): Item[] {
const head = list.slice(0, 2);
const out = [extra, head[0]!, head[1]!];
out.push(extra);
return out;
}
/** Removes the element that also arrives as `keep`, then reads `keep`. */
export function shiftThenRead(list: Item[], keep: Item): string {
const removed = list.shift()?.name.length ?? -1;
return `${keep.name}/${removed}`;
}
/** Truncates the array that owns `keep`, then reads `keep`. */
export function truncateThenRead(list: Item[], keep: Item): string {
list.length = 0;
return keep.name;
}
@@ -0,0 +1,38 @@
import { compareLists } from "./compare.ts";
export class Item {
name: string;
id = 0;
constructor(name: string) {
this.name = name;
}
}
export class Holder {
list: Item[] = [];
short: Item[] | undefined = undefined;
first: Item;
constructor(first: Item) {
this.first = first;
}
}
export function makeHolder(n: number): Holder {
const h = new Holder(new Item("first"));
for (let i = 0; i < n; i++) h.list.push(new Item(`item${i}`));
h.short = [new Item("s0"), new Item("s1")];
return h;
}
// Read while the cycle is still evaluating: the comparator sees its empty
// list binding before its declaration ran.
let early = "";
try {
early = String(compareLists(undefined, undefined));
} catch (error) {
early = error instanceof ReferenceError ? "tdz" : "other";
}
console.log("early", early);
export const emptyItems: readonly Item[] = [];
console.log("late", compareLists(undefined, []));
@@ -0,0 +1,51 @@
// Callees that append to arrays, build fresh arrays, bump an atomic id
// counter or read a module binding behind its import-cycle TDZ check remove
// no reference edge, so callers pass field projections borrowed. The values
// must stay intact even when the callee appends to the very array that owns
// a borrowed argument and moves its storage.
import { Holder, Item, makeHolder } from "./items.ts";
import { appendMany, compareLists, labelOf, shiftThenRead, snapshot, truncateThenRead } from "./compare.ts";
const h = makeHolder(3);
const filler = new Item("filler");
console.log(appendMany(h.list, h.list[0]!, 200, filler), h.list.length, h.list[0]!.name);
console.log(labelOf(h.first), labelOf(h.first), labelOf(h.list[5]!));
console.log(snapshot(h.list, h.first).map((i: Item) => i.name).join(","));
const other: Holder = makeHolder(3);
console.log(compareLists(h.short, other.short), compareLists(undefined, other.short), compareLists(h.short, undefined));
other.short = undefined;
console.log(compareLists(h.short, other.short), compareLists(other.short, other.short));
let sum = 0;
for (let i = 0; i < 300; i++) sum += appendMany(h.list, h.list[i % 7]!, 1, filler);
console.log(sum, h.list.length);
function removals(d: Holder): string {
d.list[0] = new Item("only-in-list");
const shifted = shiftThenRead(d.list, d.list[0]!);
d.list[0] = new Item("truncated");
const truncated = truncateThenRead(d.list, d.list[0]!);
return `${shifted} ${truncated} ${d.list.length}`;
}
console.log(removals(makeHolder(2)));
// Loop bindings borrow their element while the loop body preserves edges:
// appending (and moving the storage) keeps them; removing them must not.
function grow(list: Item[], filler: Item): number {
let total = 0;
for (let i = 0; i < 6; i++) {
const x = list[i]!;
total += appendMany(list, x, 64, filler) + x.name.length;
}
return total;
}
function drain(list: Item[]): string {
let out = "";
for (const x of list) out += shiftThenRead(list, x) + ";";
return out;
}
const g = makeHolder(6);
console.log(grow(g.list, filler), g.list.length);
const e = makeHolder(6);
console.log(drain(e.list), e.list.length);
@@ -0,0 +1,10 @@
export const SLASH = 0x2f;
export const DOT = 0x2e;
export const NEGATIVE = -1;
export const HALF = 0.5;
export const ENABLED = true;
export const MINUS_ZERO = -0;
export let counter = 0;
export function bump(): number {
return ++counter;
}
@@ -0,0 +1,51 @@
// Module constants read as their literal values; bindings that change or
// can be read before their declaration keep their global.
import * as ns from "./consts.ts";
import { DOT, ENABLED, HALF, MINUS_ZERO, NEGATIVE, SLASH, bump, counter } from "./consts.ts";
const COLON = 0x3a;
const LIMIT = 3;
function classify(code: number): string {
switch (code) {
case SLASH:
return "slash";
case DOT:
return "dot";
case COLON:
return "colon";
case NEGATIVE:
return "negative";
default:
return "other";
}
}
const path = "a/b.c:d";
const kinds: string[] = [];
for (let i = 0; i < path.length; i++) kinds.push(classify(path.charCodeAt(i)));
console.log(kinds.join(","), classify(-1), classify(NaN));
console.log(HALF * 3, ENABLED && LIMIT > 2, Object.is(MINUS_ZERO, -0), 1 / MINUS_ZERO);
console.log(ns.DOT, ns.SLASH, Object.keys(ns).sort().join(" "));
// A mutable export keeps observing writes.
bump();
bump();
console.log(counter, ns.counter);
// A function hoisted above the declaration it reads still throws in the TDZ.
try {
console.log(early());
} catch (error) {
console.log((error as Error).name, (error as Error).message);
}
const LATE = 7;
function early(): number {
return LATE * 2;
}
console.log(early());
// A loop binding at module scope is written once per iteration.
const totals: number[] = [];
for (const step of [1, 2, 3]) totals.push(step * LIMIT);
console.log(totals.join(" "));
+112
View File
@@ -0,0 +1,112 @@
// Indexes that are not proven integers: typed-array reads, inline ASCII
// charCodeAt, local array reads and numeric-keyed lookups decide validity
// with a signed round trip. Every index shape must keep Node's answer.
const odd: number[] = [
0, -0, 1, 2.5, -1, -0.5, 0.5, 3, 4, 7, 8, 1e-300, 2 ** 31, 2 ** 32, 2 ** 52, 2 ** 53, 2 ** 53 + 2,
2 ** 63, 2 ** 64, -(2 ** 63), 1e300, -1e300, Infinity, -Infinity, NaN,
];
// Values the compiler cannot see through.
function opaque(values: number[], i: number): number {
return values[i % values.length]!;
}
class Cursor {
pos = 0;
readonly units: Uint16Array;
readonly bytes: Uint8Array;
readonly words: Int32Array;
readonly text: string;
constructor(text: string) {
this.text = text;
this.units = new Uint16Array(text.length);
for (let i = 0; i < text.length; i++) this.units[i] = text.charCodeAt(i);
this.bytes = new Uint8Array([1, 2, 3, 250, 255]);
this.words = new Int32Array([-1, 2147483647, -2147483648, 7]);
}
unit(): number {
return this.units[this.pos];
}
byte(): number {
return this.bytes[this.pos];
}
word(): number {
return this.words[this.pos];
}
code(): number {
return this.text.charCodeAt(this.pos);
}
// ToInt32 / ToUint32 of a read that may be missing (NaN -> 0).
mixed(): number {
return (this.units[this.pos] ^ 0x5a5a) + (this.bytes[this.pos] | 0) + (this.words[this.pos] >>> 0);
}
}
const cursor = new Cursor("scriptc!");
const rows: string[] = [];
for (let k = 0; k < odd.length; k++) {
cursor.pos = opaque(odd, k);
rows.push(
[
String(cursor.pos),
String(cursor.unit()),
String(cursor.byte()),
String(cursor.word()),
String(cursor.code()),
String(cursor.mixed()),
].join(" "),
);
}
console.log(rows.join("\n"));
// Local arrays read by an unproven index: holes, out-of-range and fractions.
function arrayReads(index: number): string {
const values: (string | undefined)[] = ["a", "b", undefined, "d"];
const numbers = [10, 20, 30];
return `${values[index]}|${numbers[index]}|${numbers[index] ?? "none"}`;
}
for (let k = 0; k < odd.length; k++) console.log(arrayReads(opaque(odd, k)));
// A non-ASCII string keeps the runtime path for every index.
const wide = "héllo😀";
for (let k = 0; k < odd.length; k++) {
const i = opaque(odd, k);
console.log(i, wide.charCodeAt(i), "abc".charCodeAt(i));
}
// Numeric-keyed maps with a dense integer index.
const byIndex = new Map<number, string>();
for (let i = 0; i < 6; i++) byIndex.set(i, `v${i}`);
for (let k = 0; k < odd.length; k++) {
const key = opaque(odd, k);
console.log(key, byIndex.get(key) ?? "absent", byIndex.has(key));
}
// A hash over a range whose bounds come from fields.
function rangeHash(chars: Uint16Array, start: number, end: number): number {
let hash = 0x811c9dc5 | 0;
for (let i = start; i < end; i++) hash = Math.imul(hash ^ chars[i], 0x01000193);
return hash ^ (hash >>> 15);
}
console.log(
rangeHash(cursor.units, 0, 8),
rangeHash(cursor.units, 2, 12),
rangeHash(cursor.units, -3, 2),
rangeHash(cursor.units, 0.5, 4),
rangeHash(cursor.units, opaque(odd, 1), opaque(odd, 3)),
);
// Counted loops from an unproven start run as integer loops only when the
// start is an exact integer; -0, fractions, NaN and huge starts keep the
// double loop, and the counter's own value stays observable.
function observe(chars: Uint16Array, start: number, end: number): string {
const seen: string[] = [];
for (let i = start; i < end; i++) {
seen.push(`${Object.is(i, -0) ? "-0" : i}:${chars[i] ^ 1}:${(i * 2) | 0}`);
if (seen.length > 6) break;
}
return seen.join(" ");
}
for (const start of [0, -0, 1, 2.5, -2, NaN, 2 ** 53 - 2, -(2 ** 53), 1e300, -Infinity])
console.log(String(start), observe(cursor.units, opaque([start], 0), opaque([start + 4, 9], start === -Infinity ? 1 : 0)));
+127
View File
@@ -0,0 +1,127 @@
// Reference fields written only by their class's constructors may be read
// without a retain wherever their object is held. Every other field keeps
// its owning reads: a callee may replace it while the caller still uses the
// old value.
class Buffer2 {
readonly items: string[] = [];
label: string;
constructor(label: string) {
this.label = label;
}
push(item: string): number {
this.items.push(item);
return this.items.length;
}
describe(): string {
return `${this.label}:${this.items.join(",")}`;
}
}
class Owner {
// Final: written only here, twice, with a method call between the writes.
readonly primary: Buffer2;
// Not final: replaced by `rotate`.
current: Buffer2;
log: string[] = [];
constructor() {
this.primary = new Buffer2("first");
this.current = new Buffer2("c0");
this.log.push(this.peek());
this.primary = new Buffer2("primary");
this.log.push(this.peek());
}
peek(): string {
const primary = this.primary;
primary.push("peeked");
return primary.describe();
}
// The callee replaces `current` while the caller's receiver is the old one.
rotate(next: string): Buffer2 {
const old = this.current;
this.current = new Buffer2(next);
return old;
}
useCurrent(): string {
return this.current.push(this.rotate("c1").describe()) + " " + this.current.describe();
}
usePrimary(): string {
const before = this.primary.push("a");
const again = this.primary.push(this.rotate("c2").label);
return `${before} ${again} ${this.primary.describe()}`;
}
}
class Derived extends Owner {
readonly extra: Buffer2;
constructor() {
super();
this.extra = new Buffer2("extra");
this.extra.push(this.primary.describe());
}
report(): string {
return `${this.extra.describe()} / ${this.primary.describe()}`;
}
}
// A field a closure created in the constructor writes later is not final.
class Callbacks {
target: Buffer2;
readonly reset: () => void;
constructor() {
this.target = new Buffer2("t0");
this.reset = () => {
this.target = new Buffer2("t1");
};
}
run(): string {
return this.target.push(this.fire()) + " " + this.target.describe();
}
fire(): string {
this.reset();
return "fired";
}
}
// A field written through another object, outside any constructor.
class Node2 {
next: Node2 | undefined;
readonly name: string;
readonly payload: Buffer2;
constructor(name: string) {
this.name = name;
this.payload = new Buffer2(name);
}
}
function relink(a: Node2): string {
const first = a.next!;
a.next = new Node2("b2");
return first.payload.describe() + " -> " + a.next.payload.describe();
}
const owner = new Owner();
console.log(owner.log.join(" | "));
console.log(owner.useCurrent());
console.log(owner.usePrimary());
console.log(owner.current.describe());
const derived = new Derived();
console.log(derived.report());
const callbacks = new Callbacks();
console.log(callbacks.run());
const a = new Node2("a");
a.next = new Node2("b");
a.next.payload.push("x");
console.log(relink(a));
// Async methods keep owning reads across their suspensions.
class Pending {
readonly box: Buffer2 = new Buffer2("async");
async later(): Promise<string> {
const box = this.box;
box.push("before");
await Promise.resolve();
box.push("after");
return box.describe();
}
}
new Pending().later().then((text) => console.log(text));