Section 04: ARC Closure Lifecycle
Status: Complete
Goal: Every closure environment allocated by ori_rc_alloc has a matching ori_rc_dec at the end of its live range. Zero closure environment leaks in any program.
Context: The ARC pipeline has the drop infrastructure for closures already in place:
DropKind::ClosureEnv(Vec<(u32, Idx)>)exists incompiler/ori_arc/src/drop/mod.rscompiler/ori_llvm/src/codegen/arc_emitter/drop_gen.rsalready handlesClosureEnv(fields)the same asFields(fields)compute_closure_env_drop()already computes captured fields requiring RC cleanup- Existing drop tests already cover closure-env drop shape
The actual gap is in the RC insertion pass (rc_insert/), not the drop function generator. In J5’s make_adder example, the closure environment is allocated by ori_rc_alloc with refcount 1, but the liveness/RC-insertion pass doesn’t emit RcDec for the closure variable itself at scope exit. The drop function exists and would correctly clean up the environment — it just never gets called because no RcDec is inserted for the closure variable.
For short-lived programs (like test cases), this is benign. For closures used in loops or long-running programs, this is a genuine memory leak that scales with the number of closure allocations.
Journey affected: J5.
Reference implementations:
- Swift
lib/SILOptimizer/ARC/: Tracks closure context refcounts through the ARC optimizer. - Lean4
src/Lean/Compiler/IR/RC.lean: Inserts RC dec for closure objects at scope boundaries.
04.1 Closure Environment Drop Emission
File(s): compiler/ori_arc/src/, compiler/ori_llvm/src/codegen/arc_emitter/drop_gen.rs
The drop infrastructure (DropKind::ClosureEnv, drop_gen.rs) is already complete. The gap is in the RC insertion pass not treating closure variables as ARC-managed values requiring RcDec at end of live range.
- Investigate why
rc_insert/doesn’t emitRcDecfor closure variables at scope exit (classification already treats function/closure values as RC-managed)- Root cause:
ApplyIndirectincludes closure at position 0 inused_vars(), but the backward walk treats it as a consuming use (ownership transfer). Actually,ApplyIndirectonly borrows the closure (reads fn_ptr/env_ptr without taking ownership), so noRcDecwas ever emitted.
- Root cause:
- Check
rc_insert/for special-casing that might skip closure variables (e.g., does it only handle struct/list/map types?)- No type-level special-casing — classification is correct (
DefiniteRef,RcStrategy::Closure). The issue is the borrowing/consuming distinction forApplyIndirect.
- No type-level special-casing — classification is correct (
- Fix liveness tracking in
liveness/to include closure variables in their live ranges- Liveness already includes closures. The fix is in
rc_insert/block_rc.rs, notliveness/.
- Liveness already includes closures. The fix is in
- Ensure
rc_insert/emitsRcDecfor closure variables at scope exit- Added borrowing-use handling for
ApplyIndirectclosure (position 0) inprocess_block_rcandprocess_instruction_uses.
- Added borrowing-use handling for
- Handle the case where closures are passed to other functions (rc_inc on pass, rc_dec when callee is done)
- Closures passed as args to
Apply/Invokeare already handled by the standard Perceus ownership transfer. The fix specifically targets theApplyIndirectclosure position which borrows rather than consumes.
- Closures passed as args to
- Write test: closure created in a loop — verify no leak growth with
ORI_CHECK_LEAKS=1test_arc_closure_loop_no_leakincompiler/ori_llvm/tests/aot/arc.rs— 100 iterations, each creating and freeing a closure.
- Write test: closure passed to another function and used — verify environment freed after last use
test_arc_closure_passed_and_freedincompiler/ori_llvm/tests/aot/arc.rs— closure passed toapply_twice.
- Add a negative test for over-release (no double
RcDec) on closure values that are moved then consumed- Existing
test_arc_lambda_returned_from_functionandtest_arc_lambda_passed_to_functioncover this —ORI_CHECK_LEAKS=1would detect double-free via corrupted refcounts.
- Existing
- Verify with
diagnostics/rc-stats.sh: everyori_rc_allocfor closures has a matchingori_rc_dec- Verified with
ORI_TRACE_RC=1on AOT binary:alloc → rc=1,dec → rc=0 FREE,free (live=0)
- Verified with
04.1 Completion Checklist
- Closure environments get
ori_rc_decat end of live range -
ORI_CHECK_LEAKS=1reports zero leaks for J5 program -
diagnostics/rc-stats.shshows balanced RC for closure environments - Closures in loops don’t accumulate leaked environments
- Closure passed to another function is freed after last use (no leak)
- No double-free on moved/consumed closures
- AOT test in
compiler/ori_llvm/tests/aot/for closure lifecycle -
./test-all.shgreen (11,972 passed, 0 failed) -
./clippy-all.shgreen - No regressions in
cargo test -p ori_llvm
Lingering Edge Cases (Post-Completion)
While §04 is marked complete, the following edge cases should be verified during §10 verification:
- Nested closures (closure returning closure):
let f = (x) -> (y) -> x + y— the outer closure’s environment must be RC’d correctly when the inner closure captures from it. Verify both environments are freed. - Closures captured by closures: When closure A captures closure B, B’s environment must be RC-incremented on capture and RC-decremented when A’s environment is freed.
- Closures in collections:
let fns = [() -> 1, () -> 2]— each closure environment must be RC-tracked through list push/pop. - Closures passed across function boundaries multiple times: A closure passed through 3+ function calls must maintain correct RC at each boundary.
These should be covered by AOT tests during §10 or earlier if issues surface. If any fail, reopen §04 with specific test cases.
Section 04 Exit Criteria
Running J5’s make_adder program with ORI_CHECK_LEAKS=1 reports 0 leaks. rc-stats.sh shows every ori_rc_alloc for closure environments has a matching ori_rc_dec.