fix: dispatch unwrapped optional-closure call g!() through call_closure (issue 0170)

Calling through an unwrapped optional closure (g!()) crashed with LLVM
'Called function must be a pointer!': the indirect-call catch-all else
arm emitted call_indirect on the whole {fn,env} closure struct with a
hardcoded .i64 return. The else arm now inspects inferExprType(callee):
a .closure callee dispatches through call_closure (threads env + ctx via
the [ctx, env, user_args] ABI, returns closure.ret); a plain fn pointer
uses call_indirect with the callee's real function.ret instead of i64.

The filed repro's ?(() -> void) spelling is a tuple-optional (now
diagnosed by the 0165 fix); the real ?Closure(...) layout was already
correct. Verified load-bearing (HEAD crashes) by 3 adversarial reviews,
suite 785/0. Regression: examples/closures/0311-closures-optional-closure.sx.
Filed adjacent bug 0177 (array-element closure direct call crashes).
This commit is contained in:
agra
2026-06-23 01:02:13 +03:00
parent 28bb101a4a
commit 3605165398
7 changed files with 170 additions and 2 deletions

View File

@@ -0,0 +1,66 @@
// Optional closures: `?Closure(...) -> R`.
//
// Runtime repr is the sentinel form — the optional IS the closure struct
// `{fn_ptr, env}`; has_value = `fn_ptr != null`, no separate flag word.
// Covers: present vs null truthiness, `== null` / `!= null`, force-unwrap +
// call-through (with and without captures), `??` coalesce, pass-as-param,
// return, and args + non-void return.
//
// Regression (issue 0170): `g!()` where `g : ?Closure(...)` previously lowered
// to a `call_indirect` that treated the closure `{fn,env}` struct as a bare
// fn pointer (LLVM "Called function must be a pointer!"); the indirect-call
// catch-all also hardcoded an `.i64` return type. The else-arm in
// `src/ir/lower/call.zig` now dispatches to `call_closure` when the callee
// expression's static type is a closure, and uses the real return type.
#import "modules/std.sx";
take :: (g: ?Closure() -> void) {
if g { g!(); } else { print("param-absent\n"); }
}
give :: () -> ?Closure() -> void {
return () => print("from-give\n");
}
main :: () {
// With captures.
n := 7;
a : ?Closure() -> void = () => print("a n={}\n", n);
if a { print("a-present\n"); } else { print("a-absent\n"); }
a!();
// Without captures.
b : ?Closure() -> void = () { print("b-called\n"); };
if b { b!(); }
// Null tests absent; == / != null.
c : ?Closure() -> void = null;
if c { print("c-present\n"); } else { print("c-absent\n"); }
print("c==null: {}\n", c == null);
print("c!=null: {}\n", c != null);
// Reassign: null -> value -> null.
c = () { print("c-reassigned\n"); };
if c { c!(); }
c = null;
if c { print("c2-present\n"); } else { print("c2-absent\n"); }
// Coalesce: null falls back, present uses self.
fallback := () { print("fallback\n"); };
(c ?? fallback)();
e : ?Closure() -> void = () { print("e-real\n"); };
(e ?? fallback)();
// Pass as param (present + null).
take(a);
take(c);
// Return an optional closure.
r := give();
if r { r!(); }
// Args + non-void return.
add : ?Closure(i64, i64) -> i64 = (x: i64, y: i64) => x + y;
if add { print("add: {}\n", add!(3, 4)); }
}

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,14 @@
a-present
a n=7
b-called
c-absent
c==null: true
c!=null: false
c-reassigned
c2-absent
fallback
e-real
a n=7
param-absent
from-give
add: 7

View File

@@ -1,5 +1,22 @@
# 0170 — closure optional `?(() -> ...)` alloca is sized one word, truncating the `{fn,env}` closure value
> **RESOLVED** (root cause differs from the title's hypothesis). Two findings:
> (1) the filed repro's `?(() -> void)` spelling is a TUPLE-optional (the `(T)`
> 1-tuple rule), now correctly diagnosed by the issue-0165 fix — not a closure
> bug. (2) The REAL closure-optional `?Closure(args) -> R` layout was already
> correct (sentinel form: the value IS the closure `{fn,env}`, has_value =
> `fn_ptr != null`); `if g` / `== null` / `!= null` already worked. The genuine
> bug was calling through an unwrapped optional closure `g!()` — the indirect-call
> catch-all `else` arm (`src/ir/lower/call.zig`) emitted `call_indirect` on the
> whole `{fn,env}` struct (LLVM "Called function must be a pointer!") with a
> hardcoded `.i64` return. Fix: the `else` arm inspects `inferExprType(callee)` —
> `.closure` → `call_closure` (threads env+ctx, returns `closure.ret`); else →
> `call_indirect` with the callee's real `function.ret`. Verified load-bearing
> (HEAD crashes) by 3 adversarial reviews; suite 785/0. Regression:
> `examples/closures/0311-closures-optional-closure.sx`. (Adjacent pre-existing
> bug found + filed: 0177 — array-element closure direct call `fns[i](args)`
> crashes.)
## Symptom
An optional of a closure (`?(() -> void)`, `?Fn`) is mis-laid-out: the optional

View File

@@ -0,0 +1,41 @@
# 0177 — calling a closure stored in an array element directly (`fns[i](args)`) crashes / miscompiles
## Symptom
A closure (or `Closure(...)`-typed value) stored in an array, called DIRECTLY via
index (`fns[i](args)`), does not dispatch through the closure ABI: it emits a bare
`call_indirect` on the whole `{fn,env}` struct → LLVM "Called function must be a
pointer!" (verify fail) for some return/arg shapes, or returns garbage for others.
Pre-existing (reproduces on master); distinct from issue 0170 (which fixed the
unwrap-through-optional call `g!()`). Here the callee is a non-optional closure
reached via array index, called directly without unwrap.
## Reproduction
```sx
#import "modules/std.sx";
add :: (a: i64, b: i64) -> i64 { return a + b; }
main :: () {
fns : [1](Closure(i64, i64) -> i64) = .{ add };
print("{}\n", fns[0](3, 4)); // LLVM "Called function must be a pointer!" — expected 7
}
```
Expected: `7`. Observed: LLVM verification failure (or, for other shapes, garbage
return / f64-arg verify failure).
## Investigation prompt
`src/ir/lower/call.zig`: issue 0170 added closure-vs-fn-pointer dispatch to the
indirect-call catch-all `else` arm via `inferExprType(callee)``.closure`
`call_closure`. A direct call whose callee is an ARRAY-INDEX expression
(`fns[0]`) of closure type apparently does not reach that dispatch — either it
takes an earlier call arm that still emits `call_indirect`, or
`inferExprType(index_expr)` does not return `.closure` so the `else` arm falls to
the fn-pointer path. Trace which arm `fns[0](args)` lowers through and ensure a
closure-typed callee — regardless of whether it's a bare ident, field access,
index, or call result — dispatches through `call_closure` (threading env + ctx
via the `[ctx, env, user_args]` ABI). Compare with the working `arr[i]!()`
(unwrap) path. Follow the no-silent-fallback rule. Verify: `fns[0](3,4)` → 7;
array-of-closure with captures; non-i64 returns (void/f64/struct); f64 args.
Add an `examples/closures/03xx-array-of-closures-call.sx` regression.

View File

@@ -1356,10 +1356,38 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
return self.builder.enumInit(tag, payload, target);
},
else => {
// Indirect call through expression
// Indirect call through expression. The callee can be a plain
// function pointer OR a closure value (e.g. `g!()` where
// `g : ?Closure(...)` — the force-unwrap yields the closure
// struct). Inspect the callee's static type so we emit the
// right op: `call_closure` splits `{fn_ptr, env}` and threads
// env (and implicit ctx), whereas `call_indirect` would treat
// the whole struct as a bare fn pointer and miscompile.
const callee_ty = self.inferExprType(c.callee);
if (!callee_ty.isBuiltin()) {
const cti = self.module.types.get(callee_ty);
if (cti == .closure) {
const callee_ref = self.lowerExpr(c.callee);
// Prepend implicit ctx for the sx-side closure call ABI
// (emit_llvm builds the call as [ctx, env, user_args]).
const owned = if (self.implicit_ctx_enabled) blk: {
const arr = self.alloc.alloc(Ref, args.items.len + 1) catch unreachable;
arr[0] = self.current_ctx_ref;
@memcpy(arr[1..], args.items);
break :blk arr;
} else self.alloc.dupe(Ref, args.items) catch unreachable;
return self.builder.emit(.{ .call_closure = .{ .callee = callee_ref, .args = owned } }, cti.closure.ret);
}
}
// Plain function-pointer indirect call. Use the callee's static
// return type when known instead of a hardcoded `.i64` default.
const ret_ty: TypeId = if (!callee_ty.isBuiltin()) blk: {
const cti = self.module.types.get(callee_ty);
break :blk if (cti == .function) cti.function.ret else .i64;
} else .i64;
const callee_ref = self.lowerExpr(c.callee);
const owned = self.alloc.dupe(Ref, args.items) catch unreachable;
return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, .i64);
return self.builder.emit(.{ .call_indirect = .{ .callee = callee_ref, .args = owned } }, ret_ty);
},
}
}