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`.
This commit is contained in:
agra
2026-05-27 21:09:25 +03:00
parent 1add93f083
commit 8fcf352de8
4 changed files with 207 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
// Regression: bare `$args` slice survives crossing a function-call
// boundary — both `.len` AND per-element values come through.
//
// Before the fix landed in `lazyLowerFunction`, the callee's
// `args.len` got constant-folded to the outer pack-fn mono's
// arity. `walk(args: []Any) { return args.len; }` lazily lowered
// inside `probe(..$args)`'s first mono inherited
// `pack_param_count["args"] = N` from the pack — the
// `<pack_name>.len` intercept in `lowerFieldAccess` then baked
// `ret i64 N` into walk's IR. Every subsequent shape's call to
// walk returned the same constant, regardless of the actual slice
// it received.
//
// `describe(args)` walks element-by-element so a silent
// truncation surfaces as a missing tail (or a different type at
// some position) — not just the wrong length.
//
// Walking under `#run` is intentional: the bare-`$args` slice
// carries `const_type` elements that only the interp materialises;
// LLVM emission leaves the per-element slots as undef (4A.bare
// semantics — bare-pack is comptime-only).
#import "modules/std.sx";
describe :: (args: []Any) -> string {
s := "[";
i : s64 = 0;
while i < args.len {
if i > 0 { s = concat(s, ", "); }
s = concat(s, type_name(args[i]));
i = i + 1;
}
return concat(s, "]");
}
probe :: (..$args) -> string {
list := $args;
return describe(list);
}
run_all :: () {
print("0: {}\n", probe());
print("1: {}\n", probe(1));
print("3: {}\n", probe(1, "x", true));
print("5: {}\n", probe(1, 2.0, "x", true, 99));
}
#run run_all();
main :: () { print("rt\n"); }

View File

@@ -0,0 +1,151 @@
# 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:
```sx
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
```sx
#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:
```sx
#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.zig``buildPackSliceValue` / `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.

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,6 @@
0: []
1: [s64]
3: [s64, string, bool]
5: [s64, f64, string, bool, s64]
--- build done ---
rt