vlang

/

v Public
0 commits 39 issues 0 pull requests 0 contributors Discussions Projects CI

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.v emits builtin__memdup_uncollectable(...) for the context — memory the GC never collects.
  • It is freed only by closure_try_destroy, which fn.v emits only for temporary closures passed straight into a call. Stored closures are never destroyed → their context leaks for the process lifetime. (Separately, closure_try_destroy calls free(), 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):

  1. Have the app GC_FREE the context via closure_try_destroydouble-free when transient closure pointers are shared/duplicated (an explicit free can't be made idempotent here).
  2. Make the context collectable and register the trampoline pages as GC roots (GC_add_roots) → premature free in a real app, because Boehm's GC_MAX_ROOT_SETS silently 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.