From 8fcf352de8bb47f9266826cfee75c689cedc3950 Mon Sep 17 00:00:00 2001 From: agra Date: Wed, 27 May 2026 21:09:25 +0300 Subject: [PATCH] =?UTF-8?q?ffi=20issue-0048:=20bare=20$args=20slice=20lose?= =?UTF-8?q?s=20.len=20across=20call=20=E2=80=94=20xfail=20lock-in?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 `.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`. --- examples/173-pack-bare-args-cross-call.sx | 49 ++++++ ...e-pack-args-slice-loses-len-across-call.md | 151 ++++++++++++++++++ .../173-pack-bare-args-cross-call.exit | 1 + .../173-pack-bare-args-cross-call.txt | 6 + 4 files changed, 207 insertions(+) create mode 100644 examples/173-pack-bare-args-cross-call.sx create mode 100644 issues/0048-bare-pack-args-slice-loses-len-across-call.md create mode 100644 tests/expected/173-pack-bare-args-cross-call.exit create mode 100644 tests/expected/173-pack-bare-args-cross-call.txt diff --git a/examples/173-pack-bare-args-cross-call.sx b/examples/173-pack-bare-args-cross-call.sx new file mode 100644 index 0000000..a5c90ad --- /dev/null +++ b/examples/173-pack-bare-args-cross-call.sx @@ -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 +// `.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"); } diff --git a/issues/0048-bare-pack-args-slice-loses-len-across-call.md b/issues/0048-bare-pack-args-slice-loses-len-across-call.md new file mode 100644 index 0000000..351d031 --- /dev/null +++ b/issues/0048-bare-pack-args-slice-loses-len-across-call.md @@ -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. diff --git a/tests/expected/173-pack-bare-args-cross-call.exit b/tests/expected/173-pack-bare-args-cross-call.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/173-pack-bare-args-cross-call.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/173-pack-bare-args-cross-call.txt b/tests/expected/173-pack-bare-args-cross-call.txt new file mode 100644 index 0000000..2eac860 --- /dev/null +++ b/tests/expected/173-pack-bare-args-cross-call.txt @@ -0,0 +1,6 @@ +0: [] +1: [s64] +3: [s64, string, bool] +5: [s64, f64, string, bool, s64] +--- build done --- +rt