Files
sx/issues/0048-bare-pack-args-slice-loses-len-across-call.md
agra c21b683b08 docs(issues): mark 17 already-fixed issues RESOLVED with verified banners
Each banner was re-verified against the current binary (repro now behaves
correctly) and cites the actual fix location in current src/** plus the covering
regression example. Closes the stale-but-fixed backlog: 0019, 0042-0056, 0131.
No compiler change.
2026-06-21 09:25:52 +03:00

5.9 KiB

0048 — bare $args slice loses .len (reads 0) when passed across a call

RESOLVED. Root cause: a callee (walk/describe) lazily lowered inside a pack-fn mono inherited the active pack's pack_param_count, so the <pack_name>.len intercept in lowerFieldAccess constant-folded args.len to the outer pack's arity — every shape's cross-call read returned that one baked constant (originally observed as 0). Fix: FnBodyReentry.enter in src/ir/lower.zig (used by lowerFunctionBodyInto / lazyLowerFunction in src/ir/lower/decl.zig) now nulls pack_param_count (and the sibling pack_arg_nodes / pack_arg_types) for the nested body and restores them on exit. Covered by regression test examples/0522-packs-pack-bare-args-cross-call.sx.

Symptom

Bare $args evaluated inside a pack-fn body has the correct .len inline (e.g. ($args).len == 2 for a two-arg shape). But the moment the same slice is passed as an argument to another function, the callee's read of .len returns 0 — regardless of the pack's actual element count. The data pointer is presumably similarly broken (haven't probed yet, but every element-access would index off a zero-length view).

Observed:

inline:  len=4
callee:  len=0

Expected: callee receives the same {ptr, len} slice that the caller materialised; .len matches the pack's element count.

This blocks the step-5 generic Into(Block) impl (FFI plan): its body #insert build_block_convert($args, $R); calls a build_block_convert(args: []Type, ret: Type) -> string builder fn. The builder walks args to emit the per-shape trampoline + Block literal source. With this bug, the builder receives an empty slice and emits the empty-pack source for every call shape, silently producing wrong block trampolines.

Baseline regression check (not affected): a hand-built []i64 slice round-trips correctly across the same kind of call:

walk :: (xs: []i64) -> i64 { return xs.len; }
main :: () {
    arr : [3]i64 = .{10, 20, 30};
    sl : []i64 = arr;
    print("call: {}\n", walk(sl));   // prints 3 — works
}

So the bug is specific to slices produced by the pack-bare-$args materialisation path (buildPackSliceValue / materialisePackSlice in src/ir/lower.zig).

Reproduction

#import "modules/std.sx";

walk :: (args: []Any) -> string {
    return concat("len=", int_to_string(args.len));
}

probe :: (..$args) -> string {
    return walk($args);
}

#run print("inline: len={}\n", ($args).len);  // not legal — replace
                                              // with a body-local form
#run print("callee: {}\n", probe());           // expected: len=0
#run print("callee: {}\n", probe(1, "x"));     // expected: len=2 — fails, reads 0
#run print("callee: {}\n", probe(1, "x", true, 3.14));
                                               // expected: len=4 — fails, reads 0
main :: () {}

Cleaner repro that contrasts inline vs callee:

#import "modules/std.sx";

walk :: (args: []Any) -> i64 { return args.len; }

probe :: (..$args) -> string {
    inline_list := $args;
    callee_len  := walk($args);
    return concat(concat("inline=", int_to_string(inline_list.len)),
                  concat(" callee=", int_to_string(callee_len)));
}

#run print("{}\n", probe(1, "x", true, 3.14));
// observed: inline=4 callee=0
// expected: inline=4 callee=4
main :: () {}

Replace []Any with []Type — same wrong result (callee reads 0).

Tested on commit a394372 (master, 2026-05-27 head as of this file). zig build && bash tests/run_examples.sh is green; the bug is uncovered, not pre-existing red.

Investigation prompt

A pack-fn's bare $args lowers (per current/CHECKPOINT-FFI.md entry M5.A.next.4A.bare.1.B) to:

buildPackSliceValue(arg_types) — emits alloca [N x Any], one const_type(arg_tys[i]) per slot, then a {data_ptr, len} slice aggregate.

Suspected area:

  • src/ir/lower.zigbuildPackSliceValue / materialisePackSlice.
  • Whether the slice aggregate it returns is the same shape sx uses for an ordinary slice — { ptr: *T, len: i64 } in field order used by .len reads at consumer sites.
  • Whether the slice survives the function-call ABI: the callee reads the slice fields from its frame's slot for the argument; if buildPackSliceValue returns a temporary that's not what the call-site argument-marshal step picks up, the callee sees uninit.

What to check first:

  1. In the pack-fn (probe(..$args) -> string { walk($args); }), dump the IR of lower_pack_fn_call for walk — confirm walk's arg-0 is a slice value whose len field has been populated from arg_tys.len (or the runtime-built len).
  2. If the pack-slice goes through lowerExpr's comptime_pack_ref arm, verify the resulting Ref points at the slice aggregate produced by buildPackSliceValue — not at the underlying alloca [N x Any] (which would be the data pointer, not the slice).
  3. Compare with the []i64 round-trip path that works — what's different about how the slice is bound at the call site?

Verification step after fix:

Run the cleaner repro above; both lines should print inline=4 callee=4 (and 0/0 for the empty-pack case, 2/2 for two-arg, etc).

A regression test goes in examples/NNN-pack-bare-args-cross-call.sx once the fix lands.

Why this matters

The whole point of bare $args (step 4A) is that builder fns can walk the pack as a runtime slice — step 5 calls build_block_convert($args, $R) and the builder emits the trampoline + Block literal source by iterating args. With the slice's .len silently reading 0, every monomorphisation would emit the empty-pack source, producing wrong trampoline signatures and silently corrupt block dispatch on Apple targets.

The bug is invisible inline (the same call shape works inside the pack-fn body), so without the cross-call regression it'd ship quietly and burn the next person trying to write a #insert build_x($args, ...) style builder.