From 686d144ee3cf8ce331eec616b9f5ced73ad4b333 Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Wed, 30 Sep 2026 13:28:19 -0500 Subject: [PATCH] perf: reduce frontend work and deduplicate LLVM converters - Reuse immutable syntax metadata and property lookups during compilation. - Avoid redundant optional-value analysis and whole-scope copies. - Share recursive value converters to reduce LLVM emission and native build work. --- packages/compiler/src/backend/llvm/emitter.ts | 26 +--- .../compiler/src/backend/llvm/expr-context.ts | 6 - .../backend/llvm/expr-stream-bridges.test.ts | 57 +++++++ .../src/backend/llvm/expr-stream-bridges.ts | 140 ++---------------- .../src/frontend/lowering/lower-stmts.ts | 4 +- .../compiler/src/frontend/lowering/lowerer.ts | 102 +++++++------ .../lowering/runtime-optional-locals.test.ts | 82 ++++++++++ .../lowering/runtime-optional-locals.ts | 42 ++++++ .../compiler/src/frontend/ts7/ast-node.ts | 22 ++- .../src/frontend/ts7/ast-wire.test.ts | 21 +++ .../compiler/src/frontend/ts7/ast-wire.ts | 21 ++- .../src/frontend/ts7/checker-cache.ts | 2 + .../compiler/src/frontend/ts7/checker.test.ts | 67 +++++++++ packages/compiler/src/frontend/ts7/checker.ts | 12 +- .../test/ts7/baselines/order-parity.json | 6 + .../live-reference-shared-converters.ts | 67 +++++++++ 16 files changed, 462 insertions(+), 215 deletions(-) create mode 100644 packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts create mode 100644 packages/compiler/src/frontend/lowering/runtime-optional-locals.test.ts create mode 100644 packages/compiler/src/frontend/lowering/runtime-optional-locals.ts create mode 100644 tests/corpus/live-reference-shared-converters.ts diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts index ecb16353..7a22a238 100644 --- a/packages/compiler/src/backend/llvm/emitter.ts +++ b/packages/compiler/src/backend/llvm/emitter.ts @@ -89,7 +89,7 @@ import { traceArg, vAdapters, } from "./shapes.js"; -import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlStreamTypedRefContext, LlValue } from "./expr-context.js"; +import type { ExprOf, LibCallExpr, LlStreamTypedRefAdapter, LlValue } from "./expr-context.js"; export { LlvmUnsupportedError } from "./unsupported.js"; @@ -4698,19 +4698,10 @@ export class LlEmitter { } liveDynRefAdapter(t: IrType): LlStreamTypedRefAdapter { - const key = typeKey(t); - const existing = this.liveDynRefAdapters.get(key); - if (existing) return existing; if (!streamTypedRefEligible(t) && !isDynTypedRefType(t)) { - throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${key}`); + throw new InternalCompilerError(`llvm emitter bug: live dyn ref of ${typeKey(t)}`); } - const prefix = `sc_ldr_${this.liveDynRefAdapters.size}`; - return streamTypedRefMaterializeAdapter( - this, - t, - { prefix, adapters: this.liveDynRefAdapters }, - `${prefix}_materialize`, - ); + return streamTypedRefMaterializeAdapter(this, t); } liveDynUnionRefAdapter( @@ -4723,17 +4714,8 @@ export class LlEmitter { B: BlockBuilder, t: IrType, value: string, - ctx: LlStreamTypedRefContext, ): string { - return streamTypedRefBoxValue(this, B, t, value, ctx); - } - - streamTypedRefMaterializeAdapter( - t: IrType, - ctx: LlStreamTypedRefContext, - preferredSnapshot?: string, - ): LlStreamTypedRefAdapter { - return streamTypedRefMaterializeAdapter(this, t, ctx, preferredSnapshot); + return streamTypedRefBoxValue(this, B, t, value); } streamFromArrayAdapter( diff --git a/packages/compiler/src/backend/llvm/expr-context.ts b/packages/compiler/src/backend/llvm/expr-context.ts index 3ca0082c..6f50349c 100644 --- a/packages/compiler/src/backend/llvm/expr-context.ts +++ b/packages/compiler/src/backend/llvm/expr-context.ts @@ -23,9 +23,3 @@ export interface LlStreamTypedRefAdapter { snapshot: string; commit: string; } - -export interface LlStreamTypedRefContext { - prefix: string; - adapters: Map; - unions?: Map; -} diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts new file mode 100644 index 00000000..a57e7a57 --- /dev/null +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.test.ts @@ -0,0 +1,57 @@ +import { expect, test } from "vitest"; +import { DYN, STRING, UNDEFINED_T, VOID, type IrFunction, type IrModule, type IrType } from "../../ir/ir.js"; +import { emitLlvmModule } from "./emitter.js"; + +function sharedGraph(roots: number): IrModule { + const loc = { file: "live-references.ts", start: 0, end: 0 }; + const leaf: IrType = { kind: "record", shapeId: "leaf" }; + const root: IrType = { kind: "record", shapeId: "root" }; + const next: IrType = { kind: "union", unionId: "next" }; + const inputs: (IrType & { kind: "union" })[] = Array.from({ length: roots }, (_, index) => ({ kind: "union", unionId: `input${index}` })); + return { + irVersion: 13, sourceFile: loc.file, entry: "main", + records: [ + { id: "leaf", fields: [{ name: "name", type: STRING }, { name: "next", type: next }, { name: "children", type: { kind: "array", elem: leaf } }] }, + { id: "root", fields: [{ name: "first", type: leaf }, { name: "second", type: leaf }] }, + ], + unions: [{ id: "next", arms: [leaf, UNDEFINED_T] }, ...inputs.map((type) => ({ + id: type.unionId, arms: [root, leaf, STRING], + }))], + functions: [ + { name: "main", params: [], returnType: VOID, locals: [], body: [], loc }, + ...inputs.map((type, index): IrFunction => ({ + name: `box${index}`, params: [{ name: "value", localId: "value", type }], returnType: DYN, + locals: [{ id: "value", name: "value", type, mutable: false }], loc, + body: [{ kind: "return" as const, value: { + kind: "dynFrom" as const, value: { kind: "varRef" as const, localId: "value", type, loc }, liveRef: true, type: DYN, loc, + }, loc }], + })), + ], + }; +} + +test("overlapping union roots share the recursive materialization graph", () => { + for (const pointerBits of [32, 64] as const) { + for (const roots of [2, 32]) { + const llvm = emitLlvmModule(sharedGraph(roots), { pointerBits }); + const materializers = [...llvm.matchAll(/^define internal ptr @\S+\(ptr %p\).*; materialize live stream value (.+)$/gm)]; + // One record root, one recursive leaf, and its array, independent of + // how many enclosing union arms reach the shared graph. + expect(materializers).toHaveLength(3); + expect(new Set(materializers.map((match) => match[1])).size).toBe(3); + expect(llvm.match(/; materialize live union value /g)).toHaveLength(roots + 1); + const symbols = [...llvm.matchAll(/^define .*? @(\S+)\(/gm)].map((match) => match[1]); + expect(new Set(symbols).size).toBe(symbols.length); + } + } +}); + +test("converter registries belong to one emitted module", () => { + const original = sharedGraph(2); + const first = emitLlvmModule(original); + const changed = sharedGraph(2); + changed.records![0]!.fields.push({ name: "extra", type: STRING }); + const second = emitLlvmModule(changed); + expect(second).not.toBe(first); + expect(emitLlvmModule(original)).toBe(first); +}); diff --git a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts index bfa63f28..9155cac8 100644 --- a/packages/compiler/src/backend/llvm/expr-stream-bridges.ts +++ b/packages/compiler/src/backend/llvm/expr-stream-bridges.ts @@ -8,7 +8,7 @@ import { BlockBuilder } from "./blocks.js"; import { classFieldIndex, classStructSym } from "./classes.js"; import { llvmCommentText } from "./common.js"; import { FN_ATTRS, llFieldType, releaseSym, traceArg, vAdapters } from "./shapes.js"; -import type { LlvmEmitterContext, LlStreamTypedRefAdapter, LlStreamTypedRefContext } from "./expr-context.js"; +import type { LlvmEmitterContext, LlStreamTypedRefAdapter } from "./expr-context.js"; export function dynPromiseAdapter(host: LlvmEmitterContext, inner: IrType): string { if (!isRefCounted(inner) || inner.kind === "dyn") { @@ -304,15 +304,7 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext, const adapters = new Map(); for (const { arm, tag } of mutableArms) { - const prefix = `${sym}_${tag}`; - adapters.set( - tag, - host.streamTypedRefMaterializeAdapter( - arm, - { prefix, adapters: new Map() }, - `${prefix}_materialize`, - ), - ); + adapters.set(tag, host.liveDynRefAdapter(arm)); } const B = new BlockBuilder(); @@ -406,107 +398,10 @@ export function liveDynUnionRefAdapter(host: LlvmEmitterContext, return sym; } -function nestedTypedRefUnionAdapter( - host: LlvmEmitterContext, - t: IrType & { kind: "union" }, - ctx: LlStreamTypedRefContext, - ): string { - const key = typeKey(t); - if (ctx.unions === undefined) ctx.unions = new Map(); - const unions = ctx.unions; - const existing = unions.get(key); - if (existing) return existing; - const def = host.unionsById.get(t.unionId); - if (!def) { - throw new InternalCompilerError( - `llvm emitter bug: typed-ref union ${t.unionId} is undeclared`, - ); - } - const sym = `${ctx.prefix}_union_${unions.size}`; - unions.set(key, sym); - - const B = new BlockBuilder(); - const tagPtr = B.tmp(); - const tagValue = B.tmp(); - B.line(`${tagPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 1`); - B.line(`${tagValue} = load i32, ptr ${tagPtr}`); - const bad = B.newLabel("tr.union.bad"); - const labels = def.arms.map(() => B.newLabel("tr.union.arm")); - B.terminate( - `switch i32 ${tagValue}, label %${bad} [ ${def.arms.map((_, index) => `i32 ${index}, label %${labels[index]}`).join(" ")} ]`, - ); - def.arms.forEach((arm, index) => { - B.startBlock(labels[index]!); - if (streamTypedRefEligible(arm) || isDynTypedRefType(arm)) { - const adapter = host.streamTypedRefMaterializeAdapter(arm, ctx); - const rc = vAdapters(host.shapeHost, arm); - const armKey = typeKey(arm); - const payloadPtr = B.tmp(); - const payload = B.tmp(); - const boxed = B.tmp(); - B.line(`${payloadPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 5`); - B.line(`${payload} = load ptr, ptr ${payloadPtr}`); - B.line( - `${boxed} = call ptr ${typedRefConstructor(host.shapeHost, arm)}(ptr ${payload}, ptr ${rc.retain}, ptr ${rc.release}, ptr ${host.cstr(armKey)}, ${host.sizeType} ${Buffer.byteLength(armKey, "utf8")}, ptr @${adapter.snapshot}, ptr ${adapter.commit})`, - ); - B.terminate(`ret ptr ${boxed}`); - } else if (arm.kind === "undefinedT") { - host.declare(`declare ptr @scr_dyn_undefined()`); - host.declare(`declare ptr @scr_dyn_retain_v(ptr)`); - const undef = B.tmp(); - const boxed = B.tmp(); - B.line(`${undef} = call ptr @scr_dyn_undefined()`); - B.line(`${boxed} = call ptr @scr_dyn_retain_v(ptr ${undef})`); - B.terminate(`ret ptr ${boxed}`); - } else if (arm.kind === "nullT") { - host.declare(`declare ptr @scr_dyn_new_null()`); - const boxed = B.tmp(); - B.line(`${boxed} = call ptr @scr_dyn_new_null()`); - B.terminate(`ret ptr ${boxed}`); - } else if (arm.kind === "f64") { - host.declare(`declare double @scr_union_get_f64(ptr)`); - host.declare(`declare ptr @scr_dyn_new_num(double)`); - const value = B.tmp(); - const boxed = B.tmp(); - B.line(`${value} = call double @scr_union_get_f64(ptr %u)`); - B.line(`${boxed} = call ptr @scr_dyn_new_num(double ${value})`); - B.terminate(`ret ptr ${boxed}`); - } else if (arm.kind === "bool") { - host.declare(`declare zeroext i1 @scr_union_get_bool(ptr)`); - host.declare(`declare ptr @scr_dyn_new_bool(i1 zeroext)`); - const value = B.tmp(); - const boxed = B.tmp(); - B.line(`${value} = call zeroext i1 @scr_union_get_bool(ptr %u)`); - B.line(`${boxed} = call ptr @scr_dyn_new_bool(i1 ${value})`); - B.terminate(`ret ptr ${boxed}`); - } else { - const payloadPtr = B.tmp(); - const payload = B.tmp(); - const boxed = B.tmp(); - B.line(`${payloadPtr} = getelementptr inbounds %ScrUnion, ptr %u, i64 0, i32 5`); - B.line(`${payload} = load ptr, ptr ${payloadPtr}`); - B.line(`${boxed} = call ptr @${host.dyn.toDynHelper(arm)}(ptr ${payload})`); - B.terminate(`ret ptr ${boxed}`); - } - }); - B.startBlock(bad); - host.declare(`declare void @scr_trap(ptr)`); - B.line(`call void @scr_trap(ptr ${host.cstr("scriptc: internal error: invalid union tag\n")})`); - B.terminate(`unreachable`); - host.resolveThunkDefs.push( - `define internal ptr @${sym}(ptr %u) ${FN_ATTRS} { ; typed-ref union ${key}`, - B.render(), - `}`, - ``, - ); - return sym; - } - export function streamTypedRefBoxValue(host: LlvmEmitterContext, B: BlockBuilder, t: IrType, value: string, - ctx: LlStreamTypedRefContext, ): string { const boxed = B.tmp(); if ( @@ -515,7 +410,7 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext, (arm) => streamTypedRefEligible(arm) || isDynTypedRefType(arm), ) ?? false) ) { - B.line(`${boxed} = call ptr @${nestedTypedRefUnionAdapter(host, t, ctx)}(ptr ${value})`); + B.line(`${boxed} = call ptr @${host.liveDynUnionRefAdapter(t)}(ptr ${value})`); return boxed; } if (!streamTypedRefEligible(t) && !isDynTypedRefType(t)) { @@ -529,7 +424,7 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext, ); return boxed; } - const nested = host.streamTypedRefMaterializeAdapter(t, ctx); + const nested = host.liveDynRefAdapter(t); const rc = vAdapters(host.shapeHost, t); const key = typeKey(t); @@ -541,16 +436,15 @@ export function streamTypedRefBoxValue(host: LlvmEmitterContext, export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, t: IrType, - ctx: LlStreamTypedRefContext, - preferredSnapshot?: string, ): LlStreamTypedRefAdapter { const key = typeKey(t); - const existing = ctx.adapters.get(key); + const existing = host.liveDynRefAdapters.get(key); if (existing) return existing; - const snapshot = preferredSnapshot ?? - `${ctx.prefix}_nested_${ctx.adapters.size}`; + // The converter depends only on the static type. Intern the complete + // graph across roots and union arms so recursive types emit once. + const snapshot = `sc_ldr_${host.liveDynRefAdapters.size}_materialize`; const adapter: LlStreamTypedRefAdapter = { snapshot, commit: "null" }; - ctx.adapters.set(key, adapter); + host.liveDynRefAdapters.set(key, adapter); if (isDynTypedRefType(t)) { const fields = host.classMeta.get(t.className)?.def.fields; if (fields && !classDynViewSupported(fields, (id) => host.recordsById.get(id), (id) => host.unionsById.get(id))) { @@ -596,7 +490,7 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, B.line(`${boolValue} = trunc i8 ${fieldValue} to i1`); fieldValue = boolValue; } - const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue, ctx); + const boxed = host.streamTypedRefBoxValue(B, field.type, fieldValue); B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`); } if (meta.def.fields.some((field) => field.name === DYN_CLASS_PROPERTIES)) { @@ -650,7 +544,6 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, B, field.type, fieldValue, - ctx, ); B.line(`call void @scr_dyn_arr_push(ptr ${out}, ptr ${boxed})`); } @@ -682,7 +575,6 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, B, field.type, fieldValue, - ctx, ); B.line(`call void @scr_dyn_obj_set(ptr ${out}, ptr ${host.cstr(field.name)}, ${host.sizeType} ${Buffer.byteLength(field.name, "utf8")}, ptr ${boxed})`); } @@ -730,7 +622,7 @@ export function streamTypedRefMaterializeAdapter(host: LlvmEmitterContext, value = B.tmp(); B.line(`${value} = call ptr @scr_arr_get_ref(ptr %p, double ${index}) ; +1`); } - const boxed = host.streamTypedRefBoxValue(B, elem, value, ctx); + const boxed = host.streamTypedRefBoxValue(B, elem, value); B.line(`call void @scr_dyn_arr_push(ptr ${out}, ptr ${boxed})`); if (isRefCounted(elem)) { B.line(`call void ${releaseSym(host.shapeHost, elem)}(ptr ${value})`); @@ -779,7 +671,7 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext, elem.kind !== "dyn" && elem.kind !== "string" && elem.kind !== "union"; - const snapshot = `${sym}_materialize`; + let snapshot = `${sym}_materialize`; let value: string; if (elem.kind === "f64") { host.declare(`declare double @scr_arr_get_f64(ptr, double)`); @@ -872,11 +764,9 @@ export function streamFromArrayAdapter(host: LlvmEmitterContext, const keyPtr = host.cstr(key); let commit: string; if (streamTypedRefEligible(elem)) { - commit = host.streamTypedRefMaterializeAdapter( - elem, - { prefix: snapshot, adapters: new Map() }, - snapshot, - ).commit; + const adapter = host.liveDynRefAdapter(elem); + snapshot = adapter.snapshot; + commit = adapter.commit; } else { commit = host.streamTypedRefCommitAdapter(elem, snapshot); host.resolveThunkDefs.push( diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts index 7972e557..dccbde4c 100644 --- a/packages/compiler/src/frontend/lowering/lower-stmts.ts +++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts @@ -867,14 +867,14 @@ export function provenanceElidedConstDecl(lowerer: Lowerer, decl: ts.VariableDec } function withRuntimeOptionalScope(lowerer: Lowerer, lower: () => T): T { - const optionalOnEntry = [...lowerer.runtimeOptionalLocals]; + lowerer.runtimeOptionalLocals.beginScope(); try { return lower(); } finally { // A guard inside one branch or loop body proves presence only for // its remaining statements. Keep assignments that reactivated a // slot, and restore every absence possibility from the outer scope. - for (const local of optionalOnEntry) lowerer.runtimeOptionalLocals.add(local); + lowerer.runtimeOptionalLocals.endScope(); } } diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts index 8ab24452..6a02bd76 100644 --- a/packages/compiler/src/frontend/lowering/lowerer.ts +++ b/packages/compiler/src/frontend/lowering/lowerer.ts @@ -1,4 +1,5 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js"; +import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; import { sanitizeUnregisteredClassTypes } from "./sanitize-class-types.js"; import { buildUnionNarrow } from "./union-narrow.js"; import { planUnionRetag, buildUnionRetag, planRecordUnionWrap, buildRecordUnionWrap } from "./union-retag.js"; @@ -1242,7 +1243,7 @@ export class Lowerer { /** Locals widened beyond the checker's type because an inferred indexed * read can be absent at runtime. Bare reads preserve that union until a * surrounding JavaScript guard/default consumes it. */ - readonly runtimeOptionalLocals = new Set(); + readonly runtimeOptionalLocals = new RuntimeOptionalLocals(); /** An effectful switch test can change the original binding after the * discriminant was captured. A case match narrows the captured value, * not that mutable binding; preserve its tagged representation on reads. */ @@ -2919,8 +2920,15 @@ export class Lowerer { } return e; }; + const callableSymbols = new Map(); const callableSymbolOf = (node: ts.Expression): ts.Symbol | null => { + const cached = callableSymbols.get(node); + if (cached !== undefined) return cached; let symbol = symbolOf(ts.isPropertyAccessExpression(node) ? node.name : node); + if (symbol === null || signatureBySymbol.has(symbol)) { + callableSymbols.set(node, symbol); + return symbol; + } const seen = new Set(); while (symbol && !signatureBySymbol.has(symbol) && !seen.has(symbol)) { seen.add(symbol); @@ -2931,6 +2939,7 @@ export class Lowerer { symbol = symbolOf(peel(declaration.initializer)); } else break; } + callableSymbols.set(node, symbol); return symbol; }; const explicitlyNonNull = (node: ts.Expression): boolean => { @@ -3019,9 +3028,10 @@ export class Lowerer { const e = peel(node); if (!ts.isBinaryExpression(e) || e.operatorToken.kind !== ts.SyntaxKind.PlusToken) return null; const stringArrayRead = (part: ts.Expression): boolean => { - const t = this.mapTypeOf(this.typeOf(part)); const p = peel(part); - if (t?.kind !== "string" || !ts.isElementAccessExpression(p)) return false; + if (!ts.isElementAccessExpression(p)) return false; + const t = this.mapTypeOf(this.typeOf(part)); + if (t?.kind !== "string") return false; const recv = this.mapTypeOf(this.typeOf(p.expression)); return recv?.kind === "array" && recv.elem.kind === "string"; }; @@ -3340,50 +3350,52 @@ export class Lowerer { } if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) { const callbackIndices = hofCallbackIndices(node.expression.name.text, node.arguments.length >= 2); - const receiverNode = node.expression.expression; - const receiverTs = this.typeOf(receiverNode); - let receiver = this.mapTypeOf(receiverTs); - // An array method after an optional-chain guard only runs on the - // present receiver. The checker includes the chain's short-circuit - // undefined in intermediate call types, so use the same narrowed - // receiver the method lowerer sees when promoting HOF callbacks. - if ( - callbackIndices !== null && receiver?.kind === "union" && - (node.expression.questionDotToken !== undefined || isOptionalChainTail(this, node)) - ) { - receiver = this.mapTypeOf(this.checker.getNonNullableType(receiverTs)); - } - const callback = callbackIndices === null ? undefined : node.arguments[0]; - const tuple = receiver?.kind === "record" && this.shapes.get(receiver.shapeId)?.tuple === true; - // A JS/evolving-any array may have acquired a precise FLOW type - // at this call while its actual binding remains a checked-dynamic - // array. Its callback consumes DYN values through runtime method - // dispatch, so widening that callback to the native array value - // ABI makes it impossible to box back into DYN. Judge a simple - // binding by its declaration type, the same storage fact local - // declaration lowering uses. - let nativeArrayReceiver = receiver?.kind === "array" || tuple; - if (nativeArrayReceiver && ts.isIdentifier(receiverNode)) { - const receiverSymbol = symbolOf(receiverNode); - const declaration = receiverSymbol ? this.checker.valueDeclarationOf(receiverSymbol) : undefined; - if (declaration && ts.isVariableDeclaration(declaration)) { - const declared = this.mapTypeOf(this.typeOf(declaration.name)); - if (declared?.kind !== "array" && !(declared?.kind === "record" && this.shapes.get(declared.shapeId)?.tuple === true)) { - nativeArrayReceiver = false; + const callback = node.arguments[0]; + if (callbackIndices !== null && callback !== undefined && !ts.isSpreadElement(callback)) { + const receiverNode = node.expression.expression; + const receiverTs = this.typeOf(receiverNode); + let receiver = this.mapTypeOf(receiverTs); + // An array method after an optional-chain guard only runs on the + // present receiver. The checker includes the chain's short-circuit + // undefined in intermediate call types, so use the same narrowed + // receiver the method lowerer sees when promoting HOF callbacks. + if ( + receiver?.kind === "union" && + (node.expression.questionDotToken !== undefined || isOptionalChainTail(this, node)) + ) { + receiver = this.mapTypeOf(this.checker.getNonNullableType(receiverTs)); + } + const tuple = receiver?.kind === "record" && this.shapes.get(receiver.shapeId)?.tuple === true; + // A JS/evolving-any array may have acquired a precise FLOW type + // at this call while its actual binding remains a checked-dynamic + // array. Its callback consumes DYN values through runtime method + // dispatch, so widening that callback to the native array value + // ABI makes it impossible to box back into DYN. Judge a simple + // binding by its declaration type, the same storage fact local + // declaration lowering uses. + let nativeArrayReceiver = receiver?.kind === "array" || tuple; + if (nativeArrayReceiver && ts.isIdentifier(receiverNode)) { + const receiverSymbol = symbolOf(receiverNode); + const declaration = receiverSymbol ? this.checker.valueDeclarationOf(receiverSymbol) : undefined; + if (declaration && ts.isVariableDeclaration(declaration)) { + const declared = this.mapTypeOf(this.typeOf(declaration.name)); + if (declared?.kind !== "array" && !(declared?.kind === "record" && this.shapes.get(declared.shapeId)?.tuple === true)) { + nativeArrayReceiver = false; + } } } - } - if (nativeArrayReceiver && callback && !ts.isSpreadElement(callback)) { - if (promoteHofCallback(callback, callbackIndices!)) changed = true; - const method = node.expression.name.text; - if ((method === "reduce" || method === "reduceRight") && - (node.arguments.length < 2 || callbackReturnsOptional(callback))) { - if (promoteHofCallback(callback, [0, 1])) changed = true; - const result = this.runtimeOptionalType(this.irTypeOf(node)); - const previous = this.runtimeOptionalReduceTypes.get(node); - if (!previous || !typeEquals(previous, result)) { - this.runtimeOptionalReduceTypes.set(node, result); - changed = true; + if (nativeArrayReceiver) { + if (promoteHofCallback(callback, callbackIndices)) changed = true; + const method = node.expression.name.text; + if ((method === "reduce" || method === "reduceRight") && + (node.arguments.length < 2 || callbackReturnsOptional(callback))) { + if (promoteHofCallback(callback, [0, 1])) changed = true; + const result = this.runtimeOptionalType(this.irTypeOf(node)); + const previous = this.runtimeOptionalReduceTypes.get(node); + if (!previous || !typeEquals(previous, result)) { + this.runtimeOptionalReduceTypes.set(node, result); + changed = true; + } } } } diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-locals.test.ts b/packages/compiler/src/frontend/lowering/runtime-optional-locals.test.ts new file mode 100644 index 00000000..3e331c88 --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-locals.test.ts @@ -0,0 +1,82 @@ +import { expect, test } from "vitest"; +import { F64, type IrLocal } from "../../ir/ir.js"; +import { RuntimeOptionalLocals } from "./runtime-optional-locals.js"; + +const local = (id: string): IrLocal => ({ id, name: id, type: F64, mutable: true }); + +test("scope exit restores entry facts and keeps surviving assignments", () => { + const facts = new RuntimeOptionalLocals(); + const outer = local("outer"), added = local("added"), temporary = local("temporary"); + facts.add(outer); + facts.beginScope(); + expect(facts.delete(outer)).toBe(true); + expect(facts.delete(outer)).toBe(false); + facts.add(added); + facts.add(temporary); + facts.delete(temporary); + facts.endScope(); + expect(facts.has(outer)).toBe(true); + expect(facts.has(added)).toBe(true); + expect(facts.has(temporary)).toBe(false); +}); + +test("nested restores preserve the original entry generation", () => { + const facts = new RuntimeOptionalLocals(); + const value = local("value"); + facts.add(value); + facts.beginScope(); + facts.beginScope(); + facts.delete(value); + facts.add(value); + facts.endScope(); + facts.delete(value); + facts.endScope(); + expect(facts.has(value)).toBe(true); +}); + +test("an absent outer member added and removed inside a child stays absent until the outer exit", () => { + const facts = new RuntimeOptionalLocals(); + const value = local("value"); + facts.add(value); + facts.beginScope(); + facts.delete(value); + facts.beginScope(); + facts.add(value); + facts.delete(value); + facts.endScope(); + expect(facts.has(value)).toBe(false); + facts.endScope(); + expect(facts.has(value)).toBe(true); + expect(() => facts.endScope()).toThrow("not open"); +}); + +test("nested operations agree with snapshot-and-union restoration", () => { + const values = Array.from({ length: 12 }, (_, index) => local(String(index))); + const facts = new RuntimeOptionalLocals(); + const reference = new Set(); + const snapshots: IrLocal[][] = []; + let random = 0xabc123; + for (let step = 0; step < 12000; step++) { + random = (Math.imul(random, 1664525) + 1013904223) >>> 0; + const value = values[(random >>> 8) % values.length]!; + const action = random % 6; + if (action === 0 && snapshots.length < 12) { + snapshots.push([...reference]); + facts.beginScope(); + } else if (action === 1 && snapshots.length > 0) { + for (const entry of snapshots.pop()!) reference.add(entry); + facts.endScope(); + } else if (action < 4) { + reference.add(value); + facts.add(value); + } else { + expect(facts.delete(value)).toBe(reference.delete(value)); + } + for (const entry of values) expect(facts.has(entry), `step ${step}, local ${entry.id}`).toBe(reference.has(entry)); + } + while (snapshots.length > 0) { + for (const entry of snapshots.pop()!) reference.add(entry); + facts.endScope(); + } + for (const entry of values) expect(facts.has(entry)).toBe(reference.has(entry)); +}); diff --git a/packages/compiler/src/frontend/lowering/runtime-optional-locals.ts b/packages/compiler/src/frontend/lowering/runtime-optional-locals.ts new file mode 100644 index 00000000..47bfb46f --- /dev/null +++ b/packages/compiler/src/frontend/lowering/runtime-optional-locals.ts @@ -0,0 +1,42 @@ +import type { IrLocal } from "../../ir/ir.js"; + +/** Absence facts survive assignments, but a branch-local guard must not + * narrow an outer binding after that branch ends. Scope exit restores the + * members present on entry while retaining new facts learned inside it. */ +export class RuntimeOptionalLocals { + private generation = 0; + private readonly active = new Map(); + private readonly scopes: { generation: number; removed: Map }[] = []; + + has(local: IrLocal): boolean { return this.active.has(local); } + + add(local: IrLocal): void { + if (!this.active.has(local)) this.active.set(local, ++this.generation); + } + + delete(local: IrLocal): boolean { + const generation = this.active.get(local); + if (generation === undefined) return false; + this.active.delete(local); + const scope = this.scopes[this.scopes.length - 1]; + if (scope !== undefined && generation <= scope.generation) scope.removed.set(local, generation); + return true; + } + + beginScope(): void { + // Only removals of entry members need undoing. Recording their original + // generation avoids copying all locals seen by the lowering pass. + this.scopes.push({ generation: this.generation, removed: new Map() }); + } + + endScope(): void { + const scope = this.scopes.pop(); + if (scope === undefined) throw new Error("runtime optional scope is not open"); + for (const [local, generation] of scope.removed) { + // A removed member may have been added again in a nested scope. Keep + // its original age so an enclosing scope still recognizes it. + const current = this.active.get(local); + if (current === undefined || current > generation) this.active.set(local, generation); + } + } +} diff --git a/packages/compiler/src/frontend/ts7/ast-node.ts b/packages/compiler/src/frontend/ts7/ast-node.ts index 7f206553..ee2f125a 100644 --- a/packages/compiler/src/frontend/ts7/ast-node.ts +++ b/packages/compiler/src/frontend/ts7/ast-node.ts @@ -103,13 +103,23 @@ export class AstFile { * properties are views over the wire; they never copy identity-bearing * nodes into structural records. */ export class AstNode { - constructor(readonly file: AstFile, readonly index: number) {} + readonly kind: SyntaxKind; + readonly pos: number; + readonly end: number; + readonly flags: NodeFlags; + readonly data: number; + + constructor(readonly file: AstFile, readonly index: number) { + // Materialize immutable scalar metadata once. Lowering repeatedly reads + // it while refining the same node, without needing another wire decode. + const wire = file.wire; + this.kind = wire.kind(index); + this.pos = wire.pos(index); + this.end = wire.end(index); + this.flags = wire.flags(index); + this.data = wire.data(index); + } - get kind(): SyntaxKind { return this.file.wire.kind(this.index); } - get pos(): number { return this.file.wire.pos(this.index); } - get end(): number { return this.file.wire.end(this.index); } - get flags(): NodeFlags { return this.file.wire.flags(this.index); } - get data(): number { return this.file.wire.data(this.index); } get id(): string { return `${this.index}.${this.kind}.${this.file.root.path}`; } get parent(): AstNode | undefined { const index = this.file.wire.semanticParent(this.index); diff --git a/packages/compiler/src/frontend/ts7/ast-wire.test.ts b/packages/compiler/src/frontend/ts7/ast-wire.test.ts index c8137a90..db21d4bb 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire.test.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire.test.ts @@ -59,6 +59,27 @@ test("hash words remain unsigned and ordered", () => { expect(file.parseOptionsKey).toBe("4294967295"); }); +test("node words respect a byte view's offset, signed spans and unsigned flags", () => { + const original = response(); + const padded = new Uint8Array(original.length + 11).fill(0xff); + padded.set(original, 3); + const bytes = padded.subarray(3, 3 + original.length); + const at = 120 + 3 * NODE_LEN; + word(bytes, at + 4, 0xffffffff); + word(bytes, at + 8, 0x80000000); + word(bytes, at + 24, 0xfedcba98); + const file = new AstWireFile(bytes); + expect(file.kind(3)).toBe(AstKind.Identifier); + expect(file.pos(3)).toBe(-1); + expect(file.end(3)).toBe(-2147483648); + expect(file.flags(3)).toBe(0xfedcba98); + expect(file.data(3)).toBe(0x40000002); + expect(file.next(3)).toBe(4); + expect(file.parent(3)).toBe(2); + expect(file.kind(4)).toBe(AstKind.Identifier); + expect(() => file.kind(5)).toThrow(AstDecodeError); +}); + test("truncated headers, sections and node tables are rejected", () => { for (let size = 0; size < HEADER_SIZE; size++) expect(() => new AstWireFile(new Uint8Array(size))).toThrow(AstDecodeError); for (const [offset, value] of [[0, 0], [24, 40], [28, 59], [28, 67], [32, 59], [36, 69], [40, 119], [40, 0xffffffff], [48, 9], [52, 8], [56, 2]]) { diff --git a/packages/compiler/src/frontend/ts7/ast-wire.ts b/packages/compiler/src/frontend/ts7/ast-wire.ts index a1d453c2..4e982a78 100644 --- a/packages/compiler/src/frontend/ts7/ast-wire.ts +++ b/packages/compiler/src/frontend/ts7/ast-wire.ts @@ -1,4 +1,4 @@ -import { AstDecodeError, AstMsgpackReader, astBounds, astI32, astU32, decodeAstString } from "./ast-bytes.js"; +import { AstDecodeError, AstMsgpackReader, astBounds, astU32, decodeAstString } from "./ast-bytes.js"; import { AstKind, HEADER_OFFSET_EXTENDED_DATA, HEADER_OFFSET_HASH_HI0, HEADER_OFFSET_HASH_HI1, HEADER_OFFSET_HASH_LO0, HEADER_OFFSET_HASH_LO1, HEADER_OFFSET_METADATA, HEADER_OFFSET_NODES, @@ -64,10 +64,12 @@ export class AstWireFile { private readonly extended: number; private readonly structured: number; private readonly nodes: number; + private readonly view: DataView; private readonly stringCache = new Map(); constructor(private readonly bytes: Uint8Array) { astBounds(bytes, 0, HEADER_SIZE); + this.view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength); const metadata = astU32(bytes, HEADER_OFFSET_METADATA); if ((metadata >>> 24) !== PROTOCOL_VERSION) throw new AstDecodeError(`unsupported protocol version ${metadata >>> 24}`); this.stringOffsets = astU32(bytes, HEADER_OFFSET_STRING_TABLE_OFFSETS); @@ -104,20 +106,23 @@ export class AstWireFile { return this.nodes + index * NODE_LEN; } - kind(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_KIND); } - pos(index: number): number { return astI32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_POS); } - end(index: number): number { return astI32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_END); } - flags(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_FLAGS); } - data(index: number): number { return astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_DATA); } + // The constructor validates the complete fixed-width node table, and + // nodeOffset validates its index. Read each word in one operation while + // preserving little-endian decoding even in an unaligned byte view. + kind(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_KIND, true); } + pos(index: number): number { return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_POS, true); } + end(index: number): number { return this.view.getInt32(this.nodeOffset(index) + NODE_OFFSET_END, true); } + flags(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_FLAGS, true); } + data(index: number): number { return this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_DATA, true); } next(index: number): number { - const next = astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_NEXT); + const next = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_NEXT, true); if (next !== 0 && (next <= index || next >= this.nodeCount)) throw new AstDecodeError("invalid sibling link"); return next; } parent(index: number): number { - const parent = astU32(this.bytes, this.nodeOffset(index) + NODE_OFFSET_PARENT); + const parent = this.view.getUint32(this.nodeOffset(index) + NODE_OFFSET_PARENT, true); if (parent >= this.nodeCount || (index <= 1 ? parent > index : parent >= index)) throw new AstDecodeError("invalid parent link"); return parent; } diff --git a/packages/compiler/src/frontend/ts7/checker-cache.ts b/packages/compiler/src/frontend/ts7/checker-cache.ts index e2a25b6d..2c4b2338 100644 --- a/packages/compiler/src/frontend/ts7/checker-cache.ts +++ b/packages/compiler/src/frontend/ts7/checker-cache.ts @@ -22,6 +22,7 @@ export class CheckerCache { readonly assignableTypes = new Map>(); readonly nonNullableType = new Map(); readonly propertiesOfType = new Map(); + readonly propertyOfType = new Map>(); readonly indexInfosOfType = new Map(); readonly typeArgumentsOf = new Map(); readonly arrayTypeAnswer = new Map(); @@ -67,6 +68,7 @@ export class CheckerCache { this.assignableTypes.clear(); this.nonNullableType.clear(); this.propertiesOfType.clear(); + this.propertyOfType.clear(); this.indexInfosOfType.clear(); this.typeArgumentsOf.clear(); this.arrayTypeAnswer.clear(); diff --git a/packages/compiler/src/frontend/ts7/checker.test.ts b/packages/compiler/src/frontend/ts7/checker.test.ts index 2b98bec5..6d244a8b 100644 --- a/packages/compiler/src/frontend/ts7/checker.test.ts +++ b/packages/compiler/src/frontend/ts7/checker.test.ts @@ -253,3 +253,70 @@ test("negative assignability answers remain independent in both directions", () expect(() => facade.isTypeAssignableTo(b, a)).toThrow("disposed"); expect(requests).toHaveLength(2); }); + +test("property lookup caches hits and misses by type and name within each project", () => { + const requests: string[] = []; + let fail = true; + const snapshot = new SemanticSnapshot(1, { + text: (method, payload) => { + expect(method).toBe("getPropertyOfType"); + const query = JSON.parse(payload) as { project: string; type: number; name: string }; + requests.push(`${query.project}:${query.type}:${query.name}`); + if (query.name === "retry" && fail) { fail = false; throw new Error("transient failure"); } + if (query.name === "missing") return "null"; + return JSON.stringify({ id: query.type * 10 + (query.project === "first" ? 1 : 2), project: query.project, + name: query.name, flags: 4, checkFlags: 0 }); + }, + binary: () => { throw new Error("unexpected binary request"); }, + }); + const first = snapshot.addProject("first", () => undefined); + const second = snapshot.addProject("second", () => undefined); + const a = new CheckerFacade(new SemanticChecker(first)); + const b = new CheckerFacade(new SemanticChecker(second)); + const firstType = first.type({ id: 1, flags: TypeFlags.Object }); + const otherType = first.type({ id: 2, flags: TypeFlags.Object }); + const secondType = second.type({ id: 1, flags: TypeFlags.Object }); + const symbol = a.getPropertyOfType(firstType, "value"); + for (let repeat = 0; repeat < 3; repeat++) { + expect(a.getPropertyOfType(firstType, "value")).toBe(symbol); + expect(a.getPropertyOfType(firstType, "missing")).toBeUndefined(); + expect(a.getPropertyOfType(otherType, "value")).not.toBe(symbol); + expect(b.getPropertyOfType(secondType, "value")).not.toBe(symbol); + } + expect(requests).toEqual(["first:1:value", "first:1:missing", "first:2:value", "second:1:value"]); + expect(() => a.getPropertyOfType(firstType, "retry")).toThrow("transient failure"); + expect(a.getPropertyOfType(firstType, "retry")).toBeDefined(); + a.getPropertyOfType(firstType, "retry"); + expect(requests.slice(4)).toEqual(["first:1:retry", "first:1:retry"]); + first.dispose(); + expect(() => a.getPropertyOfType(firstType, "value")).toThrow("disposed"); + expect(() => a.getPropertyOfType(firstType, "missing")).toThrow("disposed"); + expect(b.getPropertyOfType(secondType, "value")).toBeDefined(); + snapshot.dispose(); +}); + +test("dependency edits invalidate positive and negative property answers", () => { + const h = connect({ "tsconfig.json": config, + "main.ts": 'import { value } from "./dependency.js"; export const result = value;', + "dependency.ts": "export const value = { before: 1 };" }); + try { + const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] }); + const project = first.getProjects()[0]!; + const root = project.program.getSourceFile(h.path("main.ts"))!; + const old = new CheckerFacade(project.checker); + const type = old.getTypeAtLocation(declaration(root, "result").name!); + expect(old.getPropertyOfType(type, "before")).toBeDefined(); + expect(old.getPropertyOfType(type, "after")).toBeUndefined(); + h.paths.set(h.path("dependency.ts"), "export const value = { after: 2 };"); + const second = h.session.updateSnapshot({ fileChanges: { changed: [h.path("dependency.ts")] } }); + const nextProject = second.getProjects()[0]!; + const nextRoot = nextProject.program.getSourceFile(h.path("main.ts"))!; + expect(nextRoot).toBe(root); + const next = new CheckerFacade(nextProject.checker); + const nextType = next.getTypeAtLocation(declaration(nextRoot, "result").name!); + expect(next.getPropertyOfType(nextType, "before")).toBeUndefined(); + expect(next.getPropertyOfType(nextType, "after")).toBeDefined(); + expect(old.getPropertyOfType(type, "before")).toBeDefined(); + expect(old.getPropertyOfType(type, "after")).toBeUndefined(); + } finally { h.close(); } +}); diff --git a/packages/compiler/src/frontend/ts7/checker.ts b/packages/compiler/src/frontend/ts7/checker.ts index f9c25c44..49b8506c 100644 --- a/packages/compiler/src/frontend/ts7/checker.ts +++ b/packages/compiler/src/frontend/ts7/checker.ts @@ -288,7 +288,17 @@ export class CheckerFacade { /** 5.9.3's type.getProperty(name). */ getPropertyOfType(type: Type, name: string): Ts7Symbol | undefined { this.ensureActive(); - return this.raw.getPropertyOfType(type, name); + let properties = this.cache.propertyOfType.get(type); + if (properties === undefined) { + properties = new Map(); + this.cache.propertyOfType.set(type, properties); + } + // Absence is an answer too. Both keys belong to this immutable project; + // another snapshot or project must ask its own checker. + if (properties.has(name)) return properties.get(name); + const symbol = this.raw.getPropertyOfType(type, name); + properties.set(name, symbol); + return symbol; } /** The 5.9.3 checker never answered undefined from getTypeAtLocation- diff --git a/packages/compiler/test/ts7/baselines/order-parity.json b/packages/compiler/test/ts7/baselines/order-parity.json index 34c044f9..a52b8e90 100644 --- a/packages/compiler/test/ts7/baselines/order-parity.json +++ b/packages/compiler/test/ts7/baselines/order-parity.json @@ -9274,6 +9274,12 @@ ], "diags": [] }, + "/tests/corpus/live-reference-shared-converters.ts": { + "order": [ + "/tests/corpus/live-reference-shared-converters.ts" + ], + "diags": [] + }, "/tests/corpus/llvm-read-receiver-lifetime.ts": { "order": [ "/tests/corpus/llvm-read-receiver-lifetime.ts" diff --git a/tests/corpus/live-reference-shared-converters.ts b/tests/corpus/live-reference-shared-converters.ts new file mode 100644 index 00000000..13658aff --- /dev/null +++ b/tests/corpus/live-reference-shared-converters.ts @@ -0,0 +1,67 @@ +class Cell { + name: string; + value: number; + next: Cell | undefined = undefined; + children: Cell[] = []; + constructor(name: string, value: number) { + this.name = name; + this.value = value; + } +} + +class Envelope { + label: string; + child: Cell; + constructor(label: string, child: Cell) { + this.label = label; + this.child = child; + } +} + +function first(value: Cell | Envelope | undefined): unknown { return value; } +function second(value: Cell | string | null): unknown { return value; } +function third(value: Envelope | number | boolean): unknown { return value; } +function render(value: unknown): string { return JSON.stringify(value); } + +const leaf = new Cell("leaf", 1); +const parent = new Cell("parent", 2); +parent.next = leaf; +parent.children.push(leaf); +const envelope = new Envelope("shared", parent); +const one = first(parent); +const two = second(parent); +const three = third(envelope); +console.log(one === two, (one as Cell) === parent, (three as Envelope).child === parent); +console.log(JSON.stringify(one), JSON.stringify(two), JSON.stringify(three)); + +leaf.value = 9; +console.log(JSON.stringify(one), JSON.stringify(three)); +const view = one as { value: unknown; next: unknown; children: unknown }; +view.value = 7; +console.log(parent.value, (two as Cell).value); +const childView = view.next as { value: unknown }; +childView.value = 11; +console.log(leaf.value, parent.children[0] === leaf); + +// The same type occurs as a union arm, a nested field, and a stream element. +async function streams(): Promise { + const records: { name: string; cell: Cell }[] = [ + { name: "first", cell: parent }, { name: "second", cell: leaf }, + ]; + const direct: unknown = records[0]!; + console.log(render(direct)); + for await (const item of ReadableStream.from(records)) { + console.log(item.name, item.cell === parent || item.cell === leaf, render(item)); + item.cell.value++; + } + console.log(parent.value, leaf.value, render(direct)); + console.log(first(undefined) === undefined, second(null) === null, second("text"), third(4), third(false)); + + parent.next = parent; + try { JSON.stringify(one); } + catch (error) { console.log("cycle", error instanceof TypeError); } + parent.next = leaf; + console.log(JSON.stringify(two)); +} + +void streams();