diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts index 913502b03..25a735356 100644 --- a/packages/compiler/src/backend/llvm/common.ts +++ b/packages/compiler/src/backend/llvm/common.ts @@ -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. */ diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 40e959f0b..079ca898e 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -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(); diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8eed8fc65..42f598eaa 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -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 = 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 ( diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 7212f22ce..0c4cfbc8d 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -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 }; diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index 0d9caea3c..d5f731b9a 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -28,4 +28,6 @@ export type LibCallPrefix = LibCallPrefixOf; export interface LlStreamTypedRefAdapter { snapshot: string; commit: string; + /** `@sym` of the ScrDynTypedArrayOps table for live array capsules. */ + arrayOps?: string; } diff --git a/packages/compiler/src/backend/llvm/expr-control.ts b/packages/compiler/src/backend/llvm/expr-control.ts index 4f0d29977..634671057 100644 --- a/packages/compiler/src/backend/llvm/expr-control.ts +++ b/packages/compiler/src/backend/llvm/expr-control.ts @@ -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"); diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 0a63c6e23..f82269f66 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -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") { diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index 034150a51..d415ee091 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -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})`); diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 755bea02a..a438a2c95 100644 --- a/packages/compiler/src/backend/llvm/local-array-reads.ts +++ b/packages/compiler/src/backend/llvm/local-array-reads.ts @@ -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(), diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index a1e7dcb11..1c852e1ef 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -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 = 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" || diff --git a/packages/compiler/src/backend/llvm/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index 323387d79..7cbdbd97b 100644 --- a/packages/compiler/src/backend/llvm/string-reads.ts +++ b/packages/compiler/src/backend/llvm/string-reads.ts @@ -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`, + `}`, + ]; + }, }, ]; diff --git a/packages/compiler/src/ir/constant-globals.test.ts b/packages/compiler/src/ir/constant-globals.test.ts new file mode 100644 index 000000000..2f5627885 --- /dev/null +++ b/packages/compiler/src/ir/constant-globals.test.ts @@ -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); +}); diff --git a/packages/compiler/src/ir/constant-globals.ts b/packages/compiler/src/ir/constant-globals.ts new file mode 100644 index 000000000..f18764223 --- /dev/null +++ b/packages/compiler/src/ir/constant-globals.ts @@ -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(); + 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(); + const values = new Map(); + 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(); + 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; +} diff --git a/packages/compiler/src/ir/final-fields.test.ts b/packages/compiler/src/ir/final-fields.test.ts new file mode 100644 index 000000000..385c3fbde --- /dev/null +++ b/packages/compiler/src/ir/final-fields.test.ts @@ -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; + expect(analyzeFinalFields(library).size).toBe(0); +}); diff --git a/packages/compiler/src/ir/final-fields.ts b/packages/compiler/src/ir/final-fields.ts new file mode 100644 index 000000000..e37c13bd6 --- /dev/null +++ b/packages/compiler/src/ir/final-fields.ts @@ -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(); + private readonly familyOf = new Map(); + /** Disqualified families and the first reason, for tests. */ + readonly reasons = new Map(); + + constructor(private readonly classes: ReadonlyMap = 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(); + 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; +} diff --git a/packages/compiler/src/ir/int32-slots.ts b/packages/compiler/src/ir/int32-slots.ts index 2ef822e81..2baf80819 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -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 } { - 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 } { const dynTypes = new Map(); const dynNamed = new Map(); 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) => { diff --git a/packages/compiler/src/ir/integer-loops.test.ts b/packages/compiler/src/ir/integer-loops.test.ts index 61cea9810..89b2d57bf 100644 --- a/packages/compiler/src/ir/integer-loops.test.ts +++ b/packages/compiler/src/ir/integer-loops.test.ts @@ -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(); +}); diff --git a/packages/compiler/src/ir/integer-loops.ts b/packages/compiler/src/ir/integer-loops.ts index 3b46cadf5..6daaf5bd1 100644 --- a/packages/compiler/src/ir/integer-loops.ts +++ b/packages/compiler/src/ir/integer-loops.ts @@ -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 diff --git a/packages/compiler/test/call-lifetimes.test.ts b/packages/compiler/test/call-lifetimes.test.ts index 19ab0ff5a..dde71c67c 100644 --- a/packages/compiler/test/call-lifetimes.test.ts +++ b/packages/compiler/test/call-lifetimes.test.ts @@ -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"); }); diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index d97e0dee1..fdf9e4462 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15802,6 +15802,38 @@ "/tests/corpus/4660-typed-rest-spread-forwarding.ts" ], "diags": [] + }, + "/tests/corpus/4598-live-array-ref-element-access.ts": { + "order": [ + "/tests/corpus/4598-live-array-ref-element-access.ts" + ], + "diags": [] + }, + "/tests/corpus/4620-optional-chain-borrowed-receivers.ts": { + "order": [ + "/tests/corpus/4620-optional-chain-borrowed-receivers.ts" + ], + "diags": [] + }, + "/tests/corpus/4621-edge-preserving-callees/main.ts": { + "order": [ + "/tests/corpus/4621-edge-preserving-callees/compare.ts", + "/tests/corpus/4621-edge-preserving-callees/items.ts", + "/tests/corpus/4621-edge-preserving-callees/main.ts" + ], + "diags": [] + }, + "/tests/corpus/exact-index-round-trip.ts": { + "order": [ + "/tests/corpus/exact-index-round-trip.ts" + ], + "diags": [] + }, + "/tests/corpus/final-field-borrows.ts": { + "order": [ + "/tests/corpus/final-field-borrows.ts" + ], + "diags": [] } } } diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 52175b4eb..4610ec327 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -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; } } diff --git a/packages/runtime/src/scr_dyn_invoke.c b/packages/runtime/src/scr_dyn_invoke.c index 3a3c52947..2277ef598 100644 --- a/packages/runtime/src/scr_dyn_invoke.c +++ b/packages/runtime/src/scr_dyn_invoke.c @@ -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); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index f1c25f579..0ad8fa34a 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -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); diff --git a/packages/runtime/src/scr_runtime.h b/packages/runtime/src/scr_runtime.h index 9c9631ddf..f8d63537b 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -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( diff --git a/packages/runtime/src/scr_worker.c b/packages/runtime/src/scr_worker.c index 18312cef5..d9927e8c6 100644 --- a/packages/runtime/src/scr_worker.c +++ b/packages/runtime/src/scr_worker.c @@ -11,6 +11,33 @@ #include #endif +#if defined(__linux__) +#include +#include +#include + +/* 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; diff --git a/packages/runtime/src/scr_worker.test.c b/packages/runtime/src/scr_worker.test.c index f8ecee48a..12065b43d 100644 --- a/packages/runtime/src/scr_worker.test.c +++ b/packages/runtime/src/scr_worker.test.c @@ -54,7 +54,32 @@ static int entry(uint32_t root_id) { return (int)root_id; } +#if defined(__linux__) +#include +#include +#include + +/* 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(); diff --git a/packages/runtime/test/cycle.test.ts b/packages/runtime/test/cycle.test.ts index 580986c86..0e73752c7 100644 --- a/packages/runtime/test/cycle.test.ts +++ b/packages/runtime/test/cycle.test.ts @@ -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 }; diff --git a/packages/runtime/test/test_cycle.c b/packages/runtime/test/test_cycle.c index 3d3b6fa72..14519c619 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -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) { diff --git a/packages/runtime/test/test_json.c b/packages/runtime/test/test_json.c index ed5ff4f1b..7b0d2fd73 100644 --- a/packages/runtime/test/test_json.c +++ b/packages/runtime/test/test_json.c @@ -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", 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; diff --git a/tests/corpus/4598-live-array-ref-element-access.ts b/tests/corpus/4598-live-array-ref-element-access.ts new file mode 100644 index 000000000..bd7313d92 --- /dev/null +++ b/tests/corpus/4598-live-array-ref-element-access.ts @@ -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[])); diff --git a/tests/corpus/4620-optional-chain-borrowed-receivers.ts b/tests/corpus/4620-optional-chain-borrowed-receivers.ts new file mode 100644 index 000000000..5dfeddb5a --- /dev/null +++ b/tests/corpus/4620-optional-chain-borrowed-receivers.ts @@ -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 | 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 { + const fresh = new Map(); + 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(); + 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); diff --git a/tests/corpus/4621-edge-preserving-callees/compare.ts b/tests/corpus/4621-edge-preserving-callees/compare.ts new file mode 100644 index 000000000..a292b5b4f --- /dev/null +++ b/tests/corpus/4621-edge-preserving-callees/compare.ts @@ -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; +} diff --git a/tests/corpus/4621-edge-preserving-callees/items.ts b/tests/corpus/4621-edge-preserving-callees/items.ts new file mode 100644 index 000000000..e8b4972ef --- /dev/null +++ b/tests/corpus/4621-edge-preserving-callees/items.ts @@ -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, [])); diff --git a/tests/corpus/4621-edge-preserving-callees/main.ts b/tests/corpus/4621-edge-preserving-callees/main.ts new file mode 100644 index 000000000..50b0e264c --- /dev/null +++ b/tests/corpus/4621-edge-preserving-callees/main.ts @@ -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); diff --git a/tests/corpus/constant-module-globals/consts.ts b/tests/corpus/constant-module-globals/consts.ts new file mode 100644 index 000000000..14eccc3af --- /dev/null +++ b/tests/corpus/constant-module-globals/consts.ts @@ -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; +} diff --git a/tests/corpus/constant-module-globals/main.ts b/tests/corpus/constant-module-globals/main.ts new file mode 100644 index 000000000..f71b5c0a5 --- /dev/null +++ b/tests/corpus/constant-module-globals/main.ts @@ -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(" ")); diff --git a/tests/corpus/exact-index-round-trip.ts b/tests/corpus/exact-index-round-trip.ts new file mode 100644 index 000000000..51748a5b2 --- /dev/null +++ b/tests/corpus/exact-index-round-trip.ts @@ -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(); +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))); diff --git a/tests/corpus/final-field-borrows.ts b/tests/corpus/final-field-borrows.ts new file mode 100644 index 000000000..f98b7dc9f --- /dev/null +++ b/tests/corpus/final-field-borrows.ts @@ -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 { + const box = this.box; + box.push("before"); + await Promise.resolve(); + box.push("after"); + return box.describe(); + } +} +new Pending().later().then((text) => console.log(text));