diff --git a/examples/closures/0311-closures-optional-closure.sx b/examples/closures/0311-closures-optional-closure.sx new file mode 100644 index 00000000..b171e651 --- /dev/null +++ b/examples/closures/0311-closures-optional-closure.sx @@ -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)); } +} diff --git a/examples/closures/expected/0311-closures-optional-closure.exit b/examples/closures/expected/0311-closures-optional-closure.exit new file mode 100644 index 00000000..573541ac --- /dev/null +++ b/examples/closures/expected/0311-closures-optional-closure.exit @@ -0,0 +1 @@ +0 diff --git a/examples/closures/expected/0311-closures-optional-closure.stderr b/examples/closures/expected/0311-closures-optional-closure.stderr new file mode 100644 index 00000000..8b137891 --- /dev/null +++ b/examples/closures/expected/0311-closures-optional-closure.stderr @@ -0,0 +1 @@ + diff --git a/examples/closures/expected/0311-closures-optional-closure.stdout b/examples/closures/expected/0311-closures-optional-closure.stdout new file mode 100644 index 00000000..1860ce1d --- /dev/null +++ b/examples/closures/expected/0311-closures-optional-closure.stdout @@ -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 diff --git a/issues/0170-closure-optional-layout-truncated.md b/issues/0170-closure-optional-layout-truncated.md index 855a5da8..0fbd42a7 100644 --- a/issues/0170-closure-optional-layout-truncated.md +++ b/issues/0170-closure-optional-layout-truncated.md @@ -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 diff --git a/issues/0177-array-element-closure-direct-call-crashes.md b/issues/0177-array-element-closure-direct-call-crashes.md new file mode 100644 index 00000000..c2f791f0 --- /dev/null +++ b/issues/0177-array-element-closure-direct-call-crashes.md @@ -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. diff --git a/src/ir/lower/call.zig b/src/ir/lower/call.zig index ba91e140..eef22ffd 100644 --- a/src/ir/lower/call.zig +++ b/src/ir/lower/call.zig @@ -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); }, } }