From 25e3ece9d516476a819f946c4d21ed0d0d39a07f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:24:13 -0700 Subject: [PATCH 01/14] Access live array references one element at a time A static array passed where a dynamically typed array is expected (for example a `number[]` given to an `(object | undefined)[]` or `any[]` parameter) travels as a live typed reference. Every operation on that reference refreshed its dyn view by rebuilding the entire array, so `xs[i]`, `xs.length`, `xs[i] = v`, `i in xs`, `Object.hasOwn`, each `for...of` step, each `for...in` key probe, and `push`/`pop`/`at` all cost O(n), and any loop over the array was quadratic: 10k elements took 12 s and 20k took 53 s on Linux. Writes also re-converted and swapped the whole native storage on every store. The native array is already the source of truth: each refresh rebuilds the view from it and keeps only the prototype and symbol properties. Live array capsules now carry per-element hooks (ScrDynTypedArrayOps), emitted per array type next to the existing snapshot adapter and bound when the capsule is built. The runtime answers length and presence from the native array and converts exactly one element per read, writes an in-range or appended element in place, and steps array iterators without a refresh. Holes and out-of-range reads, which can reach the prototype chain, keep using the view. A value the element type rejects, a write that would grow the array by more than one element, a multi-value push, and `at` with a coercible argument also keep the snapshot path, so their diagnostics and atomicity are unchanged. The capsule payload stays 80 bytes: the key length and the two flags now share one 32-bit word, which frees the slot for the hooks pointer on both 64- and 32-bit targets. Linux, 100k elements: the index loop went from beyond a 120 s timeout to 7 ms; Node takes 0.5 ms. Test: test_json.c live_array_ref_tests. Corpus: 4598-live-array-ref-element-access. --- packages/compiler/src/backend/llvm/dyn.ts | 15 ++ .../compiler/src/backend/llvm/expr-context.ts | 2 + .../compiler/src/backend/llvm/expr-dynamic.ts | 2 + .../src/backend/llvm/expr-stream-bridges.ts | 101 +++++++++++- .../test/ts7/baselines/order-parity.json | 6 + packages/runtime/src/scr_dyn_invoke.c | 46 ++++++ packages/runtime/src/scr_json.c | 100 +++++++++++- packages/runtime/src/scr_runtime.h | 35 +++- packages/runtime/test/test_json.c | 79 +++++++++ .../4598-live-array-ref-element-access.ts | 152 ++++++++++++++++++ 10 files changed, 530 insertions(+), 8 deletions(-) create mode 100644 tests/corpus/4598-live-array-ref-element-access.ts diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 86ac62d8d..a6472bf63 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"; @@ -212,6 +213,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 +3181,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/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-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index 0a63c6e23..f07a9d1b5 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, 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..51ebf631e 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -17,7 +17,7 @@ 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 } 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 +567,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, adapter, boxed)) B.line(line); B.terminate(`ret ptr ${boxed}`); }); valueArms.forEach(({ arm }, index) => { @@ -648,6 +649,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, nested, boxed)) B.line(line); return boxed; } @@ -972,6 +974,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 +1038,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 +1054,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: { declare(line: string): void }, + 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 +1230,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 +1250,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, 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/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 63e87957a..b65561721 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15736,6 +15736,12 @@ "/tests/corpus/4597-callback-field-method-name.ts" ], "diags": [] + }, + "/tests/corpus/4598-live-array-ref-element-access.ts": { + "order": [ + "/tests/corpus/4598-live-array-ref-element-access.ts" + ], + "diags": [] } } } 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 59410fe79..fe3c9daf3 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -2070,14 +2070,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; @@ -2237,6 +2239,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; @@ -3432,6 +3502,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; @@ -5503,6 +5594,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); @@ -5619,6 +5711,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); @@ -8644,6 +8737,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 363f89245..fefe76177 100644 --- a/packages/runtime/src/scr_runtime.h +++ b/packages/runtime/src/scr_runtime.h @@ -4321,6 +4321,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; @@ -4382,17 +4396,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 @@ -4659,6 +4677,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/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[])); From ef8a7171a9297ab0aaffd64efdd0fcecb098e76f Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 14:58:47 -0700 Subject: [PATCH 02/14] Borrow the bound receiver of optional chains An optional chain such as `resolved.members?.get(name)` took three references to its receiver: the owned receiver value, a +1 extraction into the bind slot, and a +1 copy for every chainRecv use, each released out of line (scr_map_release for maps). Now the bind slot borrows the payload whenever another owner outlives the body (the statement frame owns the receiver value), chainRecv reads are borrowable receivers and call arguments, and a receiver projected from a borrowable owner is not retained at all when the body preserves reference edges. Optional chains and chain receivers count as edge-preserving themselves; their children decide. Stack receivers, multi-arm unions and bodies that may remove an edge keep the owned forms. Corpus 4620 covers chains whose body clears or replaces the receiver field, drops the last other reference, nested and missing links, call and element receivers, self-clearing optional calls, and values that outlive later stores; a mutation that ignores the body check is caught by ASan (heap-use-after-free). --- packages/compiler/src/backend/llvm/emitter.ts | 15 +++ .../compiler/src/backend/llvm/expr-control.ts | 42 +++++- .../src/backend/llvm/reference-effects.ts | 4 + .../test/ts7/baselines/order-parity.json | 6 + .../4620-optional-chain-borrowed-receivers.ts | 125 ++++++++++++++++++ 5 files changed, 186 insertions(+), 6 deletions(-) create mode 100644 tests/corpus/4620-optional-chain-borrowed-receivers.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8b9bb381d..c91461efd 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -5326,6 +5326,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 ( @@ -7247,6 +7250,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. @@ -7499,6 +7512,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-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/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index a1e7dcb11..ba6521298 100644 --- a/packages/compiler/src/backend/llvm/reference-effects.ts +++ b/packages/compiler/src/backend/llvm/reference-effects.ts @@ -80,6 +80,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": diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 1c7788be6..32307ce8e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15748,6 +15748,12 @@ "/tests/corpus/4596-tdz-nullable-forward-capture.ts" ], "diags": [] + }, + "/tests/corpus/4620-optional-chain-borrowed-receivers.ts": { + "order": [ + "/tests/corpus/4620-optional-chain-borrowed-receivers.ts" + ], + "diags": [] } } } 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); From a854b53e03249a07144bca66e2bc7f03114b4cf6 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:05:02 -0700 Subject: [PATCH 03/14] Count module TDZ checks, atomic counters and array appends as edge-preserving A function is edge-preserving when it cannot remove a reference edge; callers then pass field projections borrowed. tsc-ts lost the property for its whole type-ordering family because compareTypeLists reads a module binding behind an import-cycle TDZ check (module.tdzCheck), and for compareSymbols because getSymbolId bumps a shared id counter with Atomics.add (atomics.op). Fresh array literals without spreads and the array operations that only read, copy into a fresh array or append (push and pushSpread retain the new element; moving the slot storage releases nothing) were rejected too. All of these may allocate or throw, which the summary already allows. Removals (pop, shift, splice), truncation, overwrites and Atomics.wait keep the conservative answer. compareTypes now passes its field arguments borrowed: 42 retains and 118 releases in its body become 4 and 4, and compareSymbols, compareTypeNames and binarySearchTypes need none. Corpus 4621 covers a comparator behind a real import-cycle TDZ error, an atomic id counter, appends that move the storage of the array owning a borrowed argument, loop bindings over appending and draining calls, and callees that shift or truncate the owning array. --- .../src/backend/llvm/reference-effects.ts | 33 +++++++++++- .../test/ts7/baselines/order-parity.json | 8 +++ .../4621-edge-preserving-callees/compare.ts | 44 ++++++++++++++++ .../4621-edge-preserving-callees/items.ts | 38 ++++++++++++++ .../4621-edge-preserving-callees/main.ts | 51 +++++++++++++++++++ 5 files changed, 173 insertions(+), 1 deletion(-) create mode 100644 tests/corpus/4621-edge-preserving-callees/compare.ts create mode 100644 tests/corpus/4621-edge-preserving-callees/items.ts create mode 100644 tests/corpus/4621-edge-preserving-callees/main.ts diff --git a/packages/compiler/src/backend/llvm/reference-effects.ts b/packages/compiler/src/backend/llvm/reference-effects.ts index ba6521298..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, @@ -94,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": @@ -113,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/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 32307ce8e..be1b2aa3e 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15754,6 +15754,14 @@ "/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": [] } } } 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); From 4d3ed5ba811f99737c7bf7df3bb4337ebe838a32 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:25 -0700 Subject: [PATCH 04/14] Check unproven indexes with a signed round trip A typed-array, inline ASCII charCodeAt, local array, constant-table or number-map read whose index is not a proven integer decided validity with two float compares against the converted length, then an fptoui/uitofp round trip. On x86-64 both unsigned conversions expand to long sequences, and the tsc-ts scanner pays them on every character it reads by position. exactIndexLines replaces them with fptosi, freeze, sitofp, one float equality and one unsigned integer compare against the length. A frozen out-of-range or NaN conversion is an arbitrary integer that never converts back to the index or is not below the length; below the length the round trip is exact, and -0 converts to 0 like ToPropertyKey(-0). --- packages/compiler/src/backend/llvm/common.ts | 39 ++++++++ packages/compiler/src/backend/llvm/emitter.ts | 38 +++---- .../compiler/src/backend/llvm/expr-bytes.ts | 30 ++---- .../src/backend/llvm/local-array-reads.ts | 39 ++++---- .../compiler/src/backend/llvm/string-reads.ts | 54 +++++----- tests/corpus/exact-index-round-trip.ts | 98 +++++++++++++++++++ 6 files changed, 208 insertions(+), 90 deletions(-) create mode 100644 tests/corpus/exact-index-round-trip.ts 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/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 8ce49a03d..88183d1b2 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -174,6 +174,7 @@ import { StackCaptures } from "./stack-captures.js"; import { ABSENT_FIELD_PAYLOAD, emitFieldAbsentTest, + exactIndexLines, f64Lit, ffiNativeTypeLl, ffiNativeParamLl, @@ -1911,25 +1912,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 = uitofp ${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:`, @@ -2754,22 +2748,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 = uitofp ${sz} %nd to double`, - ` %lo = fcmp oge double %k, 0.0`, - ` %hi = fcmp olt double %k, %ndf`, - ` %in = and i1 %lo, %hi`, - ` br i1 %in, label %direct, label %outside`, - `direct:`, - ` %i = fptoui double %k to ${sz}`, - ` %back = uitofp ${sz} %i to double`, - ` %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`, diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index 21fd1ed32..fac53b51f 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} = uitofp ${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} = uitofp ${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; } diff --git a/packages/compiler/src/backend/llvm/local-array-reads.ts b/packages/compiler/src/backend/llvm/local-array-reads.ts index 6daeb1cd0..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} = uitofp ${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} = uitofp ${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/string-reads.ts b/packages/compiler/src/backend/llvm/string-reads.ts index fe70d8a1b..1912780e3 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 = uitofp ${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/tests/corpus/exact-index-round-trip.ts b/tests/corpus/exact-index-round-trip.ts new file mode 100644 index 000000000..c3e8a6d9d --- /dev/null +++ b/tests/corpus/exact-index-round-trip.ts @@ -0,0 +1,98 @@ +// 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)), +); From ca009a0073acf0d4a15320c7943fc89516f34ba8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:26 -0700 Subject: [PATCH 05/14] Keep the ToUint32 hint of NaN-coded typed-array reads The hint is ToUint32 of the value, and ToUint32(NaN) is 0, so a read whose index may be invalid keeps it (the invalid arm already contributed 0); it was dropped unless the index was proven in bounds. Worker programs also dropped it at the join with the shared-buffer path, which now converts its integer result exactly. `hash ^ chars[i]` and similar operators then use the loaded integer instead of re-deriving ToInt32 from the double. --- .../compiler/src/backend/llvm/expr-bytes.ts | 26 ++++++++++++++++--- 1 file changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-bytes.ts b/packages/compiler/src/backend/llvm/expr-bytes.ts index fac53b51f..50b2997c3 100644 --- a/packages/compiler/src/backend/llvm/expr-bytes.ts +++ b/packages/compiler/src/backend/llvm/expr-bytes.ts @@ -185,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); @@ -199,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); } @@ -253,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 }; From bb4b16f02684d4fefedf967bb10107831781deb7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:27 -0700 Subject: [PATCH 06/14] Borrow final reference fields without a retain A reference field that only its class family's constructors write, through their own `this`, never changes outside them. While its object is held, the field's value is held too, so `this.scanner.scan()` and `const units = this.units` need no retain and release (and no collector buffering on the release). analyzeFinalFields finds such fields over the whole program. It looks through the derived constructor's guards on `this`, and disqualifies fields that live dynamic views can store into (the detection int32 slots already uses, now shared as dynamicFieldStores), fields of runtime-provided or generic classes, and every field in library builds. canBorrowCallArgument accepts a final field of a borrowable object, except in constructors, async functions and generators, and except for nullable-pointer fields, which have no box to lend. --- packages/compiler/src/backend/llvm/emitter.ts | 20 ++ packages/compiler/src/ir/final-fields.test.ts | 129 +++++++++++++ packages/compiler/src/ir/final-fields.ts | 177 ++++++++++++++++++ packages/compiler/src/ir/int32-slots.ts | 120 ++++++------ tests/corpus/final-field-borrows.ts | 127 +++++++++++++ 5 files changed, 520 insertions(+), 53 deletions(-) create mode 100644 packages/compiler/src/ir/final-fields.test.ts create mode 100644 packages/compiler/src/ir/final-fields.ts create mode 100644 tests/corpus/final-field-borrows.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 88183d1b2..4f83f89df 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -99,6 +99,7 @@ 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 { findIntegerViews } from "./integer-views.js"; import { findInitializerBindings, withInitializerBindings } from "../../ir/initializer-bindings.js"; import { findConstantNumericTables, type ConstantNumericTable } from "../../ir/constant-tables.js"; @@ -734,6 +735,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(); @@ -808,6 +814,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); @@ -4942,6 +4949,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") @@ -5311,6 +5320,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) && 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 ad790b904..2baf80819 100644 --- a/packages/compiler/src/ir/int32-slots.ts +++ b/packages/compiler/src/ir/int32-slots.ts @@ -271,6 +271,72 @@ function walkFunction( for (const s of fn.body) stmt(s); } +/** 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 => { + if (DYN_KINDS.has(type.kind)) return true; + const cached = dynTypes.get(type); + if (cached !== undefined) return cached; + let result = false; + const named = + type.kind === "record" + ? `r:${type.shapeId}` + : type.kind === "union" + ? `u:${type.unionId}` + : null; + if (named) { + const known = dynNamed.get(named); + if (known !== undefined) return known; + dynNamed.set(named, false); + const members = + type.kind === "record" + ? [ + ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), + ...(records.get(type.shapeId)?.indexValue + ? [records.get(type.shapeId)!.indexValue!] + : []), + ] + : type.kind === "union" + ? (unions.get(type.unionId)?.arms ?? []) + : []; + result = members.some(dynish); + dynNamed.set(named, result); + } else everyTypeChild(type, (child) => !(result = dynish(child))); + dynTypes.set(type, result); + return result; + }; + const dynWritten = new Set(); + let dynWritesAll = false; + for (const fn of mod.functions) + walkFunction(fn, { + expr: (e) => { + if (dynWritesAll) return; + if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; + else if ( + e.kind === "libCall" && + !DYN_READ_ONLY.has(e.fn) && + e.args.some((a) => dynish(a.type)) + ) { + const key = e.args[1]; + if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") + dynWritten.add(key.value); + else dynWritesAll = true; + } + }, + }); + return { all: dynWritesAll, names: dynWritten }; +} + export function analyzeInt32Slots(mod: IrModule): Int32Slots { const classes = new Map((mod.classes ?? []).map((c) => [c.name, c])); const slots = new Int32Slots(classes); @@ -371,59 +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 dynTypes = new Map(); - const dynNamed = new Map(); - const dynish = (type: IrType): boolean => { - if (DYN_KINDS.has(type.kind)) return true; - const cached = dynTypes.get(type); - if (cached !== undefined) return cached; - let result = false; - const named = - type.kind === "record" - ? `r:${type.shapeId}` - : type.kind === "union" - ? `u:${type.unionId}` - : null; - if (named) { - const known = dynNamed.get(named); - if (known !== undefined) return known; - dynNamed.set(named, false); - const members = - type.kind === "record" - ? [ - ...(records.get(type.shapeId)?.fields.map((f) => f.type) ?? []), - ...(records.get(type.shapeId)?.indexValue - ? [records.get(type.shapeId)!.indexValue!] - : []), - ] - : type.kind === "union" - ? (unions.get(type.unionId)?.arms ?? []) - : []; - result = members.some(dynish); - dynNamed.set(named, result); - } else everyTypeChild(type, (child) => !(result = dynish(child))); - dynTypes.set(type, result); - return result; - }; - const dynWritten = new Set(); - let dynWritesAll = false; - for (const fn of mod.functions) - walkFunction(fn, { - expr: (e) => { - if (dynWritesAll) return; - if (ISLAND_KINDS.has(e.kind) || e.type.kind === "jsval") dynWritesAll = true; - else if ( - e.kind === "libCall" && - !DYN_READ_ONLY.has(e.fn) && - e.args.some((a) => dynish(a.type)) - ) { - const key = e.args[1]; - if ((e.fn === "dyn.keySet" || e.fn === "dyn.keyDelete") && key?.kind === "strLit") - dynWritten.add(key.value); - else dynWritesAll = true; - } - }, - }); + const { all: dynWritesAll, names: dynWritten } = dynamicFieldStores(mod, records, unions); for (const fn of mod.functions) walkFunction(fn, { expr: (e) => { 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)); From 2b056343ee217ff15310b10599623d3d9fcacc60 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:28 -0700 Subject: [PATCH 07/14] Run counted loops from an unproven start as integer loops `for (let i = start; i < end; i++)` kept a double counter when the start had no proven integer range, as in a hash over `[start, end)` whose bounds come from fields. Such a loop is now versioned like one with an unproven limit: when the start is an exact integer of at most 2^53 - 1 in magnitude (compared bit for bit, so -0 takes the original loop), the integer-counter copy runs. The usual bounds on duplicated bodies apply. --- packages/compiler/src/backend/llvm/emitter.ts | 45 ++++++++++++++++--- .../compiler/src/ir/integer-loops.test.ts | 18 ++++++++ packages/compiler/src/ir/integer-loops.ts | 28 ++++++++++-- tests/corpus/exact-index-round-trip.ts | 14 ++++++ 4 files changed, 96 insertions(+), 9 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 26e0d95cf..d98165faf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -6051,14 +6051,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; 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/tests/corpus/exact-index-round-trip.ts b/tests/corpus/exact-index-round-trip.ts index c3e8a6d9d..51748a5b2 100644 --- a/tests/corpus/exact-index-round-trip.ts +++ b/tests/corpus/exact-index-round-trip.ts @@ -96,3 +96,17 @@ console.log( 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))); From b552bc135467816e49c06a725bb5a04cc0a31bcf Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 15:39:28 -0700 Subject: [PATCH 08/14] Read module constants as their literal values `const DOT = 0x2e` at module scope was a global that every reader loaded (a thread-local load in worker programs), hiding the value from integer ranges, switch dispatch and LLVM. foldConstantGlobals rewrites reads of an immutable number or boolean global that the program writes exactly once, with a literal, and that has neither a TDZ sentinel nor an initialization flag (globals that can be read early keep their checked load). The store stays, so namespace objects still find the value. Library builds keep their globals. --- packages/compiler/src/backend/llvm/emitter.ts | 5 +- .../compiler/src/ir/constant-globals.test.ts | 95 +++++++++++++++++++ packages/compiler/src/ir/constant-globals.ts | 76 +++++++++++++++ .../corpus/constant-module-globals/consts.ts | 10 ++ tests/corpus/constant-module-globals/main.ts | 51 ++++++++++ 5 files changed, 235 insertions(+), 2 deletions(-) create mode 100644 packages/compiler/src/ir/constant-globals.test.ts create mode 100644 packages/compiler/src/ir/constant-globals.ts create mode 100644 tests/corpus/constant-module-globals/consts.ts create mode 100644 tests/corpus/constant-module-globals/main.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 4f83f89df..26e0d95cf 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -100,6 +100,7 @@ import { everyExprChild, everyStmtChild, everyStmtList } from "../../ir/traverse 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"; @@ -405,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(); @@ -418,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(); 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/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(" ")); From a9d294d1c1974e44e55aae01a14e4af0e1a72669 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sat, 10 Oct 2026 19:20:04 -0700 Subject: [PATCH 09/14] Widen the old level's growth fraction two doublings at a time Full passes are scheduled by heap growth, and the fraction they wait for widens while growth-triggered passes keep finding nothing. Widening went one doubling per pass after two passes in a row had asked for it, so a heap that grows for a whole run never reached the cap of 16 that #833 introduced: on tsc-ts, runs with seven full passes widened to 9x at the sixth pass, and the seventh still walked 2.7-3.1M objects (294-393 ms) late in the run. The old level now widens two doublings per qualifying pass, never past what the pass's yield asks for, so passes that keep finding nothing reach the cap on the fourth pass instead of the sixth, while the heap is still small. Narrowing is unchanged: a pass that finds old garbage moves the fraction straight to what its yield asks for, and widening still waits for two passes in a row. The mature level keeps a doubling per pass: a mature pass walks only what was promoted since the last one, so its total walking hardly depends on how passes are spaced, and narrower spacing lets less young garbage float. The memory bound is #833's: once passes stop finding garbage, the heap may grow to 17 times its size after the last full pass before the next. It is now reached after four such passes instead of six. Jumping straight to the cap after two was also measured; it reaches the bound at heaps of a few thousand objects and is not better on tsc-ts at --checkers 8. test_cycle pins the schedule from where an old-garbage ring narrows the fraction: the pass after it does not widen, the second pass in a row that finds nothing reaches the cap (one doubling per pass fails here at caps 1 and 16), and a capped pass comes exactly at the cap. cycle.test.ts also runs SCR_CYCLE_GROWTH_CAP=8. Measured with tsc-ts origin/parallel-check e469e33 (--optimization=speed) against the parent commit, interleaved in Linux Vercel Sandboxes (8 vCPU), medians; diagnostics and exit codes identical. The build is the next commit's with its parallel walk off (SCR_CYCLE_PARALLEL=0), which runs this commit's collector. RSS in MB: single-threaded (10 rounds) wall RSS Excalidraw 7.94 -> 7.32 -7.8% 1263 -> 1280 Playwright 7.09 -> 6.90 -2.7% 1457 -> 1470 TypeORM 6.67 -> 6.39 -4.1% 1014 -> 1046 tsc-ts self-check 4.33 -> 4.03 -6.8% 626 -> 622 --checkers 8 (10 rounds, second host) Excalidraw 2.56 -> 2.55 -0.6% 2120 -> 2200 Playwright 2.46 -> 2.39 -3.0% 2649 -> 2686 TypeORM 1.96 -> 1.85 -5.4% 1270 -> 1315 tsc-ts self-check 1.04 -> 1.04 +0.5% 742 -> 744 Pass log (instrumented build, one single-threaded run each): full-pass time 337 -> 89 ms (self-check), 426 -> 130 ms (TypeORM), 323 -> 104 ms (Excalidraw); the largest full pass now walks 1.1-1.3M objects early in the run, and total collector time drops by 27-35%. --- packages/runtime/src/scr_cycle.c | 34 +++++++++++++++++++-------- packages/runtime/test/cycle.test.ts | 1 + packages/runtime/test/test_cycle.c | 36 +++++++++++++++++++---------- 3 files changed, 49 insertions(+), 22 deletions(-) diff --git a/packages/runtime/src/scr_cycle.c b/packages/runtime/src/scr_cycle.c index 3171d258c..0f2663121 100644 --- a/packages/runtime/src/scr_cycle.c +++ b/packages/runtime/src/scr_cycle.c @@ -345,9 +345,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 @@ -491,10 +490,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(); @@ -503,9 +515,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/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 4de18b1d6..ae05db24a 100644 --- a/packages/runtime/test/test_cycle.c +++ b/packages/runtime/test/test_cycle.c @@ -450,25 +450,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 @@ -476,8 +489,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) { From 79d6abd42e6a36a45e0346ba31576d94e7efc178 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:16:14 -0700 Subject: [PATCH 10/14] Pass the shape host to bindLiveArrayOps bindLiveArrayOps took an anonymous `{ declare(line: string): void }` and four callers passed the LlEmitter class instance. The self-hosted compiler projects a class instance into a record only from plain fields, not methods, so the self-hosting IR check rejected each call with SC2002. Type the parameter as ShapeHost and pass `host.shapeHost`, as the neighbouring vAdapters/typedRefConstructor calls already do. The emitted LLVM is unchanged. --- .../compiler/src/backend/llvm/expr-dynamic.ts | 2 +- .../src/backend/llvm/expr-stream-bridges.ts | 18 +++++++++++++----- 2 files changed, 14 insertions(+), 6 deletions(-) diff --git a/packages/compiler/src/backend/llvm/expr-dynamic.ts b/packages/compiler/src/backend/llvm/expr-dynamic.ts index f07a9d1b5..f82269f66 100644 --- a/packages/compiler/src/backend/llvm/expr-dynamic.ts +++ b/packages/compiler/src/backend/llvm/expr-dynamic.ts @@ -97,7 +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, adapter, boxed)) B.line(line); + 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 51ebf631e..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, elemAccess, 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,7 +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, adapter, boxed)) B.line(line); + for (const line of bindLiveArrayOps(host.shapeHost, adapter, boxed)) B.line(line); B.terminate(`ret ptr ${boxed}`); }); valueArms.forEach(({ arm }, index) => { @@ -649,7 +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, nested, boxed)) B.line(line); + for (const line of bindLiveArrayOps(host.shapeHost, nested, boxed)) B.line(line); return boxed; } @@ -1130,7 +1138,7 @@ function liveArrayOps( /** Attach the element hooks right after a live array capsule is built. */ export function bindLiveArrayOps( - host: { declare(line: string): void }, + host: ShapeHost, adapter: LlStreamTypedRefAdapter, capsule: string, ): string[] { @@ -1250,7 +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, live, boxed)) B.line(line); + 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})`); From 6b0b5023035e6fab2a279cb636fb12231ed0ba58 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 01:55:34 -0700 Subject: [PATCH 11/14] Return the shared live adapter from DynHost DynHost.liveDynRefAdapter was declared to return { snapshot; commit } while LlEmitter returns its cached LlStreamTypedRefAdapter. Under Node LlDyn received that same object and bindLiveArrayOps found arrayOps; the native compiler width-copied the record into the narrower shape, dropped arrayOps and emitted no scr_dyn_typed_ref_bind_array call, so native and Node builds of the compiler emitted different LLVM. Declare the real return type so both pass the shared record. --- packages/compiler/src/backend/llvm/dyn.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index a6472bf63..d77d4d048 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -42,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). */ @@ -84,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; From ea7ec561da9a0a2999ec2b7c26105cbf1426538e Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 05:57:16 -0700 Subject: [PATCH 12/14] Grow the descriptor table before worker threads share it Every worker mailbox is a pipe. Once a process passes 64 descriptors the kernel grows its descriptor table, and while other threads share the table expand_fdtable first waits a full RCU grace period. tsc-ts on a 16-vCPU host stalled 11-25 ms on the thread starting its 14th checker (16 parse threads plus 13 checkers), on the critical path. Worker builds now grow the table to 256 entries at load, while the process has one thread, which never waits; the table never shrinks. --- packages/runtime/src/scr_worker.c | 27 ++++++++++++++++++++++++++ packages/runtime/src/scr_worker.test.c | 25 ++++++++++++++++++++++++ 2 files changed, 52 insertions(+) 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(); From a7e91f86ac5adcd2722c9094913194522fd58fda Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:55:59 -0700 Subject: [PATCH 13/14] Record the preflight/order baselines for exact-index-round-trip and final-field-borrows --- .../compiler/test/ts7/baselines/order-parity.json | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index a00bebf93..a8ef33b1d 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -15784,6 +15784,18 @@ "/tests/corpus/4602-constructor-escape-order.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": [] } } } From e88e2adb17997bdded0fd4d5cc1fa7b567f16867 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Sun, 11 Oct 2026 08:13:09 -0700 Subject: [PATCH 14/14] Expect a borrowed result from an appending identity callee Counting array appends as edge-preserving makes `keep(item, items) { items.push(item); return item; }` a borrowed-return function: it preserves every heap edge, borrows every reference parameter and returns one of them. Its borrowing body now retains only the appended element and returns the parameter +0, and the owned adapter retains the result before releasing its parameters. An append never releases an element (the slow path stores past the old length), so the caller's owner keeps the result alive. --- packages/compiler/test/call-lifetimes.test.ts | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) 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"); });