Skip to content

Commit 24afb73

Browse files
authored
fix(runner): avoid infinite recursion in function cacher (#1776)
* fix(runner): avoid infinite recursion in function cacher * ignore literals, better types * simplify things given the new isRecord check * don't make seen a default input so we don't accidentally recurse without it * Runner: stop ref recursion & fix anonymous cell naming - prevent the runner’s function cache walk from re-entering cells, shadow refs, and opaque refs - tighten opaque-ref typing so JSON conversion uses the shared OpaqueRefMethods surface - teach the recipe factory to pull cell names from export() so unnamed internal cells get stable, meaningful aliases - refresh recipe.test.ts expectations to cover the corrected alias naming and avoid the long-standing double naming bug * re-arrange for clarity * undo the name fix, since it caused other issues * Avoid duplicate auto cell names * re-enable names and fix tests
1 parent ad86501 commit 24afb73

File tree

5 files changed

+91
-38
lines changed

5 files changed

+91
-38
lines changed

packages/runner/src/builder/json-utils.ts

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -42,7 +42,7 @@ export function toJSONWithLegacyAliases(
4242
// If this is an external reference, just copy the reference as is.
4343
if (isOpaqueRef(value)) {
4444
const { external } = value.export();
45-
if (external) return external;
45+
if (external) return external as JSONValue;
4646
}
4747

4848
if (isOpaqueRef(value) || isShadowRef(value)) {
@@ -81,7 +81,7 @@ export function toJSONWithLegacyAliases(
8181
`Shadow ref alias with parent cell not found in current frame`,
8282
);
8383
}
84-
return value;
84+
return value as JSONValue;
8585
}
8686
if (!paths.has(cell)) throw new Error(`Cell not found in paths`);
8787
return {
@@ -108,10 +108,10 @@ export function toJSONWithLegacyAliases(
108108
}
109109

110110
if (isRecord(value) || isRecipe(value)) {
111-
const valueToProcess =
112-
(isRecipe(value) && typeof (value as toJSON).toJSON === "function")
113-
? (value as toJSON).toJSON() as Record<string, any>
114-
: (value as Record<string, any>);
111+
const valueToProcess = (isRecipe(value) &&
112+
typeof (value as unknown as toJSON).toJSON === "function")
113+
? (value as unknown as toJSON).toJSON() as Record<string, any>
114+
: (value as Record<string, any>);
115115

116116
const result: any = {};
117117
let hasValue = false;

packages/runner/src/builder/recipe.ts

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -156,7 +156,7 @@ function factoryFromRecipe<T, R>(
156156
if (isOpaqueRef(value)) value = value.unsafe_getExternal();
157157
if (
158158
(isOpaqueRef(value) || isShadowRef(value)) && !cells.has(value) &&
159-
!shadows.has(value)
159+
!shadows.has(value as ShadowRef)
160160
) {
161161
if (isOpaqueRef(value) && value.export().frame !== getTopFrame()) {
162162
value = createShadowRef(value.export().value);
@@ -190,12 +190,26 @@ function factoryFromRecipe<T, R>(
190190

191191
// Fill in reasonable names for all cells, where possible:
192192

193+
const usedNames = new Set<string>();
194+
cells.forEach((cell) => {
195+
const existingName = cell.export().name;
196+
if (existingName) usedNames.add(existingName);
197+
});
198+
193199
// First from results
194200
if (isRecord(outputs)) {
195201
Object.entries(outputs).forEach(([key, value]: [string, unknown]) => {
196202
if (isOpaqueRef(value)) {
197203
const ref = value; // Typescript needs this to avoid type errors
198-
if (!ref.export().path.length && !ref.export().name) ref.setName(key);
204+
const exported = ref.export();
205+
if (
206+
!exported.path.length &&
207+
!exported.name &&
208+
!usedNames.has(key)
209+
) {
210+
ref.setName(key);
211+
usedNames.add(key);
212+
}
199213
}
200214
});
201215
}
@@ -207,9 +221,11 @@ function factoryFromRecipe<T, R>(
207221
if (isRecord(node.inputs)) {
208222
Object.entries(node.inputs).forEach(([key, input]) => {
209223
if (
210-
isOpaqueRef(input) && input.cell === cell && !cell.export().name
224+
isOpaqueRef(input) && input.export().cell === cell &&
225+
!cell.export().name && !usedNames.has(key)
211226
) {
212227
cell.setName(key);
228+
usedNames.add(key);
213229
}
214230
});
215231
}

packages/runner/src/builder/types.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import type {
2424
NavigateToFunction,
2525
Opaque,
2626
OpaqueRef,
27+
OpaqueRefMethods,
2728
Recipe,
2829
RecipeFunction,
2930
RenderFunction,
@@ -132,11 +133,13 @@ declare module "@commontools/api" {
132133
}
133134
}
134135

135-
export type { OpaqueRefMethods } from "@commontools/api";
136+
export type { OpaqueRefMethods };
136137

137138
export const isOpaqueRefMarker = Symbol("isOpaqueRef");
138139

139-
export function isOpaqueRef<T = any>(value: unknown): value is OpaqueRef<T> {
140+
export function isOpaqueRef<T = any>(
141+
value: unknown,
142+
): value is OpaqueRefMethods<T> {
140143
return !!value &&
141144
typeof (value as OpaqueRef<T>)[isOpaqueRefMarker] === "boolean";
142145
}

packages/runner/src/runner.ts

Lines changed: 57 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
isModule,
77
isOpaqueRef,
88
isRecipe,
9+
isShadowRef,
910
isStreamValue,
1011
type JSONSchema,
1112
type JSONValue,
@@ -24,7 +25,7 @@ import {
2425
pushFrameFromCause,
2526
recipeFromFrame,
2627
} from "./builder/recipe.ts";
27-
import { type Cell } from "./cell.ts";
28+
import { type Cell, isCell } from "./cell.ts";
2829
import { type Action } from "./scheduler.ts";
2930
import { diffAndUpdate } from "./data-updating.ts";
3031
import {
@@ -332,7 +333,7 @@ export class Runner implements IRunner {
332333
}
333334

334335
// Discover and cache all JavaScript functions in the recipe before start
335-
this.discoverAndCacheFunctions(recipe);
336+
this.discoverAndCacheFunctions(recipe, new Set());
336337

337338
return { resultCell, recipe, processCell, needsStart: true };
338339
}
@@ -401,7 +402,7 @@ export class Runner implements IRunner {
401402
this.allCancels.add(cancel);
402403

403404
// Re-discover functions to be safe (idempotent)
404-
this.discoverAndCacheFunctions(recipe);
405+
this.discoverAndCacheFunctions(recipe, new Set());
405406

406407
for (const node of recipe.nodes) {
407408
this.instantiateNode(
@@ -665,14 +666,18 @@ export class Runner implements IRunner {
665666
*
666667
* @param recipe The recipe to discover functions from
667668
*/
668-
private discoverAndCacheFunctions(recipe: Recipe): void {
669+
private discoverAndCacheFunctions(
670+
recipe: Recipe,
671+
seen: Set<object>,
672+
): void {
673+
if (seen.has(recipe)) return;
674+
seen.add(recipe);
675+
669676
for (const node of recipe.nodes) {
670-
this.discoverAndCacheFunctionsFromModule(node.module);
677+
this.discoverAndCacheFunctionsFromModule(node.module, seen);
671678

672679
// Also check inputs for nested recipes (e.g., in map operations)
673-
if (isRecord(node.inputs)) {
674-
this.discoverAndCacheFunctionsFromValue(node.inputs);
675-
}
680+
this.discoverAndCacheFunctionsFromValue(node.inputs, seen);
676681
}
677682
}
678683

@@ -681,7 +686,13 @@ export class Runner implements IRunner {
681686
*
682687
* @param module The module to process
683688
*/
684-
private discoverAndCacheFunctionsFromModule(module: Module): void {
689+
private discoverAndCacheFunctionsFromModule(
690+
module: Module,
691+
seen: Set<object>,
692+
): void {
693+
if (seen.has(module)) return;
694+
seen.add(module);
695+
685696
if (!isModule(module)) return;
686697

687698
switch (module.type) {
@@ -698,7 +709,7 @@ export class Runner implements IRunner {
698709
case "recipe":
699710
// Recursively discover functions in nested recipes
700711
if (isRecipe(module.implementation)) {
701-
this.discoverAndCacheFunctions(module.implementation);
712+
this.discoverAndCacheFunctions(module.implementation, seen);
702713
}
703714
break;
704715

@@ -708,7 +719,7 @@ export class Runner implements IRunner {
708719
const referencedModule = this.runtime.moduleRegistry.getModule(
709720
module.implementation as string,
710721
);
711-
this.discoverAndCacheFunctionsFromModule(referencedModule);
722+
this.discoverAndCacheFunctionsFromModule(referencedModule, seen);
712723
} catch (error) {
713724
console.warn(
714725
`Failed to resolve module reference for implementation "${module.implementation}":`,
@@ -725,22 +736,43 @@ export class Runner implements IRunner {
725736
*
726737
* @param value The value to search for recipes
727738
*/
728-
private discoverAndCacheFunctionsFromValue(value: JSONValue): void {
739+
private discoverAndCacheFunctionsFromValue(
740+
value: JSONValue,
741+
seen: Set<object>,
742+
): void {
729743
if (isRecipe(value)) {
730-
this.discoverAndCacheFunctions(value);
731-
} else if (isModule(value)) {
732-
this.discoverAndCacheFunctionsFromModule(value);
733-
} else if (isRecord(value)) {
734-
// Recursively search in objects and arrays
735-
if (Array.isArray(value)) {
736-
for (const item of value) {
737-
this.discoverAndCacheFunctionsFromValue(item);
738-
}
739-
} else {
740-
for (const key in value) {
741-
this.discoverAndCacheFunctionsFromValue(value[key] as JSONValue);
742-
}
744+
this.discoverAndCacheFunctions(value, seen);
745+
return;
746+
}
747+
748+
if (isModule(value)) {
749+
this.discoverAndCacheFunctionsFromModule(value, seen);
750+
return;
751+
}
752+
753+
if (
754+
!isRecord(value) || isOpaqueRef(value) || isShadowRef(value) ||
755+
isCell(value)
756+
) {
757+
return;
758+
}
759+
760+
if (seen.has(value)) return;
761+
seen.add(value);
762+
763+
// Recursively search in objects and arrays
764+
if (Array.isArray(value)) {
765+
for (const item of value) {
766+
this.discoverAndCacheFunctionsFromValue(item, seen);
743767
}
768+
return;
769+
}
770+
771+
for (const key in value) {
772+
this.discoverAndCacheFunctionsFromValue(
773+
value[key] as JSONValue,
774+
seen,
775+
);
744776
}
745777
}
746778

packages/runner/test/recipe.test.ts

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,9 +71,11 @@ describe("complex recipe function", () => {
7171
);
7272
expect(nodes[0].inputs).toEqual({ $alias: { path: ["argument", "x"] } });
7373
expect(nodes[0].outputs).toEqual({
74-
$alias: { path: ["internal", "__#0"] },
74+
$alias: { path: ["internal", "defaultValue"] },
75+
});
76+
expect(nodes[1].inputs).toEqual({
77+
$alias: { path: ["internal", "defaultValue"] },
7578
});
76-
expect(nodes[1].inputs).toEqual({ $alias: { path: ["internal", "__#0"] } });
7779
expect(nodes[1].outputs).toEqual({
7880
$alias: { path: ["internal", "double"] },
7981
});

0 commit comments

Comments
 (0)