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:
49
examples/173-pack-bare-args-cross-call.sx
Normal file
49
examples/173-pack-bare-args-cross-call.sx
Normal 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"); }
|
||||
151
issues/0048-bare-pack-args-slice-loses-len-across-call.md
Normal file
151
issues/0048-bare-pack-args-slice-loses-len-across-call.md
Normal 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.
|
||||
1
tests/expected/173-pack-bare-args-cross-call.exit
Normal file
1
tests/expected/173-pack-bare-args-cross-call.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
6
tests/expected/173-pack-bare-args-cross-call.txt
Normal file
6
tests/expected/173-pack-bare-args-cross-call.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
0: []
|
||||
1: [s64]
|
||||
3: [s64, string, bool]
|
||||
5: [s64, f64, string, bool, s64]
|
||||
--- build done ---
|
||||
rt
|
||||
Reference in New Issue
Block a user