From 06e26853502e2f58e97c756eea114b0663ffce9b Mon Sep 17 00:00:00 2001 From: agra Date: Mon, 1 Jun 2026 20:35:25 +0300 Subject: [PATCH] fix(lower): closure literals compose with bare function-type slots (issue 0060) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../0309-closures-literal-as-bare-fn-param.sx | 17 +++ .../1039-errors-failable-closure-literal.sx | 24 +++++ ...309-closures-literal-as-bare-fn-param.exit | 1 + ...9-closures-literal-as-bare-fn-param.stderr | 1 + ...9-closures-literal-as-bare-fn-param.stdout | 3 + .../1039-errors-failable-closure-literal.exit | 1 + ...039-errors-failable-closure-literal.stderr | 1 + ...039-errors-failable-closure-literal.stdout | 2 + ...closure-literal-composition-miscompiles.md | 28 +++++ ...closure-literal-composition-miscompiles.sx | 15 --- src/ir/lower.zig | 100 +++++++++++++++++- 11 files changed, 176 insertions(+), 17 deletions(-) create mode 100644 examples/0309-closures-literal-as-bare-fn-param.sx create mode 100644 examples/1039-errors-failable-closure-literal.sx create mode 100644 examples/expected/0309-closures-literal-as-bare-fn-param.exit create mode 100644 examples/expected/0309-closures-literal-as-bare-fn-param.stderr create mode 100644 examples/expected/0309-closures-literal-as-bare-fn-param.stdout create mode 100644 examples/expected/1039-errors-failable-closure-literal.exit create mode 100644 examples/expected/1039-errors-failable-closure-literal.stderr create mode 100644 examples/expected/1039-errors-failable-closure-literal.stdout delete mode 100644 issues/0060-closure-literal-composition-miscompiles.sx diff --git a/examples/0309-closures-literal-as-bare-fn-param.sx b/examples/0309-closures-literal-as-bare-fn-param.sx new file mode 100644 index 0000000..0205eb1 --- /dev/null +++ b/examples/0309-closures-literal-as-bare-fn-param.sx @@ -0,0 +1,17 @@ +// Regression (issue 0060): a closure LITERAL passed directly as a bare +// function-type argument `(T) -> U` and then called inside the callee. The +// closure's underlying function takes a hidden env arg that a bare fn-ptr slot +// doesn't pass, so the compiler bridges a capture-free closure to the bare ABI +// with a generated adapter. Both block and arrow bodies. (The working contrast +// where the param is a `Closure(...)` type is examples/0302.) + +#import "modules/std.sx"; + +apply :: (f: (s64) -> s64) -> s64 { return f(5); } +twice :: (f: (s64) -> s64, x: s64) -> s64 { return f(f(x)); } + +main :: () { + print("block={}\n", apply(closure((x: s64) -> s64 { return x * 2; }))); // 10 + print("arrow={}\n", apply(closure((x: s64) -> s64 => x * 2))); // 10 + print("twice={}\n", twice(closure((x: s64) -> s64 => x + 3), 1)); // ((1+3)+3) = 7 +} diff --git a/examples/1039-errors-failable-closure-literal.sx b/examples/1039-errors-failable-closure-literal.sx new file mode 100644 index 0000000..5a16233 --- /dev/null +++ b/examples/1039-errors-failable-closure-literal.sx @@ -0,0 +1,24 @@ +// Failable closure literals (ERR E5.1): a `closure(...)` literal may declare a +// failable return type — `-> (T, !)` / `-> !Named` — in both block and arrow +// body forms, and `raise` inside. Called directly through the bound local, the +// error channel is consumed by `catch` / `or`; passed as a `Closure(...)` +// parameter, it composes through the callee (here absorbed with `catch`). +// (A capturing closure into a bare `(T)->U` slot, and a failable closure into a +// non-failable slot, are rejected — see issue 0060 / the FFI-boundary rule.) + +#import "modules/std.sx"; + +E :: error { Neg } + +runwith :: (cb: Closure(s64) -> (s64, !E), n: s64) -> s64 { return cb(n) catch e -1; } + +main :: () -> s32 { + // block-body and arrow-body failable closures, called directly + m := closure((x: s64) -> (s64, !E) { if x < 0 { raise error.Neg; } return x * 2; }); + n := closure((x: s64) -> (s64, !E) => x + 1); + print("{} {} {} {}\n", m(5) catch e 0, m(-1) catch e 99, m(-1) or 7, n(40) catch e 0); // 10 99 7 41 + + // failable closure passed as a Closure(...) parameter + print("param ok={} err={}\n", runwith(m, 5), runwith(m, -1)); // 10 -1 + return 0; +} diff --git a/examples/expected/0309-closures-literal-as-bare-fn-param.exit b/examples/expected/0309-closures-literal-as-bare-fn-param.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/0309-closures-literal-as-bare-fn-param.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/0309-closures-literal-as-bare-fn-param.stderr b/examples/expected/0309-closures-literal-as-bare-fn-param.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/0309-closures-literal-as-bare-fn-param.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/0309-closures-literal-as-bare-fn-param.stdout b/examples/expected/0309-closures-literal-as-bare-fn-param.stdout new file mode 100644 index 0000000..f065fdf --- /dev/null +++ b/examples/expected/0309-closures-literal-as-bare-fn-param.stdout @@ -0,0 +1,3 @@ +block=10 +arrow=10 +twice=7 diff --git a/examples/expected/1039-errors-failable-closure-literal.exit b/examples/expected/1039-errors-failable-closure-literal.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/examples/expected/1039-errors-failable-closure-literal.exit @@ -0,0 +1 @@ +0 diff --git a/examples/expected/1039-errors-failable-closure-literal.stderr b/examples/expected/1039-errors-failable-closure-literal.stderr new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/examples/expected/1039-errors-failable-closure-literal.stderr @@ -0,0 +1 @@ + diff --git a/examples/expected/1039-errors-failable-closure-literal.stdout b/examples/expected/1039-errors-failable-closure-literal.stdout new file mode 100644 index 0000000..2b6c396 --- /dev/null +++ b/examples/expected/1039-errors-failable-closure-literal.stdout @@ -0,0 +1,2 @@ +10 99 7 41 +param ok=10 err=-1 diff --git a/issues/0060-closure-literal-composition-miscompiles.md b/issues/0060-closure-literal-composition-miscompiles.md index 74e9ba1..1b0f315 100644 --- a/issues/0060-closure-literal-composition-miscompiles.md +++ b/issues/0060-closure-literal-composition-miscompiles.md @@ -1,5 +1,33 @@ # 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.zig`** — `isLambda` now accepts `.bang` in the return-type +> lookahead, so failable closure literals (`-> !` / `-> (T, !)`) parse. +> - **`src/ir/lower.zig`** — `createClosureToBareFnAdapter`: 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 diff --git a/issues/0060-closure-literal-composition-miscompiles.sx b/issues/0060-closure-literal-composition-miscompiles.sx deleted file mode 100644 index 437b17c..0000000 --- a/issues/0060-closure-literal-composition-miscompiles.sx +++ /dev/null @@ -1,15 +0,0 @@ -// Repro for issue 0060: a closure LITERAL passed directly as a function-type -// argument, where the callee calls it with a literal, miscompiles. The working -// contrast is examples/0302-closures-closures.sx, where the value flows in as a -// SEPARATE argument (`apply(f, x) { return f(x); }`). -// -// Expected: block=10, arrow=10. Actual: block=192, arrow=20. - -#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 -} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 61024c9..6c1a507 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -7848,8 +7848,15 @@ pub const Lowering = struct { if (self.lowerBlockValue(lam.body)) |val| { if (!self.currentBlockHasTerminator()) { const val_ty = self.builder.getRefType(val); - const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val; - self.builder.ret(coerced, ret_ty); + // A value-carrying failable arrow lambda (`-> (T, !) => expr`) + // yields the bare success value; the compiler appends the + // no-error slot (0) — same as a `return v` in a block body. + if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) { + self.lowerFailableSuccessReturn(val, ret_ty, lam.body.span); + } else { + const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val; + self.builder.ret(coerced, ret_ty); + } } } } else { @@ -7874,6 +7881,29 @@ pub const Lowering = struct { // surrounding `push Context.{ allocator = ... }`. self.current_ctx_ref = saved_ctx_ref_lam; + // Closure flowing into a BARE function-pointer slot (`(T) -> U`, no env): + // the slot is called without the closure env arg, so the closure fn can't + // be passed directly. For a capture-free closure whose return type matches + // the slot, emit an adapter with the bare ABI. Reject the cases the bare + // ABI can't represent: a capturing closure (env has nowhere to live), and + // a failable closure into a non-failable slot (foreign code can't observe + // the error channel — ERR E5.1 FFI-boundary rule). + if (self.target_type) |tt| { + if (!tt.isBuiltin() and self.module.types.get(tt) == .function) { + const slot_ret = self.module.types.get(tt).function.ret; + if (capture_list.len > 0) { + if (self.diagnostics) |d| d.addFmt(.err, lam.body.span, "a capturing closure cannot be passed as a bare function pointer; declare the parameter type as `Closure(...)` so its environment is carried", .{}); + } else if (ret_ty == slot_ret) { + const adapter = self.createClosureToBareFnAdapter(func_id, self.module.types.get(tt).function); + return self.builder.emit(.{ .func_ref = adapter }, tt); + } else if (self.errorChannelOf(ret_ty) != null and self.errorChannelOf(slot_ret) == null) { + if (self.diagnostics) |d| d.addFmt(.err, lam.body.span, "failable closure cannot be assigned to a non-failable function-type slot; foreign code can't observe the error channel — handle the error in a wrapper closure that absorbs it", .{}); + } else if (self.diagnostics) |d| { + d.addFmt(.err, lam.body.span, "closure return type does not match the function-type slot", .{}); + } + } + } + // Create proper closure type (user-visible params only — skip ctx + env). const skip_count: usize = if (lambda_wants_ctx) 2 else 1; var param_types_list = std.ArrayList(TypeId).empty; @@ -7992,6 +8022,72 @@ pub const Lowering = struct { return func_id; } + /// Adapter for coercing a closure into a BARE function-pointer slot + /// (`(T) -> U`, no env). The closure's underlying function has signature + /// `[ctx?] + env + user-params`, but a bare fn-ptr slot is *called* without + /// the env arg — so the closure fn can't be used directly (the env slot + /// would swallow the first user arg). This adapter carries the bare ABI + /// (`[ctx?] + user-params`) and forwards to the closure fn with a null env. + /// Only sound for capture-free closures (a null env is correct iff the body + /// reads no captures); the caller rejects capturing closures. + fn createClosureToBareFnAdapter(self: *Lowering, closure_func_id: FuncId, fn_info: types.TypeInfo.FunctionInfo) FuncId { + var params = std.ArrayList(inst_mod.Function.Param).empty; + defer params.deinit(self.alloc); + const void_ptr_ty = self.module.types.ptrTo(.void); + const wants_ctx = self.implicit_ctx_enabled; + if (wants_ctx) { + params.append(self.alloc, .{ .name = self.module.types.internString("__sx_ctx"), .ty = void_ptr_ty }) catch unreachable; + } + for (fn_info.params, 0..) |pty, i| { + var buf: [32]u8 = undefined; + const pname = std.fmt.bufPrint(&buf, "a{d}", .{i}) catch "arg"; + params.append(self.alloc, .{ .name = self.module.types.internString(pname), .ty = pty }) catch unreachable; + } + + const closure_func = self.module.functions.items[closure_func_id.index()]; + const closure_name = self.module.types.getString(closure_func.name); + var name_buf: [128]u8 = undefined; + const adapter_name = std.fmt.bufPrint(&name_buf, "__cl2fn_{s}", .{closure_name}) catch "__cl2fn"; + const adapter_name_id = self.module.types.internString(adapter_name); + + const saved_func = self.builder.func; + const saved_block = self.builder.current_block; + const saved_counter = self.builder.inst_counter; + + const owned_params = self.alloc.dupe(inst_mod.Function.Param, params.items) catch unreachable; + var func = inst_mod.Function.init(adapter_name_id, owned_params, fn_info.ret); + func.has_implicit_ctx = wants_ctx; + const func_id = self.module.addFunction(func); + self.builder.func = func_id; + self.builder.inst_counter = @intCast(owned_params.len); + const entry_name = self.module.types.internString("entry"); + const entry_block = self.builder.appendBlock(entry_name, &.{}); + self.builder.switchToBlock(entry_block); + + // Forward [ctx?] + null env + user params to the closure fn. + const ctx_slots: usize = if (wants_ctx) 1 else 0; + var call_args = std.ArrayList(Ref).empty; + defer call_args.deinit(self.alloc); + if (wants_ctx) call_args.append(self.alloc, Ref.fromIndex(0)) catch unreachable; + call_args.append(self.alloc, self.builder.constNull(void_ptr_ty)) catch unreachable; + for (fn_info.params, 0..) |_, i| { + call_args.append(self.alloc, Ref.fromIndex(@intCast(ctx_slots + i))) catch unreachable; + } + const owned_args = self.alloc.dupe(Ref, call_args.items) catch unreachable; + const result = self.builder.emit(.{ .call = .{ .callee = closure_func_id, .args = owned_args } }, fn_info.ret); + if (fn_info.ret != .void) { + self.builder.ret(result, fn_info.ret); + } else { + self.builder.retVoid(); + } + self.builder.finalize(); + + self.builder.func = saved_func; + self.builder.current_block = saved_block; + self.builder.inst_counter = saved_counter; + return func_id; + } + /// Walk an AST node and collect free variable references (identifiers that are /// in the current scope but not in lambda params). fn collectCaptures(self: *Lowering, node: *const Node, param_names: *std.StringHashMap(void), captures: *std.ArrayList(CaptureInfo)) void {