on master help sign in
Free JIT modules for code-free base.compile results

Every base.compile leaked its JITModule (`std::mem::forget`) because the JIT'd code can be re-entered: a returned Function/IO value carries a `fn_ptr` into the module's code segment, callable arbitrarily later. A long-running host that compiles snippets in a loop grew code memory without bound. A pure-data result can never call back into the module, so its module can be freed instead of leaked. Scan the result and free when it's code-free: - knot-runtime: add `knot_value_is_code_free`, a depth-bounded walk that reports whether any Function/IO is reachable through records, relations, constructor payloads, and pairs. Only those two variants hold code pointers. - knot-jit: keep the module boxed past codegen (raw pointer across the non-Send `stack::grow` boundary); after the body runs, free the module with Cranelift's `free_memory` when the scan proves the value code-free, else leak as before. New optional scan param keeps knot-jit runtime-agnostic (None conservatively keeps the module — the const-eval path). - knot-compile-rt: pass the runtime's scan via a `c_void` adapter so the real base.compile path frees pure-data modules. Function/IO-returning snippets still leak — their code must outlive any closure pointing into it; eliminating that needs refcounted code modules. Tests: jit_frees_module_for_pure_data_result (record read after the free is still valid) and jit_keeps_module_for_function_result (lambda result keeps a live code pointer).

commit616fdc7c35eae85090207640a6d6169cabc5b32b authoriko <ilyakooo0@gmail.com> dateFri 4 Sep 2026, 05:41 (1 month ago) parent36ccf17
5 files changed+142−13
crates/knot-compile-rt/src/lib.rs+15−1 crates/knot-jit/src/lib.rs+37−3 crates/knot-jit/tests/harness.rs+3−3 crates/knot-jit/tests/jit_e2e.rs+55−6 crates/knot-runtime/src/lib.rs+32
crates/knot-compile-rt/src/lib.rs+15−1
@@ -8,6 +8,13 @@
88
99use std::ffi::c_void;
1010
11+/// Adapter bridging the JIT's `c_void`-typed scan signature to the runtime's
12+/// `*mut Value` scan. `c_void` and `Value` are both opaque here, so the cast
13+/// is just a pointer repoint.
14+unsafe extern "C" fn scan_code_free(v: *mut c_void) -> i32 {
15+ knot_runtime::knot_value_is_code_free(v.cast())
16+}
17+
1118/// The compile implementation registered into knot-runtime. Matches the
1219/// `CompileImpl` signature in knot-runtime: takes the program source and a db
1320/// handle, writes the inferred type into the out-params, returns the forced
@@ -72,7 +79,14 @@ pub unsafe extern "C" fn knot_compile_impl(
7279 std::str::from_utf8(b).ok()
7380 };
7481
75− match knot_jit::compile_and_run(source, db, expected) {
82+ match knot_jit::compile_and_run(
83+ source,
84+ db,
85+ expected,
86+ // The runtime's code-free scan lets the JIT free the module when the
87+ // result holds no Function/IO (the common, pure-data case).
88+ Some(scan_code_free as unsafe extern "C" fn(*mut c_void) -> i32),
89+ ) {
7690 Ok(cv) => {
7791 // Hand the inferred type back as a malloc'd buffer (C ABI — the
7892 // runtime frees it). Only when we actually have one.
crates/knot-jit/src/lib.rs+37−3
@@ -53,10 +53,18 @@ impl std::error::Error for CompileError {}
5353/// `Priority`). It is threaded into inference so the snippet's body type is
5454/// checked against it on REAL types (`infer::ty_subsumes`); a `false` verdict
5555/// makes this return `Err` (the runtime then yields `Nothing`).
56+///
57+/// `is_code_free`, when `Some`, is the runtime's `knot_value_is_code_free`:
58+/// after the body runs, the JIT'd module is FREED (not leaked) when the
59+/// produced value holds no `Function`/`IO` — those carry `fn_ptr`s into the
60+/// module's code, so a code-bearing value forces the module to stay alive.
61+/// `None` → always keep the module (the const-eval path has no runtime to
62+/// scan with).
5663pub fn compile_and_run(
5764 source: &str,
5865 db: *mut std::ffi::c_void,
5966 expected_src: Option<&str>,
67+ is_code_free: Option<unsafe extern "C" fn(*mut std::ffi::c_void) -> i32>,
6068) -> Result<CompiledValue, CompileError> {
6169 // ── Lex ──
6270 let lexer = knot::lexer::Lexer::new(source);
@@ -156,7 +164,8 @@ pub fn compile_and_run(
156164 let compile_expected_types = knot_compiler::infer::CompileExpectedTypes::new();
157165 // Return the entry address as usize (Send) — *const u8 isn't Send and
158166 // can't cross stack::grow's thread boundary.
159− let entry = knot_compiler::stack::grow(|| -> Result<usize, CompileError> {
167+ let entry_and_module =
168+ knot_compiler::stack::grow(|| -> Result<(usize, usize), CompileError> {
160169 let cg = codegen::compile_with(
161170 Codegen::new_jit(),
162171 &program,
@@ -194,15 +203,40 @@ pub fn compile_and_run(
194203 )
195204 .map_err(|diags| CompileError(render_diags(&diags, source)))?;
196205 let (entry, module) = cg.finish_jit();
197− std::mem::forget(module);
198− Ok(entry as usize)
206+ // Keep the module alive (boxed) until the produced value is scanned.
207+ // A raw pointer crosses the `stack::grow` boundary because JITModule is
208+ // not Send; it is reconstituted and either freed or leaked below.
209+ let module = Box::into_raw(Box::new(module)) as usize;
210+ Ok((entry as usize, module))
199211 })?;
212+ let (entry, module_raw) = entry_and_module;
200213
201214 // ── Call knot_user_main(db) -> Value* ──
202215 let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut std::ffi::c_void =
203216 unsafe { std::mem::transmute(entry) };
204217 let value = entry_fn(db);
205218
219+ // ── Free the JIT module when the value can't re-enter its code ──
220+ // A `Function`/`IO` value holds a `fn_ptr` into the module's code segment,
221+ // so the module must stay alive while such a value is reachable. A pure
222+ // data result (no lambdas, no IO thunks) can never call back into the
223+ // module, so its code/data memory is freed here instead of leaked — this is
224+ // what keeps a `compile` loop from growing code memory without bound.
225+ let module = unsafe { Box::from_raw(module_raw as *mut cranelift_jit::JITModule) };
226+ let code_free = match is_code_free {
227+ Some(scan) => unsafe { scan(value) != 0 },
228+ // No runtime to scan with (const-eval): conservatively keep the module.
229+ None => false,
230+ };
231+ if code_free {
232+ // SAFETY: the scan proved the value holds no `Function`/`IO`, so no
233+ // reachable `fn_ptr` refers into this module; `knot_user_main` has
234+ // returned and won't run again.
235+ unsafe { module.free_memory() };
236+ } else {
237+ std::mem::forget(module);
238+ }
239+
206240 Ok(CompiledValue {
207241 value,
208242 ty: body_ty,
crates/knot-jit/tests/harness.rs+3−3
@@ -50,7 +50,7 @@ pub fn eval_show(body: &str) -> String {
5050 init();
5151 let db = db();
5252 let src = format!("base.show ({body})");
53− let out = compile_and_run(&src, db, None)
53+ let out = compile_and_run(&src, db, None, None)
5454 .unwrap_or_else(|e| panic!("compile failed for `{body}`: {e}"));
5555 unsafe { text_of(out.value.cast()) }
5656}
@@ -61,7 +61,7 @@ pub fn eval_prog_show(program: &str) -> String {
6161 init();
6262 let db = db();
6363 let src = format!("base.show ({program})");
64− let out = compile_and_run(&src, db, None)
64+ let out = compile_and_run(&src, db, None, None)
6565 .unwrap_or_else(|e| panic!("compile failed for program: {e}\n---\n{program}"));
6666 unsafe { text_of(out.value.cast()) }
6767}
@@ -126,7 +126,7 @@ pub fn assert_compile_err(body: &str, needle: &str) {
126126 init();
127127 let db = db();
128128 let src = format!("base.show ({body})");
129− match compile_and_run(&src, db, None) {
129+ match compile_and_run(&src, db, None, None) {
130130 Err(e) => {
131131 let msg = e.to_string();
132132 assert!(
crates/knot-jit/tests/jit_e2e.rs+55−6
@@ -20,12 +20,19 @@ fn db() -> *mut std::ffi::c_void {
2020 knot_runtime::knot_db_open(PATH.as_ptr(), PATH.len())
2121}
2222
23+/// The runtime's code-free scan, passed so the JIT frees modules whose result
24+/// is pure data. These tests read the result back as a value (never call a
25+/// returned lambda), so a freed module is never re-entered.
26+unsafe extern "C" fn scan(v: *mut std::ffi::c_void) -> i32 {
27+ knot_runtime::knot_value_is_code_free(v.cast())
28+}
29+
2330#[test]
2431fn jit_compiles_and_runs_pure_int() {
2532 init_compile_rt();
2633 let db = db();
2734 let out =
28− compile_and_run("base.println (base.show (40 + 2))", db, None).expect("JIT compile failed");
35+ compile_and_run("base.println (base.show (40 + 2))", db, None, Some(scan)).expect("JIT compile failed");
2936 assert!(!out.value.is_null(), "JIT produced a null Value");
3037}
3138
@@ -33,7 +40,7 @@ fn jit_compiles_and_runs_pure_int() {
3340fn jit_compiles_and_runs_closure() {
3441 init_compile_rt();
3542 let db = db();
36− let out = compile_and_run("base.println (base.show ((\\x -> x * 2 + 1) 10))", db, None)
43+ let out = compile_and_run("base.println (base.show ((\\x -> x * 2 + 1) 10))", db, None, Some(scan))
3744 .expect("JIT compile failed");
3845 assert!(!out.value.is_null());
3946}
@@ -42,7 +49,7 @@ fn jit_compiles_and_runs_closure() {
4249fn jit_rejects_type_error() {
4350 init_compile_rt();
4451 let db = db();
45− let res = compile_and_run("1 + \"not an int\"", db, None);
52+ let res = compile_and_run("1 + \"not an int\"", db, None, Some(scan));
4653 assert!(res.is_err(), "type error should fail compilation");
4754}
4855
@@ -53,7 +60,7 @@ fn jit_rejects_type_error() {
5360fn jit_surfaces_scalar_body_type() {
5461 init_compile_rt();
5562 let db = db();
56− let out = compile_and_run("42", db, None).expect("JIT compile failed");
63+ let out = compile_and_run("42", db, None, Some(scan)).expect("JIT compile failed");
5764 assert_eq!(out.ty.as_deref(), Some("Int"));
5865}
5966
@@ -61,7 +68,7 @@ fn jit_surfaces_scalar_body_type() {
6168fn jit_surfaces_polymorphic_lambda_type() {
6269 init_compile_rt();
6370 let db = db();
64− let out = compile_and_run("\\x -> x", db, None).expect("JIT compile failed");
71+ let out = compile_and_run("\\x -> x", db, None, Some(scan)).expect("JIT compile failed");
6572 // Identity is polymorphic: a type variable on both sides.
6673 assert_eq!(out.ty.as_deref(), Some("a -> a"));
6774}
@@ -70,10 +77,51 @@ fn jit_surfaces_polymorphic_lambda_type() {
7077fn jit_surfaces_text_body_type() {
7178 init_compile_rt();
7279 let db = db();
73− let out = compile_and_run("\"hello\"", db, None).expect("JIT compile failed");
80+ let out = compile_and_run("\"hello\"", db, None, Some(scan)).expect("JIT compile failed");
7481 assert_eq!(out.ty.as_deref(), Some("Text"));
7582}
7683
84+/// A pure-data result lets the JIT free the module (the scan reports
85+/// code-free). The value must stay valid after the free — its content is pure
86+/// data, not code. This exercises the `free_memory` path directly.
87+#[test]
88+fn jit_frees_module_for_pure_data_result() {
89+ use knot_runtime::Value;
90+ init_compile_rt();
91+ let db = db();
92+ let out = compile_and_run("{n 42 label \"answer\"}", db, None, Some(scan))
93+ .expect("JIT compile failed");
94+ // Read the record AFTER the module was freed inside compile_and_run.
95+ match unsafe { &*out.value.cast::<Value>() } {
96+ Value::Record(fields) => {
97+ assert_eq!(fields.len(), 2);
98+ let n = fields.iter().find(|f| &*f.name == "n").expect("n field");
99+ match unsafe { &*n.value } {
100+ Value::Int(n) => assert_eq!(*n, 42),
101+ _ => panic!("n is not an Int"),
102+ }
103+ }
104+ _ => panic!("expected a record"),
105+ }
106+}
107+
108+/// A function-returning result keeps the module alive: the scan reports the
109+/// value holds code, so the JIT must NOT free it. The surfaced value is a
110+/// callable Function whose `fn_ptr` points into the (still-mapped) module.
111+#[test]
112+fn jit_keeps_module_for_function_result() {
113+ use knot_runtime::Value;
114+ init_compile_rt();
115+ let db = db();
116+ let out = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("JIT compile failed");
117+ // The scan saw a Function, so the module was kept (leaked) — and the value
118+ // is a real Function value carrying a non-null code pointer.
119+ match unsafe { &*out.value.cast::<Value>() } {
120+ Value::Function(f) => assert!(!f.fn_ptr.is_null(), "function must hold a code ptr"),
121+ _ => panic!("expected a Function"),
122+ }
123+}
124+
77125/// A user ADT extracted in the JIT renders self-contained: the declaration is
78126/// inlined (`with {…} (Type.Ctor …)`), matching the compiled-binary path.
79127/// Regression: the ctor-decl registration lived in the C `main` prologue,
@@ -89,6 +137,7 @@ fn jit_extract_user_adt_is_self_contained() {
89137 "with {\nPriority Low {} High {}\n}\n(base.extract (Priority.Low {}))",
90138 db,
91139 None,
140+ Some(scan),
92141 )
93142 .expect("JIT compile failed");
94143 let text = match unsafe { &*out.value.cast::<Value>() } {
crates/knot-runtime/src/lib.rs+32
@@ -11593,6 +11593,38 @@ pub extern "C-unwind" fn knot_register_compile_impl(f: *mut c_void) {
1159311593 COMPILE_IMPL.store(f, std::sync::atomic::Ordering::Release);
1159411594}
1159511595
11596+/// Is `v` free of any JIT'd code reference? A compiled snippet's JIT module
11597+/// may be freed only when its result cannot re-enter the module's code. Only
11598+/// `Function` and `IO` values carry a `fn_ptr` into that code (a lambda or an
11599+/// IO thunk); every other variant is pure data whose children are values, not
11600+/// code. Recurses through records, relations, constructor payloads, and pairs.
11601+/// `1` = code-free (module safe to free), `0` = holds code (must keep the
11602+/// module alive).
11603+///
11604+/// # Safety
11605+/// `v` must be a live `Value*` produced by this runtime.
11606+#[unsafe(no_mangle)]
11607+pub extern "C-unwind" fn knot_value_is_code_free(v: *mut Value) -> i32 {
11608+ if v.is_null() {
11609+ return 1;
11610+ }
11611+ // Depth-bounded: a pathological cyclic value can't overflow the stack.
11612+ fn go(v: *mut Value, depth: usize) -> bool {
11613+ if v.is_null() || depth > 512 {
11614+ return depth > 512; // too deep to prove code-free → keep the module
11615+ }
11616+ match unsafe { as_ref(v) } {
11617+ Value::Function(_) | Value::IO(_) => false,
11618+ Value::Record(fields) => fields.iter().all(|f| go(f.value, depth + 1)),
11619+ Value::Relation(elems) => elems.iter().all(|e| go(*e, depth + 1)),
11620+ Value::Constructor(_, payload) => go(*payload, depth + 1),
11621+ Value::Pair(a, b) => go(*a, depth + 1) && go(*b, depth + 1),
11622+ _ => true,
11623+ }
11624+ }
11625+ go(v, 0) as i32
11626+}
11627+
1159611628/// Look up the registered compile implementation, if any.
1159711629fn compile_impl() -> Option<CompileImpl> {
1159811630 let p = COMPILE_IMPL.load(std::sync::atomic::Ordering::Acquire);