Skip to content

Commit 84cecbf

Browse files
committed
runtime: closure-cycle collector — honest env refcounts + registry mark-sweep
The env<->fn cycle (docs/CLOSURE_CYCLE_GC.md) is now reclaimed, both mid-run and at exit, following the staged plan in the design note. Stage 1 — Env lifetime is a real refcount. env_refcount counts every owner: creator/frame, closures (make_fn), child envs (the parent link is an owned edge), and a chunk's parked env_cache. env_free's captured-gated semantics are replaced by env_incref/env_decref; LOOP_ENV_FRESH/END move the frame's ref explicitly (also fixing the latent fn_env leak on early return from a loop scope); vm_park_call_env requires refcount == 1 and the parked env keeps its owned parent ref, making the take-side parent compare dangling-pointer-free. OP_IMPORT releases the module env's creator ref so fn-free modules are reclaimed. Stage 2 — the collector. Captured envs register in a per-thread intrusive list at OP_CLOSURE (global env excluded); collection triggers from the registration threshold (zero cost on dispatch/call hot paths) and once at exit. The universe is everything reachable from registered envs over owned edges only — env slots, parent, fn->closure, fn->chunk, chunk->functions[], chunk->env_cache, list/dict elements — and roots are derived from refcounts alone: any node whose rc exceeds its in-universe reference count has an external holder, so no VM root-set enumeration exists to get wrong. Accounting mismatches abort the collection (leak, never free). Unmarked nodes are pinned, edge-cleared, and released through the ordinary destructors. gc_collect_at_exit additionally snapshots global container bindings (pinned, accounted) so global-scope pure value cycles — a list appended to itself — die at teardown. spawn() drains the registry and disables collection; spawned programs keep the previous tolerated-leak behavior, still visible to LSan. Results: 500 discarded closures leak 0 allocations (was 6,004); a 100k closure-churn loop runs ~40% faster with peak RSS 124 MB -> 4.3 MB; bench_perf / bench_dmg_shape / bench_idxset are flat. Suite: 1832/1832 release and ASan (detect_leaks=1); tolerated-leak tally 28 -> 13, every remaining report byte-identical to the pre-collector baseline (spawn-thread programs + pre-existing non-closure shapes); fuzz 44/44. test_closure_cycles.eigs (17 checks, incl. dict-routed cycles) is now gated strictly leak-clean — section [87] opts out of rc_ok's tolerance, so a collector regression fails CI instead of being tallied. https://claude.ai/code/session_01UiKD8EDViybJuxHbHLr9TW
1 parent 7d3bccf commit 84cecbf

15 files changed

Lines changed: 890 additions & 220 deletions

CHANGELOG.md

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,42 @@
22

33
All notable changes to EigenScript are documented here.
44

5+
## [Unreleased]
6+
7+
### Runtime — closure-cycle collector
8+
9+
- **The env↔fn closure-cycle leak is fixed** (docs/CLOSURE_CYCLE_GC.md).
10+
Escaping closures — `define inner ... return inner`, counters,
11+
per-iteration handler closures, method dicts — are now reclaimed, both
12+
mid-run (registry-threshold collections triggered at closure-capture
13+
time, zero cost on the dispatch hot paths) and at exit. A
14+
100k-iteration closure-churn loop runs ~40% faster with peak RSS down
15+
from ~124 MB to ~4 MB; long-running programs that build closures over
16+
time no longer grow without bound.
17+
- **`Env` lifetime is an honest refcount.** `env_refcount` now counts
18+
every owner: the creating frame or C caller, closures, child envs (the
19+
`parent` link is an owned edge), and a chunk's parked recycled call
20+
env. `env_free`'s captured-gated semantics are replaced by plain
21+
`env_incref`/`env_decref`; loop-scope envs move the frame's ref
22+
explicitly. This also fixes latent leaks on early `return` from inside
23+
a loop scope and makes the env-recycling parent compare
24+
dangling-pointer-free.
25+
- **Exit teardown reclaims global-scope value cycles** too (e.g. a list
26+
appended to itself), via a pinned snapshot of global bindings before
27+
the final collection. `import` module envs are released when the last
28+
closure defined in them dies (previously leaked unconditionally).
29+
- The collector is conservative by construction: roots are derived from
30+
refcounts (any uncounted holder makes a node a root), and an
31+
accounting mismatch aborts the collection — the failure mode is a
32+
leak, never a use-after-free. Disabled once `spawn` goes
33+
multithreaded; spawned programs keep the previous behavior.
34+
- Suite: `tests/test_closure_cycles.eigs` (now 17 checks, incl. dict-
35+
routed cycles and 500 discarded counters) is gated **strictly**
36+
leak-clean under ASan — section [87] no longer tolerates a
37+
LeakSanitizer exit. The suite-wide tolerated-leak tally drops 28 → 13;
38+
every remaining report is byte-identical to the pre-collector
39+
baseline (spawn-thread programs + pre-existing non-closure shapes).
40+
541
## [0.13.0] — 2026-06-12
642

743
A language-features release.

CLAUDE.md

Lines changed: 36 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -18,13 +18,15 @@ make jit-smoke # standalone emitter tests (jit_smoke.c stubs all helpers)
1818

1919
- The suite must pass **both** release and ASan with leaks on:
2020
`make asan && cd tests && ASAN_OPTIONS=detect_leaks=1 bash run_all_tests.sh`
21-
CI enforces `detect_leaks=1`. Known limit: a fn bound by `define`
22-
inside the env it captures is an env↔fn refcount cycle the runtime
23-
can't reclaim, so closure-heavy tests exit nonzero on a LeakSanitizer
24-
report after fully correct output. The runner's `rc_ok` tolerates
25-
exactly that case and tallies it in the final summary ("NOTE: N test
26-
program(s)..."); any other nonzero exit — crash, assert, UBSan — fails.
27-
Watch that tally: a jump means a new leak.
21+
CI enforces `detect_leaks=1`. The env↔fn closure cycle is reclaimed by
22+
the cycle collector (docs/CLOSURE_CYCLE_GC.md); section [87]
23+
(test_closure_cycles.eigs) is gated **strictly** leak-clean — a
24+
LeakSanitizer exit there is a collector regression. The runner's
25+
`rc_ok` still tolerates LeakSanitizer exits elsewhere and tallies them
26+
("NOTE: N test program(s)..."): currently 13, all spawn-thread
27+
programs (collector off once multithreaded) plus pre-existing
28+
non-closure shapes. Any other nonzero exit — crash, assert, UBSan —
29+
fails. Watch that tally: a jump means a new leak.
2830
- `make asan` overwrites `src/eigenscript` — rebuild with `make`
2931
before timing anything.
3032
- Benchmarks: `tests/bench_perf.eigs` (micro), `tests/bench_dmg_shape.eigs`
@@ -58,7 +60,20 @@ make jit-smoke # standalone emitter tests (jit_smoke.c stubs all helpers)
5860
run_all_tests.sh): marker-grep alone used to let a crash *after*
5961
correct output pass. New .eigs sections should use `check_eigs_suite`
6062
(rc + marker). The one tolerated nonzero exit is a LeakSanitizer
61-
report (known closure-cycle leaks — see the ASan bullet above).
63+
report (spawn-thread programs + known non-closure shapes — see the
64+
ASan bullet above; section [87] deliberately opts out of that
65+
tolerance).
66+
- **Env refcounts are honest and the cycle collector depends on it**:
67+
every owner of an `Env` — frame/creator, closure (`make_fn`), child
68+
env (`parent` is an owned edge), parked `chunk->env_cache` — holds a
69+
counted ref via `env_incref`/`env_decref`. Never stash a bare `Env*`
70+
that outlives its creator. The collector's `GC_FOR_EACH_CHILD` walker
71+
and `gc_clear_node` (eigenscript.c) must move in lockstep with the
72+
ownership model: a new owning edge out of Value/Env/Chunk goes into
73+
both, and only *counted* edges may be traversed (an uncounted edge
74+
trips the accounting abort and collection silently stops working).
75+
Conservative direction: missing an edge leaks; inventing one frees
76+
live memory.
6277
- **New JIT helpers need stubs in `src/jit_smoke.c`** or `make
6378
jit-smoke` fails to link.
6479
- **JIT emitter invariants** (`src/jit.c`): the scanner and the
@@ -121,9 +136,16 @@ make jit-smoke # standalone emitter tests (jit_smoke.c stubs all helpers)
121136
/dev/null — `test_terminal.eigs` blocks forever reading a pipe that
122137
never EOFs (e.g. backgrounded runs).
123138

124-
## Current state: 0.13.0 released; next up
139+
## Current state: 0.13.0 released + closure-cycle collector; next up
125140

126-
0.13.0 is cut (CHANGELOG.md [0.13.0] is the full record). Highlights on
141+
0.13.0 is cut (CHANGELOG.md [0.13.0] is the full record). Landed since,
142+
unreleased: the **closure-cycle collector** — honest `Env` refcounts
143+
(creator/frame + closures + parent links + parked env_cache all
144+
counted; `env_free``env_incref`/`env_decref`) plus a registry-driven
145+
mark-sweep over captured envs (docs/CLOSURE_CYCLE_GC.md is the as-built
146+
record, including the maintainer invariants). Escaping closures are
147+
reclaimed mid-run and at exit; closure churn is ~40% faster with flat
148+
RSS; disabled once spawn() goes multithreaded. 0.13.0 highlights on
127149
top of the language-features run (destructuring, slicing, negative
128150
indexing, default params, streaming subprocess I/O, recv_timeout,
129151
multi-arg spawn) and the twelve post-merge fixes (#148#159):
@@ -156,15 +178,12 @@ multi-arg spawn) and the twelve post-merge fixes (#148–#159):
156178
cannot push tags, and GITHUB_TOKEN-pushed tags don't retrigger
157179
workflows — hence the dispatch path.
158180

159-
Suite: ~1830 checks; must pass release **and** ASan with
160-
detect_leaks=1 (the leak tally — currently 28 — is the gate; a jump
161-
means a new leak).
181+
Suite: ~1832 checks; must pass release **and** ASan with
182+
detect_leaks=1 (the leak tally — currently 13, spawn-thread programs +
183+
pre-existing non-closure shapes — is the gate; a jump means a new
184+
leak, and section [87] must stay strictly leak-clean).
162185

163186
Open items, in rough priority:
164-
- **Closure-cycle collector** — the env↔fn cycle accumulates (~12
165-
allocs per escaping closure). Design + staging in
166-
docs/CLOSURE_CYCLE_GC.md; it's a dedicated reviewed project (every
167-
shortcut is a use-after-free; see the doc before attempting).
168187
- **`make lsp` fails on macOS runners** (skipped cleanly by the suite;
169188
add the compile check to the macOS CI leg to surface the error).
170189
- Windows port (runtime is POSIX-only), package/dependency story,

docs/ARCHITECTURE.md

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -201,10 +201,25 @@ malloc/free churn in arithmetic-heavy loops.
201201
1024 entries) and reused by `env_new`, avoiding allocation in tight
202202
function-call loops.
203203

204-
**Closure environments.** Environments captured by closures
205-
(`env->captured = 1`) track a reference count (`env_refcount`, atomic).
206-
When the last closure referencing an env is freed, the env becomes
207-
eligible for cleanup.
204+
**Environment lifetime.** Every `Env` carries an honest reference count
205+
(`env_refcount`, atomic once spawn() goes multithreaded). The owners are:
206+
the creating frame or C caller, each closure capturing the env
207+
(`make_fn`), each child env (the `parent` link is an owned reference),
208+
and a chunk's parked recycled call env (`env_cache`). `env_decref`
209+
destroys at zero — there is no special-cased teardown path.
210+
211+
**Cycle collector.** An env that binds a closure capturing it forms an
212+
`env<->fn` reference cycle that plain counts cannot reclaim. Captured
213+
envs register in a per-thread list; when the registry crosses an
214+
adaptive threshold (and once at exit), `gc_collect_cycles` walks the
215+
subgraph reachable from registered envs over owned edges (env slots,
216+
`parent`, `fn->closure`, list/dict elements, `fn->chunk->env_cache`),
217+
counts in-subgraph references per node, and treats any node whose
218+
refcount exceeds that count as externally rooted. Unmarked remainder is
219+
cyclic garbage: pinned, edge-cleared, then released through the normal
220+
destructors. Conservative by construction — any accounting mismatch
221+
aborts the collection (leaking instead of freeing). Disabled once
222+
spawn() goes multithreaded. See `docs/CLOSURE_CYCLE_GC.md`.
208223

209224
## Extensions
210225

0 commit comments

Comments
 (0)