Files
sx/issues/0060-closure-literal-composition-miscompiles.md
agra 06e2685350 fix(lower): closure literals compose with bare function-type slots (issue 0060)
A closure's underlying function carries a hidden env arg that a bare (T)->U slot
doesn't pass, so a closure flowing into a bare function-type slot dropped the
env — the first user arg landed in the env slot and the rest read garbage
(apply(closure((x)->s64 { x*2 })) returned 192 instead of 10; non-failable too).

- createClosureToBareFnAdapter: a capture-free closure into a bare (T)->U slot is
  bridged by a generated adapter carrying the bare ABI (forwards a null env);
  lowerLambda returns its func_ref. Rejected (no silent miscompile): a capturing
  closure into a bare slot (env has nowhere to live) and a failable closure into
  a non-failable slot (the ERR E5.1 FFI-boundary rule).
- Arrow-body failable closures (-> (T,!) => expr) now wrap the bare success value
  into {value, 0} via lowerFailableSuccessReturn (the implicit return previously
  returned a malformed tuple → caught value read as 0).

The isLambda .bang parser fix (failable closure literals parse) already landed in
485b4fa. Regressions: examples/0309-closures-literal-as-bare-fn-param (non-
failable, block + arrow, called in callee) + 1039-errors-failable-closure-literal
(failable, block + arrow, direct + Closure(...) param). Resolves issue 0060
(remaining E5.1 follow-ups noted in the .md). Suite: 328 passed.
2026-06-01 20:35:25 +03:00

5.9 KiB

0060 — closure-literal composition miscompiles (blocks ERR/E5.1)

RESOLVED. A closure's underlying function carries a hidden env arg that a bare (T) -> U slot doesn't pass, so a closure flowing into a bare function-type slot dropped the env (the first user arg landed in the env slot; the rest read garbage). Fixes (all in this commit):

  • src/parser.zigisLambda now accepts .bang in the return-type lookahead, so failable closure literals (-> ! / -> (T, !)) parse.
  • src/ir/lower.zigcreateClosureToBareFnAdapter: a capture-free closure flowing into a bare (T) -> U slot is bridged by a generated adapter carrying the bare ABI (forwards a null env). lowerLambda returns the adapter func_ref for that case. Rejected (no silent miscompile): a capturing closure into a bare slot (env has nowhere to live), and a failable closure into a non-failable slot (the FFI-boundary rule).
  • src/ir/lower.zig — arrow-body failable closures (-> (T, !) => expr) now wrap the bare success value into {value, 0} via lowerFailableSuccessReturn (the implicit return previously coerced a bare value into the failable tuple and returned 0).

Regression tests: examples/0309-closures-literal-as-bare-fn-param.sx (non-failable, block + arrow, called inside the callee) and examples/1039-errors-failable-closure-literal.sx (failable closures, block + arrow, direct + Closure(...) param).

Remaining E5.1 follow-up (not 0060): calling a bare failable function-type param (cb: (s64) -> (s64, !E)) resolves the call result as unresolved (the idiomatic Closure(s64) -> (s64, !E) form works); the non-failable→failable widening adapter is currently rejected rather than generated; and the program-wide SCC union per closure shape is unimplemented.

Symptom

A closure(...) literal passed directly as a function-type argument, where the callee invokes it, produces wrong values. Surfaced while implementing ERR E5.1 (composition with closures), but it is not error-specific — plain non-failable closures miscompile too.

issues/0060-closure-literal-composition-miscompiles.sx:

#import "modules/std.sx";
apply :: (f: (s64) -> s64) -> s64 { return f(5); }
main :: () {
    print("block={}\n", apply(closure((x: s64) -> s64 { return x * 2; })));  // want 10
    print("arrow={}\n", apply(closure((x: s64) -> s64 => x * 2)));           // want 10
}
  • Expected: block=10, arrow=10.
  • Actual: block=192, arrow=20 (exit 0 — silent miscompile, no diagnostic).

Working contrast: examples/0302-closures-closures.sxapply :: (f, x) -> s64 { return f(x); } called as apply(closure(... => ...), 10) works. There the piped value arrives as a separate argument; here the callee calls the closure param with a literal, and the literal/closure-env marshalling is wrong. Likely an env/arg-slot mixup when a closure literal is materialized as a call argument and then invoked with a constant inside the callee.

Failable-closure follow-ons (the actual E5.1 surface)

Failable closures (closure((x) -> (T, !) { ... })) are the point of E5.1. Two further gaps sit on top of 0060:

  1. Parser — isLambda doesn't accept a ! return type. A closure/lambda literal with -> ! / -> (T, !) fails to parse ("expected ','") because the return-type token-skipper in isLambda (src/parser.zig, the arrow branch ~line 3302) omits .bang. One-line fix:

    //   self.current.tag == .star or self.current.tag == .question)
    // becomes:
        self.current.tag == .star or self.current.tag == .question or
        self.current.tag == .bang)
    

    With that patch, failable closure literals parse and block-body, directly- called ones work end-to-end (success / catch / or all correct).

  2. Arrow-body failable closures miscompile. After the parser patch, n := closure((x: s64) -> (s64, !E) => x + 1); n(40) catch e 0 returns 0 instead of 41 — the value slot reads as undef/0. Block-body equivalents are correct, so it's an arrow-body (=>) failable-closure lowering bug (the expression-body return isn't assembled into the {value, error} tuple the same way the block-body path does). Compare lowerLambda (src/ir/lower.zig ~7617) block vs arrow return handling against the named- function failable return path (lowerFailableSuccessReturn).

Investigation prompt (paste into a fresh session)

Closure literals passed as a function-type argument miscompile when the callee calls them: apply :: (f: (s64)->s64) -> s64 { return f(5); } then apply(closure((x: s64) -> s64 { return x*2; })) prints 192 (want 10); the arrow form prints 20. The working pattern (examples/0302) passes the value as a separate arg. Suspect the closure-literal-as-call-argument lowering: the closure env / the inner call's constant argument is marshalled into the wrong slot. Look at how a closure(...) literal in argument position is lowered (closure construction + the Closure calling convention) in src/ir/lower.zig / src/ir/emit_llvm.zig, vs the working separate-arg path.

Then unblock ERR/E5.1: (a) apply the one-line isLambda .bang patch above; (b) fix arrow-body failable closure lowering (returns 0). Verify with a new examples/XXXX-errors-failable-closure-literal.sx: a block-body and an arrow-body failable closure, called directly, consumed by catch / or; and a failable closure passed as a (T)->(U,!) parameter and try-called inside the callee.

Impact

Blocks ERR/E5.1 (composition with closures/methods/generics): every E5.1 sub-feature — failable closures as parameters, the program-wide SCC union per closure shape, the FFI rejection check, and the non-failable→failable widening adapter — needs closure literals to compose correctly first. Per the project's impassable rule, E5.1 is paused here rather than built on miscompiling closures.