Skip to content

Commit f8ab59e

Browse files
authored
Fix infinite loop with self-referential data (#1240)
Self-referential data causes issues. Verified that the creation itself is fine. Changed QueryResultProxy in two ways to improve situation: - It now uses a cache to return the exact same object when recursing, so any code that already detects cycles acts correctly - throw an error after 100 recursions to at least error more sanely.
1 parent 3d127f0 commit f8ab59e

File tree

2 files changed

+110
-3
lines changed

2 files changed

+110
-3
lines changed

packages/runner/src/query-result-proxy.ts

Lines changed: 45 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@ import { type ReactivityLog } from "./scheduler.ts";
66
import { diffAndUpdate, setNestedValue } from "./data-updating.ts";
77
import { resolveLinkToValue } from "./link-resolution.ts";
88

9+
// Maximum recursion depth to prevent infinite loops
10+
const MAX_RECURSION_DEPTH = 100;
11+
12+
// Cache of target objects to their proxies, scoped by ReactivityLog
13+
const proxyCacheByLog = new WeakMap<ReactivityLog, WeakMap<object, any>>();
14+
// Use this to index cache if there is no log provided.
15+
const fallbackLog: ReactivityLog = { reads: [], writes: [] };
16+
917
// Array.prototype's entries, and whether they modify the array
1018
enum ArrayMethodType {
1119
ReadOnly,
@@ -51,7 +59,15 @@ export function createQueryResultProxy<T>(
5159
valueCell: DocImpl<T>,
5260
valuePath: PropertyKey[],
5361
log?: ReactivityLog,
62+
depth: number = 0,
5463
): T {
64+
// Check recursion depth
65+
if (depth > MAX_RECURSION_DEPTH) {
66+
throw new Error(
67+
`Maximum recursion depth of ${MAX_RECURSION_DEPTH} exceeded`,
68+
);
69+
}
70+
5571
// Resolve path and follow links to actual value.
5672
({ cell: valueCell, path: valuePath } = resolveLinkToValue(
5773
valueCell,
@@ -64,7 +80,19 @@ export function createQueryResultProxy<T>(
6480

6581
if (!isRecord(target)) return target;
6682

67-
return new Proxy(target as object, {
83+
// Get the appropriate cache index by log
84+
const cacheIndex = log ?? fallbackLog;
85+
let logCache = proxyCacheByLog.get(cacheIndex);
86+
if (!logCache) {
87+
logCache = new WeakMap<object, any>();
88+
proxyCacheByLog.set(cacheIndex, logCache);
89+
}
90+
91+
// Check if we already have a proxy for this target in the cache
92+
const existingProxy = logCache?.get(target);
93+
if (existingProxy) return existingProxy;
94+
95+
const proxy = new Proxy(target as object, {
6896
get: (target, prop, receiver) => {
6997
if (typeof prop === "symbol") {
7098
if (prop === getCellLink) {
@@ -88,7 +116,12 @@ export function createQueryResultProxy<T>(
88116
// methods implicitly read all elements. TODO: Deal with
89117
// exceptions like at().
90118
const copy = target.map((_, index) =>
91-
createQueryResultProxy(valueCell, [...valuePath, index], log)
119+
createQueryResultProxy(
120+
valueCell,
121+
[...valuePath, index],
122+
log,
123+
depth + 1,
124+
)
92125
);
93126

94127
return method.apply(copy, args);
@@ -179,7 +212,12 @@ export function createQueryResultProxy<T>(
179212
};
180213
}
181214

182-
return createQueryResultProxy(valueCell, [...valuePath, prop], log);
215+
return createQueryResultProxy(
216+
valueCell,
217+
[...valuePath, prop],
218+
log,
219+
depth + 1,
220+
);
183221
},
184222
set: (target, prop, value) => {
185223
if (isQueryResult(value)) value = value[getCellLink];
@@ -221,6 +259,10 @@ export function createQueryResultProxy<T>(
221259
return true;
222260
},
223261
}) as T;
262+
263+
// Cache the proxy in the appropriate cache before returning
264+
logCache.set(target, proxy);
265+
return proxy;
224266
}
225267

226268
// Wraps a value on an array so that it can be read as literal or object,

packages/runner/test/recipes.test.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1081,4 +1081,69 @@ describe("Recipe Runner", () => {
10811081
// That same value was updated, which shows that the id was stable
10821082
expect(ref.cell.get()).toBe(10);
10831083
});
1084+
1085+
it("should handle pushing objects that reference their containing array", async () => {
1086+
const addItemHandler = handler<
1087+
{ detail: { message: string } },
1088+
{ items: Array<{ title: string; items: any[] }> }
1089+
>((event, { items }) => {
1090+
const title = event.detail?.message?.trim();
1091+
if (title) {
1092+
items.push({ title, items });
1093+
}
1094+
});
1095+
1096+
const itemsRecipe = recipe<
1097+
{ items: Array<{ title: string; items: any[] }> }
1098+
>(
1099+
"Items with self-reference",
1100+
({ items }) => {
1101+
return { items, stream: addItemHandler({ items }) };
1102+
},
1103+
);
1104+
1105+
const result = runtime.run(
1106+
itemsRecipe,
1107+
{ items: [] },
1108+
runtime.documentMap.getDoc(
1109+
undefined,
1110+
"should handle pushing objects that reference their containing array",
1111+
"test",
1112+
),
1113+
);
1114+
1115+
await runtime.idle();
1116+
1117+
// Add first item
1118+
result.asCell(["stream"]).send({ detail: { message: "First Item" } });
1119+
await runtime.idle();
1120+
1121+
const firstState = result.getAsQueryResult();
1122+
expect(firstState.items).toHaveLength(1);
1123+
expect(firstState.items[0].title).toBe("First Item");
1124+
1125+
// Test reuse of proxy for array items
1126+
expect(firstState.items[0].items).toBe(firstState.items);
1127+
1128+
// Add second item
1129+
result.asCell(["stream"]).send({ detail: { message: "Second Item" } });
1130+
await runtime.idle();
1131+
1132+
const secondState = result.getAsQueryResult();
1133+
expect(secondState.items).toHaveLength(2);
1134+
expect(secondState.items[1].title).toBe("Second Item");
1135+
1136+
// All three should point to the same array
1137+
expect(secondState.items[0].items).toBe(secondState.items);
1138+
expect(secondState.items[1].items).toBe(secondState.items);
1139+
1140+
// And triple check that it actually refers to the same underlying array
1141+
expect(firstState.items[0].items[1].title).toBe("Second Item");
1142+
1143+
const recurse = ({ items }: { items: { items: any[] }[] }): any =>
1144+
items.map((item) => recurse(item));
1145+
1146+
// Now test that we catch infinite recursion
1147+
expect(() => recurse(firstState)).toThrow();
1148+
});
10841149
});

0 commit comments

Comments
 (0)