From 6a30f02a69ab3a2421e0355ff8ab35f84f926da8 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 20:43:11 -0700 Subject: [PATCH 1/9] Answer cross-layout record casts with views of the original record A checked conversion of a capsule of record A into record type B, when A has all of B's fields with the same types in any order, now answers a view: a B object whose two trailing words hold A and A's offsets for B's fields. Every field address of a B value tests the first word (recordViewFieldPtr, the record clone and the JSON walker), so reads and writes through either name reach the same storage. The view's record-source entry owns A's capsule: boxing the view answers that capsule, so identity holds, B's reflective surfaces and spreads see the whole object, and the entry dies with the view, which releases A's capsule. The capsule finds its views again through borrowed cast-list entries. Only the target types of such conversions carry the words and the test. They are planned from the IR before emission (record-views.ts): record types a checked conversion can produce for which another record type in the module has all their fields. Programs without checked conversions are unchanged. This replaces the prefix pointer pass, whose narrower release could not free the source's extra fields. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 128 ++++++------- packages/compiler/src/backend/llvm/emitter.ts | 30 ++- .../compiler/src/backend/llvm/record-views.ts | 171 +++++++++++++++++ packages/compiler/src/backend/llvm/shapes.ts | 34 +++- .../src/backend/llvm/stack-objects.ts | 3 + packages/compiler/src/backend/llvm/walkers.ts | 24 ++- .../tests/corpus/record-layout-views.ts.json | 6 + .../corpus/record-view-extras-release.ts.json | 6 + packages/runtime/src/scr_dyn.h | 4 +- packages/runtime/src/scr_json.c | 181 ++++++++++-------- packages/runtime/src/scr_region.c | 2 +- tests/corpus/record-layout-views.ts | 26 +++ tests/corpus/record-view-extras-release.ts | 44 +++++ .../corpus-costs/record-layout-views.ts.json | 1 + .../record-view-extras-release.ts.json | 1 + 15 files changed, 502 insertions(+), 159 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/record-views.ts create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-layout-views.ts.json create mode 100644 packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-view-extras-release.ts.json create mode 100644 tests/corpus/record-layout-views.ts create mode 100644 tests/corpus/record-view-extras-release.ts create mode 100644 tests/harness/corpus-costs/record-layout-views.ts.json create mode 100644 tests/harness/corpus-costs/record-view-extras-release.ts.json diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 762bc52f4e..5bc71a3310 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1,5 +1,6 @@ import { emitPerKeyOrder } from "./key-orders.js"; -import { rcMembers, typedRefConstructor } from "./shapes.js"; +import { typedRefConstructor } from "./shapes.js"; +import { recordViewWords } from "./record-views.js"; import { bindLiveArrayOps } from "./expr-stream-bridges.js"; import { BYTES_ELEM_NUM, @@ -45,7 +46,6 @@ import { DYN_HANDLE_KINDS, isDirentShape, KEY_ORDER_FIELD, - shapeHasAccessorSlots, shapeKeyOrders, isDynTypedRefType, isRefCounted, @@ -261,7 +261,7 @@ export class LlDyn { /** Emitted function definitions, in interning order. */ readonly defs: string[] = []; - private readonly prefixSources = new Map(); + private readonly recordViewTables = new Map(); constructor(private readonly host: DynHost) {} @@ -1161,51 +1161,51 @@ export class LlDyn { ); } - /** The record shapes whose values a pointer of shape `targetId` may - * reference unchanged: their struct begins with exactly the target's - * fields (same names, types and representations, so the same offsets), - * and everything the target's release, teardown and collector support - * would get wrong on them is absent. The extra fields hold no - * references (the target's release frees the block without visiting - * them; frees are size-class agnostic), neither shape carries a shape - * word, a cycle header, an overflow map, key order variants or accessor - * slots, and both freeze alike. A record that keeps undeclared source - * properties is refused at run time (scr_dyn_typed_ref_prefix_view). */ - private prefixViewSources(targetId: string): string[] { - const memo = this.prefixSources.get(targetId); - if (memo) return memo; + /** `scr_dyn_record_view(d, sources, n, key, key_len, new, words)` for + * view target `targetId`: the view (+1) of the record a capsule `%d` + * holds when its type is one of the target's sources, else NULL. The + * sources table lists each source's type key and the byte offsets of + * the target's fields in it. */ + private recordViewCall(B: BlockBuilder, targetId: string): string { const host = this.host; - const plain = (shape: IrRecordShape): boolean => - !shape.tuple && - !shape.indexValue && - !isDirentShape(shape) && - !shapeHasAccessorSlots(shape) && - shapeKeyOrders(shape).length === 1 && - !shape.fields.some((field) => field.name === KEY_ORDER_FIELD) && - host.nullableUnions.shapeNumber(shape.id) === 0 && - !host.tracedShapes.has(`record:${shape.id}`); - const target = host.recordsById.get(targetId); - const out: string[] = []; - if (target && plain(target) && target.fields.length > 0) { - const base = host.nullableUnions.fieldBase(targetId); - for (const shape of host.recordsById.values()) { - if (shape.id === targetId || shape.fields.length <= target.fields.length) continue; - if (!plain(shape) || shape.freezable !== target.freezable) continue; - if (host.nullableUnions.fieldBase(shape.id) !== base) continue; - const prefix = target.fields.every( - (field, i) => - shape.fields[i]!.name === field.name && - typeKey(shape.fields[i]!.type) === typeKey(field.type), + let table = this.recordViewTables.get(targetId); + const sources = host.recordViews!.sources(targetId); + const target = host.recordsById.get(targetId)!; + if (table === undefined) { + table = `@sc_rv_${this.recordViewTables.size}`; + this.recordViewTables.set(targetId, table); + const S = this.S; + const entries = sources.map((sourceId, k) => { + const source = host.recordsById.get(sourceId)!; + const struct = mangleRecordStruct(sourceId); + const base = host.nullableUnions.fieldBase(sourceId); + const offsets = target.fields.map((field) => { + const index = source.fields.findIndex((f) => f.name === field.name) + base; + return `${S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${index}) to ${S})`; + }); + const offsetTable = `${table}_${k}`; + this.defs.push( + `${offsetTable} = internal constant [${offsets.length} x ${S}] [${offsets.join(", ")}]`, ); - if (!prefix) continue; - const extra = rcMembers(shape, host.inlineUnions, base).some( - (member) => member.index >= target.fields.length + base && isRefCounted(member.type), - ); - if (!extra) out.push(shape.id); - } + const key = typeKey({ kind: "record", shapeId: sourceId }); + return `{ ptr, ${S}, ptr } { ptr ${host.cstr(key)}, ${S} ${Buffer.byteLength(key, "utf8")}, ptr ${offsetTable} }`; + }); + this.defs.push( + `${table} = internal constant [${entries.length} x { ptr, ${S}, ptr }] [${entries.join(", ")}]`, + ``, + ); } - this.prefixSources.set(targetId, out); - return out; + const key = typeKey({ kind: "record", shapeId: targetId }); + const words = recordViewWords(target.fields.length, host.nullableUnions.fieldBase(targetId)); + const struct = mangleRecordStruct(targetId); + host.declare( + `declare ptr @scr_dyn_record_view(ptr, ptr, ${this.S}, ptr, ${this.S}, ptr, ${this.S})`, + ); + const view = B.tmp(); + B.line( + `${view} = call ptr @scr_dyn_record_view(ptr %d, ptr ${table}, ${this.S} ${sources.length}, ptr ${host.cstr(key)}, ${this.S} ${Buffer.byteLength(key, "utf8")}, ptr @${mangleRecordNew(targetId)}, ${this.S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${this.S}))`, + ); + return view; } private typedRefMatchesAny(B: BlockBuilder, types: readonly IrType[]): string { @@ -2222,31 +2222,19 @@ export class LlDyn { B.line(`${ref} = call ptr @scr_dyn_typed_ref_unbox(ptr %d)`); B.terminate(`ret ptr ${ref}`); B.startBlock(lNext); - if (t.kind === "record") { - // A record whose storage begins with exactly this type's storage - // is this type's value too: the conversion passes the pointer, so - // both names keep one object (decision D3). - const sources = this.prefixViewSources(t.shapeId); - if (sources.length > 0) { - const viewed = this.typedRefMatchesAny( - B, - sources.map((shapeId) => ({ kind: "record", shapeId })), - ); - const lView = B.newLabel("dc.prefix"); - const lOther = B.newLabel("dc.noprefix"); - B.condBr(viewed, lView, lOther); - B.startBlock(lView); - host.declare(`declare ptr @scr_dyn_typed_ref_prefix_view(ptr)`); - const view = B.tmp(); - B.line(`${view} = call ptr @scr_dyn_typed_ref_prefix_view(ptr %d)`); - const passed = B.tmp(); - B.line(`${passed} = icmp ne ptr ${view}, null`); - const lPass = B.newLabel("dc.prefix.pass"); - B.condBr(passed, lPass, lOther); - B.startBlock(lPass); - B.terminate(`ret ptr ${view}`); - B.startBlock(lOther); - } + if (t.kind === "record" && host.recordViews?.targets.has(t.shapeId) === true) { + // A capsule of a record with all of this type's fields converts + // to a view of that record: both names keep one object + // (record-views.ts, decision D3). + const view = this.recordViewCall(B, t.shapeId); + const viewed = B.tmp(); + B.line(`${viewed} = icmp ne ptr ${view}, null`); + const lView = B.newLabel("dc.view"); + const lOther = B.newLabel("dc.noview"); + B.condBr(viewed, lView, lOther); + B.startBlock(lView); + B.terminate(`ret ptr ${view}`); + B.startBlock(lOther); } // A constructor-function stream (scr_dyn_builtin_stream.c) carries // its runtime stream. diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index e6c1b98c59..f24e3a862a 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -357,6 +357,7 @@ import { import { children, type Node } from "./region-ir.js"; import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js"; import { VirtualBorrows } from "./virtual-borrows.js"; +import { planRecordViews, recordViewFieldPtr, recordViewWords } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { LlWalkers } from "./walkers.js"; import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js"; @@ -2523,6 +2524,13 @@ export class LlEmitter { this.shapeHost.arenasClosed = this.arenas.allClosed; } } + this.shapeHost.recordViews = planRecordViews(mod, { + traced: (id) => this.tracedShapes.has(`record:${id}`), + excluded: (id) => + this.shapeHost.arenaShapes?.has(id) === true || + this.publishedTypes?.recordGuarded(id) === true || + mod.workers === true, + }); for (const [name, rec] of RUNTIME_ERROR_CLASSES) { const meta = this.classMeta.get(name); if (!meta) break; // hand-written IR without the builtin defs: no stamps @@ -4844,10 +4852,24 @@ export class LlEmitter { throw new InternalCompilerError( `llvm emitter bug: unknown field ${field} on shape ${shapeId}`, ); - const p = this.B.tmp(); - this.B.line( - `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + this.nullableUnions.fieldBase(shapeId)}`, - ); + const base = this.nullableUnions.fieldBase(shapeId); + let p: string; + if (this.shapeHost.recordViews?.targets.has(shapeId) === true) { + p = recordViewFieldPtr( + this.B, + this.sizeType, + mangleRecordStruct(shapeId), + objName, + idx + base, + idx, + recordViewWords(shape.fields.length, base), + ); + } else { + p = this.B.tmp(); + this.B.line( + `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${objName}, i64 0, i32 ${idx + base}`, + ); + } const type = shape.fields[idx]!.type; return { ptr: p, type, inline: this.inlineUnions.record(type) }; } diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts new file mode 100644 index 0000000000..41df2cb710 --- /dev/null +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -0,0 +1,171 @@ +/* Record views: one object across record layouts (decision D3). + * + * A checked conversion of a capsule of a native record A into another + * record type B whose fields A also has (same names and types, any + * offsets) answers a view: a B-shaped object whose fields live in A. The + * view's storage is B's struct plus two trailing words, the A record + * (borrowed: the view's record-source entry owns A's capsule, which owns + * A) and a table of A's byte offsets for B's fields. Every field address + * of a B value tests the first word (recordViewFieldPtr), so reads and + * writes through either name reach the same storage, and boxing a view + * answers A's capsule (scr_dyn_record_view), so identity holds too. + * + * Only the target types of such conversions carry the words and the + * test. They are found from the IR, before any code is emitted: B is a + * record type a checked conversion can produce (the closure of dynCheck + * targets and of the types live capsules expose) and some other record + * type in the module has all of B's fields. A program without checked + * conversions has none, so its code is unchanged. */ +import { + isDirentShape, + KEY_ORDER_FIELD, + shapeHasAccessorSlots, + shapeKeyOrders, + typeKey, + type IrModule, + type IrRecordShape, + type IrType, +} from "../../ir/ir.js"; +import { everyStmtList } from "../../ir/traverse.js"; +import type { BlockBuilder } from "./blocks.js"; + +export interface RecordViews { + /** Shape ids whose values may be views. */ + readonly targets: ReadonlySet; + /** The record shapes whose capsules convert to a view of `target`. */ + sources(target: string): readonly string[]; +} + +export const NO_RECORD_VIEWS: RecordViews = { targets: new Set(), sources: () => [] }; + +interface ViewOptions { + readonly traced: (shapeId: string) => boolean; + readonly excluded: (shapeId: string) => boolean; +} + +function plainShape(shape: IrRecordShape): boolean { + return ( + !shape.tuple && + !shape.indexValue && + shape.fields.length > 0 && + !isDirentShape(shape) && + !shapeHasAccessorSlots(shape) && + shapeKeyOrders(shape).length === 1 && + !shape.fields.some((field) => field.name === KEY_ORDER_FIELD || field.name.startsWith("%")) + ); +} + +/** Whether every field of `target` is a field of `source` with the same + * type (the view reads A's field with B's representation). */ +function covers(source: IrRecordShape, target: IrRecordShape): boolean { + const fields = new Map(source.fields.map((field) => [field.name, typeKey(field.type)])); + return target.fields.every((field) => fields.get(field.name) === typeKey(field.type)); +} + +export function planRecordViews(mod: IrModule, options: ViewOptions): RecordViews { + const shapes = new Map((mod.records ?? []).map((shape) => [shape.id, shape])); + const unions = new Map((mod.unions ?? []).map((union) => [union.id, union])); + const reachable = new Set(); + const seen = new Set(); + const visit = (t: IrType): void => { + const key = typeKey(t); + if (seen.has(key)) return; + seen.add(key); + switch (t.kind) { + case "record": { + reachable.add(t.shapeId); + for (const field of shapes.get(t.shapeId)?.fields ?? []) visit(field.type); + return; + } + case "array": + visit(t.elem); + return; + case "union": + for (const arm of unions.get(t.unionId)?.arms ?? []) visit(arm); + return; + default: + return; + } + }; + for (const fn of mod.functions) + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + if (e.kind === "dynCheck") visit(e.type); + else if (e.kind === "dynFrom" && e.liveRef === true) visit(e.value.type); + return true; + }, + }); + if (reachable.size === 0) return NO_RECORD_VIEWS; + const targets = new Set(); + const sources = new Map(); + for (const id of reachable) { + const target = shapes.get(id); + if (!target || !plainShape(target) || target.freezable === true) continue; + if (options.traced(id) || options.excluded(id)) continue; + const found: string[] = []; + for (const source of shapes.values()) { + if (source.id === id || !reachable.has(source.id) || !plainShape(source)) continue; + if (covers(source, target)) found.push(source.id); + } + if (found.length === 0) continue; + targets.add(id); + sources.set(id, found); + } + if (targets.size === 0) return NO_RECORD_VIEWS; + return { targets, sources: (target) => sources.get(target) ?? [] }; +} + +/** The struct indices of a view target's two trailing words. */ +export function recordViewWords( + fieldCount: number, + base: number, +): { record: number; offsets: number } { + return { record: fieldCount + base, offsets: fieldCount + base + 1 }; +} + +/** The address of field `position` (declaration index) of the record + * `obj` of view-target shape `struct`: the slot itself, or the source + * record's slot when `obj` is a view. Leaves `B` in a fresh block. */ +export function recordViewFieldPtr( + B: BlockBuilder, + sizeType: "i32" | "i64", + struct: string, + obj: string, + slotIndex: number, + position: number, + words: { record: number; offsets: number }, +): string { + const own = B.tmp(); + B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); + const recordPtr = B.tmp(); + const record = B.tmp(); + const isView = B.tmp(); + B.line( + `${recordPtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.record}`, + ); + B.line(`${record} = load ptr, ptr ${recordPtr}`); + B.line(`${isView} = icmp ne ptr ${record}, null`); + const from = B.currentLabel(); + const view = B.newLabel("rv.view"); + const join = B.newLabel("rv.join"); + B.condBr(isView, view, join); + B.startBlock(view); + const tablePtr = B.tmp(); + const table = B.tmp(); + const offsetPtr = B.tmp(); + const offset = B.tmp(); + const viewed = B.tmp(); + B.line( + `${tablePtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.offsets}`, + ); + B.line(`${table} = load ptr, ptr ${tablePtr}`); + B.line(`${offsetPtr} = getelementptr inbounds ${sizeType}, ptr ${table}, i64 ${position}`); + B.line(`${offset} = load ${sizeType}, ptr ${offsetPtr}`); + B.line(`${viewed} = getelementptr inbounds i8, ptr ${record}, ${sizeType} ${offset}`); + B.br(join); + B.startBlock(join); + const ptr = B.tmp(); + B.line(`${ptr} = phi ptr [ ${own}, %${from} ], [ ${viewed}, %${view} ]`); + return ptr; +} diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 25afbad698..8661783b89 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -69,6 +69,7 @@ import { disposeFieldLines, plainRefType, } from "./arenas.js"; +import { recordViewWords, type RecordViews } from "./record-views.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -151,6 +152,8 @@ export interface ShapeHost { /** Record shapes and classes with an arena-aware constructor * (arenas.ts). */ arenaShapes?: ReadonlySet; + /** Record view targets and their sources (record-views.ts). */ + recordViews?: RecordViews; arenaClasses?: ReadonlySet; /** Every arena the program opens is closed (ArenaPlan.allClosed): the * arena-aware constructors take no dispose list. */ @@ -2172,6 +2175,8 @@ export function emitRecordShapes( llFieldType(f.type, host.inlineUnions.record(f.type)), ); if (shape.indexValue) fieldTys.push("ptr"); // the overflow ScrMap * + // A record view target: the viewed record and its offset table. + if (host.recordViews?.targets.has(shape.id) === true) fieldTys.push("ptr", "ptr"); // A tagged shape (an arm of a shape union) carries its shape number. if (host.nullableUnions.shapeNumber(shape.id) !== 0) fieldTys.unshift("i32"); typeDefs.push( @@ -2390,10 +2395,31 @@ export function emitRecordShapes( const index = i + base; const inline = host.inlineUnions.record(field.type); const fieldTy = llFieldType(field.type, inline); - clone.push( - ` %sp${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, - ` %sv${i} = load ${fieldTy}, ptr %sp${i}`, - ); + if (host.recordViews?.targets.has(shape.id) === true) { + // A view's fields live in the viewed record (record-views.ts). + const words = recordViewWords(shape.fields.length, base); + clone.push( + ` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, + ` %sr${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.record}`, + ` %sr${i}r = load ptr, ptr %sr${i}p`, + ` %sr${i}v = icmp ne ptr %sr${i}r, null`, + ` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`, + `sv${i}.view:`, + ` %st${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.offsets}`, + ` %st${i} = load ptr, ptr %st${i}p`, + ` %sx${i}p = getelementptr inbounds ${host.sizeType}, ptr %st${i}, i64 ${i}`, + ` %sx${i} = load ${host.sizeType}, ptr %sx${i}p`, + ` %sw${i} = getelementptr inbounds i8, ptr %sr${i}r, ${host.sizeType} %sx${i}`, + ` br label %sv${i}.join`, + `sv${i}.join:`, + ` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`, + ` %sv${i} = load ${fieldTy}, ptr %sp${i}`, + ); + } else + clone.push( + ` %sp${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, + ` %sv${i} = load ${fieldTy}, ptr %sp${i}`, + ); const owned = isRefCounted(field.type) && !inline; const stored = owned ? `%sr${i}` : `%sv${i}`; if (owned) { diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index 36793598bd..eaba2d4374 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -54,6 +54,9 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO return false; if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true) return false; + // A record view target's frame copy would need its view words zeroed. + if (e.type.kind === "record" && host.shapeHost.recordViews?.targets.has(e.type.shapeId) === true) + return false; if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true) return false; // A producer with a result-slot entry builds its fresh result in the diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 7c67e98ae1..c688422abc 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -1,4 +1,5 @@ import { emitPerKeyOrder } from "./key-orders.js"; +import { recordViewFieldPtr, recordViewWords } from "./record-views.js"; import { f64Lit } from "./common.js"; import { InternalCompilerError } from "../../errors.js"; /* Structure-walking helper EMITTERS for the LLVM backend — the .ll mirror @@ -280,10 +281,25 @@ export class LlWalkers { index: number, t: IrType, ): string { - const p = B.tmp(); - B.line( - `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${recName}, i64 0, i32 ${index}`, - ); + const shape = this.host.recordsById.get(shapeId)!; + const base = this.host.nullableUnions.fieldBase(shapeId); + let p: string; + if (this.host.recordViews?.targets.has(shapeId) === true) { + p = recordViewFieldPtr( + B, + this.S, + mangleRecordStruct(shapeId), + recName, + index, + index - base, + recordViewWords(shape.fields.length, base), + ); + } else { + p = B.tmp(); + B.line( + `${p} = getelementptr inbounds %${mangleRecordStruct(shapeId)}, ptr ${recName}, i64 0, i32 ${index}`, + ); + } // An inline union field lends a private stack box (ABSENT kept). if (this.host.inlineUnions.record(t)) return loadStructField(B, this.S, p, t, true); const fieldTy = llFieldType(t, false); diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-layout-views.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-layout-views.ts.json new file mode 100644 index 0000000000..e413e3ee6a --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-layout-views.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-layout-views.ts" + ], + "diags": [] +} diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-view-extras-release.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-view-extras-release.ts.json new file mode 100644 index 0000000000..cbdf42556e --- /dev/null +++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/record-view-extras-release.ts.json @@ -0,0 +1,6 @@ +{ + "order": [ + "/tests/corpus/record-view-extras-release.ts" + ], + "diags": [] +} diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 3a1e833080..9faf7135b7 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -301,6 +301,9 @@ typedef struct ScrDynTypedCast { void *(*retain)(void *); void (*release)(void *); bool traced; + /* A record view (scr_dyn_record_view): the view's own record-source + * entry owns the capsule, so the list neither owns nor releases it. */ + bool borrowed; struct ScrDynTypedCast *next; } ScrDynTypedCast; @@ -1315,7 +1318,6 @@ bool scr_dyn_typed_ref_is( const ScrDyn *d, const char *type_key, size_t type_key_len); bool scr_dyn_typed_ref_is_key(const ScrDyn *d, const ScrStr *type_key); void *scr_dyn_typed_ref_unbox(const ScrDyn *d); /* +1 */ -void *scr_dyn_typed_ref_prefix_view(const ScrDyn *d); /* +1 or NULL */ ScrDyn *scr_dyn_class_view_unavailable(void *ptr); ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d); /* +1 */ ScrDyn *scr_dyn_typed_ref_peek(const ScrDyn *d); /* +1 */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index eb40100cf4..26f794b839 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -1526,7 +1526,7 @@ static void scr_dyn_dispose(ScrDyn *d, bool collected) { while (d->v.typed_ref.casts) { ScrDynTypedCast *cast = d->v.typed_ref.casts; d->v.typed_ref.casts = cast->next; - if (!collected || !cast->traced) cast->release(cast->ptr); + if (!cast->borrowed && (!collected || !cast->traced)) cast->release(cast->ptr); free(cast); } if (!collected || !d->v.typed_ref.traced) d->v.typed_ref.release(d->v.typed_ref.ptr); @@ -3745,24 +3745,13 @@ typedef struct { const char *names; const char *lens; size_t name_stride, len_stride, count; - /* A record that a narrower record type also references (a prefix view, - * scr_dyn_typed_ref_prefix_view) has an entry with no view and this, its - * own capsule's parameters: every capsule of the record then answers - * for its whole storage (scr_dyn_typed_ref_new), and reflective surfaces - * of the narrower type go through that capsule (scr_record_source_has). - * Owned; NULL for an ordinary source. */ - struct ScrRecordPrefix *prefix; + /* A record view (scr_dyn_record_view) has an entry with no view and + * this: the capsule of the record it views (+1). Boxing the view answers + * that capsule (scr_dyn_typed_ref_new), and the view type's reflective + * surfaces go through it (scr_record_source_has). NULL for an ordinary + * source. */ + ScrDyn *capsule; } ScrRecordSource; -typedef struct ScrRecordPrefix { - void *(*retain)(void *); - void (*release)(void *); - const char *type_key; - size_t type_key_len; - ScrDyn *(*materialize)(void *); - void (*commit)(void *, const ScrDyn *); - const ScrDynTypedArrayOps *ops; /* the record's ScrDynTypedRecordOps */ - bool traced, record; -} ScrRecordPrefix; static SCR_TL ScrRecordSource *scr_record_sources; static SCR_TL size_t scr_record_source_capacity, scr_record_source_count; /* Deleted slots keep this marker so probe runs stay intact without moving @@ -3803,17 +3792,20 @@ static ScrRecordSource *scr_record_source_slot(const void *rec) { return NULL; } -/* The record's kept source, or NULL (also for a prefix-view entry). */ +/* The record's kept source, or NULL (also for a record view's entry). */ static ScrRecordSource *scr_record_source_find(const void *rec) { ScrRecordSource *entry = scr_record_source_slot(rec); - return entry && !entry->prefix ? entry : NULL; + return entry && !entry->capsule ? entry : NULL; } -static const ScrRecordPrefix *scr_record_prefix_find(const void *rec) { +/* The capsule a record view views (borrowed), or NULL. */ +static ScrDyn *scr_record_view_capsule(const void *rec) { ScrRecordSource *entry = scr_record_source_slot(rec); - return entry ? entry->prefix : NULL; + return entry ? entry->capsule : NULL; } +static void scr_record_view_forget(ScrDyn *capsule, const void *view); + /* The retained view of a record with undeclared keys (borrowed), or NULL. */ ScrDyn *scr_record_source_view(const void *rec) { ScrRecordSource *entry = scr_record_source_find(rec); @@ -3829,11 +3821,16 @@ bool scr_record_source_has(const void *rec) { * the dispose hook sees a consistent table. */ static void scr_record_source_drop(ScrRecordSource *found) { ScrDyn *view = found->view; - free(found->prefix); + ScrDyn *capsule = found->capsule; + void *rec = found->rec; *found = (ScrRecordSource){ .rec = SCR_RECORD_SOURCE_DELETED }; scr_record_source_count--; scr_record_source_deleted++; scr_dyn_release(view); + if (capsule) { + scr_record_view_forget(capsule, rec); + scr_dyn_release(capsule); + } } static void scr_record_source_disposed(void *rec) { @@ -3913,8 +3910,8 @@ static void scr_record_source_teardown(void) { scr_record_source_filter = NULL; for (size_t i = 0; i < capacity; i++) { if (table[i].rec && table[i].rec != SCR_RECORD_SOURCE_DELETED) { - free(table[i].prefix); scr_dyn_release(table[i].view); + scr_dyn_release(table[i].capsule); } } free(table); @@ -3991,6 +3988,14 @@ void scr_record_source_note(void *rec, const ScrDyn *src, const char *const *nam void scr_record_source_spread(void *dst, const void *src, const char *const *names, const size_t *lens, size_t count) { if (!scr_record_source_count || !dst || !src) return; + ScrDyn *viewed = scr_record_view_capsule(src); + if (viewed) { + /* A record view spreads every property of the record it views. */ + ScrDyn *snapshot = scr_dyn_typed_ref_peek(viewed); + if (!scr_exc_pending()) scr_record_source_note(dst, snapshot, names, lens, count); + scr_dyn_release(snapshot); + return; + } ScrRecordSource *found = scr_record_source_find(src); if (!found) return; ScrRecordSource probe = { dst, NULL, (const char *)names, (const char *)lens, @@ -4033,9 +4038,9 @@ void scr_record_source_move(void *from, void *to) { if (!scr_record_source_count) return; ScrRecordSource *old = scr_record_source_slot(to); ScrRecordSource *found = scr_record_source_find(from); - if (old && old->prefix) { - /* A prefix-viewed record keeps its entry: its capsule's view holds the - * undeclared properties, and narrower names read them through it. */ + if (old && old->capsule) { + /* A record view keeps its entry: the viewed capsule holds every + * property, and the view's name reads them through it. */ if (found) scr_record_source_drop(found); return; } @@ -4099,7 +4104,7 @@ ScrDyn *scr_record_source_apply(ScrDyn *snapshot, const void *rec) { } static ScrDyn *scr_json_member(const ScrDyn *holder, const ScrStr *key); -static void scr_jb_put_record_prefix(ScrJsonBuf *b, void *rec); +static void scr_jb_put_record_view(ScrJsonBuf *b, const ScrDyn *capsule); static bool scr_jb_try_pure_member(ScrJsonBuf *b, const char *key, size_t key_len, const ScrStr *key_str, const ScrDyn *value, bool *first); static bool scr_jb_put_member(ScrJsonBuf *b, const ScrStr *key, ScrDyn *value, bool *first, @@ -4177,8 +4182,9 @@ static void scr_record_source_key_order(const ScrDyn *view, size_t *out) { bool scr_jb_try_record_source(ScrJsonBuf *b, void *rec, bool (*field)(ScrJsonBuf *, void *, size_t, bool)) { - if (scr_record_prefix_find(rec)) { - scr_jb_put_record_prefix(b, rec); + ScrDyn *viewed = scr_record_view_capsule(rec); + if (viewed) { + scr_jb_put_record_view(b, viewed); return true; } ScrRecordSource *found = scr_record_source_find(rec); @@ -4278,20 +4284,12 @@ static ScrDyn *scr_dyn_typed_ref_new( const char *type_key, size_t type_key_len, ScrDyn *(*materialize)(void *), void (*commit)(void *, const ScrDyn *), bool traced, bool observed) { - const ScrRecordPrefix *prefix = - scr_record_source_count && type_key_len >= 7 && memcmp(type_key, "record:", 7) == 0 - ? scr_record_prefix_find(ptr) - : NULL; - if (prefix) { - /* A record seen through a narrower type: its capsule is its own. */ - retain = prefix->retain; - release = prefix->release; - type_key = prefix->type_key; - type_key_len = prefix->type_key_len; - materialize = prefix->materialize; - commit = prefix->commit; - traced = traced || prefix->traced; - } + /* A record view boxes as the record it views. */ + ScrDyn *viewed = scr_record_source_count && type_key_len >= 7 && + memcmp(type_key, "record:", 7) == 0 + ? scr_record_view_capsule(ptr) + : NULL; + if (viewed) return scr_dyn_retain(viewed); scr_typed_ref_reserve(); size_t slot = scr_typed_ref_bucket(ptr); for (ScrDyn *value; (value = scr_typed_ref_slots[slot]); slot = (slot + 1) & (scr_typed_ref_capacity - 1)) { @@ -4327,11 +4325,6 @@ static ScrDyn *scr_dyn_typed_ref_new( d->v.typed_ref.record = false; d->v.typed_ref.klass = false; d->v.typed_ref.bag_class = false; - if (prefix) { - /* Bound here: the narrower name's bind keeps them (bind_record). */ - d->v.typed_ref.array = prefix->ops; - d->v.typed_ref.record = prefix->record; - } scr_typed_ref_slots[slot] = d; /* the empty slot that ended the probe */ scr_typed_ref_count++; return d; @@ -4378,29 +4371,68 @@ void *scr_dyn_typed_ref_unbox(const ScrDyn *d) { return d->v.typed_ref.retain(d->v.typed_ref.ptr); } -/* A record capsule's native record for a conversion into a type whose - * storage is a prefix of the record's (the compiler's prefix-view sources): - * the pointer itself (+1), so both names keep one object. The record - * remembers its own capsule parameters (ScrRecordPrefix), so every later - * capsule of it, whichever name boxes it, answers for its whole storage. - * NULL while the record keeps undeclared source properties, whose table - * is keyed by the record's own declaration; the conversion then builds - * its copy as before. */ -void *scr_dyn_typed_ref_prefix_view(const ScrDyn *d) { +/* Record views (compiler record-views.ts). A checked conversion of a + * capsule of record A into record type B, when A has all of B's fields, + * answers a view (+1): a B object (allocated by `alloc`, zeroed) whose + * trailing words at byte offset `words` hold A (borrowed) and A's offset + * table for B's fields, so every field access of the B value reaches A. + * `sources` lists, for each A the compiler allows, its type key and offset + * table. The view's record-source entry owns A's capsule: boxing the view + * answers that capsule, and the entry dies with the view. One view per + * capsule and B, found again through the capsule's cast list, where it is + * borrowed. NULL for any other capsule and for a frozen A, whose + * conversion keeps its copy. */ +typedef struct { + const char *key; + size_t key_len; + const size_t *offsets; +} ScrRecordViewSource; +static void *scr_record_view_retain(void *view); + +void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, + const char *key, size_t key_len, void *(*alloc)(void), size_t words) { + if (d->kind != SCR_DYN_TYPED_REF) return NULL; + const ScrRecordViewSource *source = NULL; + for (size_t i = 0; i < count && !source; i++) + if (sources[i].key_len == d->v.typed_ref.type_key_len && + memcmp(sources[i].key, d->v.typed_ref.type_key, sources[i].key_len) == 0) + source = &sources[i]; void *record = d->v.typed_ref.ptr; - if (!scr_record_prefix_find(record)) { - if (scr_record_source_find(record) || !d->v.typed_ref.record) return NULL; - ScrRecordPrefix *prefix = malloc(sizeof *prefix); - if (!prefix) scr_json_oom(); - *prefix = (ScrRecordPrefix){ - d->v.typed_ref.retain, d->v.typed_ref.release, d->v.typed_ref.type_key, - d->v.typed_ref.type_key_len, d->v.typed_ref.materialize, d->v.typed_ref.commit, - d->v.typed_ref.array, d->v.typed_ref.traced, d->v.typed_ref.record, - }; - ScrRecordSource entry = { .rec = record, .prefix = prefix }; - scr_record_source_put(&entry); + if (!source || scr_rc_frozen(record)) return NULL; + for (ScrDynTypedCast *cast = d->v.typed_ref.casts; cast; cast = cast->next) + if (cast->borrowed && cast->type_key_len == key_len && memcmp(cast->type_key, key, key_len) == 0) + return cast->retain(cast->ptr); + void *view = alloc(); + void **slots = (void **)((char *)view + words); + slots[0] = record; + slots[1] = (void *)source->offsets; + ScrRecordSource entry = { .rec = view, .capsule = scr_dyn_retain(d) }; + scr_record_source_put(&entry); + ScrDynTypedCast *cast = malloc(sizeof *cast); + if (!cast) scr_trap("scriptc: out of memory\n"); + *cast = (ScrDynTypedCast){ .type_key = key, .type_key_len = key_len, .ptr = view, + .retain = scr_record_view_retain, .release = NULL, + .borrowed = true, .next = d->v.typed_ref.casts }; + d->v.typed_ref.casts = cast; + return view; +} + +/* A view itself counts like any record (its target's retain). */ +static void *scr_record_view_retain(void *view) { + if (!scr_rc_immortal(view)) SCR_RC_COUNT(view)++; + return view; +} + +/* A view left the table: its capsule's cast list stops naming it. */ +static void scr_record_view_forget(ScrDyn *capsule, const void *view) { + for (ScrDynTypedCast **at = &capsule->v.typed_ref.casts; *at; at = &(*at)->next) { + if ((*at)->borrowed && (*at)->ptr == view) { + ScrDynTypedCast *cast = *at; + *at = cast->next; + free(cast); + return; + } } - return d->v.typed_ref.retain(record); } @@ -4988,7 +5020,7 @@ 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; cast = cast->next) { - if (cast->type_key_len == type_key_len && + if (!cast->borrowed && cast->type_key_len == type_key_len && memcmp(cast->type_key, type_key, type_key_len) == 0) { return cast->retain(cast->ptr); } @@ -5008,6 +5040,7 @@ void scr_dyn_typed_ref_cache_cast( cast->retain = retain; cast->release = release; cast->traced = traced; + cast->borrowed = false; cast->next = d->v.typed_ref.casts; d->v.typed_ref.casts = cast; } @@ -14845,11 +14878,9 @@ ScrStr *scr_util_format_j_text(ScrStr *text) { /* The gap of a compact walk; the walks read only its length. */ static const ScrStr scr_json_compact_gap = { .rc = SIZE_MAX, .len = 0 }; -/* JSON for a prefix-viewed record: its own capsule's. */ -static void scr_jb_put_record_prefix(ScrJsonBuf *b, void *rec) { - ScrDyn *capsule = scr_dyn_new_typed_ref(rec, NULL, NULL, "record:", 7, NULL, NULL); +/* JSON for a record view: the viewed capsule's. */ +static void scr_jb_put_record_view(ScrJsonBuf *b, const ScrDyn *capsule) { scr_json_replaced_write(b, capsule, scr_dyn_undefined(), NULL, &scr_json_compact_gap, b->seen_len); - scr_dyn_release(capsule); } #define SCR_JSON_PURE_DEPTH 32 diff --git a/packages/runtime/src/scr_region.c b/packages/runtime/src/scr_region.c index dacd9f1635..bbd1632596 100644 --- a/packages/runtime/src/scr_region.c +++ b/packages/runtime/src/scr_region.c @@ -871,7 +871,7 @@ static void scr_rgn_walk_dyn(ScrRgnWalk *w, ScrDyn *d) { scr_region_slot(w, d->v.typed_ref.ptr, d->v.typed_ref.release); scr_rgn_enter(w, d->v.typed_ref.materialized, SCR_RGN_W_DYN, NULL); for (ScrDynTypedCast *cast = d->v.typed_ref.casts; cast; cast = cast->next) - scr_region_slot(w, cast->ptr, cast->release); + if (!cast->borrowed) scr_region_slot(w, cast->ptr, cast->release); break; case SCR_DYN_PROXY: scr_rgn_enter(w, d->v.proxy.target, SCR_RGN_W_DYN, NULL); diff --git a/tests/corpus/record-layout-views.ts b/tests/corpus/record-layout-views.ts new file mode 100644 index 0000000000..4176f4210d --- /dev/null +++ b/tests/corpus/record-layout-views.ts @@ -0,0 +1,26 @@ +// A native record converted from an untyped value into another record type +// that names some of its fields, in any order, is the same object: writes +// through either name show through the other, identity holds, and the +// untyped views, JSON and spreads of the narrower name see every field. +type Wide = { a: number; b: string; c: boolean; d: number[] }; +type AC = { a: number; c: boolean }; +type DB = { d: number[]; b: string }; +const wide: Wide = { a: 1, b: "x", c: true, d: [1] }; +const o: unknown = wide; +const v = o as AC; +v.c = false; +console.log((o as Wide).c, wide.c, o === v, (v as unknown) === o); +wide.a = 5; +console.log(v.a, JSON.stringify(v), Object.keys(v).join(",")); +const w = o as DB; +w.d.push(2); +w.b = "y"; +w.d = [7, 8]; +console.log(wide.b, wide.d.length, JSON.stringify(o), w === (o as DB), v === (o as AC)); +const v2 = { ...v }; +console.log(JSON.stringify(v2)); +function bump(x: AC): void { x.a += 1; } +bump(v); +console.log(wide.a, v.a); +const list: AC[] = [v, { a: 9, c: true }]; +console.log(list.map((x) => x.a).join(","), JSON.stringify(list)); diff --git a/tests/corpus/record-view-extras-release.ts b/tests/corpus/record-view-extras-release.ts new file mode 100644 index 0000000000..b1de5ca824 --- /dev/null +++ b/tests/corpus/record-view-extras-release.ts @@ -0,0 +1,44 @@ +// Views of records whose other fields hold strings, arrays and records: every view and every record is released exactly once, whichever +// name drops the last reference (the sanitized lane checks for leaks). +type Node = { id: number; label: string; children: Node[]; meta: { tag: string }; seen: { key: string; count: number }[] }; +type Head = { id: number; meta: { tag: string } }; +type Tail = { children: Node[]; label: string }; + +function make(id: number): Node { + return { id, label: "n" + id, children: [], meta: { tag: "t" + id }, seen: [{ key: "k", count: id }] }; +} +function asHead(value: unknown): Head { + return value as Head; +} +function asTail(value: unknown): Tail { + return value as Tail; +} + +let total = 0; +for (let round = 0; round < 200; round++) { + const node = make(round); + node.children.push(make(round + 1000)); + const head = asHead(node); + const tail = asTail(node); + head.meta = { tag: "h" + round }; + tail.label = tail.label + "!"; + tail.children.push(make(round + 2000)); + total += head.id + tail.children.length + node.seen[0]!.count; + if (round === 199) console.log(JSON.stringify(head), node.label, node.meta.tag, tail.children.length); +} +console.log(total); + +// The last reference is the view's. +let kept: Head | undefined; +for (let round = 0; round < 100; round++) kept = asHead(make(round)); +if (kept) console.log(kept.id, kept.meta.tag, JSON.stringify(kept)); +kept = undefined; + +// The last reference is the record's. +const records: Node[] = []; +for (let round = 0; round < 100; round++) { + const node = make(round); + asTail(node).label = "v" + round; + records.push(node); +} +console.log(records.map((node) => node.label).slice(0, 3).join(","), records.length); diff --git a/tests/harness/corpus-costs/record-layout-views.ts.json b/tests/harness/corpus-costs/record-layout-views.ts.json new file mode 100644 index 0000000000..8c0e66c3c2 --- /dev/null +++ b/tests/harness/corpus-costs/record-layout-views.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 } diff --git a/tests/harness/corpus-costs/record-view-extras-release.ts.json b/tests/harness/corpus-costs/record-view-extras-release.ts.json new file mode 100644 index 0000000000..8c0e66c3c2 --- /dev/null +++ b/tests/harness/corpus-costs/record-view-extras-release.ts.json @@ -0,0 +1 @@ +{ "plain": 3000, "san": 3600 } From fe34846a54761f7f06e6672b6141e77aa7dfe8d4 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 22:23:05 -0700 Subject: [PATCH 2/9] Keep tagged record shapes out of record views and declare the view entry point A tagged shape's values share field reads with the other arms of its shape union, which carry no view words, so tagged shapes keep their copies. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 3 +++ packages/runtime/src/scr_dyn.h | 9 +++++++++ packages/runtime/src/scr_json.c | 5 ----- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index f24e3a862a..f2998db785 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -2526,7 +2526,10 @@ export class LlEmitter { } this.shapeHost.recordViews = planRecordViews(mod, { traced: (id) => this.tracedShapes.has(`record:${id}`), + // A tagged shape's values share field reads with the other arms of + // its shape union, which do not carry view words. excluded: (id) => + this.nullableUnions.shapeNumber(id) !== 0 || this.shapeHost.arenaShapes?.has(id) === true || this.publishedTypes?.recordGuarded(id) === true || mod.workers === true, diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index 9faf7135b7..c90b29419b 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1318,6 +1318,15 @@ bool scr_dyn_typed_ref_is( const ScrDyn *d, const char *type_key, size_t type_key_len); bool scr_dyn_typed_ref_is_key(const ScrDyn *d, const ScrStr *type_key); void *scr_dyn_typed_ref_unbox(const ScrDyn *d); /* +1 */ +/* Record views (scr_json.c): a checked conversion's view of the record a + * capsule holds, or NULL. */ +typedef struct { + const char *key; + size_t key_len; + const size_t *offsets; +} ScrRecordViewSource; +void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, + const char *key, size_t key_len, void *(*alloc)(void), size_t words); ScrDyn *scr_dyn_class_view_unavailable(void *ptr); ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d); /* +1 */ ScrDyn *scr_dyn_typed_ref_peek(const ScrDyn *d); /* +1 */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 26f794b839..d3e7431e0d 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4382,11 +4382,6 @@ void *scr_dyn_typed_ref_unbox(const ScrDyn *d) { * capsule and B, found again through the capsule's cast list, where it is * borrowed. NULL for any other capsule and for a frozen A, whose * conversion keeps its copy. */ -typedef struct { - const char *key; - size_t key_len; - const size_t *offsets; -} ScrRecordViewSource; static void *scr_record_view_retain(void *view); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, From 10185c214796c8821d658060058d0e648d5db82d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Mon, 26 Oct 2026 23:09:38 -0700 Subject: [PATCH 3/9] Mark record views with the frozen bit instead of testing a trailing word A value of a view target type can point at another storage (a shared field read of a shape union, for one), whose block ends before the view words. The view mark is now the rc word's frozen bit, which no other value of a view target has (targets are never freezable; an immortal word is no view), and the trailing words are read only after it. View targets release like freezable shapes. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/record-views.ts | 36 ++++++++++++++----- packages/compiler/src/backend/llvm/shapes.ts | 18 ++++++---- packages/runtime/src/scr_json.c | 9 +++-- 3 files changed, 45 insertions(+), 18 deletions(-) diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 41df2cb710..ce27c84a82 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -5,8 +5,9 @@ * offsets) answers a view: a B-shaped object whose fields live in A. The * view's storage is B's struct plus two trailing words, the A record * (borrowed: the view's record-source entry owns A's capsule, which owns - * A) and a table of A's byte offsets for B's fields. Every field address - * of a B value tests the first word (recordViewFieldPtr), so reads and + * A) and a table of A's byte offsets for B's fields, and its rc word + * carries the frozen bit as the view mark. Every field address of a B + * value tests the mark (recordViewFieldPtr), so reads and * writes through either name reach the same storage, and boxing a view * answers A's capsule (scr_dyn_record_view), so identity holds too. * @@ -116,6 +117,23 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView return { targets, sources: (target) => sources.get(target) ?? [] }; } +/** Lines setting `dest` (i1) to whether the record `obj` of a view target + * type is a view. A view's rc word carries the frozen bit (scr_rc.h + * SCR_RC_FROZEN), which no other value of such a type has: view targets + * are never freezable, and an immortal word (all ones) is not a view. The + * trailing words are read only after this test, so a value of another + * storage seen through the type is never read past its end. */ +export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: string): string[] { + const bit = sizeType === "i64" ? "4611686018427387904" : "1073741824"; + return [ + ` ${dest}.w = load ${sizeType}, ptr ${obj}`, + ` ${dest}.b = and ${sizeType} ${dest}.w, ${bit}`, + ` ${dest}.m = icmp ne ${sizeType} ${dest}.b, 0`, + ` ${dest}.i = icmp ne ${sizeType} ${dest}.w, -1`, + ` ${dest} = and i1 ${dest}.m, ${dest}.i`, + ]; +} + /** The struct indices of a view target's two trailing words. */ export function recordViewWords( fieldCount: number, @@ -138,19 +156,19 @@ export function recordViewFieldPtr( ): string { const own = B.tmp(); B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); - const recordPtr = B.tmp(); - const record = B.tmp(); const isView = B.tmp(); - B.line( - `${recordPtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.record}`, - ); - B.line(`${record} = load ptr, ptr ${recordPtr}`); - B.line(`${isView} = icmp ne ptr ${record}, null`); + for (const line of recordViewTestLines(sizeType, obj, isView)) B.line(line.trimStart()); const from = B.currentLabel(); const view = B.newLabel("rv.view"); const join = B.newLabel("rv.join"); B.condBr(isView, view, join); B.startBlock(view); + const recordPtr = B.tmp(); + const record = B.tmp(); + B.line( + `${recordPtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.record}`, + ); + B.line(`${record} = load ptr, ptr ${recordPtr}`); const tablePtr = B.tmp(); const table = B.tmp(); const offsetPtr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index 8661783b89..c5d4526bc9 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -69,7 +69,7 @@ import { disposeFieldLines, plainRefType, } from "./arenas.js"; -import { recordViewWords, type RecordViews } from "./record-views.js"; +import { recordViewTestLines, recordViewWords, type RecordViews } from "./record-views.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -454,7 +454,11 @@ export function emitShapeUnionHelpers(host: ShapeHost): string[] { const traced = union.arms.map((arm) => host.tracedShapes.has(`record:${arm.shapeId}`)); // A freezable arm's count is the word without its frozen bit, as in // releaseFastPath: a frozen record's last reference takes the slow path. - const frozen = union.arms.some((arm) => host.recordsById.get(arm.shapeId)?.freezable === true); + const frozen = union.arms.some( + (arm) => + host.recordsById.get(arm.shapeId)?.freezable === true || + host.recordViews?.targets.has(arm.shapeId) === true, + ); const notFrozen = ~recordFrozenBit(S); const release = shapeReleaseFn(union.unionId); if (traced.every((t) => t === traced[0])) @@ -2296,7 +2300,9 @@ export function emitRecordShapes( false, chained, segmented, - shape.freezable === true, + // A record view's word carries the frozen bit as its view mark + // (record-views.ts), so view targets release like freezable shapes. + shape.freezable === true || host.recordViews?.targets.has(shape.id) === true, iterative ? iterativeTeardownDefinitions( host, @@ -2400,11 +2406,11 @@ export function emitRecordShapes( const words = recordViewWords(shape.fields.length, base); clone.push( ` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, - ` %sr${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.record}`, - ` %sr${i}r = load ptr, ptr %sr${i}p`, - ` %sr${i}v = icmp ne ptr %sr${i}r, null`, + ...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`), ` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`, `sv${i}.view:`, + ` %sr${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.record}`, + ` %sr${i}r = load ptr, ptr %sr${i}p`, ` %st${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.offsets}`, ` %st${i} = load ptr, ptr %st${i}p`, ` %sx${i}p = getelementptr inbounds ${host.sizeType}, ptr %st${i}, i64 ${i}`, diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d3e7431e0d..d094f15db8 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4373,9 +4373,9 @@ void *scr_dyn_typed_ref_unbox(const ScrDyn *d) { /* Record views (compiler record-views.ts). A checked conversion of a * capsule of record A into record type B, when A has all of B's fields, - * answers a view (+1): a B object (allocated by `alloc`, zeroed) whose - * trailing words at byte offset `words` hold A (borrowed) and A's offset - * table for B's fields, so every field access of the B value reaches A. + * answers a view (+1): a B object (allocated by `alloc`, zeroed, its rc + * word marked with the frozen bit) whose trailing words at byte offset + * `words` hold A (borrowed) and A's offset table for B's fields, so every field access of the B value reaches A. * `sources` lists, for each A the compiler allows, its type key and offset * table. The view's record-source entry owns A's capsule: boxing the view * answers that capsule, and the entry dies with the view. One view per @@ -4398,6 +4398,9 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t if (cast->borrowed && cast->type_key_len == key_len && memcmp(cast->type_key, key, key_len) == 0) return cast->retain(cast->ptr); void *view = alloc(); + /* The frozen bit marks a view: view target types are never freezable, + * and their emitted releases ignore the bit (record-views.ts). */ + *(size_t *)view |= SCR_RC_FROZEN; void **slots = (void **)((char *)view + words); slots[0] = record; slots[1] = (void *)source->offsets; From 7b91c5fe5c4ce396c158091327ede08188ec4a4c Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 00:53:10 -0700 Subject: [PATCH 4/9] Plan record views before any host copies the shape host The walkers take a narrower host type than the emitter's shape host; in the self-hosted compiler that conversion copies the host, so a plan assigned afterwards never reached them and the native emitter left view guards out. The plan is now made before the copies, and arena shapes leave its shared target set once arenas are planned. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 33 +++++++++++++------ 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index f2998db785..f2025bdf2d 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -1397,6 +1397,8 @@ export class LlEmitter { readonly dyn: LlDyn; /** Explicit callback boundary for standalone layout and walker stages. */ readonly shapeHost: DynHost; + /** The record view plan's target set (record-views.ts), shared by reference. */ + private recordViewTargets: Set | null = null; /** External declarations, in first-use order. */ readonly decls = new Set(); /** Declared functions referenced as values: each needs an env-signature @@ -2402,6 +2404,20 @@ export class LlEmitter { } } } + // Planned before any host copies the shape host (record-views.ts). + { + const views = planRecordViews(mod, { + traced: (id) => this.tracedShapes.has(`record:${id}`), + // A tagged shape's values share field reads with the other arms of + // its shape union. + excluded: (id) => + this.nullableUnions.shapeNumber(id) !== 0 || + this.publishedTypes?.recordGuarded(id) === true || + mod.workers === true, + }); + this.shapeHost.recordViews = views; + this.recordViewTargets = views.targets as Set; + } this.walkers = new LlWalkers(this.shapeHost); this.dyn = new LlDyn(this.shapeHost); for (const g of mod.globals ?? []) { @@ -2524,16 +2540,13 @@ export class LlEmitter { this.shapeHost.arenasClosed = this.arenas.allClosed; } } - this.shapeHost.recordViews = planRecordViews(mod, { - traced: (id) => this.tracedShapes.has(`record:${id}`), - // A tagged shape's values share field reads with the other arms of - // its shape union, which do not carry view words. - excluded: (id) => - this.nullableUnions.shapeNumber(id) !== 0 || - this.shapeHost.arenaShapes?.has(id) === true || - this.publishedTypes?.recordGuarded(id) === true || - mod.workers === true, - }); + // Arena shapes are known only now; the plan's target set is shared by + // every host that copied the shape host, so dropping them here reaches + // all of them. + const arenaShapes = this.shapeHost.arenaShapes; + const viewTargets = this.recordViewTargets; + if (arenaShapes !== undefined && viewTargets !== null) + for (const id of arenaShapes) viewTargets.delete(id); for (const [name, rec] of RUNTIME_ERROR_CLASSES) { const meta = this.classMeta.get(name); if (!meta) break; // hand-written IR without the builtin defs: no stamps From ae74868e7ac521531bcb87dcae226d8941529a3d Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 01:45:06 -0700 Subject: [PATCH 5/9] Plan record views only where a checked target meets a live capsule source Targets come from the closure of checked conversion types and sources from the closure of types boxed as live capsules; a conversion anywhere else keeps its copy. Fewer types carry view guards, the compiler's own among them. Co-Authored-By: Claude Opus 5.5 --- .../compiler/src/backend/llvm/record-views.ts | 62 +++++++++++-------- 1 file changed, 37 insertions(+), 25 deletions(-) diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index ce27c84a82..135a330ede 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -14,8 +14,8 @@ * Only the target types of such conversions carry the words and the * test. They are found from the IR, before any code is emitted: B is a * record type a checked conversion can produce (the closure of dynCheck - * targets and of the types live capsules expose) and some other record - * type in the module has all of B's fields. A program without checked + * targets), and some other record type whose values reach untyped code as + * live capsules (the closure of live dynFrom types) has all of B's fields. A program without checked * conversions has none, so its code is unchanged. */ import { isDirentShape, @@ -66,37 +66,49 @@ function covers(source: IrRecordShape, target: IrRecordShape): boolean { export function planRecordViews(mod: IrModule, options: ViewOptions): RecordViews { const shapes = new Map((mod.records ?? []).map((shape) => [shape.id, shape])); const unions = new Map((mod.unions ?? []).map((union) => [union.id, union])); - const reachable = new Set(); - const seen = new Set(); - const visit = (t: IrType): void => { - const key = typeKey(t); - if (seen.has(key)) return; - seen.add(key); - switch (t.kind) { - case "record": { - reachable.add(t.shapeId); - for (const field of shapes.get(t.shapeId)?.fields ?? []) visit(field.type); - return; + // Targets: record types a checked conversion produces. Sources: record + // types whose values reach untyped code as live capsules. A view arises + // only where the two meet; elsewhere a conversion keeps its copy. + const closure = (roots: IrType[]): Set => { + const out = new Set(); + const seen = new Set(); + const visit = (t: IrType): void => { + const key = typeKey(t); + if (seen.has(key)) return; + seen.add(key); + switch (t.kind) { + case "record": { + out.add(t.shapeId); + for (const field of shapes.get(t.shapeId)?.fields ?? []) visit(field.type); + return; + } + case "array": + visit(t.elem); + return; + case "union": + for (const arm of unions.get(t.unionId)?.arms ?? []) visit(arm); + return; + default: + return; } - case "array": - visit(t.elem); - return; - case "union": - for (const arm of unions.get(t.unionId)?.arms ?? []) visit(arm); - return; - default: - return; - } + }; + for (const root of roots) visit(root); + return out; }; + const checked: IrType[] = []; + const boxed: IrType[] = []; for (const fn of mod.functions) everyStmtList(fn.body, { stmt: () => true, expr: (e) => { - if (e.kind === "dynCheck") visit(e.type); - else if (e.kind === "dynFrom" && e.liveRef === true) visit(e.value.type); + if (e.kind === "dynCheck") checked.push(e.type); + else if (e.kind === "dynFrom" && e.liveRef === true) boxed.push(e.value.type); return true; }, }); + if (checked.length === 0 || boxed.length === 0) return NO_RECORD_VIEWS; + const reachable = closure(checked); + const live = closure(boxed); if (reachable.size === 0) return NO_RECORD_VIEWS; const targets = new Set(); const sources = new Map(); @@ -106,7 +118,7 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView if (options.traced(id) || options.excluded(id)) continue; const found: string[] = []; for (const source of shapes.values()) { - if (source.id === id || !reachable.has(source.id) || !plainShape(source)) continue; + if (source.id === id || !live.has(source.id) || !plainShape(source)) continue; if (covers(source, target)) found.push(source.id); } if (found.length === 0) continue; From 3f772c0ee6878212730f121a6cf572ec1b4b33fc Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 02:27:46 -0700 Subject: [PATCH 6/9] Look up view slots out of line behind a cold branch The view guard's slot lookup moves into scr_record_view_slot, and the branch to it is weighted as unlikely, so a field access of a view target type adds one rc-word test to its hot path. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 1 + .../compiler/src/backend/llvm/record-views.ts | 21 +++++-------------- packages/compiler/src/backend/llvm/walkers.ts | 1 + packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_json.c | 9 ++++++++ 5 files changed, 17 insertions(+), 16 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index f2025bdf2d..059190eb51 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4879,6 +4879,7 @@ export class LlEmitter { idx + base, idx, recordViewWords(shape.fields.length, base), + (decl) => this.declare(decl), ); } else { p = this.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 135a330ede..92238fd549 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -165,6 +165,7 @@ export function recordViewFieldPtr( slotIndex: number, position: number, words: { record: number; offsets: number }, + declare: (decl: string) => void, ): string { const own = B.tmp(); B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); @@ -173,26 +174,14 @@ export function recordViewFieldPtr( const from = B.currentLabel(); const view = B.newLabel("rv.view"); const join = B.newLabel("rv.join"); - B.condBr(isView, view, join); + // Views are rare: the slot lookup stays out of line. + B.condBr(isView, view, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`); B.startBlock(view); - const recordPtr = B.tmp(); - const record = B.tmp(); - B.line( - `${recordPtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.record}`, - ); - B.line(`${record} = load ptr, ptr ${recordPtr}`); - const tablePtr = B.tmp(); - const table = B.tmp(); - const offsetPtr = B.tmp(); - const offset = B.tmp(); + declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType}, ${sizeType})`); const viewed = B.tmp(); B.line( - `${tablePtr} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${words.offsets}`, + `${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${sizeType}), ${sizeType} ${position})`, ); - B.line(`${table} = load ptr, ptr ${tablePtr}`); - B.line(`${offsetPtr} = getelementptr inbounds ${sizeType}, ptr ${table}, i64 ${position}`); - B.line(`${offset} = load ${sizeType}, ptr ${offsetPtr}`); - B.line(`${viewed} = getelementptr inbounds i8, ptr ${record}, ${sizeType} ${offset}`); B.br(join); B.startBlock(join); const ptr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index c688422abc..47a3c2dd45 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -293,6 +293,7 @@ export class LlWalkers { index, index - base, recordViewWords(shape.fields.length, base), + (decl) => this.host.declare(decl), ); } else { p = B.tmp(); diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index c90b29419b..cc55661e71 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1325,6 +1325,7 @@ typedef struct { size_t key_len; const size_t *offsets; } ScrRecordViewSource; +void *scr_record_view_slot(void *view, size_t words, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, const char *key, size_t key_len, void *(*alloc)(void), size_t words); ScrDyn *scr_dyn_class_view_unavailable(void *ptr); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index d094f15db8..eba125a7ce 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4415,6 +4415,15 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t return view; } +/* The slot of field `position` of a view (emitted view guards, cold): the + * viewed record's slot at its offset table's entry. `words` is the byte + * offset of the view's two trailing words. */ +void *scr_record_view_slot(void *view, size_t words, size_t position) { + void **slots = (void **)((char *)view + words); + const size_t *offsets = slots[1]; + return (char *)slots[0] + offsets[position]; +} + /* A view itself counts like any record (its target's retain). */ static void *scr_record_view_retain(void *view) { if (!scr_rc_immortal(view)) SCR_RC_COUNT(view)++; From 47dfa71ab19641a1fe4d16627ed82454adad8cd7 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 03:30:28 -0700 Subject: [PATCH 7/9] Keep the view guard to one value in unoptimized builds Unoptimized builds give every value a frame slot, so the inline guard grew the frames of large recursive functions enough for the sanitized self-rebuild to run out of stack. Development builds now call scr_record_view_field for the slot address; optimized builds test the view mark as one range (the frozen bit plus a count) before the cold lookup. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/emitter.ts | 1 + .../compiler/src/backend/llvm/record-views.ts | 28 ++++++++++++++----- packages/compiler/src/backend/llvm/walkers.ts | 1 + packages/runtime/src/scr_dyn.h | 1 + packages/runtime/src/scr_json.c | 13 +++++++++ 5 files changed, 37 insertions(+), 7 deletions(-) diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 059190eb51..928742b2b6 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -4880,6 +4880,7 @@ export class LlEmitter { idx, recordViewWords(shape.fields.length, base), (decl) => this.declare(decl), + this.rcMode === undefined, ); } else { p = this.B.tmp(); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 92238fd549..052b9f5c8e 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -132,17 +132,19 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView /** Lines setting `dest` (i1) to whether the record `obj` of a view target * type is a view. A view's rc word carries the frozen bit (scr_rc.h * SCR_RC_FROZEN), which no other value of such a type has: view targets - * are never freezable, and an immortal word (all ones) is not a view. The + * are never freezable, and a word with the program-lifetime bit (an + * immortal one included) is no view, so a view's word is the frozen bit + * plus a count. The * trailing words are read only after this test, so a value of another * storage seen through the type is never read past its end. */ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: string): string[] { - const bit = sizeType === "i64" ? "4611686018427387904" : "1073741824"; + // The frozen bit with a count and no program-lifetime bit: one range. + const [bit, span] = + sizeType === "i64" ? ["4611686018427387904", "4294967296"] : ["1073741824", "1073741824"]; return [ ` ${dest}.w = load ${sizeType}, ptr ${obj}`, - ` ${dest}.b = and ${sizeType} ${dest}.w, ${bit}`, - ` ${dest}.m = icmp ne ${sizeType} ${dest}.b, 0`, - ` ${dest}.i = icmp ne ${sizeType} ${dest}.w, -1`, - ` ${dest} = and i1 ${dest}.m, ${dest}.i`, + ` ${dest}.d = sub ${sizeType} ${dest}.w, ${bit}`, + ` ${dest} = icmp ult ${sizeType} ${dest}.d, ${span}`, ]; } @@ -166,9 +168,21 @@ export function recordViewFieldPtr( position: number, words: { record: number; offsets: number }, declare: (decl: string) => void, + outOfLine: boolean, ): string { const own = B.tmp(); B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); + const wordsOffset = `${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${sizeType})`; + if (outOfLine) { + // Unoptimized builds give every value a frame slot: one call keeps + // the guard to a single value per access. + declare(`declare ptr @scr_record_view_field(ptr, ptr, ${sizeType}, ${sizeType})`); + const ptr = B.tmp(); + B.line( + `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ptr ${own}, ${wordsOffset}, ${sizeType} ${position})`, + ); + return ptr; + } const isView = B.tmp(); for (const line of recordViewTestLines(sizeType, obj, isView)) B.line(line.trimStart()); const from = B.currentLabel(); @@ -180,7 +194,7 @@ export function recordViewFieldPtr( declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType}, ${sizeType})`); const viewed = B.tmp(); B.line( - `${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${sizeType}), ${sizeType} ${position})`, + `${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${wordsOffset}, ${sizeType} ${position})`, ); B.br(join); B.startBlock(join); diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 47a3c2dd45..8c32558c2e 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -294,6 +294,7 @@ export class LlWalkers { index - base, recordViewWords(shape.fields.length, base), (decl) => this.host.declare(decl), + this.host.rcMode === undefined, ); } else { p = B.tmp(); diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index cc55661e71..c77218de2a 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1326,6 +1326,7 @@ typedef struct { const size_t *offsets; } ScrRecordViewSource; void *scr_record_view_slot(void *view, size_t words, size_t position); +void *scr_record_view_field(void *value, void *own, size_t words, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, const char *key, size_t key_len, void *(*alloc)(void), size_t words); ScrDyn *scr_dyn_class_view_unavailable(void *ptr); diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index eba125a7ce..12e35df93f 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -4424,6 +4424,19 @@ void *scr_record_view_slot(void *view, size_t words, size_t position) { return (char *)slots[0] + offsets[position]; } +/* The address of field `position` of a value of a view target type, `own` + * being its own slot (the unoptimized builds' guard): the viewed record's + * slot for a view, whose word is the frozen bit plus a count. */ +void *scr_record_view_field(void *value, void *own, size_t words, size_t position) { + size_t word = *(const size_t *)value; +#if SIZE_MAX > UINT32_MAX + bool view = word - SCR_RC_FROZEN < ((size_t)1 << 32); +#else + bool view = word - SCR_RC_FROZEN < SCR_RC_FROZEN; +#endif + return view ? scr_record_view_slot(value, words, position) : own; +} + /* A view itself counts like any record (its target's retain). */ static void *scr_record_view_retain(void *view) { if (!scr_rc_immortal(view)) SCR_RC_COUNT(view)++; From 85d2a63410eb1ef3677559b717583239ff818096 Mon Sep 17 00:00:00 2001 From: Malte Ubl Date: Tue, 27 Oct 2026 04:39:28 -0700 Subject: [PATCH 8/9] Keep a view's viewed record in its record-source entry instead of trailing words View target structs no longer grow: every stack copy of such a record grew by the two words, which enlarged unoptimized frames enough for the sanitized self-rebuild to run out of stack. The guard's cold path looks the slot up through the view's record-source entry (scr_record_view_slot), which now holds the viewed record and its offsets, and stack objects of target types are allowed again. Co-Authored-By: Claude Opus 5.5 --- packages/compiler/src/backend/llvm/dyn.ts | 11 +--- packages/compiler/src/backend/llvm/emitter.ts | 3 +- .../compiler/src/backend/llvm/record-views.ts | 62 +++++++------------ packages/compiler/src/backend/llvm/shapes.ts | 14 +---- .../src/backend/llvm/stack-objects.ts | 3 - packages/compiler/src/backend/llvm/walkers.ts | 4 +- packages/runtime/src/scr_dyn.h | 6 +- packages/runtime/src/scr_json.c | 47 +++++++------- 8 files changed, 58 insertions(+), 92 deletions(-) diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 5bc71a3310..dfeffdecb0 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1,6 +1,5 @@ import { emitPerKeyOrder } from "./key-orders.js"; import { typedRefConstructor } from "./shapes.js"; -import { recordViewWords } from "./record-views.js"; import { bindLiveArrayOps } from "./expr-stream-bridges.js"; import { BYTES_ELEM_NUM, @@ -1161,7 +1160,7 @@ export class LlDyn { ); } - /** `scr_dyn_record_view(d, sources, n, key, key_len, new, words)` for + /** `scr_dyn_record_view(d, sources, n, key, key_len, new)` for * view target `targetId`: the view (+1) of the record a capsule `%d` * holds when its type is one of the target's sources, else NULL. The * sources table lists each source's type key and the byte offsets of @@ -1196,14 +1195,10 @@ export class LlDyn { ); } const key = typeKey({ kind: "record", shapeId: targetId }); - const words = recordViewWords(target.fields.length, host.nullableUnions.fieldBase(targetId)); - const struct = mangleRecordStruct(targetId); - host.declare( - `declare ptr @scr_dyn_record_view(ptr, ptr, ${this.S}, ptr, ${this.S}, ptr, ${this.S})`, - ); + host.declare(`declare ptr @scr_dyn_record_view(ptr, ptr, ${this.S}, ptr, ${this.S}, ptr)`); const view = B.tmp(); B.line( - `${view} = call ptr @scr_dyn_record_view(ptr %d, ptr ${table}, ${this.S} ${sources.length}, ptr ${host.cstr(key)}, ${this.S} ${Buffer.byteLength(key, "utf8")}, ptr @${mangleRecordNew(targetId)}, ${this.S} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${this.S}))`, + `${view} = call ptr @scr_dyn_record_view(ptr %d, ptr ${table}, ${this.S} ${sources.length}, ptr ${host.cstr(key)}, ${this.S} ${Buffer.byteLength(key, "utf8")}, ptr @${mangleRecordNew(targetId)})`, ); return view; } diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index 928742b2b6..4f8a3ec298 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -357,7 +357,7 @@ import { import { children, type Node } from "./region-ir.js"; import { LlDyn, recordDynKeepsIdentity, type DynHost } from "./dyn.js"; import { VirtualBorrows } from "./virtual-borrows.js"; -import { planRecordViews, recordViewFieldPtr, recordViewWords } from "./record-views.js"; +import { planRecordViews, recordViewFieldPtr } from "./record-views.js"; import { LlvmUnsupportedError } from "./unsupported.js"; import { LlWalkers } from "./walkers.js"; import { isObjectArm, NULLABLE_ABSENT, NULLABLE_NULL, NullableUnions } from "./nullable-unions.js"; @@ -4878,7 +4878,6 @@ export class LlEmitter { objName, idx + base, idx, - recordViewWords(shape.fields.length, base), (decl) => this.declare(decl), this.rcMode === undefined, ); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index 052b9f5c8e..d11cb3170e 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -2,21 +2,20 @@ * * A checked conversion of a capsule of a native record A into another * record type B whose fields A also has (same names and types, any - * offsets) answers a view: a B-shaped object whose fields live in A. The - * view's storage is B's struct plus two trailing words, the A record - * (borrowed: the view's record-source entry owns A's capsule, which owns - * A) and a table of A's byte offsets for B's fields, and its rc word - * carries the frozen bit as the view mark. Every field address of a B - * value tests the mark (recordViewFieldPtr), so reads and - * writes through either name reach the same storage, and boxing a view - * answers A's capsule (scr_dyn_record_view), so identity holds too. + * offsets) answers a view: a B object, its rc word marked with the frozen + * bit, whose record-source entry names A and A's byte offsets for B's + * fields (scr_dyn_record_view). Every field address of a B value tests + * the mark (recordViewFieldPtr) and takes A's slot for a view, so reads + * and writes through either name reach the same storage, and boxing a + * view answers A's capsule, so identity holds too. B's storage itself is + * unchanged. * - * Only the target types of such conversions carry the words and the - * test. They are found from the IR, before any code is emitted: B is a - * record type a checked conversion can produce (the closure of dynCheck - * targets), and some other record type whose values reach untyped code as - * live capsules (the closure of live dynFrom types) has all of B's fields. A program without checked - * conversions has none, so its code is unchanged. */ + * Only the target types of such conversions carry the test. They are + * found from the IR, before any code is emitted: B is a record type a + * checked conversion can produce (the closure of dynCheck targets), and + * some other record type whose values reach untyped code as live capsules + * (the closure of live dynFrom types) has all of B's fields. A program + * without checked conversions has none, so its code is unchanged. */ import { isDirentShape, KEY_ORDER_FIELD, @@ -135,8 +134,7 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView * are never freezable, and a word with the program-lifetime bit (an * immortal one included) is no view, so a view's word is the frozen bit * plus a count. The - * trailing words are read only after this test, so a value of another - * storage seen through the type is never read past its end. */ + */ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: string): string[] { // The frozen bit with a count and no program-lifetime bit: one range. const [bit, span] = @@ -148,17 +146,11 @@ export function recordViewTestLines(sizeType: "i32" | "i64", obj: string, dest: ]; } -/** The struct indices of a view target's two trailing words. */ -export function recordViewWords( - fieldCount: number, - base: number, -): { record: number; offsets: number } { - return { record: fieldCount + base, offsets: fieldCount + base + 1 }; -} - /** The address of field `position` (declaration index) of the record - * `obj` of view-target shape `struct`: the slot itself, or the source - * record's slot when `obj` is a view. Leaves `B` in a fresh block. */ + * `obj` of view-target shape `struct`: the slot itself, or the viewed + * record's slot when `obj` is a view. Optimized builds test the mark + * inline and look the slot up out of line; unoptimized builds, which give + * every value a frame slot, make the address one call. */ export function recordViewFieldPtr( B: BlockBuilder, sizeType: "i32" | "i64", @@ -166,23 +158,19 @@ export function recordViewFieldPtr( obj: string, slotIndex: number, position: number, - words: { record: number; offsets: number }, declare: (decl: string) => void, outOfLine: boolean, ): string { - const own = B.tmp(); - B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); - const wordsOffset = `${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${words.record}) to ${sizeType})`; if (outOfLine) { - // Unoptimized builds give every value a frame slot: one call keeps - // the guard to a single value per access. - declare(`declare ptr @scr_record_view_field(ptr, ptr, ${sizeType}, ${sizeType})`); + declare(`declare ptr @scr_record_view_field(ptr, ${sizeType}, ${sizeType})`); const ptr = B.tmp(); B.line( - `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ptr ${own}, ${wordsOffset}, ${sizeType} ${position})`, + `${ptr} = call ptr @scr_record_view_field(ptr ${obj}, ${sizeType} ptrtoint (ptr getelementptr (%${struct}, ptr null, i32 0, i32 ${slotIndex}) to ${sizeType}), ${sizeType} ${position})`, ); return ptr; } + const own = B.tmp(); + B.line(`${own} = getelementptr inbounds %${struct}, ptr ${obj}, i64 0, i32 ${slotIndex}`); const isView = B.tmp(); for (const line of recordViewTestLines(sizeType, obj, isView)) B.line(line.trimStart()); const from = B.currentLabel(); @@ -191,11 +179,9 @@ export function recordViewFieldPtr( // Views are rare: the slot lookup stays out of line. B.condBr(isView, view, join, `, !prof !{!"branch_weights", i32 1, i32 1000}`); B.startBlock(view); - declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType}, ${sizeType})`); + declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`); const viewed = B.tmp(); - B.line( - `${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${wordsOffset}, ${sizeType} ${position})`, - ); + B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`); B.br(join); B.startBlock(join); const ptr = B.tmp(); diff --git a/packages/compiler/src/backend/llvm/shapes.ts b/packages/compiler/src/backend/llvm/shapes.ts index c5d4526bc9..69ca911d38 100644 --- a/packages/compiler/src/backend/llvm/shapes.ts +++ b/packages/compiler/src/backend/llvm/shapes.ts @@ -69,7 +69,7 @@ import { disposeFieldLines, plainRefType, } from "./arenas.js"; -import { recordViewTestLines, recordViewWords, type RecordViews } from "./record-views.js"; +import { recordViewTestLines, type RecordViews } from "./record-views.js"; import { recordMayKeepSource } from "./record-sources.js"; import { LlvmUnsupportedError } from "./unsupported.js"; @@ -2179,8 +2179,6 @@ export function emitRecordShapes( llFieldType(f.type, host.inlineUnions.record(f.type)), ); if (shape.indexValue) fieldTys.push("ptr"); // the overflow ScrMap * - // A record view target: the viewed record and its offset table. - if (host.recordViews?.targets.has(shape.id) === true) fieldTys.push("ptr", "ptr"); // A tagged shape (an arm of a shape union) carries its shape number. if (host.nullableUnions.shapeNumber(shape.id) !== 0) fieldTys.unshift("i32"); typeDefs.push( @@ -2403,19 +2401,13 @@ export function emitRecordShapes( const fieldTy = llFieldType(field.type, inline); if (host.recordViews?.targets.has(shape.id) === true) { // A view's fields live in the viewed record (record-views.ts). - const words = recordViewWords(shape.fields.length, base); + host.declare(`declare ptr @scr_record_view_slot(ptr, ${host.sizeType})`); clone.push( ` %so${i} = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${index}`, ...recordViewTestLines(host.sizeType, "%src", `%sr${i}v`), ` br i1 %sr${i}v, label %sv${i}.view, label %sv${i}.join`, `sv${i}.view:`, - ` %sr${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.record}`, - ` %sr${i}r = load ptr, ptr %sr${i}p`, - ` %st${i}p = getelementptr inbounds %${struct}, ptr %src, i64 0, i32 ${words.offsets}`, - ` %st${i} = load ptr, ptr %st${i}p`, - ` %sx${i}p = getelementptr inbounds ${host.sizeType}, ptr %st${i}, i64 ${i}`, - ` %sx${i} = load ${host.sizeType}, ptr %sx${i}p`, - ` %sw${i} = getelementptr inbounds i8, ptr %sr${i}r, ${host.sizeType} %sx${i}`, + ` %sw${i} = call ptr @scr_record_view_slot(ptr %src, ${host.sizeType} ${i})`, ` br label %sv${i}.join`, `sv${i}.join:`, ` %sp${i} = phi ptr [ %so${i}, %${i === 0 ? "entry" : `sv${i - 1}.join`} ], [ %sw${i}, %sv${i}.view ]`, diff --git a/packages/compiler/src/backend/llvm/stack-objects.ts b/packages/compiler/src/backend/llvm/stack-objects.ts index eaba2d4374..36793598bd 100644 --- a/packages/compiler/src/backend/llvm/stack-objects.ts +++ b/packages/compiler/src/backend/llvm/stack-objects.ts @@ -54,9 +54,6 @@ export function canStackObject(host: LlvmEmitterContext, e: IrExpr): e is StackO return false; if (e.type.kind === "record" && host.publishedTypes?.recordGuarded(e.type.shapeId) === true) return false; - // A record view target's frame copy would need its view words zeroed. - if (e.type.kind === "record" && host.shapeHost.recordViews?.targets.has(e.type.shapeId) === true) - return false; if (e.type.kind === "record" && host.recordsById.get(e.type.shapeId)?.freezable === true) return false; // A producer with a result-slot entry builds its fresh result in the diff --git a/packages/compiler/src/backend/llvm/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 8c32558c2e..f475f073bb 100644 --- a/packages/compiler/src/backend/llvm/walkers.ts +++ b/packages/compiler/src/backend/llvm/walkers.ts @@ -1,5 +1,5 @@ import { emitPerKeyOrder } from "./key-orders.js"; -import { recordViewFieldPtr, recordViewWords } from "./record-views.js"; +import { recordViewFieldPtr } from "./record-views.js"; import { f64Lit } from "./common.js"; import { InternalCompilerError } from "../../errors.js"; /* Structure-walking helper EMITTERS for the LLVM backend — the .ll mirror @@ -281,7 +281,6 @@ export class LlWalkers { index: number, t: IrType, ): string { - const shape = this.host.recordsById.get(shapeId)!; const base = this.host.nullableUnions.fieldBase(shapeId); let p: string; if (this.host.recordViews?.targets.has(shapeId) === true) { @@ -292,7 +291,6 @@ export class LlWalkers { recName, index, index - base, - recordViewWords(shape.fields.length, base), (decl) => this.host.declare(decl), this.host.rcMode === undefined, ); diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h index c77218de2a..85ecef5041 100644 --- a/packages/runtime/src/scr_dyn.h +++ b/packages/runtime/src/scr_dyn.h @@ -1325,10 +1325,10 @@ typedef struct { size_t key_len; const size_t *offsets; } ScrRecordViewSource; -void *scr_record_view_slot(void *view, size_t words, size_t position); -void *scr_record_view_field(void *value, void *own, size_t words, size_t position); +void *scr_record_view_slot(void *view, size_t position); +void *scr_record_view_field(void *value, size_t offset, size_t position); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, - const char *key, size_t key_len, void *(*alloc)(void), size_t words); + const char *key, size_t key_len, void *(*alloc)(void)); ScrDyn *scr_dyn_class_view_unavailable(void *ptr); ScrDyn *scr_dyn_typed_ref_materialize(const ScrDyn *d); /* +1 */ ScrDyn *scr_dyn_typed_ref_peek(const ScrDyn *d); /* +1 */ diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c index 12e35df93f..018fde0f32 100644 --- a/packages/runtime/src/scr_json.c +++ b/packages/runtime/src/scr_json.c @@ -3751,6 +3751,10 @@ typedef struct { * surfaces go through it (scr_record_source_has). NULL for an ordinary * source. */ ScrDyn *capsule; + /* A view's record (borrowed: the capsule holds it) and its byte offsets + * for the view type's fields. */ + void *viewed; + const size_t *offsets; } ScrRecordSource; static SCR_TL ScrRecordSource *scr_record_sources; static SCR_TL size_t scr_record_source_capacity, scr_record_source_count; @@ -4374,18 +4378,18 @@ void *scr_dyn_typed_ref_unbox(const ScrDyn *d) { /* Record views (compiler record-views.ts). A checked conversion of a * capsule of record A into record type B, when A has all of B's fields, * answers a view (+1): a B object (allocated by `alloc`, zeroed, its rc - * word marked with the frozen bit) whose trailing words at byte offset - * `words` hold A (borrowed) and A's offset table for B's fields, so every field access of the B value reaches A. - * `sources` lists, for each A the compiler allows, its type key and offset - * table. The view's record-source entry owns A's capsule: boxing the view - * answers that capsule, and the entry dies with the view. One view per - * capsule and B, found again through the capsule's cast list, where it is - * borrowed. NULL for any other capsule and for a frozen A, whose + * word marked with the frozen bit) whose record-source entry names A + * (borrowed) and A's byte offsets for B's fields, so every field access of + * the B value reaches A. `sources` lists, for each A the compiler allows, + * its type key and offset table. The entry owns A's capsule: boxing the + * view answers that capsule, and the entry dies with the view. One view + * per capsule and B, found again through the capsule's cast list, where it + * is borrowed. NULL for any other capsule and for a frozen A, whose * conversion keeps its copy. */ static void *scr_record_view_retain(void *view); void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t count, - const char *key, size_t key_len, void *(*alloc)(void), size_t words) { + const char *key, size_t key_len, void *(*alloc)(void)) { if (d->kind != SCR_DYN_TYPED_REF) return NULL; const ScrRecordViewSource *source = NULL; for (size_t i = 0; i < count && !source; i++) @@ -4401,10 +4405,8 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t /* The frozen bit marks a view: view target types are never freezable, * and their emitted releases ignore the bit (record-views.ts). */ *(size_t *)view |= SCR_RC_FROZEN; - void **slots = (void **)((char *)view + words); - slots[0] = record; - slots[1] = (void *)source->offsets; - ScrRecordSource entry = { .rec = view, .capsule = scr_dyn_retain(d) }; + ScrRecordSource entry = { .rec = view, .capsule = scr_dyn_retain(d), .viewed = record, + .offsets = source->offsets }; scr_record_source_put(&entry); ScrDynTypedCast *cast = malloc(sizeof *cast); if (!cast) scr_trap("scriptc: out of memory\n"); @@ -4415,26 +4417,23 @@ void *scr_dyn_record_view(ScrDyn *d, const ScrRecordViewSource *sources, size_t return view; } -/* The slot of field `position` of a view (emitted view guards, cold): the - * viewed record's slot at its offset table's entry. `words` is the byte - * offset of the view's two trailing words. */ -void *scr_record_view_slot(void *view, size_t words, size_t position) { - void **slots = (void **)((char *)view + words); - const size_t *offsets = slots[1]; - return (char *)slots[0] + offsets[position]; +/* The slot of field `position` of a view (emitted view guards, cold). */ +void *scr_record_view_slot(void *view, size_t position) { + ScrRecordSource *entry = scr_record_source_slot(view); + return (char *)entry->viewed + entry->offsets[position]; } -/* The address of field `position` of a value of a view target type, `own` - * being its own slot (the unoptimized builds' guard): the viewed record's - * slot for a view, whose word is the frozen bit plus a count. */ -void *scr_record_view_field(void *value, void *own, size_t words, size_t position) { +/* The address of field `position` of a value of a view target type, whose + * own slot is at byte `offset` (the unoptimized builds' guard): the viewed + * record's slot for a view, whose word is the frozen bit plus a count. */ +void *scr_record_view_field(void *value, size_t offset, size_t position) { size_t word = *(const size_t *)value; #if SIZE_MAX > UINT32_MAX bool view = word - SCR_RC_FROZEN < ((size_t)1 << 32); #else bool view = word - SCR_RC_FROZEN < SCR_RC_FROZEN; #endif - return view ? scr_record_view_slot(value, words, position) : own; + return view ? scr_record_view_slot(value, position) : (char *)value + offset; } /* A view itself counts like any record (its target's retain). */ From 74b1d43e523e92f14f395f332cc1dd64bd6e4edd Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Tue, 27 Oct 2026 12:53:04 -0500 Subject: [PATCH 9/9] Keep record views off sources that may be frozen or hold the view A view writes its source's slots through the target type's stores, which have no frozen-write guard, so a write through a view made before Object.freeze(source) changed the frozen record. A view's record-source entry also owns the source's capsule, an edge the cycle analysis does not see: a source that stored its own view, or a value reaching it, kept both records until process exit. A source now takes part in a view only when it is not freezable and its fields, elements, map entries, union arms and overflow properties cannot hold a value of the target type. A class instance, function, untyped value or other reference that can reach arbitrary objects counts as holding every type, and a value of a view target type counts as holding the records it may view, so two views cannot close a cycle either. Other conversions keep their copy. --- .../src/backend/llvm/record-views.test.ts | 130 ++++++++++++++++++ .../compiler/src/backend/llvm/record-views.ts | 90 +++++++++++- 2 files changed, 214 insertions(+), 6 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/record-views.test.ts diff --git a/packages/compiler/src/backend/llvm/record-views.test.ts b/packages/compiler/src/backend/llvm/record-views.test.ts new file mode 100644 index 0000000000..02a82d873d --- /dev/null +++ b/packages/compiler/src/backend/llvm/record-views.test.ts @@ -0,0 +1,130 @@ +import { mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "vitest"; +import { compile, deserializeModule } from "../../index.js"; +import type { IrModule } from "../../ir/ir.js"; +import { computeTraced } from "../cycle-analysis.js"; +import { planRecordViews } from "./record-views.js"; + +/** The views planned for `source`'s module, as target field names → source + * field names (each list sorted). */ +async function plannedViews(source: string): Promise> { + const dir = await mkdtemp(join(tmpdir(), "scriptc-record-views-")); + try { + const entry = join(dir, "main.ts"), + outPath = join(dir, "main.ir.json"); + await writeFile(entry, source); + const result = await compile(entry, { outDir: dir, outPath, outputKind: "ir" }); + if (!result.ok) throw new Error(result.diagnostics.map((d) => d.message).join("\n")); + const mod: IrModule = deserializeModule(await readFile(outPath, "utf8")); + const traced = computeTraced(mod).shapes; + const views = planRecordViews(mod, { + traced: (id) => traced.has(`record:${id}`), + excluded: () => false, + }); + const names = new Map( + (mod.records ?? []).map((shape) => [shape.id, shape.fields.map((f) => f.name).join(",")]), + ); + return new Map( + [...views.targets].map((target) => [ + names.get(target)!, + views.sources(target).map((id) => names.get(id)!), + ]), + ); + } finally { + await rm(dir, { recursive: true, force: true }); + } +} + +const conversions = ` +const o: unknown = wide; +const v = o as AC; +v.a = 2; +console.log(wide.a, v.a, o === v); +`; + +test("a checked conversion of a live record capsule into a record type it covers plans a view", async () => { + const views = await plannedViews(` +type Wide = { a: number; b: string; c: boolean }; +type AC = { a: number; c: boolean }; +const wide: Wide = { a: 1, b: "x", c: true }; +${conversions}`); + expect(views).toEqual(new Map([["a,c", ["a,b,c"]]])); +}); + +test("a source whose fields can hold the target type keeps the copy", async () => { + // A view stored in its own source would be a cycle through the view's + // capsule reference, which the cycle analysis does not see. + const views = await plannedViews(` +type AC = { a: number; c: boolean }; +type Holder = { items: AC[] }; +type Wide = { a: number; b: string; c: boolean; self: AC | undefined; holder: Holder }; +const wide: Wide = { a: 1, b: "x", c: true, self: undefined, holder: { items: [] } }; +${conversions} +wide.self = v; +wide.holder.items.push(v);`); + expect(views).toEqual(new Map()); +}); + +test("a source that holds the target type through a class instance keeps the copy", async () => { + const views = await plannedViews(` +type AC = { a: number; c: boolean }; +class Base { note = 0; } +class Keeper extends Base { kept: AC | undefined = undefined; } +type Wide = { a: number; b: string; c: boolean; keeper: Base }; +const wide: Wide = { a: 1, b: "x", c: true, keeper: new Keeper() }; +${conversions}`); + expect(views).toEqual(new Map()); +}); + +test("a source that holds itself still views a type it cannot hold", async () => { + const views = await plannedViews(` +type Tree = { a: number; b: string; c: boolean; children: Tree[] }; +type AC = { a: number; c: boolean }; +const wide: Tree = { a: 1, b: "x", c: true, children: [] }; +wide.children.push({ a: 2, b: "y", c: false, children: [] }); +${conversions}`); + expect(views).toEqual(new Map([["a,c", ["a,b,c,children"]]])); +}); + +test("of two sources that can hold each other's views, one keeps the copy", async () => { + // Each source only holds the other's target type, but a view of that type + // names the other source, so the two views could form a cycle. Without + // Parent's view, Kid holds only a copy and its own view is safe. + const views = await plannedViews(` +type PView = { id: number; name: string }; +type KView = { id: number; tag: string }; +type Parent = { id: number; name: string; kids: KView[] }; +type Kid = { id: number; tag: string; parent: PView | undefined }; +const parent: Parent = { id: 1, name: "p", kids: [] }; +const kid: Kid = { id: 2, tag: "k", parent: undefined }; +const p: unknown = parent; +const k: unknown = kid; +kid.parent = p as PView; +parent.kids.push(k as KView); +console.log(parent.kids.length, kid.parent === p);`); + expect(views).toEqual(new Map([["id,tag", ["id,parent,tag"]]])); +}); + +test("a source with a function field keeps the copy", async () => { + // A function can capture anything, the view included. + const views = await plannedViews(` +type AC = { a: number; c: boolean }; +type Wide = { a: number; b: string; c: boolean; f: () => number }; +const wide: Wide = { a: 1, b: "x", c: true, f: () => 1 }; +${conversions}`); + expect(views).toEqual(new Map()); +}); + +test("a source that may be frozen keeps the copy", async () => { + // Writes through a view take the target type's stores, which have no + // frozen-write guard. + const views = await plannedViews(` +type Wide = { a: number; b: string; c: boolean }; +type AC = { a: number; c: boolean }; +const wide: Wide = { a: 1, b: "x", c: true }; +${conversions} +Object.freeze(wide);`); + expect(views).toEqual(new Map()); +}); diff --git a/packages/compiler/src/backend/llvm/record-views.ts b/packages/compiler/src/backend/llvm/record-views.ts index d11cb3170e..5574d1ddec 100644 --- a/packages/compiler/src/backend/llvm/record-views.ts +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -43,6 +43,24 @@ interface ViewOptions { readonly excluded: (shapeId: string) => boolean; } +/** Value kinds that hold no reference to a record. */ +const LEAF_KINDS: ReadonlySet = new Set([ + "f64", + "bool", + "string", + "bigint", + "date", + "regex", + "bytes", + "symbol", + "url", + "searchParams", + "stats", + "undefinedT", + "nullT", + "void", +]); + function plainShape(shape: IrRecordShape): boolean { return ( !shape.tuple && @@ -94,6 +112,47 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView for (const root of roots) visit(root); return out; }; + // The views planned so far: target shape → the sources it may view. + const sources = new Map(); + // Whether a value of a record can hold a value of another record type: + // through its fields, elements, map entries, union arms and overflow + // properties, and through the record a view of it names. Class instances, + // functions, untyped values, promises and the other kinds that can + // reference arbitrary objects count as holding every type. + const held = new Map | "any">(); + const holds = (shapeId: string): Set | "any" => { + const known = held.get(shapeId); + if (known) return known; + const out = new Set(); + const seen = new Set(); + const visit = (t: IrType): boolean => { + const key = typeKey(t); + if (seen.has(key)) return true; + seen.add(key); + switch (t.kind) { + case "record": { + out.add(t.shapeId); + const shape = shapes.get(t.shapeId); + if (shape?.indexValue !== undefined && !visit(shape.indexValue)) return false; + if (!(sources.get(t.shapeId) ?? []).every((id) => visit({ kind: "record", shapeId: id }))) + return false; + return (shape?.fields ?? []).every((field) => visit(field.type)); + } + case "array": + case "set": + return visit(t.elem); + case "map": + return visit(t.key) && visit(t.value); + case "union": + return (unions.get(t.unionId)?.arms ?? []).every(visit); + default: + return LEAF_KINDS.has(t.kind); + } + }; + const result = visit({ kind: "record", shapeId }) ? out : "any"; + held.set(shapeId, result); + return result; + }; const checked: IrType[] = []; const boxed: IrType[] = []; for (const fn of mod.functions) @@ -109,8 +168,6 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView const reachable = closure(checked); const live = closure(boxed); if (reachable.size === 0) return NO_RECORD_VIEWS; - const targets = new Set(); - const sources = new Map(); for (const id of reachable) { const target = shapes.get(id); if (!target || !plainShape(target) || target.freezable === true) continue; @@ -118,13 +175,34 @@ export function planRecordViews(mod: IrModule, options: ViewOptions): RecordView const found: string[] = []; for (const source of shapes.values()) { if (source.id === id || !live.has(source.id) || !plainShape(source)) continue; + // A view writes the source's slots through the target type, which has + // no frozen-write guard, so a source that may be frozen keeps the copy. + if (source.freezable === true) continue; if (covers(source, target)) found.push(source.id); } - if (found.length === 0) continue; - targets.add(id); - sources.set(id, found); + if (found.length > 0) sources.set(id, found); } - if (targets.size === 0) return NO_RECORD_VIEWS; + // A view's record-source entry owns the source's capsule, an edge the + // cycle analysis does not see. A view stays off every cycle only when its + // source cannot hold a value of the target type, such as the view itself, + // including through other views. Dropping a pair removes edges, so the + // check repeats until no pair is dropped. + for (let changed = true; changed;) { + changed = false; + held.clear(); + for (const [id, list] of sources) { + const kept = list.filter((source) => { + const sourceHolds = holds(source); + return sourceHolds !== "any" && !sourceHolds.has(id); + }); + if (kept.length === list.length) continue; + changed = true; + if (kept.length > 0) sources.set(id, kept); + else sources.delete(id); + } + } + if (sources.size === 0) return NO_RECORD_VIEWS; + const targets = new Set(sources.keys()); return { targets, sources: (target) => sources.get(target) ?? [] }; }