Skip to content

Commit 3adc899

Browse files
RoyLinRoyLin
authored andcommitted
test(sdk): soak the native bindings' evaluate() hot path
- sdk/python/soak.py + sdk/typescript/soak.mjs: hammer evaluate() (2M calls) and assert RSS flat across the FFI boundary. Results: Python -2.1MB, TS -39.8MB over 2M evals — no leak on the hot path. - Python also soaks create()x5000 (+0.96MB — pyo3 finalizes promptly). - Documented finding: a napi create() loop holds transient memory (V8 lazy finalization of #[napi] objects, plateaus + reclaims — NOT a leak); the pattern is create-once + evaluate-many.
1 parent 36d74e6 commit 3adc899

3 files changed

Lines changed: 103 additions & 1 deletion

File tree

sdk/python/soak.py

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
#!/usr/bin/env python3
2+
"""Soak the PyO3 binding: hammer evaluate() (and create()) and assert RSS stays flat — an FFI
3+
boundary is where per-call leaks hide. Run: python soak.py [evals] (default 2,000,000)."""
4+
5+
import os
6+
import subprocess
7+
import sys
8+
9+
from a3s_sentry import Sentry, dns, egress, tool_exec
10+
11+
CFG = """
12+
deny { egress = "" }
13+
rules = [
14+
{ name = "evil-dns", on = "Dns", match = "evil", verdict = "block", severity = "high", reason = "x" },
15+
]
16+
"""
17+
18+
EVENTS = [
19+
egress(1, "169.254.169.254", 80), # block (built-in)
20+
tool_exec(1, ["ls", "-la"]), # allow
21+
dns(1, "evil.test"), # block (custom)
22+
egress(1, "8.8.8.8", 443), # allow
23+
]
24+
25+
26+
def rss_kb() -> int:
27+
out = subprocess.check_output(["ps", "-o", "rss=", "-p", str(os.getpid())])
28+
return int(out.strip())
29+
30+
31+
def main() -> int:
32+
n = int(sys.argv[1]) if len(sys.argv) > 1 else 2_000_000
33+
s = Sentry.create(CFG)
34+
35+
# Phase 1: steady-state evaluate() — the hot path.
36+
for i in range(100_000):
37+
s.evaluate(EVENTS[i % len(EVENTS)]) # warm up allocators
38+
base = rss_kb()
39+
peak = base
40+
for i in range(n):
41+
s.evaluate(EVENTS[i % len(EVENTS)])
42+
if i % 250_000 == 0:
43+
peak = max(peak, rss_kb())
44+
end = rss_kb()
45+
print(f"evaluate x{n}: base={base}KB end={end}KB peak={peak}KB delta={end - base}KB")
46+
assert end - base < 20_000, f"RSS grew {end - base}KB — leak across the pyo3 boundary"
47+
48+
# Phase 2: build + drop the whole pipeline repeatedly (RuleEngine compiles ~14 regexes each time).
49+
base2 = rss_kb()
50+
for _ in range(5_000):
51+
Sentry.create(CFG) # built and dropped each iteration
52+
end2 = rss_kb()
53+
print(f"create x5000: base={base2}KB end={end2}KB delta={end2 - base2}KB")
54+
assert end2 - base2 < 30_000, f"create/drop leaked {end2 - base2}KB"
55+
56+
print("SOAK OK — RSS flat across the FFI boundary")
57+
return 0
58+
59+
60+
if __name__ == "__main__":
61+
sys.exit(main())

sdk/typescript/package.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,8 @@
3232
"scripts": {
3333
"build": "napi build --platform --release",
3434
"build:debug": "napi build --platform",
35-
"test": "node --test test/*.test.mjs"
35+
"test": "node --test test/*.test.mjs",
36+
"soak": "node --expose-gc soak.mjs"
3637
},
3738
"devDependencies": {
3839
"@napi-rs/cli": "^2"

sdk/typescript/soak.mjs

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
// Soak the napi binding's HOT PATH: hammer evaluate() and assert RSS stays flat — an FFI boundary is
2+
// where per-call leaks hide. Run: node --expose-gc soak.mjs [evals] (default 2,000,000).
3+
//
4+
// We soak evaluate() (called millions of times), not create() (called once, at startup). A loop of
5+
// Sentry.create() does grow RSS transiently, but it's napi/V8 *lazy finalization* of the #[napi]
6+
// object, not a leak — it plateaus and reclaims under pressure (verified: 5k→+1.1GB, 10k→+1.2GB). The
7+
// pattern is create-once: build one Sentry, then evaluate() forever.
8+
import { Sentry, egress, toolExec, dns } from "./index.js";
9+
10+
const CFG = `
11+
deny { egress = "" }
12+
rules = [
13+
{ name = "evil-dns", on = "Dns", match = "evil", verdict = "block", severity = "high", reason = "x" },
14+
]
15+
`;
16+
17+
const EVENTS = [
18+
egress(1, "169.254.169.254", 80), // block (built-in)
19+
toolExec(1, ["ls", "-la"]), // allow
20+
dns(1, "evil.test"), // block (custom)
21+
egress(1, "8.8.8.8", 443), // allow
22+
];
23+
24+
const MB = (b) => (b / 1e6).toFixed(1);
25+
const gc = () => global.gc && global.gc();
26+
const N = Number(process.argv[2] || 2_000_000);
27+
28+
const s = Sentry.create(CFG);
29+
for (let i = 0; i < 100_000; i++) s.evaluate(EVENTS[i % EVENTS.length]); // warm up
30+
gc();
31+
const base = process.memoryUsage().rss;
32+
for (let i = 0; i < N; i++) s.evaluate(EVENTS[i % EVENTS.length]);
33+
gc();
34+
const end = process.memoryUsage().rss;
35+
console.log(`evaluate x${N}: base=${MB(base)}MB end=${MB(end)}MB delta=${MB(end - base)}MB`);
36+
if (end - base > 30e6) {
37+
console.error("RSS grew — possible napi leak on the evaluate hot path");
38+
process.exit(1);
39+
}
40+
console.log("SOAK OK — evaluate() RSS flat across the FFI boundary");

0 commit comments

Comments
 (0)