fix: resolve closure/fn-pointer struct-field call types (issue 0201)
Calling a closure or function-pointer value stored in a struct data field (`box.run(args)`) typed the call as 'unresolved': value returns marshalled as garbage, failable fields could not be try/catch-ed. Lowering already dispatched these (call_closure / call_indirect); only CallResolver.plan lacked a field-access arm. Add a closure/fn-pointer field arm to plan (before the instance-method check, mirroring lowering's precedence — a closure-typed field shadows a same-named method) and extend the lowering closure-field arm to also handle bare .function fields via call_indirect. Lock: examples/closures/0315-closures-struct-field-call.sx.
This commit is contained in:
41
examples/closures/0315-closures-struct-field-call.sx
Normal file
41
examples/closures/0315-closures-struct-field-call.sx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
// Calling a closure / function-pointer value stored in a STRUCT FIELD
|
||||||
|
// (`box.run(args)`) resolves the call's return type correctly — value returns
|
||||||
|
// marshal properly, and a failable field (`Closure(..) -> (T, !)`) is `try`/
|
||||||
|
// `catch`-able. The call-type resolver mirrors the lowering dispatch: a
|
||||||
|
// closure/fn-ptr field is called directly (and shadows a same-named method).
|
||||||
|
//
|
||||||
|
// Regression (issue 0201): the field-access call path typed such calls as
|
||||||
|
// `unresolved` — value returns came out as garbage, failable returns rejected
|
||||||
|
// `catch`/`try` ("operand has type 'unresolved'").
|
||||||
|
#import "modules/std.sx";
|
||||||
|
|
||||||
|
CB :: struct {
|
||||||
|
add: Closure(i64, i64) -> i64; // closure field, with args
|
||||||
|
fp: (i64) -> i64; // bare function-pointer field
|
||||||
|
work: Closure(i64) -> (i64, !); // failable closure field
|
||||||
|
}
|
||||||
|
|
||||||
|
triple :: (x: i64) -> i64 { return x * 3; }
|
||||||
|
|
||||||
|
// Field call through a `*CB` receiver inside a method, consuming the failable
|
||||||
|
// field's error channel.
|
||||||
|
run_work :: (self: *CB, n: i64) -> i64 {
|
||||||
|
v := self.work(n) catch { return -1; };
|
||||||
|
return v;
|
||||||
|
}
|
||||||
|
|
||||||
|
main :: () -> i64 {
|
||||||
|
b : CB = ---;
|
||||||
|
b.add = (x: i64, y: i64) => x + y;
|
||||||
|
b.fp = triple;
|
||||||
|
b.work = (n: i64) -> (i64, !) => {
|
||||||
|
if n < 0 { raise error.Negative; }
|
||||||
|
n * 10
|
||||||
|
};
|
||||||
|
|
||||||
|
print("{}\n", b.add(3, 4)); // 7
|
||||||
|
print("{}\n", b.fp(5)); // 15
|
||||||
|
print("{}\n", run_work(@b, 6)); // 60
|
||||||
|
print("{}\n", run_work(@b, -1)); // -1 (error path)
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
7
|
||||||
|
15
|
||||||
|
60
|
||||||
|
-1
|
||||||
91
issues/0201-closure-struct-field-call-unresolved.md
Normal file
91
issues/0201-closure-struct-field-call-unresolved.md
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
> **RESOLVED** (2026-06-28). Root cause: `CallResolver.plan`'s `.field_access`
|
||||||
|
> branch had no arm for a struct field whose type is a `.closure` / `.function`
|
||||||
|
> value, so the call typed as `.unresolved` (the lowering side already dispatched
|
||||||
|
> it via `call_closure`/`call_indirect`, but the type used for arg-boxing /
|
||||||
|
> `try`/`catch` came from `plan`). Fix: added a closure/fn-pointer field arm in
|
||||||
|
> `src/ir/calls.zig` (`plan`), placed before the instance-method check to mirror
|
||||||
|
> lowering's precedence (a closure-typed field shadows a same-named method), and
|
||||||
|
> extended the lowering closure-field arm in `src/ir/lower/call.zig` to also
|
||||||
|
> handle bare `.function` fields (`call_indirect`, ctx-prepend gated on the
|
||||||
|
> fn-ptr ABI). Regression test:
|
||||||
|
> `examples/closures/0315-closures-struct-field-call.sx` (closure field with
|
||||||
|
> args, failable field via `*self` receiver — success + error, and a bare
|
||||||
|
> fn-pointer field). Suite 852/0.
|
||||||
|
|
||||||
|
# 0201 — calling a closure stored in a struct field types as `unresolved`
|
||||||
|
|
||||||
|
**Symptom** — Calling a closure value held in a **struct data field**
|
||||||
|
(`box.run()` where `run: Closure(...) -> R`) does not resolve the call's
|
||||||
|
return type: the result types as `unresolved`. For a value-returning closure
|
||||||
|
this silently produces garbage (the result is never marshaled); for a failable
|
||||||
|
closure (`Closure() -> (T, !)`) `try`/`catch` reject the call with "`catch`
|
||||||
|
requires a failable expression; operand has type 'unresolved'".
|
||||||
|
|
||||||
|
Observed vs expected:
|
||||||
|
- `b.run()` where `run: Closure() -> i64` prints a garbage pointer-ish integer
|
||||||
|
(e.g. `4313325408`) instead of the closure's actual return value `7`.
|
||||||
|
- The IDENTICAL closure bound to a **local variable** (`f := () => {7}; f()`)
|
||||||
|
works and prints `7`.
|
||||||
|
- A **void**-returning closure field (`run: Closure() -> void; b.run()`) works
|
||||||
|
for its side effects (no result to marshal), which is why `std/io.sx`'s
|
||||||
|
`ThunkBox { run: Closure() -> void }` is unaffected.
|
||||||
|
|
||||||
|
**Scope / impact** — Pre-existing, independent of the PLAN-IO-UNIFY Phase 3
|
||||||
|
capture-typing fix (it reproduces at top level with no closures-capturing-
|
||||||
|
closures and no nesting). Does **NOT** block Phase 3: the async layer routes all
|
||||||
|
generic-ness through a captured worker + a void completion closure field, both of
|
||||||
|
which work. Worth fixing because the value-return case is silent corruption.
|
||||||
|
|
||||||
|
**Reproduction** (standalone, only needs the prelude):
|
||||||
|
|
||||||
|
```sx
|
||||||
|
#import "modules/std.sx";
|
||||||
|
Box :: struct { run: Closure() -> i64; }
|
||||||
|
main :: () -> i64 {
|
||||||
|
b : Box = ---;
|
||||||
|
b.run = () => { 7 };
|
||||||
|
print("{}\n", b.run()); // prints garbage; expected 7
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Failable variant (the shape that surfaced it):
|
||||||
|
|
||||||
|
```sx
|
||||||
|
#import "modules/std.sx";
|
||||||
|
Box :: struct { run: Closure() -> (i64, !); }
|
||||||
|
main :: () -> i64 {
|
||||||
|
b : Box = ---;
|
||||||
|
b.run = () -> (i64, !) => { 7 };
|
||||||
|
r := b.run() catch { return -1; }; // error: catch requires a failable
|
||||||
|
print("{}\n", r); // expression; operand 'unresolved'
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**Investigation prompt** — The call-type resolver `CallResolver.plan` in
|
||||||
|
[src/ir/calls.zig](../src/ir/calls.zig) has a `field_access` callee branch
|
||||||
|
(~line 230 onward) that handles protocol dispatch, runtime-class instance
|
||||||
|
methods, `StructName.method` instance methods, and free-fn UFCS — but has **no
|
||||||
|
arm for a struct field whose type is a `.closure` (or `.function`) value**. When
|
||||||
|
none of those match it falls through to `.unresolved` (e.g. line ~315 / the tail
|
||||||
|
return). Compare the BARE-identifier path (~lines 211–227) which already handles
|
||||||
|
`ti == .closure → ti.closure.ret` / `ti == .function → ti.function.ret` for a
|
||||||
|
local binding — the field-access path needs the equivalent: resolve the
|
||||||
|
receiver's struct type, look up the named field, and if the field's type is a
|
||||||
|
closure/function, return its `.ret` with the right call kind (an indirect/closure
|
||||||
|
call on the loaded field value).
|
||||||
|
|
||||||
|
The fix likely needs a new `CallPlan.kind` (or reuse of the closure-call kind)
|
||||||
|
for "call a closure loaded from a struct field", and the lowering side
|
||||||
|
(`lowerCall` field-access path in [src/ir/lower/expr.zig](../src/ir/lower/expr.zig))
|
||||||
|
must load the field then perform an indirect closure call (env + fn-ptr), the
|
||||||
|
same machinery a local closure-variable call uses. Mind the failable case: once
|
||||||
|
the return type resolves to a `(T, !)` tuple, `errorChannelOf` and the
|
||||||
|
`try`/`catch` paths work automatically (verified: a local failable closure call
|
||||||
|
already does).
|
||||||
|
|
||||||
|
**Verification** — run both repros above; expect `7` from the first and `7`
|
||||||
|
(success) / `-1` (error) from the failable variant, with no `unresolved`
|
||||||
|
diagnostic. Add a regression example under `examples/closures/` (value + failable
|
||||||
|
field-call) once fixed.
|
||||||
@@ -270,6 +270,47 @@ pub const CallResolver = struct {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Struct field holding a CLOSURE value, called directly
|
||||||
|
// (`box.run(args)` where `run: Closure(..) -> R`). Mirrors the
|
||||||
|
// lowering dispatch (call.zig closure-field arm) which runs in the
|
||||||
|
// value-receiver path BEFORE instance-method dispatch — so a
|
||||||
|
// closure-typed field shadows a same-named method, exactly as
|
||||||
|
// lowering binds it. Without this the call typed as `.unresolved`
|
||||||
|
// (issue 0201): value returns marshalled as garbage, failable
|
||||||
|
// returns couldn't be `try`/`catch`-ed. Lowering owns the dispatch;
|
||||||
|
// plan only needs the field's `.ret` so typing matches.
|
||||||
|
{
|
||||||
|
var fld_recv = recv_ty;
|
||||||
|
if (!fld_recv.isBuiltin()) {
|
||||||
|
const ri = self.l.module.types.get(fld_recv);
|
||||||
|
if (ri == .pointer) fld_recv = ri.pointer.pointee;
|
||||||
|
}
|
||||||
|
if (!fld_recv.isBuiltin()) {
|
||||||
|
const ri = self.l.module.types.get(fld_recv);
|
||||||
|
if (ri == .@"struct") {
|
||||||
|
const field_name_id = self.l.module.types.internString(cfa.field);
|
||||||
|
for (ri.@"struct".fields) |f| {
|
||||||
|
if (f.name == field_name_id and !f.ty.isBuiltin()) {
|
||||||
|
const fti = self.l.module.types.get(f.ty);
|
||||||
|
if (fti == .closure) return .{
|
||||||
|
.kind = .closure,
|
||||||
|
.return_type = fti.closure.ret,
|
||||||
|
.target = .{ .named = cfa.field },
|
||||||
|
};
|
||||||
|
// Bare function-pointer field (`fp: (T) -> R`),
|
||||||
|
// symmetric with the bare-identifier fn-pointer
|
||||||
|
// path above — call via `call_indirect`.
|
||||||
|
if (fti == .function) return .{
|
||||||
|
.kind = .fn_pointer,
|
||||||
|
.return_type = fti.function.ret,
|
||||||
|
.target = .{ .named = cfa.field },
|
||||||
|
.prepends_ctx = self.l.implicit_ctx_enabled and fti.function.call_conv != .c,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
// Instance method call: obj.method(args) → StructName.method.
|
// Instance method call: obj.method(args) → StructName.method.
|
||||||
{
|
{
|
||||||
var obj_ty = recv_ty;
|
var obj_ty = recv_ty;
|
||||||
|
|||||||
@@ -990,6 +990,28 @@ pub fn lowerCall(self: *Lowering, c_in: *const ast.Call) Ref {
|
|||||||
} else self.alloc.dupe(Ref, args.items) catch unreachable;
|
} else self.alloc.dupe(Ref, args.items) catch unreachable;
|
||||||
return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret);
|
return self.builder.emit(.{ .call_closure = .{ .callee = closure_val, .args = owned } }, fti.closure.ret);
|
||||||
}
|
}
|
||||||
|
// Bare function-pointer field (`fp: (T) -> R`, no env) —
|
||||||
|
// load the field value and call it via `call_indirect`,
|
||||||
|
// mirroring the bare-identifier / global fn-pointer paths
|
||||||
|
// (ctx prepend gated on the fn-ptr's own ABI).
|
||||||
|
if (fti == .function) {
|
||||||
|
var agg = obj;
|
||||||
|
const oi = self.module.types.get(obj_ty);
|
||||||
|
if (oi == .pointer) {
|
||||||
|
agg = self.builder.load(obj, oi.pointer.pointee);
|
||||||
|
}
|
||||||
|
const fp_val = self.builder.structGet(agg, @intCast(fi), f.ty);
|
||||||
|
// Coerce user args to the fn-ptr's param types (issue 0186).
|
||||||
|
coerceClosureCallArgs(self, args.items, fti.function.params);
|
||||||
|
var final_args = std.ArrayList(Ref).empty;
|
||||||
|
defer final_args.deinit(self.alloc);
|
||||||
|
if (self.fnPtrTypeWantsCtx(f.ty)) {
|
||||||
|
final_args.append(self.alloc, self.current_ctx_ref) catch unreachable;
|
||||||
|
}
|
||||||
|
final_args.appendSlice(self.alloc, args.items) catch unreachable;
|
||||||
|
const owned = self.alloc.dupe(Ref, final_args.items) catch unreachable;
|
||||||
|
return self.builder.emit(.{ .call_indirect = .{ .callee = fp_val, .args = owned } }, fti.function.ret);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user