Skip to content

Commit 950760a

Browse files
committed
crude memory leak test
1 parent ef978c9 commit 950760a

9 files changed

Lines changed: 283 additions & 22 deletions

File tree

crates/ion/src/values/js_external.rs

Lines changed: 18 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ use crate::values::ToJsValue;
1313
pub struct JsExternal<T> {
1414
pub(crate) value: Value,
1515
pub(crate) env: Env,
16-
ptr: *mut c_void,
16+
data: *mut (*mut c_void, RefCounter),
1717
ref_count: RefCounter,
1818
_data: PhantomData<T>,
1919
}
@@ -23,12 +23,14 @@ impl<T> JsExternal<T> {
2323
env: &Env,
2424
data: T,
2525
) -> crate::Result<Self> {
26-
let ptr = Box::into_raw(Box::new(data)) as *mut c_void;
2726
let scope = &mut env.scope();
2827

2928
// One for Rust, One for JavaScript
3029
let ref_count = RefCounter::new(2);
3130

31+
// Convert the data into a pointer
32+
let ptr = Box::into_raw(Box::new(data)) as *mut c_void;
33+
3234
// Store both the data pointer AND the RefCounter in the V8 External
3335
let external_data = Box::into_raw(Box::new((ptr, ref_count.clone())));
3436
let value = v8::External::new(scope, external_data as _);
@@ -38,23 +40,25 @@ impl<T> JsExternal<T> {
3840
move || {
3941
if ref_count.dec() {
4042
// Clean up both the data and the external_data tuple
41-
drop(unsafe { Box::from_raw(ptr as *mut T) });
42-
drop(unsafe { Box::from_raw(external_data) });
43+
let (ptr, ref_counter) = *unsafe { Box::from_raw(external_data) };
44+
let data = unsafe { Box::from_raw(ptr as *mut T) };
45+
drop(data);
46+
drop(ref_counter);
4347
}
4448
}
4549
});
4650

4751
Ok(Self {
4852
value: sys::v8_from_value(value),
4953
env: env.clone(),
50-
ptr,
54+
data: external_data,
5155
ref_count,
5256
_data: Default::default(),
5357
})
5458
}
5559

5660
pub fn as_inner(&self) -> crate::Result<&T> {
57-
let data = unsafe { &*(self.ptr as *mut T) };
61+
let data = unsafe { &*((*self.data).0 as *mut T) };
5862
Ok(data)
5963
}
6064
}
@@ -65,7 +69,7 @@ impl<T> Clone for JsExternal<T> {
6569
Self {
6670
value: self.value,
6771
env: self.env.clone(),
68-
ptr: self.ptr,
72+
data: self.data,
6973
ref_count: self.ref_count.clone(),
7074
_data: self._data,
7175
}
@@ -75,7 +79,10 @@ impl<T> Clone for JsExternal<T> {
7579
impl<T> Drop for JsExternal<T> {
7680
fn drop(&mut self) {
7781
if self.ref_count.dec() {
78-
drop(unsafe { Box::from_raw(self.ptr as *mut T) });
82+
let (ptr, ref_counter) = *unsafe { Box::from_raw(self.data) };
83+
let data = unsafe { Box::from_raw(ptr as *mut T) };
84+
drop(data);
85+
drop(ref_counter);
7986
}
8087
}
8188
}
@@ -98,16 +105,16 @@ impl<T> FromJsValue for JsExternal<T> {
98105
value: Value,
99106
) -> crate::Result<Self> {
100107
let external = value.cast::<v8::External>();
101-
let external_data_ptr = external.value() as *const (*mut c_void, RefCounter);
102-
let (ptr, ref_count) = unsafe { &*external_data_ptr };
108+
let external_data_ptr = external.value() as *mut (*mut c_void, RefCounter);
109+
let (_, ref_count) = unsafe { &*external_data_ptr };
103110

104111
// Increment the original RefCounter instead of creating a new one
105112
ref_count.inc();
106113

107114
Ok(Self {
108115
value,
109116
env: env.clone(),
110-
ptr: *ptr,
117+
data: external_data_ptr,
111118
ref_count: ref_count.clone(),
112119
_data: Default::default(),
113120
})

examples/src/_utils/memory_blob.rs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/// Create a 100kb memory allocation
2+
pub fn blob_100kb() -> Vec<u8> {
3+
vec![0u8; 100 * 1024]
4+
}

examples/src/_utils/memory_usage.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ impl MemoryUsageCounter {
3131
} else if current == *previous {
3232
0
3333
} else {
34-
-(current - *previous)
34+
current - *previous
3535
};
3636

3737
(*previous) = current;

examples/src/_utils/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod memory_blob;
12
pub mod memory_usage;

examples/src/main.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ mod eval;
1010
mod external_value;
1111
mod http_server;
1212
mod memory_usage_context;
13+
mod memory_usage_external_value;
1314
mod memory_usage_module;
1415
mod memory_usage_tsfn;
1516
mod memory_usage_value;
@@ -55,6 +56,7 @@ fn main() -> anyhow::Result<()> {
5556
"memory_usage_module" => memory_usage_module::main(),
5657
"memory_usage_value" => memory_usage_value::main(),
5758
"external_value" => external_value::main(),
59+
"memory_usage_external_value" => memory_usage_external_value::main(),
5860
_ => Err(anyhow::anyhow!("No example for: \"{}\"", example)),
5961
}
6062
}
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
import { executeMemoryTest } from "../../test-utils/memory-leak.ts";
2+
import { assertLessOrEqual } from "jsr:@std/assert@^1";
3+
4+
Deno.test("memory_usage_external_value", async () => {
5+
const report = await executeMemoryTest("memory_usage_external_value");
6+
7+
assertLessOrEqual(
8+
report.overAllMemoryTrendPerSample,
9+
5,
10+
`Memory usage in the last quarter increase at an average of ${report.overAllMemoryTrendPerSample.toFixed(
11+
0
12+
)}kb per sample`
13+
);
14+
});
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
use ion::*;
2+
3+
use crate::_utils::memory_blob::blob_100kb;
4+
use crate::_utils::memory_usage::MemoryUsageCounter;
5+
6+
pub fn main() -> anyhow::Result<()> {
7+
let memu = MemoryUsageCounter::default();
8+
println!("{}", memu.kilobytes().json());
9+
10+
let runtime = JsRuntime::initialize_once(JsRuntimeOptions::debug(JsRuntimeOptions {
11+
..Default::default()
12+
}))?;
13+
14+
let worker = runtime.spawn_worker(JsWorkerOptions::default())?;
15+
16+
for _ in 0..10 {
17+
worker.run_garbage_collection_for_testing()?;
18+
19+
let ctx = worker.create_context()?;
20+
println!("{}", memu.kilobytes().json());
21+
22+
for _ in 0..100 {
23+
println!("{}", memu.kilobytes().json());
24+
let data = blob_100kb();
25+
26+
ctx.exec_blocking(move |env| {
27+
let external_js = JsExternal::new(env, data)?;
28+
env.global_this()?
29+
.set_named_property("external", external_js)?;
30+
31+
Ok(())
32+
})?;
33+
34+
// TODO
35+
// Currently GC only seems to fire when the context is dropped
36+
ctx.exec_blocking(|env| env.global_this()?.delete_named_property("external"))?;
37+
worker.run_garbage_collection_for_testing()?;
38+
// TODO-END
39+
40+
println!("{}", memu.kilobytes().json());
41+
}
42+
43+
println!("{}", memu.kilobytes().json());
44+
}
45+
46+
worker.run_garbage_collection_for_testing()?;
47+
println!("{}", memu.kilobytes().json());
48+
49+
Ok(())
50+
}

examples/test-utils/memory-leak.ts

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
// Credit to ChatGPT
2+
3+
import { executeExampleStream } from './run_test.ts'
4+
5+
type ProcessMessage = {
6+
value: number,
7+
change: number,
8+
}
9+
10+
export type MemoryUsageReport = {
11+
overAllMemoryTrendPerSample: number,
12+
firstHalfAverage: number,
13+
secondHalfAverage: number,
14+
lastQuarterTrendPerSample: number,
15+
lastQuarterStdDev: number,
16+
memoryIncreaseFromFirstToSecondHalf: number,
17+
memoryIncreaseFromFirstToSecondHalfPercent: number,
18+
}
19+
20+
export async function executeMemoryTest(testName: string,
21+
args: string[] = [],
22+
env: Record<string, string> = {}): Promise<MemoryUsageReport> {
23+
const { done, stdout } = await executeExampleStream(testName, args, env)
24+
25+
const samples: number[] = [];
26+
const WARMUP_SAMPLES = 5; // Skip initial samples during warmup
27+
28+
for await (const recordStr of stdout) {
29+
const record: ProcessMessage = JSON.parse(recordStr + '\n')
30+
samples.push(record.value);
31+
// console.log(`Memory: ${record.value}KB (${record.change >= 0 ? '+' : ''}${record.change}KB)`);
32+
}
33+
34+
await done
35+
36+
// Analysis after process completes
37+
if (samples.length < 15) {
38+
throw new Error('Not enough samples collected for reliable leak detection (need at least 15)');
39+
}
40+
41+
// Skip warmup phase and analyze the rest
42+
const steadyStateSamples = samples.slice(WARMUP_SAMPLES);
43+
44+
// Strategy 1: Check if memory trend is consistently upward
45+
const overallTrend = calculateTrend(steadyStateSamples);
46+
// console.log(`\nOverall memory trend: ${overallTrend > 0 ? '+' : ''}${overallTrend.toFixed(2)}KB per sample`);
47+
48+
// Strategy 2: Compare first half vs second half
49+
const midpoint = Math.floor(steadyStateSamples.length / 2);
50+
const firstHalf = steadyStateSamples.slice(0, midpoint);
51+
const secondHalf = steadyStateSamples.slice(midpoint);
52+
53+
const firstHalfAvg = average(firstHalf);
54+
const secondHalfAvg = average(secondHalf);
55+
const halfDiff = secondHalfAvg - firstHalfAvg;
56+
57+
// console.log(`First half average: ${firstHalfAvg.toFixed(2)}KB`);
58+
// console.log(`Second half average: ${secondHalfAvg.toFixed(2)}KB`);
59+
// console.log(`Difference: ${halfDiff >= 0 ? '+' : ''}${halfDiff.toFixed(2)}KB`);
60+
61+
// Strategy 3: Check for stabilization
62+
const lastQuarter = steadyStateSamples.slice(-Math.floor(steadyStateSamples.length / 4));
63+
const lastQuarterTrend = calculateTrend(lastQuarter);
64+
const lastQuarterStdDev = standardDeviation(lastQuarter);
65+
66+
// console.log(`Last quarter trend: ${lastQuarterTrend > 0 ? '+' : ''}${lastQuarterTrend.toFixed(2)}KB per sample`);
67+
// console.log(`Last quarter std dev: ${lastQuarterStdDev.toFixed(2)}KB`);
68+
69+
// Detect leak based on multiple signals
70+
// const leakSignals: string[] = [];
71+
72+
// Signal 1: Strong upward trend overall (>512KB per sample)
73+
// if (overallTrend > 512) {
74+
// leakSignals.push(`Strong upward trend: ${overallTrend.toFixed(2)}KB per sample`);
75+
// }
76+
77+
// Signal 2: Second half significantly higher than first (>20MB or >20% increase)
78+
const percentIncrease = (halfDiff / firstHalfAvg) * 100;
79+
// if (halfDiff > 20480 || percentIncrease > 20) {
80+
// leakSignals.push(`Memory increased ${halfDiff.toFixed(2)}KB (${percentIncrease.toFixed(1)}%) from first to second half`);
81+
// }
82+
83+
// Signal 3: Memory still climbing at the end (trend in last quarter >307KB)
84+
// if (lastQuarterTrend > 307) {
85+
// leakSignals.push(`Memory still climbing at end: ${lastQuarterTrend.toFixed(2)}KB per sample in last quarter`);
86+
// }
87+
88+
// Signal 4: High variance in last quarter suggests unstable memory (may indicate leak)
89+
// if (lastQuarterStdDev > 15360 && lastQuarterTrend > 205) {
90+
// leakSignals.push(`High memory variance with upward trend: ${lastQuarterStdDev.toFixed(2)}KB std dev`);
91+
// }
92+
93+
// if (leakSignals.length >= 2) {
94+
// throw new Error(
95+
// `Memory leak detected (${leakSignals.length} signals):\n` +
96+
// leakSignals.map(s => ` - ${s}`).join('\n')
97+
// );
98+
// }
99+
100+
// if (leakSignals.length === 1) {
101+
// console.warn(`\n⚠ Potential leak indicator: ${leakSignals[0]}`);
102+
// }
103+
104+
// console.log('\n✓ No significant memory leak detected');
105+
106+
return {
107+
overAllMemoryTrendPerSample: overallTrend,
108+
firstHalfAverage: firstHalfAvg,
109+
secondHalfAverage: secondHalfAvg,
110+
lastQuarterTrendPerSample: lastQuarterTrend,
111+
lastQuarterStdDev: lastQuarterStdDev,
112+
memoryIncreaseFromFirstToSecondHalf: halfDiff,
113+
memoryIncreaseFromFirstToSecondHalfPercent: percentIncrease,
114+
}
115+
};
116+
117+
function average(samples: number[]): number {
118+
return samples.reduce((a, b) => a + b, 0) / samples.length;
119+
}
120+
121+
function standardDeviation(samples: number[]): number {
122+
const avg = average(samples);
123+
const squareDiffs = samples.map(value => Math.pow(value - avg, 2));
124+
return Math.sqrt(average(squareDiffs));
125+
}
126+
127+
function calculateTrend(samples: number[]): number {
128+
const n = samples.length;
129+
const indices = Array.from({ length: n }, (_, i) => i);
130+
131+
const sumX = indices.reduce((a, b) => a + b, 0);
132+
const sumY = samples.reduce((a, b) => a + b, 0);
133+
const sumXY = indices.reduce((sum, x, i) => sum + x * samples[i], 0);
134+
const sumX2 = indices.reduce((sum, x) => sum + x * x, 0);
135+
136+
const slope = (n * sumXY - sumX * sumY) / (n * sumX2 - sumX * sumX);
137+
return slope;
138+
}

0 commit comments

Comments
 (0)