Skip to content

apply_fn(closure, ()) on (Unit -> X) closure trips WASM type mismatch (phantom i64 param in call_indirect sig) #586

@aallan

Description

@aallan

Summary

apply_fn(closure, ()) where the closure has signature (Unit -> X) trips a WASM type-mismatch at compile time:

type mismatch: expected i64, found i32

The compiled artifact never makes it past WASM validation. vera check passes (the program is well-typed at the source level); the failure is purely in codegen.

Discovered while smoke-testing the v0.0.119 closure-capture bugs (#514 / #535) on v0.0.135 to confirm they were closed. Same bug reproduces with no captures at all — it's not a heap-pointer issue.

Reproducer

type UnitToInt = fn(Unit -> Int) effects(pure);

private fn make_const(@Unit -> @UnitToInt)
  requires(true) ensures(true) effects(pure)
{
  fn(@Unit -> @Int) effects(pure) { 42 }
}

public fn main(@Unit -> @Int)
  requires(true) ensures(true) effects(pure)
{
  let @UnitToInt = make_const(());
  apply_fn(@UnitToInt.0, ())
}

vera check → OK
vera run:

Error: Unhandled WASM trap: failed to compile: wasm[0]::function[3]::main

Caused by:
    0: WebAssembly translation error
    1: Invalid input WebAssembly code at offset 993: type mismatch: expected i64, found i32

Root cause

_translate_apply_fn in vera/wasm/closures.py (line ~148) builds the call_indirect type signature from the value-args' inferred WASM types. Per-arg:

wt = self._infer_expr_wasm_type(arg)
if wt == "i32_pair":
    arg_wasm_types.extend(["i32", "i32"])
else:
    arg_wasm_types.append(wt or "i64")  # default to i64

For a UnitLit argument:

  • translate_expr(UnitLit, env) returns [] — no WASM value pushed (correct)
  • _infer_expr_wasm_type(UnitLit) returns None (correct — Unit has no representation)
  • wt or "i64" evaluates to "i64" — a phantom param is registered in the call_indirect signature

The closure-lift side (vera/codegen/closures.py:85) correctly skips Unit params:

elif pwt and pwt != "unsupported":
    param_wasm.append(pwt)

So the closure is registered with (param i32) (result i64) (env-only), but the apply_fn site emits a call_indirect of type (param i32) (param i64) (result i64). The validator sees the func_table_idx (an i32) where it expected the phantom i64 value-arg.

WAT diff (boiled down):

(type $closure_sig_0 (func (param i32) (result i64)))      ;; closure registered correctly
(type $closure_sig_1 (func (param i32) (param i64) (result i64)))  ;; phantom

(func $main
  ...
  call_indirect (type $closure_sig_1)  ;; <-- uses sig_1, but stack only has [env, fn_idx]
)

Same falsy-pitfall pattern as #584's _fn_ret_types filter — None was overloaded between "Unit" (skip) and "unknown" (fall back to default).

Fix

vera/wasm/closures.py::_translate_apply_fn — change the per-arg branch to skip Unit args from the signature instead of defaulting to i64:

wt = self._infer_expr_wasm_type(arg)
if wt == "i32_pair":
    arg_wasm_types.extend(["i32", "i32"])
elif wt is not None:
    arg_wasm_types.append(wt)

Three lines of change.

Acceptance

  • The reproducer above runs cleanly post-fix and prints 42.
  • New conformance test in tests/conformance/ covering Unit-arg closure with and without captures.
  • Existing ch05_closures.vera and ch05_nested_closures.vera keep passing.
  • All existing tests remain green.

Workaround

Until fixed: take an Int parameter (or any non-Unit type) instead of Unit:

type IntToInt = fn(Int -> Int) effects(pure);

private fn make_const(@Int -> @IntToInt) ...
{
  fn(@Int -> @Int) effects(pure) { 42 }
}
...
apply_fn(@IntToInt.0, 0)  -- pass a dummy Int

Mechanical but distorts the signature.

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions