Files
sx/issues/0045-pack-fn-call-llvm-verifier-failure.md
agra 9e78790ebf ffi issue-0045 fix: inline-return slot for comptime-call bodies
`lowerComptimeCall` now scans the body for `return` statements
via `fnBodyHasReturn`. When found, it allocates a stack slot
typed to the fn's return type and installs it as
`self.inline_return_target` before lowering the body.

`lowerReturn` checks `inline_return_target` first:
- If set, it stores the coerced return value into the slot,
  drains pending defers, sets `block_terminated = true`, and
  returns without emitting a `ret` into the caller's basic
  block.
- Otherwise it emits the standard `ret` as before.

After the body lowers, the inliner either returns the
tail-expression value (existing fast path — bodies with no
`return` skip the slot entirely) or loads the slot when
`block_terminated` is set.

Why the bug was invisible until now: `format`/`print` and
every other stdlib comptime fn use arrow form (`=> expr`) or
`#insert`-only bodies — no `return` statement, no path through
`lowerReturn`. Step 1.b of the pack feature made `..$args`
parseable; the natural smoke test
`foo :: (..$args) -> s64 { return 42; }` was the first
comptime-fn body to take the `return`-with-trailing-statements
path, surfacing the LLVM verifier crash.

`examples/issue-0045.sx` flips from the lock-in failure to
`42`. 194/194 example tests + `zig build test` green.
2026-05-27 13:21:23 +03:00

4.7 KiB

FIXED. lowerComptimeCall now allocates a result slot when the body contains a return statement and reroutes lowerReturn to store into it instead of emitting ret into the caller's basic block. Regression test: examples/issue-0045.sx.

Symptom

Calling a fn declared with ..$args (variadic heterogeneous type pack, parser-accepted as of commit a51fe26) — even with zero positional arguments — emits LLVM IR that fails verification:

LLVM verification failed: Terminator found in the middle of a basic block!
label %entry

No IR is printed by sx ir; the sx run JIT exits 1 immediately.

Expected: at minimum, the empty-pack call site should compile and execute the fn body. Plan step 2 ("Runtime indexing + mono expansion") specifies per-mono mangling and ..$args expansion to N positional IR params; until that lands, calling such a fn should at minimum emit a clear "pack-fn calls not yet implemented" diagnostic rather than corrupt IR.

Reproduction

foo :: (..$args) -> s64 { return 42; }

main :: () -> s32 {
    n : s64 = foo();
    return 0;
}
$ ./zig-out/bin/sx run repro.sx
LLVM verification failed: Terminator found in the middle of a basic block!
label %entry

foo() with zero args, one arg (foo(1)), or multiple args (foo(1, "hello")) all produce the same crash.

Background

After M5.A.next.1b (commit a51fe26), parseParams accepts ..$args as a parameter declaration. The Param is recorded with is_variadic = true, is_comptime = true, type_expr = inferred_type. parseFnDecl's collectTypeParams then registers args as a type-param (because is_comptime = true), so fd.type_params.len > 0.

This routes the fn through the existing generic-fn path: lowerFnDecl skips eager lowering, expecting calls to monomorphise at first use. But the existing monomorphisation machinery binds a single TypeId per $T name — it has no notion of a pack (a variable-length list of TypeIds bound positionally). When the call site tries to monomorphise with the call's args, the body's args parameter gets resolved to a single (probably default .s64) TypeId, but the call-site arg-packing path (packVariadicCallArgs) treats it as a regular ..T slice — the two views disagree and the emitted IR is malformed.

The bug isn't in step 1's code itself; it's the gap between "step 1 made the syntax parseable" and "step 2 hasn't made the calls executable yet."

Investigation prompt

For a fresh session picking this up:

Plan step 2 ("Runtime indexing + mono expansion") in ~/.claude/plans/lets-see-options-for-merry-dijkstra.md is the intended fix:

  1. Detect pack-fns at declaration: the fn has a trailing param with is_variadic && is_comptime (no concrete type annotation distinguishes it from a regular args: ..T variadic).
  2. Per-call monomorphisation: bind $args := [T1, ..., Tn] from the call site's concrete arg types. Each unique (arg-type-tuple, $ret) combination gets its own mono.
  3. Expand the pack into N positional IR params in the mono's signature; mangling encodes the pack shape so distinct monos get distinct symbols.
  4. Body args[$i] at comptime-known $i lowers to the i-th expanded param load (return type from $args[$i]).

Key files:

  • src/ir/lower.zig:
    • lowerFnDecl (around line 949 — generic skip) needs to keep skipping pack-fns.
    • monomorphizeFunction (line 7834) needs a pack-aware path that binds pack_bindings (the field added in commit 08feb60 for impl matching) instead of just type_bindings.
    • packVariadicCallArgs (line 7275) should NOT run for pack fns — args stay positional, not slice-packed.
    • Index-expression lowering needs an args[$i] arm that reads the i-th positional param.
  • src/ir/types.zig: FunctionInfo/ClosureInfo have pack_start already (added in commit 6582449); the mono's expanded signature should NOT carry pack_start (it's a concrete shape).

Verification: the repro above compiles and prints "42" when run as ./zig-out/bin/sx run repro.sx. A new examples/156-pack-fn-mono.sx (number depends on next free slot) should be added per the FFI cadence rule (xfail-lock-in then green).

Alternative interim option: if step 2 is too large to land in one session, gate parseFnDecl to reject pack params with an explicit "pack-fn body lowering not yet implemented; only impl target types accept ..$args today" diagnostic. Lets the parser accept the syntax in impl headers (step 1's payoff) while preventing the LLVM verifier crash. The diagnostic disappears when step 2 lands.

Verification

Once the fix is in:

./zig-out/bin/sx run examples/156-pack-fn-mono.sx
# Expected: prints "42"

Full suite + zig test must still pass.