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

4.6 KiB
Raw Blame History

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):

#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):

#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 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) 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.