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.
This commit is contained in:
17
examples/0309-closures-literal-as-bare-fn-param.sx
Normal file
17
examples/0309-closures-literal-as-bare-fn-param.sx
Normal file
@@ -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
|
||||||
|
}
|
||||||
24
examples/1039-errors-failable-closure-literal.sx
Normal file
24
examples/1039-errors-failable-closure-literal.sx
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
block=10
|
||||||
|
arrow=10
|
||||||
|
twice=7
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
10 99 7 41
|
||||||
|
param ok=10 err=-1
|
||||||
@@ -1,5 +1,33 @@
|
|||||||
# 0060 — closure-literal composition miscompiles (blocks ERR/E5.1)
|
# 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
|
## Symptom
|
||||||
|
|
||||||
A `closure(...)` literal passed **directly as a function-type argument**, where
|
A `closure(...)` literal passed **directly as a function-type argument**, where
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
100
src/ir/lower.zig
100
src/ir/lower.zig
@@ -7848,8 +7848,15 @@ pub const Lowering = struct {
|
|||||||
if (self.lowerBlockValue(lam.body)) |val| {
|
if (self.lowerBlockValue(lam.body)) |val| {
|
||||||
if (!self.currentBlockHasTerminator()) {
|
if (!self.currentBlockHasTerminator()) {
|
||||||
const val_ty = self.builder.getRefType(val);
|
const val_ty = self.builder.getRefType(val);
|
||||||
const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
|
// A value-carrying failable arrow lambda (`-> (T, !) => expr`)
|
||||||
self.builder.ret(coerced, ret_ty);
|
// 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 {
|
} else {
|
||||||
@@ -7874,6 +7881,29 @@ pub const Lowering = struct {
|
|||||||
// surrounding `push Context.{ allocator = ... }`.
|
// surrounding `push Context.{ allocator = ... }`.
|
||||||
self.current_ctx_ref = saved_ctx_ref_lam;
|
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).
|
// Create proper closure type (user-visible params only — skip ctx + env).
|
||||||
const skip_count: usize = if (lambda_wants_ctx) 2 else 1;
|
const skip_count: usize = if (lambda_wants_ctx) 2 else 1;
|
||||||
var param_types_list = std.ArrayList(TypeId).empty;
|
var param_types_list = std.ArrayList(TypeId).empty;
|
||||||
@@ -7992,6 +8022,72 @@ pub const Lowering = struct {
|
|||||||
return func_id;
|
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
|
/// Walk an AST node and collect free variable references (identifiers that are
|
||||||
/// in the current scope but not in lambda params).
|
/// 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 {
|
fn collectCaptures(self: *Lowering, node: *const Node, param_names: *std.StringHashMap(void), captures: *std.ArrayList(CaptureInfo)) void {
|
||||||
|
|||||||
Reference in New Issue
Block a user