Files
sx/issues/0048-bare-pack-args-slice-loses-len-across-call.md
agra 8fcf352de8 ffi issue-0048: bare $args slice loses .len across call — xfail lock-in
Bare `$args` evaluated inside a pack-fn body has the right `.len` /
per-element types inline, but the moment the same slice is passed
as an argument to another function, the callee silently reads
length 0 and every element comes back as undef.

Cause (per issue file): `lazyLowerFunction` saves/restores builder
state but not `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` / `inline_return_target`. When a regular fn like
`describe(args: []Any)` is lazily lowered from inside a pack-fn
mono, the outer pack maps are still active; `lowerFieldAccess`'s
`<pack_name>.len` intercept fires on `describe`'s same-named param
and bakes the outer mono's arity as a constant into describe's IR.
Every subsequent shape's call to describe returns that constant.

`examples/173-pack-bare-args-cross-call.sx` exercises four shapes
(0, 1, 3, 5 elements) through the same `describe(args: []Any)`
walker. The expected output holds the per-position type names
(`[s64]`, `[s64, string, bool]`, etc); today's diff fails — the
walker reads `args.len = 0` for every shape and returns `[]`. The
next commit fixes `lazyLowerFunction`.
2026-05-27 21:09:25 +03:00

5.2 KiB

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

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 []s64 slice round-trips correctly across the same kind of call:

walk :: (xs: []s64) -> s64 { return xs.len; }
main :: () {
    arr : [3]s64 = .{10, 20, 30};
    sl : []s64 = 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) -> s64 { 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: s64 } 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 []s64 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.