Keep integer clones off records that may be views

A checked conversion can answer a record view: an object of the target
record type whose fields live in the record it views, at that record's
offsets (record-views.ts). An integer clone read the fields of a borrowed
record at its own shape's offsets, so for a view it read the view's unused
slots and returned wrong results.

Record shapes that may be views are no longer references a clone reads, so
functions that read them keep the ordinary body, which reads a view's fields
through the original record. The emitter now plans integer clones after
record views.

integer-recursion-records.ts gains a recursive sum over a view.
This commit is contained in:
Chris Tate
2026-10-27 21:15:37 -05:00
parent 677c74c78a
commit d97e816848
2 changed files with 48 additions and 28 deletions
+33 -27
View File
@@ -2288,30 +2288,6 @@ export class LlEmitter {
};
}
this.constantCallbacks = findConstantCallbacks(mod, this.callLifetimes, this.nodeIndex);
// Integer clones signal a stack overflow through their result. Worker
// programs have them only as ELF executables, where the stack guard
// and the pending word clones poll for termination are plain
// thread-locals; debug builds and stack traces keep every frame.
// Clones read number arrays and record graphs their function borrows
// (64-bit layouts).
this.cloneReferences = this.sizeType === "i64" ? this.recordCloneReferences() : undefined;
this.integerClones =
this.debug === null &&
this.stackChecks !== null &&
(mod.workers !== true || this.executableTls) &&
mod.lib === undefined &&
!this.stackTraces
? findIntegerClones(
mod,
this.sizeType === "i64"
? (fn, index) => this.callLifetimes.borrowed.get(fn)?.has(index) === true
: undefined,
this.cloneReferences,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.stackCallbacks = new StackCallbacks(this.fnByName);
this.receiverReaders = new AmbientReceiverReaders(this.fnByName, this.nodeIndex);
this.stackCaptures = new StackCaptures(this.fnByName, this.callLifetimes, this.stackCallbacks);
@@ -2467,6 +2443,30 @@ export class LlEmitter {
this.shapeHost.recordViews = views;
this.recordViewTargets = views.targets as Set<string>;
}
// Integer clones signal a stack overflow through their result. Worker
// programs have them only as ELF executables, where the stack guard
// and the pending word clones poll for termination are plain
// thread-locals; debug builds and stack traces keep every frame.
// Clones read number arrays and record graphs their function borrows
// (64-bit layouts), but no record that may be a view (planned above).
this.cloneReferences = this.sizeType === "i64" ? this.recordCloneReferences() : undefined;
this.integerClones =
this.debug === null &&
this.stackChecks !== null &&
(mod.workers !== true || this.executableTls) &&
mod.lib === undefined &&
!this.stackTraces
? findIntegerClones(
mod,
this.sizeType === "i64"
? (fn, index) => this.callLifetimes.borrowed.get(fn)?.has(index) === true
: undefined,
this.cloneReferences,
)
: NO_INTEGER_CLONES;
// Integer clones and their dispatchers call each other through their
// own lowering, which keeps plain calls and flag tests.
for (const name of this.integerClones.members) this.unwindAbi.delete(name);
this.walkers = new LlWalkers(this.shapeHost);
this.dyn = new LlDyn(this.shapeHost);
for (const g of mod.globals ?? []) {
@@ -4925,17 +4925,23 @@ export class LlEmitter {
* `inline`: the slot stores a scalar union's tag and bits. */
/** The record layouts integer clones read in place: record pointers,
* `R | null` unions of a record (NULL is `null`), and fields stored as a
* double, an i8 boolean or such a pointer. */
* double, an i8 boolean or such a pointer. Records that may be views
* (record-views.ts) are not among them. */
private recordCloneReferences(): CloneReferences {
// A view's fields live in the record it views (recordViewFieldPtr), not
// at its own shape's offsets.
const plain = (shapeId: string): boolean =>
this.recordsById.has(shapeId) && this.shapeHost.recordViews?.targets.has(shapeId) !== true;
const nullable = (unionId: string): NullableUnion | null => {
const single = this.nullableUnions.single(unionId);
if (single === null || single.nullTag !== -1 || single.arm.kind !== "record") return null;
if (!plain(single.arm.shapeId)) return null;
const unit = this.unionsById.get(unionId)?.arms[single.unitTag];
return unit?.kind === "nullT" ? single : null;
};
const reference = (type: IrType): boolean =>
type.kind === "record"
? this.recordsById.has(type.shapeId)
? plain(type.shapeId)
: type.kind === "union" && nullable(type.unionId) !== null;
return {
reference,
@@ -4947,7 +4953,7 @@ export class LlEmitter {
field: (shapeId, field) => {
const shape = this.recordsById.get(shapeId);
const index = shape?.fields.findIndex((f) => f.name === field) ?? -1;
if (shape === undefined || index < 0) return null;
if (shape === undefined || index < 0 || !plain(shapeId)) return null;
const type = shape.fields[index]!.type;
if (this.inlineUnions.record(type)) return null;
if (type.kind !== "f64" && type.kind !== "bool" && !reference(type)) return null;
+15 -1
View File
@@ -1,6 +1,7 @@
// Pure integer recursion over record graphs: counts and sums read from
// fields, sums that leave the int32 range, number fields that are not
// int32s, boolean fields, null tests and narrows, and endless recursion.
// int32s, boolean fields, null tests and narrows, endless recursion, and
// records that view another record.
interface Tree {
left: Tree | null;
@@ -106,6 +107,14 @@ function nodeSum(node: Node): number {
return node.next === null ? node.value : node.value + nodeSum(node.next);
}
// A record converted from an untyped value into a narrower record type is
// a view of the original: its fields are the original's.
type Measured = { label: string; hi: number; open: boolean; lo: number };
type Span = { lo: number; hi: number };
function spanSum(span: Span, n: number): number {
return n === 0 ? span.lo : span.hi + spanSum(span, n - 1);
}
function attempt(label: string, run: () => number | boolean): void {
try {
console.log(label, run());
@@ -158,3 +167,8 @@ attempt("ring total", () => total(ring));
console.log("pair", sumPair({ a: 2147483647, b: 1 }), sumPair({ a: 3, b: 4 }), sumPair({ a: 0.5, b: 0.25 }));
console.log("link", linkSum({ value: 1, next: { value: 2, next: { value: 3 } } }));
console.log("node", nodeSum(new Node(1, new Node(2, new Node(2147483647, null)))));
const measured: Measured = { label: "m", hi: 2, open: true, lo: 7 };
const untyped: unknown = measured;
const span = untyped as Span;
span.hi = 3;
console.log("view", spanSum(span, 4), measured.hi, spanSum({ lo: 1, hi: 1 }, 3));