Summary
Closures that capture an outer-scope pair-typed binding (String, Array<T>) compile and run without erroring, but the captured value is silently corrupted: the length field is dropped, so any operation on the captured value reads it as empty. This is the pair-type capture subset of the broader #514 "heap capture" symptom.
#514 was filed as a single umbrella for "heap capture broken." Investigation during the v0.0.121 nested-closure fix showed the symptom decomposes into three cases:
| Capture |
Status |
Primitives (Int, Nat, Bool, Byte, Float64) |
works |
ADTs (Option, Result, user data types) — single-i32-pointer representation |
works |
Pair types (String, Array<T>) — i32 ptr + i32 len on the stack |
broken (this issue) |
The first two are correct; the third is the residual bug.
Reproducer
public fn test(@Unit -> @Int)
requires(true) ensures(true) effects(pure)
{
let @Array<Int> = array_range(0, 7); -- captured: length 7
let @Array<Int> = array_map(
array_range(0, 3),
fn(@Int -> @Int) effects(pure) {
nat_to_int(array_length(@Array<Int>.0)) -- reads length of captured array
}
);
array_fold(@Array<Int>.0, 0, fn(@Int, @Int -> @Int) effects(pure) { @Int.0 + @Int.1 })
}
Expected: 21 (3 elements × captured length 7).
Actual: 0 (captured array reads as empty).
Same shape with String:
let @String = "hello";
-- inside closure: nat_to_int(string_length(@String.0)) reads as 0, not 5.
ADT capture works correctly (proof that this is specifically a pair-type bug, not a general capture bug):
let @Option<Int> = Some(42);
-- inside closure: match @Option<Int>.0 { Some(@Int) -> @Int.0, None -> -1 }
-- Returns 42 as expected.
Root cause
vera/wasm/closures.py::_translate_anon_fn builds the closure struct using cap_wt (one of "i32", "i64", "f64") per capture and emits a single i32.store / i64.store / f64.store. The cap_wt value comes from _type_name_to_wasm(type_name) in vera/wasm/inference.py:1061, which returns "i32" for any composite type:
# ADT or function type alias → i32 pointer
return "i32"
For String and Array<T>, the value on the stack is two i32 values (ptr + len), but _type_name_to_wasm collapses them to a single "i32". So the closure-struct serialisation:
- Allocates only 4 bytes per pair capture instead of 8
- Stores only the ptr (the value at
local.get {local_idx}); the len at local.get {local_idx + 1} is never stored
- The lifted body loads back only the ptr; len is read as garbage from adjacent struct memory (which happens to be 0 in this layout, hence the always-zero symptom)
The fix needs to:
- Teach
_walk_free_vars to recognise pair-type captures (returning "i32_pair" from a sharpened _type_name_to_wasm, or branching on _is_pair_element_type from vera/wasm/helpers.py:237).
_translate_anon_fn must allocate 8 bytes per pair capture, store both ptr and len at consecutive offsets.
_compile_lifted_closure must load both, register them as a pair in the slot env (mirroring the existing param == "i32_pair" branch at lines 121–130 which already does this for parameters).
The infrastructure for pair handling already exists (parameters use it, array elements use it via _is_pair_element_type); captures just need to plug into the same mechanism.
What v0.0.121 did fix
#514 shipped its main payload in v0.0.121: nested closures (the 2D array_map(rows, fn(row) { array_map(cols, fn(col) { ... }) }) shape) now work for all return types and all primitive/ADT captures. The closure-lifting worklist now bubbles inner closures correctly, and _walk_free_vars recurses into nested AnonFn so captures from the outer's scope through nested closures resolve.
This issue is the residual: pair-type captures (and only pair-type captures) still fail.
Workaround
Lift the closure body to a top-level private fn and pass the pair-typed value as an explicit parameter. The parameter path in _compile_lifted_closure already handles pair types correctly; only the capture path is broken.
private fn use_array(@Int, @Array<Int> -> @Int)
requires(true) ensures(true) effects(pure)
{
@Int.0 + nat_to_int(array_length(@Array<Int>.0))
}
public fn test(@Unit -> @Int)
requires(true) ensures(true) effects(pure)
{
let @Array<Int> = array_range(0, 7);
let @Array<Int> = array_map(
array_range(0, 3),
-- Lifted helper takes the array as a parameter, no capture needed.
fn(@Int -> @Int) effects(pure) { use_array(@Int.0, @Array<Int>.1) }
);
-- ...
}
Awkward — defeats the point of the closure form — but works.
Roadmap placement
ROADMAP Phase 4 / bug-killing campaign, immediately after #514's main fix lands. Estimated 1–2 hours given the parameter path already does the right thing.
Summary
Closures that capture an outer-scope pair-typed binding (
String,Array<T>) compile and run without erroring, but the captured value is silently corrupted: the length field is dropped, so any operation on the captured value reads it as empty. This is the pair-type capture subset of the broader #514 "heap capture" symptom.#514 was filed as a single umbrella for "heap capture broken." Investigation during the v0.0.121 nested-closure fix showed the symptom decomposes into three cases:
Int,Nat,Bool,Byte,Float64)Option,Result, userdatatypes) — single-i32-pointer representationString,Array<T>) — i32 ptr + i32 len on the stackThe first two are correct; the third is the residual bug.
Reproducer
Expected:
21(3 elements × captured length 7).Actual:
0(captured array reads as empty).Same shape with
String:ADT capture works correctly (proof that this is specifically a pair-type bug, not a general capture bug):
Root cause
vera/wasm/closures.py::_translate_anon_fnbuilds the closure struct usingcap_wt(one of"i32","i64","f64") per capture and emits a singlei32.store/i64.store/f64.store. Thecap_wtvalue comes from_type_name_to_wasm(type_name)invera/wasm/inference.py:1061, which returns"i32"for any composite type:For
StringandArray<T>, the value on the stack is two i32 values (ptr + len), but_type_name_to_wasmcollapses them to a single"i32". So the closure-struct serialisation:local.get {local_idx}); the len atlocal.get {local_idx + 1}is never storedThe fix needs to:
_walk_free_varsto recognise pair-type captures (returning"i32_pair"from a sharpened_type_name_to_wasm, or branching on_is_pair_element_typefromvera/wasm/helpers.py:237)._translate_anon_fnmust allocate 8 bytes per pair capture, store both ptr and len at consecutive offsets._compile_lifted_closuremust load both, register them as a pair in the slot env (mirroring the existingparam == "i32_pair"branch at lines 121–130 which already does this for parameters).The infrastructure for pair handling already exists (parameters use it, array elements use it via
_is_pair_element_type); captures just need to plug into the same mechanism.What v0.0.121 did fix
#514 shipped its main payload in v0.0.121: nested closures (the 2D
array_map(rows, fn(row) { array_map(cols, fn(col) { ... }) })shape) now work for all return types and all primitive/ADT captures. The closure-lifting worklist now bubbles inner closures correctly, and_walk_free_varsrecurses into nestedAnonFnso captures from the outer's scope through nested closures resolve.This issue is the residual: pair-type captures (and only pair-type captures) still fail.
Workaround
Lift the closure body to a top-level
private fnand pass the pair-typed value as an explicit parameter. The parameter path in_compile_lifted_closurealready handles pair types correctly; only the capture path is broken.Awkward — defeats the point of the closure form — but works.
Roadmap placement
ROADMAP Phase 4 / bug-killing campaign, immediately after #514's main fix lands. Estimated 1–2 hours given the parameter path already does the right thing.