Capturing closures leak their captured context under -gc boehm (unbounded growth in frame-based apps) #11
Describe the bug
A closure that captures data (fn [x] (...) { ... }) never has its captured context reclaimed by the GC when the closure is stored (assigned to a variable, returned, kept in a collection, used as a struct-field event handler, …). Programs that create capturing closures repeatedly — most importantly any immediate-mode GUI that rebuilds handler closures every frame — grow in memory without bound.
Reproduction Steps
Self-contained, runnable (v run closure_leak.v):
module main
fn main() {
for n in 0 .. 2_000_000 {
big := []int{len: 200, init: index + n} // ~1.6 KB heap array, new each iteration
// Capture `big` in a stored closure, use it once, drop it:
h := fn [big] (x int) int { return big[x % big.len] }
_ = h(n % 200)
if n % 400_000 == 0 {
gc_collect() // force a full collection so `live` = truly reachable memory
println('n=${n:8} live=${gc_memory_use() / 1024 / 1024} MB')
}
}
}
Control (no leak): replace the two closure lines with a direct read — _ = big[n % 200].
Expected Behavior
Both versions allocate the same big each iteration and drop it; after each gc_collect() the unreferenced arrays should be reclaimed, so live stays roughly flat (as it does for the control).
n= 0 live=0 MB
n= 1600000 live=0 MB
final live=0 MB # control (no closure)
Current Behavior
With the closure, live climbs linearly to ~2 GB and never plateaus (≈1 KB/iteration: the closure context plus the captured array it pins). The control is flat at ~0 MB.
n= 0 live=0 MB
n= 800000 live=805 MB
n= 1600000 live=1611 MB
final live=2014 MB # with closure
Possible Solution
The captured context is allocated uncollectable and only reclaimed for closures the codegen treats as temporary:
vlib/v/gen/c/fn.vemitsbuiltin__memdup_uncollectable(...)for the context — memory the GC never collects.- It is freed only by
closure_try_destroy, whichfn.vemits only for temporary closures passed straight into a call. Stored closures are never destroyed → their context leaks for the process lifetime. (Separately,closure_try_destroycallsfree(), which is a no-op under-gc boehm, so even that path only reclaimed the trampoline slot, not the context.)Proposed fix (see the linked PR): allocate the context collectably (memdup) and keep each live closure's context reachable via a GC-scanned table, so it's collected once the closure is gone; add an opt-in frame-epoch reclamation API (begin_frame_build/end_frame_build/reclaim_frames) for long-running frame-based apps (an immediate-mode GUI calls it once per frame). Non-frame programs behave exactly as today.
Additional Information/Context
Found while building a long-running GUI on vlang/gui: per-frame event-handler closures leaked ~1.3 MB/s unbounded. Bisected to this with the SSCCE above (closure leaks; identical array churn without a closure is flat). The fix takes the GUI's live from a linear climb (→ 364 MB / 4 min) to bounded (46–126 MB).
Two simpler fixes were tried and fail (documented so they aren't re-suggested):
- Have the app
GC_FREEthe context viaclosure_try_destroy→ double-free when transient closure pointers are shared/duplicated (an explicit free can't be made idempotent here). - Make the context collectable and register the trampoline pages as GC roots (
GC_add_roots) → premature free in a real app, because Boehm'sGC_MAX_ROOT_SETSsilently stops registering after enough pages, so later contexts go unscanned and are collected mid-use.
V version
V 0.5.1 93df383320a86c07da8cf636c76e36f7190895db.ed17e5f (built from source).
Environment details (OS name and version, etc.)
Linux x86-64 (WSL2, Ubuntu 24.04.4 LTS), -gc boehm (default), cc gcc 13.3.0.
[!NOTE] You can use the 👍 reaction to increase the issue's priority for developers.
Please note that only the 👍 reaction to the issue itself counts as a vote. Other reactions and those to comments will not be taken into account.