Files
sx/issues/0046-comptime-fn-nested-print-with-return.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00

6.2 KiB

FIXED. createComptimeFunction now saves/restores the outer lowerComptimeCall's state — specifically inline_return_target, pack_arg_nodes, pack_param_count, pack_arg_types, comptime_param_nodes, block_terminated, target_type, and func_defer_base — so the wrapper fn it builds for the nested comptime expression runs in isolation. Without the saves, the wrapper inherited an inline-return slot belonging to a different basic block; the interp executed it and tripped a null pointer store at storeAtRawPtr.

The pack-fn face of this bug (filed as face 2) was fixed incidentally by step 2b's mono refactor — pack-fn calls bypass the inline-return-slot setup entirely. Plain ($x: i32) comptime fns stay on the inline path; the createComptimeFunction save/restore fix covers that path.

Regression test: examples/issue-0046.sx.

Symptom

A comptime fn body containing BOTH a nested comptime call (e.g. print(...)) AND a return X; statement fails in one of two shapes depending on the comptime-param flavour:

Outer fn shape Failure
helper :: ($x: i32) -> i64 { print("inside\n"); return 42; } (plain comptime) Panic: cast causes pointer to be null at src/ir/interp.zig:207 storeAtRawPtr.
dump :: (..$args) -> i64 { n := args[0]; print("got {}\n", n); return n; } (pack-fn) Compile error: unresolved 'result' at fake span 1:5 (inside the inserted code).

Both vanish if you remove either the nested print(...) OR the return X; statement:

  • Arrow-form bodies (=> expr) work.
  • Bodies with return but no nested comptime call work.
  • Bodies with a nested comptime call but no return work.

Both faces share one root: my fix for issue-0045 (commit 9e78790) added an inline_return_target slot + alloca in lowerComptimeCall, which is now active when the outer call's body recursively invokes another comptime fn that itself runs the #insert build_format(fmt) → interpreter → parse-and-lower pipeline.

Pre-fix this pattern crashed too — at the LLVM verifier with "Terminator found in the middle of a basic block" — because the outer return emitted ret X into the caller's basic block mid-flight. My fix routed return into the slot so the outer body now fully completes, which means the recursive comptime call runs to completion too. The interpreter / #insert scope chain then has to be correct in this newly-reachable context, and it isn't.

Reproduction

#import "modules/std.sx";

// Face 1 — interp panic:
helper :: ($x: i32) -> i64 {
    print("inside\n");
    return 42;
}

// Face 2 — "unresolved 'result'":
dump :: (..$args) -> i64 {
    n : i64 = args[0];
    print("got {}\n", n);
    return n;
}

main :: () -> i32 {
    n := helper(7);        // ← panic in interp
    print("{}\n", dump(7)); // ← "unresolved 'result'"
    return 0;
}

Each face reproduces independently — they don't need to coexist in the same program.

What's NOT happening

  • Not a regression introduced by issue-0045's fix per se: the same pattern hit a different fatal stage (LLVM verifier) before the fix. The fix exposed it; it didn't create it.
  • Not caused by step 2a (pack typed indexing, commit cd36784): Face 1 reproduces with a plain ($x: i32) comptime fn, no pack involved.
  • Not exercised by any test in the suite today. format/print use arrow form or #insert-only bodies — no return in a block. User code historically followed the same pattern.

Why this didn't block step 2a

Step 2a only tests args[$i] in arrow-form pack bodies and arithmetic chains. No nested comptime call in any test body. Step 2b (per-mono mangling) and step 3 (type-reflection intrinsics, $args[$i] in type positions) don't inherently require nested comptime calls either — builder fns run inside #insert contexts, not inside the public pack-fn body, so they have a different lowering path.

The pattern WILL bite when:

  • Step 5 of the pack plan refactors stdlib's print/format to use ..$args — print's body itself becomes the outer comptime fn that nests comptime calls.
  • User code writes a pack-fn that wants both print for debug output AND return X; for early exit.

Investigation prompt

For a fresh session picking this up:

The interaction is between (a) my issue-0045 inline_return_target slot + alloca setup in lowerComptimeCall and (b) the recursive comptime path that invokes #insert build_format(fmt)evalComptimeStringcreateComptimeFunctioninterp.call on a wrapper fn, then parses the returned source string and lowers each parsed statement into the current scope.

Three angles worth probing:

  1. Saved/restored state in createComptimeFunction at src/ir/lower.zig:8851+. It saves builder.func, builder.current_block, builder.inst_counter, self.scope, current_ctx_ref. It does NOT save/restore inline_return_target, pack_arg_nodes, comptime_param_nodes. The first two were added by my recent commits (9e78790, cd36784). One of these leaking into the wrapper-fn lowering is the most likely cause of Face 1.

  2. Ref numbering — the alloca I added for ret_slot shifts subsequent Ref values in the outer fn (main). The interp shouldn't see those refs (it executes the wrapper fn's IR, not main's), but check whether the wrapper fn carries a stale Ref handle from the outer build context.

  3. Scope chain visible to parsed #insert statements. For Face 2 the inserted code declares result := "" then references result in the next stmt. The lookup fails. Maybe the lowerBlockValue exit defer fires the parent scope deinit before the next stmt lowers — or block_terminated from the inline-return slot setup is interfering with the inserted-stmt loop in lowerInsertExprValue (src/ir/lower.zig:7065+).

A reasonable starting place: add the missing save/restore for inline_return_target (and pack_arg_nodes) in createComptimeFunction, then re-run both repros. If Face 1 disappears, that confirms angle 1.

Verification

./zig-out/bin/sx run /tmp/issue-0046-face1.sx  # expect "n=42"
./zig-out/bin/sx run /tmp/issue-0046-face2.sx  # expect "got 7\n7"

Full suite + zig test must still pass after the fix.