on master help sign in
Refcount JIT code modules; free them when the last closure drops
commit8faff2f0bbb86e40e01ff2fb45871c0aaf3775e5 authoriko <ilyakooo0@gmail.com> dateFri 4 Sep 2026, 05:41 (1 month ago) parentfdfd878
5 files changed+438−110
crates/knot-compile-rt/src/lib.rs+10−8 crates/knot-jit/src/lib.rs+98−32 crates/knot-jit/tests/jit_e2e.rs+64−15 crates/knot-jit/tests/persistence.rs+1−1 crates/knot-runtime/src/lib.rs+265−54
crates/knot-compile-rt/src/lib.rs+10−8
@@ -8,11 +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())
11+/// Adapter bridging the JIT's `c_void`-typed set-code signature to the
12+/// runtime's `knot_value_set_code`. Both pointer args are opaque to the JIT
13+/// (a `Value*` and a `CodeHandle*`), so the casts are just pointer repoints.
14+unsafe extern "C" fn set_code(v: *mut c_void, handle: *const c_void) {
15+ unsafe {
16+ knot_runtime::knot_value_set_code(v.cast(), handle.cast());
17+ }
1618}
1719
1820/// The compile implementation registered into knot-runtime. Matches the
@@ -83,9 +85,9 @@ pub unsafe extern "C" fn knot_compile_impl(
8385 source,
8486 db,
8587 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),
88+ // Stamp the result's closures with the JIT module's code handle so the
89+ // module's lifetime is refcounted by the values that reference it.
90+ Some(set_code as unsafe extern "C" fn(*mut c_void, *const c_void)),
8991 ) {
9092 Ok(cv) => {
9193 // Hand the inferred type back as a malloc'd buffer (C ABI — the
crates/knot-jit/src/lib.rs+98−32
@@ -10,6 +10,51 @@
1010use knot_compiler::codegen::{self, Codegen};
1111use knot_compiler::{desugar, infer, types};
1212
13+/// The runtime's type-erased code-handle vtable, mirrored here so knot-jit can
14+/// hand a JIT module's lifetime to `Value::Function`/`Value::IO` without
15+/// depending on knot-runtime (a Cargo cycle). Must match `CodeHandle`'s field
16+/// order exactly — the runtime reads it via the same layout.
17+#[repr(C)]
18+struct CodeHandleLayout {
19+ refcount: *const std::sync::atomic::AtomicUsize,
20+ free: unsafe extern "C" fn(*const std::sync::atomic::AtomicUsize),
21+}
22+
23+/// Owns a JIT module for the whole life of the closures it produced. A single
24+/// heap allocation carries the shared refcount, the module, and the vtable the
25+/// runtime reads. `free_jit_module` (the vtable's `free`) reconstitutes this
26+/// block and frees the module's code — called by the runtime when the last
27+/// `Function`/`IO` referencing the module is dropped.
28+///
29+/// `repr(C)` guarantees `refcount` is at offset 0, so `free_jit_module` can
30+/// recover the block pointer from the refcount address the runtime passes it.
31+#[repr(C)]
32+struct JitModuleBlock {
33+ refcount: std::sync::atomic::AtomicUsize,
34+ module: Option<cranelift_jit::JITModule>,
35+ handle: CodeHandleLayout,
36+}
37+
38+/// Vtable `free`: the runtime calls this with the block's `refcount` address
39+/// when the count reaches zero. Reclaims the module's code/data memory and the
40+/// block itself.
41+///
42+/// # Safety
43+/// `refcount` must be the `&block.refcount` of a live, boxed `JitModuleBlock`
44+/// whose refcount has just reached zero (no closure can reference it again).
45+unsafe extern "C" fn free_jit_module(refcount: *const std::sync::atomic::AtomicUsize) {
46+ // The refcount field is at offset 0 of the block, so the block pointer is
47+ // the refcount pointer (the struct's first field).
48+ let block = refcount as *mut JitModuleBlock;
49+ let block = unsafe { Box::from_raw(block) };
50+ if let Some(module) = block.module {
51+ // SAFETY: the refcount reached zero, so no Function/IO referencing this
52+ // module is alive; none of its code can run again.
53+ unsafe { module.free_memory() };
54+ }
55+ // block drops here, freeing the refcount + handle storage.
56+}
57+
1358/// The result of JIT-compiling a knot source string: the resolved entry
1459/// address plus the value produced by running it. The produced value is an
1560/// opaque pointer into the host runtime's arena (a `Value*` in knot-runtime's
@@ -54,17 +99,17 @@ impl std::error::Error for CompileError {}
5499/// checked against it on REAL types (`infer::ty_subsumes`); a `false` verdict
55100/// makes this return `Err` (the runtime then yields `Nothing`).
56101///
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).
102+/// `set_code` is the runtime's `knot_value_set_code`: after the body runs, the
103+/// produced value is walked and every `Function`/`IO` in it is stamped with a
104+/// handle to this compile's JIT module, so the module's code stays mapped while
105+/// any closure referencing it is alive and is freed when the last one drops.
106+/// `None` (the const-eval path) leaves the module leaked — it has no runtime to
107+/// stamp with.
63108pub fn compile_and_run(
64109 source: &str,
65110 db: *mut std::ffi::c_void,
66111 expected_src: Option<&str>,
67− is_code_free: Option<unsafe extern "C" fn(*mut std::ffi::c_void) -> i32>,
112+ set_code: Option<unsafe extern "C" fn(*mut std::ffi::c_void, *const std::ffi::c_void)>,
68113) -> Result<CompiledValue, CompileError> {
69114 // ── Lex ──
70115 let lexer = knot::lexer::Lexer::new(source);
@@ -203,38 +248,59 @@ pub fn compile_and_run(
203248 )
204249 .map_err(|diags| CompileError(render_diags(&diags, source)))?;
205250 let (entry, module) = cg.finish_jit();
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))
251+ // Move the module into a refcounted block that outlives codegen. A raw
252+ // pointer crosses the `stack::grow` boundary because JITModule is not
253+ // Send; it is reconstituted below and handed to the runtime.
254+ let block = Box::new(JitModuleBlock {
255+ refcount: std::sync::atomic::AtomicUsize::new(0),
256+ module: Some(module),
257+ handle: CodeHandleLayout {
258+ // Filled in after the box is placed, so the address is stable.
259+ refcount: std::ptr::null(),
260+ free: free_jit_module,
261+ },
262+ });
263+ let block = Box::into_raw(block);
264+ // Now that the block has its final address, point the handle's refcount
265+ // field at the block's counter.
266+ unsafe {
267+ (*block).handle.refcount = &raw const (*block).refcount;
268+ }
269+ Ok((entry as usize, block as usize))
211270 })?;
212− let (entry, module_raw) = entry_and_module;
271+ let (entry, block_raw) = entry_and_module;
213272
214273 // ── Call knot_user_main(db) -> Value* ──
215274 let entry_fn: extern "C" fn(*mut std::ffi::c_void) -> *mut std::ffi::c_void =
216275 unsafe { std::mem::transmute(entry) };
217276 let value = entry_fn(db);
218277
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);
278+ // ── Hand the module's lifetime to the returned value ──
279+ // Stamp every Function/IO in the result with this compile's code handle;
280+ // the runtime frees the module when the last such value drops. If the value
281+ // carries no code, nothing is stamped and the module is freed here.
282+ let block = block_raw as *mut JitModuleBlock;
283+ match set_code {
284+ Some(set_code) => unsafe {
285+ set_code(value, &raw const (*block).handle as *const std::ffi::c_void);
286+ // If no closure referenced the module, the refcount is still 0 —
287+ // free the module's code and the block now rather than leak.
288+ if (*block).refcount.load(std::sync::atomic::Ordering::Acquire) == 0 {
289+ let block = Box::from_raw(block);
290+ if let Some(module) = block.module {
291+ // SAFETY: refcount 0 means no Function/IO points into the
292+ // module; `knot_user_main` has returned and won't run again.
293+ module.free_memory();
294+ }
295+ }
296+ // Otherwise the block is now owned by the stamped values; their
297+ // destructors drive the count to zero and free_jit_module reclaims
298+ // it. Intentionally not freed here.
299+ },
300+ // No runtime to stamp with (const-eval): leak the module as before.
301+ None => {
302+ std::mem::forget(unsafe { Box::from_raw(block) });
303+ }
238304 }
239305
240306 Ok(CompiledValue {
crates/knot-jit/tests/jit_e2e.rs+64−15
@@ -20,11 +20,10 @@ 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())
23+/// The runtime's code-stamping hook, passed so the JIT attaches each returned
24+/// closure's module handle (refcounted module lifetime).
25+unsafe extern "C" fn scan(v: *mut std::ffi::c_void, handle: *const std::ffi::c_void) {
26+ unsafe { knot_runtime::knot_value_set_code(v.cast(), handle.cast()) }
2827}
2928
3029#[test]
@@ -81,9 +80,9 @@ fn jit_surfaces_text_body_type() {
8180 assert_eq!(out.ty.as_deref(), Some("Text"));
8281}
8382
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.
83+/// A pure-data result carries no Function/IO, so nothing references the JIT
84+/// module: its refcount stays 0 and the module is freed before
85+/// `compile_and_run` returns. The value stays valid — it's pure data, not code.
8786#[test]
8887fn jit_frees_module_for_pure_data_result() {
8988 use knot_runtime::Value;
@@ -91,7 +90,7 @@ fn jit_frees_module_for_pure_data_result() {
9190 let db = db();
9291 let out = compile_and_run("{n 42 label \"answer\"}", db, None, Some(scan))
9392 .expect("JIT compile failed");
94− // Read the record AFTER the module was freed inside compile_and_run.
93+ // Read the record AFTER the (code-free) module was reclaimed.
9594 match unsafe { &*out.value.cast::<Value>() } {
9695 Value::Record(fields) => {
9796 assert_eq!(fields.len(), 2);
@@ -105,23 +104,73 @@ fn jit_frees_module_for_pure_data_result() {
105104 }
106105}
107106
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.
107+/// A function-returning result is stamped with the module's code handle: the
108+/// refcounted handle keeps the module mapped while the closure is alive. The
109+/// surfaced Function carries a non-null `code` handle and a live `fn_ptr`.
111110#[test]
112111fn jit_keeps_module_for_function_result() {
113112 use knot_runtime::Value;
114113 init_compile_rt();
115114 let db = db();
116115 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.
119116 match unsafe { &*out.value.cast::<Value>() } {
120− Value::Function(f) => assert!(!f.fn_ptr.is_null(), "function must hold a code ptr"),
117+ Value::Function(f) => {
118+ assert!(!f.fn_ptr.is_null(), "function must hold a code ptr");
119+ assert!(!f.code.is_null(), "function must carry the module handle");
120+ }
121121 _ => panic!("expected a Function"),
122122 }
123123}
124124
125+/// The whole point of refcounting the module: a closure returned from
126+/// `compile` stays callable *after* compile returns, because its code handle
127+/// keeps the module mapped. Apply it through the runtime and check the result —
128+/// if the module had been freed, this would jump into unmapped memory.
129+#[test]
130+fn jit_returned_closure_stays_callable() {
131+ use knot_runtime::Value;
132+ init_compile_rt();
133+ let db = db();
134+ let out = compile_and_run("\\x -> x + 20", db, None, Some(scan)).expect("JIT compile failed");
135+ let f = out.value.cast::<Value>();
136+ assert!(matches!(unsafe { &*f }, Value::Function(_)), "expected a Function");
137+ // Apply the closure to 1: (x + 20) 1 = 21. knot_value_call(db, fn, arg)
138+ // runs the module's fn_ptr — only possible because the module is mapped.
139+ let arg = knot_runtime::knot_value_int(1);
140+ let result = knot_runtime::knot_value_call(db, f, arg);
141+ match unsafe { &*result } {
142+ Value::Int(n) => assert_eq!(*n, 21),
143+ _ => panic!("expected Int result from applying the closure"),
144+ }
145+}
146+
147+/// A closure that is never kept alive is reclaimed by the arena reset, which
148+/// runs its destructor and drops the module refcount to zero. A second compile
149+/// of the same source then still produces a working closure — the first
150+/// module's reclamation didn't wedge the JIT or corrupt the allocator.
151+#[test]
152+fn jit_dropped_closure_frees_module() {
153+ use knot_runtime::Value;
154+ init_compile_rt();
155+ let db = db();
156+ // Compile a closure, then reset the arena — dropping every value it holds,
157+ // including the closure and its module reference.
158+ let mark = knot_runtime::knot_arena_mark();
159+ {
160+ let out = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("JIT compile failed");
161+ assert!(matches!(unsafe { &*out.value.cast::<Value>() }, Value::Function(_)));
162+ knot_runtime::knot_arena_reset_to(mark);
163+ }
164+ // A fresh compile of the same source must still produce a working closure.
165+ let out2 = compile_and_run("\\x -> x + 1", db, None, Some(scan)).expect("second JIT compile failed");
166+ let arg = knot_runtime::knot_value_int(41);
167+ let result = knot_runtime::knot_value_call(db, out2.value.cast(), arg);
168+ match unsafe { &*result } {
169+ Value::Int(n) => assert_eq!(*n, 42),
170+ _ => panic!("expected Int result"),
171+ }
172+}
173+
125174/// A user ADT extracted in the JIT renders self-contained: the declaration is
126175/// inlined (`with {…} (Type.Ctor …)`), matching the compiled-binary path.
127176/// Regression: the ctor-decl registration lived in the C `main` prologue,
crates/knot-jit/tests/persistence.rs+1−1
@@ -708,4 +708,4 @@ Rel E *emps
708708 // knot) aborts the same way. That is the existing, general JIT-panic
709709 // behavior, not something this fix changes; the fix is that the *message*
710710 // is the clean overflow report, not the raw SQLite error.
711−}
711+}
crates/knot-runtime/src/lib.rs+265−54
@@ -1077,11 +1077,13 @@ impl Arena {
10771077 Value::Function(f) => {
10781078 let ne = self.promote_value(f.env);
10791079 if ne != f.env {
1080+ unsafe { code_handle_retain(f.code) };
10801081 self.alloc_pinned(Value::Function(Box::new(FunctionInner {
10811082 fn_ptr: f.fn_ptr,
10821083 env: ne,
10831084 source: f.source.clone(),
10841085 extract_source: f.extract_source.clone(),
1086+ code: f.code,
10851087 })))
10861088 } else {
10871089 val
@@ -1090,10 +1092,12 @@ impl Arena {
10901092 Value::IO(io) => {
10911093 let ne = self.promote_value(io.env);
10921094 if ne != io.env {
1095+ unsafe { code_handle_retain(io.code) };
10931096 self.alloc_pinned(Value::IO(Box::new(IOInner {
10941097 fn_ptr: io.fn_ptr,
10951098 env: ne,
10961099 source: io.source.clone(),
1100+ code: io.code,
10971101 })))
10981102 } else {
10991103 val
@@ -1220,17 +1224,25 @@ impl Arena {
12201224 Value::Constructor(tag, inner) => {
12211225 Value::Constructor(tag.clone(), self.promote_value(*inner))
12221226 }
1223− Value::Function(f) => Value::Function(Box::new(FunctionInner {
1224− fn_ptr: f.fn_ptr,
1225− env: self.promote_value(f.env),
1226− source: f.source.clone(),
1227− extract_source: f.extract_source.clone(),
1228− })),
1229− Value::IO(io) => Value::IO(Box::new(IOInner {
1230− fn_ptr: io.fn_ptr,
1231− env: self.promote_value(io.env),
1232− source: io.source.clone(),
1233− })),
1227+ Value::Function(f) => {
1228+ unsafe { code_handle_retain(f.code) };
1229+ Value::Function(Box::new(FunctionInner {
1230+ fn_ptr: f.fn_ptr,
1231+ env: self.promote_value(f.env),
1232+ source: f.source.clone(),
1233+ extract_source: f.extract_source.clone(),
1234+ code: f.code,
1235+ }))
1236+ }
1237+ Value::IO(io) => {
1238+ unsafe { code_handle_retain(io.code) };
1239+ Value::IO(Box::new(IOInner {
1240+ fn_ptr: io.fn_ptr,
1241+ env: self.promote_value(io.env),
1242+ source: io.source.clone(),
1243+ code: io.code,
1244+ }))
1245+ }
12341246 Value::Pair(a, b) => Value::Pair(self.promote_value(*a), self.promote_value(*b)),
12351247 };
12361248 self.alloc_pinned(cloned)
@@ -1304,17 +1316,25 @@ impl Arena {
13041316 Value::Constructor(tag, inner) => {
13051317 Value::Constructor(tag.clone(), self.clone_from_child(*inner, child))
13061318 }
1307− Value::Function(f) => Value::Function(Box::new(FunctionInner {
1308− fn_ptr: f.fn_ptr,
1309− env: self.clone_from_child(f.env, child),
1310− source: f.source.clone(),
1311− extract_source: f.extract_source.clone(),
1312− })),
1313− Value::IO(io) => Value::IO(Box::new(IOInner {
1314− fn_ptr: io.fn_ptr,
1315− env: self.clone_from_child(io.env, child),
1316− source: io.source.clone(),
1317− })),
1319+ Value::Function(f) => {
1320+ unsafe { code_handle_retain(f.code) };
1321+ Value::Function(Box::new(FunctionInner {
1322+ fn_ptr: f.fn_ptr,
1323+ env: self.clone_from_child(f.env, child),
1324+ source: f.source.clone(),
1325+ extract_source: f.extract_source.clone(),
1326+ code: f.code,
1327+ }))
1328+ }
1329+ Value::IO(io) => {
1330+ unsafe { code_handle_retain(io.code) };
1331+ Value::IO(Box::new(IOInner {
1332+ fn_ptr: io.fn_ptr,
1333+ env: self.clone_from_child(io.env, child),
1334+ source: io.source.clone(),
1335+ code: io.code,
1336+ }))
1337+ }
13181338 Value::Pair(a, b) => Value::Pair(
13191339 self.clone_from_child(*a, child),
13201340 self.clone_from_child(*b, child),
@@ -4392,6 +4412,10 @@ pub struct FunctionInner {
43924412 /// qualified, re-parseable `Maybe.Nothing {}`). `None` → extract derives
43934413 /// from `source` + `env` as usual.
43944414 pub extract_source: Option<Arc<str>>,
4415+ /// Keep-alive handle for the JIT module this function's `fn_ptr` points
4416+ /// into. Null for natively-compiled (AOT) functions, whose code lives in
4417+ /// the process image and never needs freeing. See [`CodeHandle`].
4418+ pub code: *const CodeHandle,
43954419}
43964420
43974421/// Payload for `Value::IO`: boxed to keep `Value` compact. `source` is the
@@ -4402,6 +4426,128 @@ pub struct IOInner {
44024426 pub fn_ptr: *const u8,
44034427 pub env: *mut Value,
44044428 pub source: Arc<str>,
4429+ /// Keep-alive handle for the JIT module this thunk's `fn_ptr` points into.
4430+ /// Null for AOT-compiled actions. See [`CodeHandle`].
4431+ pub code: *const CodeHandle,
4432+}
4433+
4434+/// A type-erased, refcounted keep-alive handle for a JIT'd code module.
4435+///
4436+/// A `Value::Function`/`Value::IO` produced by `base.compile` holds a `fn_ptr`
4437+/// into the JIT module's executable memory, which must stay mapped while any
4438+/// such value is reachable. But knot-runtime can't name the JIT's module type
4439+/// (that would be a dependency cycle), so the module's lifetime is exposed as
4440+/// two function pointers plus an opaque refcount word:
4441+///
4442+/// - `refcount` is a `*const AtomicUsize` owned by the JIT side.
4443+/// - `free` reclaims the module; the caller (here, on drop of the last
4444+/// function/IO referencing it) invokes it exactly once, when the shared
4445+/// refcount reaches zero.
4446+///
4447+/// Cloning a function/IO increments the refcount; dropping one decrements it.
4448+/// The arena's `reset_to`/frame-drop paths run value destructors, which is what
4449+/// drives the decrement — so a JIT module is freed precisely when the last
4450+/// closure pointing into it is reclaimed.
4451+///
4452+/// # Safety
4453+/// `refcount` and `free` must remain valid until the refcount reaches zero and
4454+/// `free` has been called. The JIT side guarantees this by leaking the handle's
4455+/// storage into the module's own lifetime.
4456+///
4457+/// `repr(C)` pins the field order — knot-jit constructs this layout via a
4458+/// mirror struct, so the two must agree byte-for-byte.
4459+#[repr(C)]
4460+pub struct CodeHandle {
4461+ pub refcount: *const std::sync::atomic::AtomicUsize,
4462+ pub free: unsafe extern "C" fn(*const std::sync::atomic::AtomicUsize),
4463+}
4464+
4465+/// Increment a code handle's refcount (a new function/IO now references the
4466+/// module). Null-safe: a null handle (AOT code) is a no-op.
4467+///
4468+/// # Safety
4469+/// `handle` must be null or point to a live [`CodeHandle`].
4470+pub unsafe fn code_handle_retain(handle: *const CodeHandle) {
4471+ if handle.is_null() {
4472+ return;
4473+ }
4474+ unsafe {
4475+ (*(*handle).refcount).fetch_add(1, std::sync::atomic::Ordering::Relaxed);
4476+ }
4477+}
4478+
4479+/// Decrement a code handle's refcount; on the last drop, free the module.
4480+/// Null-safe.
4481+///
4482+/// # Safety
4483+/// `handle` must be null or a [`CodeHandle`] whose refcount is above zero.
4484+pub unsafe fn code_handle_release(handle: *const CodeHandle) {
4485+ if handle.is_null() {
4486+ return;
4487+ }
4488+ unsafe {
4489+ // Release so prior writes are visible to whoever frees; Acquire on the
4490+ // final decrement pairs with it. On zero, hand the refcount word to the
4491+ // JIT's free routine, which reclaims the module (and the word).
4492+ if (*(*handle).refcount).fetch_sub(1, std::sync::atomic::Ordering::AcqRel) == 1 {
4493+ ((*handle).free)((*handle).refcount);
4494+ }
4495+ }
4496+}
4497+
4498+/// Recursively stamp a JIT code handle onto every `Function`/`IO` reachable
4499+/// from `v` that doesn't already have one (those have `code == null`). Sets the
4500+/// refcount to the number of values stamped, so the count matches the number of
4501+/// code-bearing values sharing the module. Called once by the JIT right after a
4502+/// `base.compile` body returns, before the value escapes to the host.
4503+///
4504+/// Depth-bounded against pathological values. Values that already carry a
4505+/// handle (e.g. a closure captured from an earlier `compile`) are left alone —
4506+/// their module is already accounted for.
4507+///
4508+/// # Safety
4509+/// `v` must be a live `Value*` produced by this runtime; `handle` a valid
4510+/// [`CodeHandle`] whose refcount is about to be initialized by this call.
4511+#[unsafe(no_mangle)]
4512+pub unsafe extern "C-unwind" fn knot_value_set_code(v: *mut Value, handle: *const CodeHandle) {
4513+ if v.is_null() || handle.is_null() {
4514+ return;
4515+ }
4516+ fn stamp(v: *mut Value, handle: *const CodeHandle, depth: usize) -> usize {
4517+ if v.is_null() || depth > 512 {
4518+ return 0;
4519+ }
4520+ match unsafe { as_ref(v) } {
4521+ Value::Function(f) => {
4522+ if f.code.is_null() {
4523+ // Cast away the shared ref to write the field once.
4524+ unsafe { (*(f.as_ref() as *const FunctionInner as *mut FunctionInner)).code = handle };
4525+ 1
4526+ } else {
4527+ 0
4528+ }
4529+ }
4530+ Value::IO(io) => {
4531+ if io.code.is_null() {
4532+ unsafe { (*(io.as_ref() as *const IOInner as *mut IOInner)).code = handle };
4533+ 1
4534+ } else {
4535+ 0
4536+ }
4537+ }
4538+ Value::Record(fields) => fields.iter().map(|f| stamp(f.value, handle, depth + 1)).sum(),
4539+ Value::Relation(elems) => elems.iter().map(|e| stamp(*e, handle, depth + 1)).sum(),
4540+ Value::Constructor(_, payload) => stamp(*payload, handle, depth + 1),
4541+ Value::Pair(a, b) => stamp(*a, handle, depth + 1) + stamp(*b, handle, depth + 1),
4542+ _ => 0,
4543+ }
4544+ }
4545+ let stamped = stamp(v, handle, 0);
4546+ if stamped > 0 {
4547+ unsafe {
4548+ (*(*handle).refcount).store(stamped, std::sync::atomic::Ordering::Relaxed);
4549+ }
4550+ }
44054551}
44064552
44074553pub struct RecordField {
@@ -4412,6 +4558,87 @@ pub struct RecordField {
44124558 pub value: *mut Value,
44134559}
44144560
4561+// Dropping a function/IO releases its JIT code handle; when the last value
4562+// referencing a module drops, the module's code memory is freed. The arena
4563+// runs these destructors on every reclaim path (`reset_to`, frame drop), so a
4564+// JIT module lives exactly as long as its longest-lived closure.
4565+impl Drop for FunctionInner {
4566+ fn drop(&mut self) {
4567+ unsafe { code_handle_release(self.code) };
4568+ }
4569+}
4570+
4571+impl Drop for IOInner {
4572+ fn drop(&mut self) {
4573+ unsafe { code_handle_release(self.code) };
4574+ }
4575+}
4576+
4577+#[cfg(test)]
4578+mod code_handle_tests {
4579+ use super::*;
4580+ use std::sync::atomic::{AtomicUsize, Ordering};
4581+
4582+ // A fake "module": a boxed (refcount, freed-flag, handle). `free` flips the
4583+ // freed flag via the refcount's container, mirroring how the JIT's block
4584+ // reclaims its module when the last closure drops.
4585+ #[repr(C)]
4586+ struct FakeBlock {
4587+ refcount: AtomicUsize,
4588+ freed: std::sync::atomic::AtomicBool,
4589+ handle: CodeHandle,
4590+ }
4591+
4592+ unsafe extern "C" fn fake_free(refcount: *const AtomicUsize) {
4593+ let block = refcount as *mut FakeBlock;
4594+ unsafe {
4595+ (*block).freed.store(true, Ordering::SeqCst);
4596+ drop(Box::from_raw(block));
4597+ }
4598+ }
4599+
4600+ fn new_block(count: usize) -> (*mut FakeBlock, *const CodeHandle) {
4601+ let mut b = Box::new(FakeBlock {
4602+ refcount: AtomicUsize::new(count),
4603+ freed: std::sync::atomic::AtomicBool::new(false),
4604+ handle: CodeHandle {
4605+ refcount: std::ptr::null(),
4606+ free: fake_free,
4607+ },
4608+ });
4609+ b.handle.refcount = &raw const b.refcount;
4610+ let handle = &raw const b.handle;
4611+ (Box::into_raw(b), handle)
4612+ }
4613+
4614+ #[test]
4615+ fn release_to_zero_frees() {
4616+ let (block, handle) = new_block(2);
4617+ unsafe { code_handle_release(handle) }; // 2 -> 1
4618+ assert!(!unsafe { (*block).freed.load(Ordering::SeqCst) });
4619+ let freed_flag = unsafe { (*block).freed.load(Ordering::SeqCst) };
4620+ assert!(!freed_flag);
4621+ unsafe { code_handle_release(handle) }; // 1 -> 0 → free
4622+ // block is now freed; we can't read it. The test not crashing/hanging is
4623+ // the assertion; freed was set inside fake_free before the Box drop.
4624+ }
4625+
4626+ #[test]
4627+ fn retain_then_release_balances() {
4628+ let (block, handle) = new_block(1);
4629+ unsafe { code_handle_retain(handle) }; // 1 -> 2
4630+ unsafe { code_handle_release(handle) }; // 2 -> 1
4631+ assert!(!unsafe { (*block).freed.load(Ordering::SeqCst) });
4632+ unsafe { code_handle_release(handle) }; // 1 -> 0 → free
4633+ }
4634+
4635+ #[test]
4636+ fn null_handle_is_noop() {
4637+ unsafe { code_handle_retain(std::ptr::null()) };
4638+ unsafe { code_handle_release(std::ptr::null()) };
4639+ }
4640+}
4641+
44154642impl Drop for Value {
44164643 fn drop(&mut self) {
44174644 // Custom Drop intercepts `Value::Relation` / `Value::Record` to
@@ -4958,6 +5185,7 @@ fn alloc_io(fn_ptr: *const u8, env: *mut Value, source: Arc<str>) -> *mut Value
49585185 fn_ptr,
49595186 env,
49605187 source,
5188+ code: std::ptr::null(),
49615189 })))
49625190}
49635191
@@ -5592,6 +5820,7 @@ pub extern "C-unwind" fn knot_value_function(
55925820 env,
55935821 source,
55945822 extract_source: None,
5823+ code: std::ptr::null(),
55955824 })))
55965825}
55975826
@@ -5615,6 +5844,7 @@ pub extern "C-unwind" fn knot_value_function_full(
56155844 env,
56165845 source,
56175846 extract_source,
5847+ code: std::ptr::null(),
56185848 })))
56195849}
56205850
@@ -9458,19 +9688,23 @@ fn deep_clone_with(val: *mut Value, mut alloc_node: impl FnMut(Value) -> *mut Va
94589688 }
94599689 Value::Function(f) => {
94609690 to_alloc.push(f.env);
9691+ unsafe { code_handle_retain(f.code) };
94619692 Value::Function(Box::new(FunctionInner {
94629693 fn_ptr: f.fn_ptr,
94639694 env: std::ptr::null_mut(),
94649695 source: f.source.clone(),
94659696 extract_source: f.extract_source.clone(),
9697+ code: f.code,
94669698 }))
94679699 }
94689700 Value::IO(io) => {
94699701 to_alloc.push(io.env);
9702+ unsafe { code_handle_retain(io.code) };
94709703 Value::IO(Box::new(IOInner {
94719704 fn_ptr: io.fn_ptr,
94729705 env: std::ptr::null_mut(),
94739706 source: io.source.clone(),
9707+ code: io.code,
94749708 }))
94759709 }
94769710 Value::Pair(a, b) => {
@@ -10740,6 +10974,7 @@ pub extern "C-unwind" fn knot_trace(
1074010974 env,
1074110975 source: Arc::from("trace"),
1074210976 extract_source: None,
10977+ code: std::ptr::null(),
1074310978 })))
1074410979}
1074510980
@@ -11593,38 +11828,6 @@ pub extern "C-unwind" fn knot_register_compile_impl(f: *mut c_void) {
1159311828 COMPILE_IMPL.store(f, std::sync::atomic::Ordering::Release);
1159411829}
1159511830
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−
1162811831/// Look up the registered compile implementation, if any.
1162911832fn compile_impl() -> Option<CompileImpl> {
1163011833 let p = COMPILE_IMPL.load(std::sync::atomic::Ordering::Acquire);
@@ -19049,6 +19252,7 @@ fn build_request_ctx(
1904919252 env: header_env,
1905019253 source: intern_str("RequestCtx.header"),
1905119254 extract_source: None,
19255+ code: std::ptr::null(),
1905219256 })));
1905319257 let mut fields = vec![
1905419258 RecordField {
@@ -19490,6 +19694,7 @@ pub extern "C-unwind" fn knot_constraint_register(
1949019694
1949119695// ── Atomic (transactions) ─────────────────────────────────────────
1949219696
19697+
1949319698#[unsafe(no_mangle)]
1949419699pub extern "C-unwind" fn knot_atomic_begin(db: *mut c_void) {
1949519700 let _guard = write_lock_guard();
@@ -24312,6 +24517,7 @@ mod extract_source_tests {
2431224517 env,
2431324518 source: intern_str(source),
2431424519 extract_source: None,
24520+ code: std::ptr::null(),
2431524521 })))
2431624522 }
2431724523 fn func_full(source: &str, env: *mut Value, xsrc: &str) -> *mut Value {
@@ -24320,6 +24526,7 @@ mod extract_source_tests {
2432024526 env,
2432124527 source: intern_str(source),
2432224528 extract_source: Some(intern_str(xsrc)),
24529+ code: std::ptr::null(),
2432324530 })))
2432424531 }
2432524532
@@ -24465,6 +24672,7 @@ mod extract_source_tests {
2446524672 fn_ptr: std::ptr::null(),
2446624673 env,
2446724674 source: intern_str(source),
24675+ code: std::ptr::null(),
2446824676 })))
2446924677 }
2447024678 #[test]
@@ -24514,6 +24722,7 @@ mod show_tests {
2451424722 env,
2451524723 source: intern_str(source),
2451624724 extract_source: None,
24725+ code: std::ptr::null(),
2451724726 })))
2451824727 }
2451924728 fn io(source: &str) -> *mut Value {
@@ -24521,6 +24730,7 @@ mod show_tests {
2452124730 fn_ptr: std::ptr::null(),
2452224731 env: std::ptr::null_mut(),
2452324732 source: intern_str(source),
24733+ code: std::ptr::null(),
2452424734 })))
2452524735 }
2452624736
@@ -24558,6 +24768,7 @@ mod show_tests {
2455824768 env: alloc(Value::Unit),
2455924769 source: intern_str("Nothing"),
2456024770 extract_source: Some(intern_str("Maybe.Nothing {}")),
24771+ code: std::ptr::null(),
2456124772 })));
2456224773 assert_eq!(format_value(f), "Nothing");
2456324774 }