Files
sx/issues/0156-comptime-pack-captured-into-closure.md
agra d3944570b9 lang: generic $R type-arg resolution + receiver-driven ufcs overload (issues 0156, 0157)
0156 Part 1: a single-type generic $R (parsed as comptime_pack_ref)
used as a type-arg in a pack-fn body (Box($R), size_of(Box($R))) hit a
missing arm in resolveTypeWithBindings -> .unresolved -> LLVM panic.
Fix: mirror resolveTypeArg's comptime_pack_ref arm (look up
type_bindings, else a loud diagnostic). Regression: examples/generics/0216.
(Part 2 -- deferred .. spread crashes -- reframed OPEN/non-blocking.)

0157: a user generic ufcs method whose name collides with a stdlib
re-export resolved via last-wins fn_ast_map with no receiver filtering,
so the wrong overload won, $R never bound, and .unresolved reached LLVM.
Fix: selectUfcsGenericByReceiver enumerates all module authors, keeps
the receiver-binding ones, picks the most receiver-specific (concrete >
bare $T), dedups re-exports, and flags a genuine tie as a deterministic
'ambiguous -- qualify' diagnostic. Regression: examples/generics/0217.
2026-06-21 18:43:49 +03:00

4.5 KiB

0156 — deferred .. spread (pack captured into a closure / tuple spread) crashes the backend

Two bugs were conflated under this number. Investigation split them:

Part 1 — $R (single-type generic) in a type-arg slot inside a pack-fn body → LLVM panic — FIXED. The parser tags every $name expression as comptime_pack_ref, so a single-type binding ($R from Closure(..$args) -> $R) used as Box($R) / size_of(Box($R)) reached resolveTypeWithBindings (the resolver instantiateGenericStruct runs each type-arg through) as a comptime_pack_ref it had no arm for → catch-all else.unresolvedsrc/backend/llvm/types.zig:196 panic. Fix: mirror resolveTypeArg's comptime_pack_ref arm in resolveTypeWithBindings (src/ir/lower.zig) — look up type_bindings, else emit a loud "pack used where a single type is required" diagnostic (never a silent default type). Regression test: examples/generics/0216-generics-typearg-in-pack-fn-body.sx (size_of(Box($R)) in a pack-fn → r: 42).

Part 2 — deferred .. spread crashes — OPEN, NON-BLOCKING (below).

Part 2 — Symptom (OPEN)

A comptime variadic pack is comptime state, not a runtime value: a spread f(..args) is expanded at the spread site from pack_arg_nodes (the original call-site arg AST, referencing the caller's locals). Trying to make a .. spread cross a deferred / value boundary crashes instead of either working or diagnosing:

  • pack captured into a closure then spread later — () => { ... worker(..args) ... }SEGFAULTs at runtime (the deferred body re-expands args[i] from the spawner's locals, which are gone by the time the closure runs on another stack), or panics in the backend when types don't resolve.
  • spreading a concrete TUPLEt := .{40, 2}; w(..t)panics (unresolved type reached LLVM emission): .. only accepts a comptime pack, not a runtime aggregate, and the unsupported case degrades to .unresolved rather than a diagnostic.

Expected: either (a) a .. spread of a concrete tuple/array is a real feature that lowers to N positional args, and capturing a pack into a closure materializes it; or (b) both are rejected with a clean diagnostic at the spread site. Never a segfault / .unresolved-reaches-backend.

Reproduction (Part 2)

#import "modules/std.sx";
main :: () {
    w := (a: i64, b: i64) -> i64 => a + b;
    t := .{40, 2};
    out : i64 = 0; po := @out;
    captured :: () => { po.* = w(..t); };   // tuple spread inside a closure
    captured();
    print("out: {}\n", out);                // panics: unresolved type reached LLVM emission
}

(Pack-into-closure variant — segfault: see the original repro shape in this issue's history; runner :: ufcs (io, worker: Closure(..$args)->i64, ..$args) with captured :: () => { po.* = worker(..args); } segfaults at runtime.)

Why it is NON-BLOCKING for the fiber async work (B1.4a)

The fiber async/await layer does NOT need a .. spread to cross the fiber boundary. Deferred async is expressed as a nullary thunk that captures its inputs at the call site (where they are live) — async(io, work: Closure() -> $R), used context.io.async(() => a + b). The user's lambda captures a/b; async spawns the already-bound nullary closure as a fiber. No pack crosses the deferral. This is the idiomatic deferred-async shape (cf. go func(){...}()), proven end-to-end (.sx-tmp/pnullary.sxlog: 1 2 3 42 100). So Part 2 is filed for its own session, not a B1.4a blocker.

Investigation prompt (Part 2)

Decide the intended semantics of .. on a concrete value first (consult specs.md §packs). If a .. spread of a runtime tuple/array SHOULD lower to N positional args: implement it in the pack-spread call lowering (src/ir/lower/pack.zig lowerPackElems / the .spread_expr handling) for a concrete-aggregate operand (emit a GEP+load per element), and make closure capture of a pack materialize the pack's monomorphized element values into the env. If .. is intentionally comptime-pack-only: emit a diagnostic at the spread site when the operand is a runtime value or a captured pack ("cannot spread a runtime value / a captured pack; .. applies to a comptime pack only"), and ensure the capture-analysis pass rejects a comptime_pack_ref capture cleanly — never let .unresolved reach the backend (the segfault path must become a diagnostic). Verify: the Part-2 repro above either prints out: 42 or emits one clean diagnostic — never a segfault / panic.