Files
sx/issues/0201-closure-struct-field-call-unresolved.md
agra 45bd561a0d 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.
2026-06-28 09:18:30 +03:00

92 lines
4.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
> **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 211227) 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.