diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts index 762bc52f4e..dfeffdecb0 100644 --- a/packages/compiler/src/backend/llvm/dyn.ts +++ b/packages/compiler/src/backend/llvm/dyn.ts @@ -1,5 +1,5 @@ import { emitPerKeyOrder } from "./key-orders.js"; -import { rcMembers, typedRefConstructor } from "./shapes.js"; +import { typedRefConstructor } from "./shapes.js"; import { bindLiveArrayOps } from "./expr-stream-bridges.js"; import { BYTES_ELEM_NUM, @@ -45,7 +45,6 @@ import { DYN_HANDLE_KINDS, isDirentShape, KEY_ORDER_FIELD, - shapeHasAccessorSlots, shapeKeyOrders, isDynTypedRefType, isRefCounted, @@ -261,7 +260,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 +1160,47 @@ 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)` 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 }); + 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)})`, + ); + return view; } private typedRefMatchesAny(B: BlockBuilder, types: readonly IrType[]): string { @@ -2222,31 +2217,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 f3bff1ac6d..003c2a2787 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -358,6 +358,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 } 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"; @@ -1397,6 +1398,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 @@ -2409,6 +2412,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 ?? []) { @@ -2531,6 +2548,13 @@ export class LlEmitter { this.shapeHost.arenasClosed = this.arenas.allClosed; } } + // 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 @@ -4852,10 +4876,25 @@ 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, + (decl) => this.declare(decl), + this.rcMode === undefined, + ); + } 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.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 new file mode 100644 index 0000000000..5574d1ddec --- /dev/null +++ b/packages/compiler/src/backend/llvm/record-views.ts @@ -0,0 +1,268 @@ +/* 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 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 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, + 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; +} + +/** 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 && + !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])); + // 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; + } + }; + 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) + everyStmtList(fn.body, { + stmt: () => true, + expr: (e) => { + 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; + 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 || !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) sources.set(id, found); + } + // 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) ?? [] }; +} + +/** 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 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 + */ +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] = + sizeType === "i64" ? ["4611686018427387904", "4294967296"] : ["1073741824", "1073741824"]; + return [ + ` ${dest}.w = load ${sizeType}, ptr ${obj}`, + ` ${dest}.d = sub ${sizeType} ${dest}.w, ${bit}`, + ` ${dest} = icmp ult ${sizeType} ${dest}.d, ${span}`, + ]; +} + +/** The address of field `position` (declaration index) of the record + * `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", + struct: string, + obj: string, + slotIndex: number, + position: number, + declare: (decl: string) => void, + outOfLine: boolean, +): string { + if (outOfLine) { + 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}, ${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(); + const view = B.newLabel("rv.view"); + const join = B.newLabel("rv.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); + declare(`declare ptr @scr_record_view_slot(ptr, ${sizeType})`); + const viewed = B.tmp(); + B.line(`${viewed} = call ptr @scr_record_view_slot(ptr ${obj}, ${sizeType} ${position})`); + 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..69ca911d38 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 { recordViewTestLines, 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. */ @@ -451,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])) @@ -2291,7 +2298,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, @@ -2390,10 +2399,25 @@ 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). + 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:`, + ` %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 ]`, + ` %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/walkers.ts b/packages/compiler/src/backend/llvm/walkers.ts index 7c67e98ae1..f475f073bb 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 } 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 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, + (decl) => this.host.declare(decl), + this.host.rcMode === undefined, + ); + } 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..85ecef5041 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,17 @@ 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 */ +/* 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_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)); 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 93a0bc1ae3..88a92e7724 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); @@ -3759,24 +3759,17 @@ 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; + /* 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; -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 @@ -3817,17 +3810,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); @@ -3843,11 +3839,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) { @@ -3927,8 +3928,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); @@ -4005,6 +4006,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, @@ -4047,9 +4056,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; } @@ -4113,7 +4122,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, @@ -4191,8 +4200,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); @@ -4292,20 +4302,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)) { @@ -4341,11 +4343,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; @@ -4392,29 +4389,83 @@ 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, its rc + * 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)) { + 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(); + /* 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; + 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"); + *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; +} + +/* 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, 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, position) : (char *)value + offset; +} + +/* 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); } @@ -5002,7 +5053,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); } @@ -5022,6 +5073,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; } @@ -14859,11 +14911,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 }