Skip to content

Commit 160a860

Browse files
committed
test to see if memory doubles when using map on array
1 parent 7bb002a commit 160a860

File tree

2 files changed

+297
-0
lines changed

2 files changed

+297
-0
lines changed
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
#!/usr/bin/env -S deno run --allow-net --allow-env --allow-read
2+
3+
/**
4+
* Integration test to reproduce memory leak when using derive() with array.map()
5+
*
6+
* Bug: When a cell updates repeatedly and a derived array is created for each update,
7+
* the old derived arrays are never garbage collected, causing unbounded memory growth.
8+
*
9+
* Expected behavior: Memory should stabilize or grow modestly
10+
* Actual behavior: Memory grows by gigabytes (1GB+ per 100 increments)
11+
*/
12+
import { ANYONE, Identity, Session } from "@commontools/identity";
13+
import { env } from "@commontools/integration";
14+
import { StorageManager } from "../src/storage/cache.ts";
15+
import { Runtime } from "../src/index.ts";
16+
import { CharmManager, compileRecipe } from "@commontools/charm";
17+
18+
(Error as any).stackTraceLimit = 100;
19+
20+
const { API_URL } = env;
21+
const SPACE_NAME = "runner_integration";
22+
const TIMEOUT_MS = 120000; // 2 minutes to handle server restarts and settle time
23+
24+
// Test parameters
25+
const INCREMENTS_PER_CLICK = 50; // How many times each click increments (must match .tsx file)
26+
const MAX_MEMORY_INCREASE_RATIO = 2.0; // Fail if memory more than doubles
27+
28+
console.log("Derive Array Leak Test");
29+
console.log(`Connecting to: ${API_URL}`);
30+
console.log(`Will increment ${INCREMENTS_PER_CLICK} times in one click`);
31+
32+
// Helper to get server process memory (portable across Linux and macOS)
33+
async function getServerMemoryMB(): Promise<number> {
34+
// First, find the PID using pgrep (more portable than ps aux | grep)
35+
const pgrepProcess = new Deno.Command("pgrep", {
36+
args: ["-f", "deno run --unstable-otel"],
37+
stdout: "piped",
38+
});
39+
const { stdout: pgrepOut, code } = await pgrepProcess.output();
40+
41+
if (code !== 0) {
42+
throw new Error("Could not find toolshed server process (pgrep failed)");
43+
}
44+
45+
const pid = new TextDecoder().decode(pgrepOut).trim().split("\n")[0];
46+
if (!pid) {
47+
throw new Error("Could not find toolshed server process");
48+
}
49+
50+
// Then get RSS using portable ps -o format (works on Linux, macOS, BSD)
51+
const psProcess = new Deno.Command("ps", {
52+
args: ["-p", pid, "-o", "rss="],
53+
stdout: "piped",
54+
});
55+
const { stdout: psOut } = await psProcess.output();
56+
const rssKB = parseInt(new TextDecoder().decode(psOut).trim());
57+
58+
return rssKB / 1024; // Convert KB to MB
59+
}
60+
61+
// Set up timeout
62+
const timeoutPromise = new Promise((_, reject) => {
63+
setTimeout(() => {
64+
reject(new Error(`Test timed out after ${TIMEOUT_MS}ms`));
65+
}, TIMEOUT_MS);
66+
});
67+
68+
// Main test function
69+
async function runTest() {
70+
const account = await Identity.fromPassphrase(ANYONE);
71+
const space_thingy = await account.derive(SPACE_NAME);
72+
const space_thingy_space = space_thingy.did();
73+
const session = {
74+
private: false,
75+
name: SPACE_NAME,
76+
space: space_thingy_space,
77+
as: space_thingy,
78+
} as Session;
79+
80+
// Create storage manager
81+
const storageManager = StorageManager.open({
82+
as: session.as,
83+
address: new URL("/api/storage/memory", API_URL),
84+
});
85+
86+
// Create runtime
87+
const runtime = new Runtime({
88+
apiUrl: new URL(API_URL),
89+
storageManager,
90+
});
91+
92+
// Create charm manager for the specified space
93+
const charmManager = new CharmManager(session, runtime);
94+
await charmManager.ready;
95+
96+
// Read the recipe file content
97+
const recipeContent = await Deno.readTextFile(
98+
"./integration/derive_array_leak.test.tsx",
99+
);
100+
101+
const recipe = await compileRecipe(
102+
recipeContent,
103+
"recipe",
104+
runtime,
105+
space_thingy_space,
106+
);
107+
console.log("Recipe compiled successfully");
108+
109+
const charm = (await charmManager.runPersistent(recipe, {})).asSchema({
110+
type: "object",
111+
properties: {
112+
value: { type: "number" },
113+
increment: {
114+
asStream: true,
115+
},
116+
},
117+
required: ["value", "increment"],
118+
});
119+
console.log("Charm created:", charm.entityId);
120+
121+
// Wait for initial state
122+
await runtime.idle();
123+
await runtime.storageManager.synced();
124+
125+
// Give it 5 seconds to settle after initialization
126+
console.log("Waiting 5 seconds for memory to settle...");
127+
await new Promise((resolve) => setTimeout(resolve, 5000));
128+
129+
// Measure baseline server memory (where the leak occurs)
130+
const serverMemoryBeforeMB = await getServerMemoryMB();
131+
console.log(`Baseline server memory: ${serverMemoryBeforeMB.toFixed(1)} MB`);
132+
console.log(`Initial counter value: ${charm.get().value}`);
133+
134+
// Trigger the leak by incrementing
135+
// The handler increments INCREMENTS_PER_CLICK times per click
136+
console.log(
137+
`Clicking increment (${INCREMENTS_PER_CLICK} increments total)...`,
138+
);
139+
const incrementStream = charm.key("increment");
140+
incrementStream.send({});
141+
142+
// Wait for all updates to complete
143+
console.log("Waiting for runtime to finish...");
144+
await runtime.idle();
145+
await runtime.storageManager.synced();
146+
console.log(`Final counter value: ${charm.get().value}`);
147+
148+
// Verify the counter actually incremented
149+
const finalValue = charm.get().value;
150+
const expectedValue = INCREMENTS_PER_CLICK;
151+
if (finalValue !== expectedValue) {
152+
console.warn(
153+
`WARNING: Counter value is ${finalValue}, expected ${expectedValue}. ` +
154+
`This may indicate the derive action failed due to array size.`,
155+
);
156+
}
157+
158+
// Measure server memory after operations complete
159+
const serverMemoryAfterMB = await getServerMemoryMB();
160+
const serverMemoryIncreaseMB = serverMemoryAfterMB - serverMemoryBeforeMB;
161+
const memoryRatio = serverMemoryAfterMB / serverMemoryBeforeMB;
162+
163+
console.log(`Final server memory: ${serverMemoryAfterMB.toFixed(1)} MB`);
164+
console.log(
165+
`Server memory increase: ${serverMemoryIncreaseMB.toFixed(1)} MB (${
166+
((memoryRatio - 1) * 100).toFixed(1)
167+
}% increase)`,
168+
);
169+
console.log(`Memory ratio: ${memoryRatio.toFixed(2)}x`);
170+
171+
// Clean up
172+
await runtime.dispose();
173+
await storageManager.close();
174+
175+
// Check if server memory increase indicates a leak
176+
if (memoryRatio > MAX_MEMORY_INCREASE_RATIO) {
177+
console.error(
178+
`FAIL: Server memory increased to ${memoryRatio.toFixed(2)}x baseline, ` +
179+
`exceeds limit of ${MAX_MEMORY_INCREASE_RATIO}x`,
180+
);
181+
console.error("This indicates a memory leak is present");
182+
throw new Error(
183+
`Memory leak detected: ${
184+
memoryRatio.toFixed(2)
185+
}x increase (limit: ${MAX_MEMORY_INCREASE_RATIO}x)`,
186+
);
187+
}
188+
189+
console.log(
190+
`PASS: Memory increase ${
191+
memoryRatio.toFixed(2)
192+
}x is within acceptable limit (< ${MAX_MEMORY_INCREASE_RATIO}x)`,
193+
);
194+
console.log(`Counter reached ${finalValue} (expected ${expectedValue})`);
195+
}
196+
197+
// Run the test with timeout
198+
try {
199+
await Promise.race([runTest(), timeoutPromise]);
200+
console.log("Test completed successfully");
201+
Deno.exit(0);
202+
} catch (error) {
203+
const errorMessage = error instanceof Error ? error.message : String(error);
204+
console.error("Test failed:", errorMessage);
205+
if (error instanceof Error && error.stack) {
206+
console.error(error.stack);
207+
}
208+
Deno.exit(1);
209+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/// <cts-enable />
2+
import {
3+
Cell,
4+
Default,
5+
derive,
6+
handler,
7+
NAME,
8+
recipe,
9+
str,
10+
Stream,
11+
UI,
12+
} from "commontools";
13+
14+
// How many times to increment per click
15+
const INCREMENTS_PER_CLICK = 50;
16+
17+
interface RecipeState {
18+
value: Default<number, 0>;
19+
}
20+
21+
interface RecipeOutput {
22+
value: Default<number, 0>;
23+
increment: Stream<void>;
24+
decrement: Stream<void>;
25+
}
26+
27+
// Inline handlers to avoid import resolution issues
28+
const increment = handler<
29+
unknown,
30+
{ value: Cell<number> }
31+
>(
32+
(_args, state) => {
33+
// Increment multiple times per click to trigger derive() multiple times
34+
for (let i = 0; i < INCREMENTS_PER_CLICK; i++) {
35+
state.value.set(state.value.get() + 1);
36+
}
37+
},
38+
);
39+
40+
const decrement = handler<
41+
unknown,
42+
{ value: Cell<number> }
43+
>(
44+
(_args, state) => {
45+
state.value.set(state.value.get() - 1);
46+
},
47+
);
48+
49+
function nth(value: number) {
50+
if (value === 1) return "1st";
51+
if (value === 2) return "2nd";
52+
if (value === 3) return "3rd";
53+
return `${value}th`;
54+
}
55+
56+
function previous(value: number) {
57+
return value - 1;
58+
}
59+
60+
export default recipe<RecipeState, RecipeOutput>("Counter", (state) => {
61+
const array = derive(state.value, (value: number) => {
62+
return new Array(value).fill(0);
63+
});
64+
return {
65+
[NAME]: str`Simple counter: ${state.value}`,
66+
[UI]: (
67+
<div>
68+
<div>
69+
<ct-button onClick={decrement(state)}>
70+
dec to {previous(state.value)}
71+
</ct-button>
72+
<span id="counter-result">
73+
Counter is the {nth(state.value)} number
74+
</span>
75+
<ct-button onClick={increment({ value: state.value })}>
76+
inc to {state.value + 1}
77+
</ct-button>
78+
</div>
79+
<div>
80+
{array.map((v: number) => <span>{v % 10}</span>)}
81+
</div>
82+
</div>
83+
),
84+
value: state.value,
85+
increment: increment(state) as unknown as Stream<void>,
86+
decrement: decrement(state) as unknown as Stream<void>,
87+
};
88+
});

0 commit comments

Comments
 (0)