Skip to content

Pair-type captures (String, Array<T>) silently drop the len field #535

@aallan

Description

@aallan

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:

  1. 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).
  2. _translate_anon_fn must allocate 8 bytes per pair capture, store both ptr and len at consecutive offsets.
  3. _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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingcodegenCode generation backend

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions