ffi issue-0050: monomorphizeFunction leaks pack-fn state — xfail lock-in

A generic fn (with `$T: Type` type params) called from inside a
pack-fn mono inherits the outer pack maps during its OWN body
lowering. Same root cause as issue-0048 — the lowering helper
doesn't save/null `pack_arg_nodes` / `pack_param_count` /
`pack_arg_types` — but on the generic-mono path
(`monomorphizeFunction`, ~line 8718) rather than
`lazyLowerFunction`.

`examples/175-generic-fn-pack-state-leak.sx` calls
`build(args: []Type, $ret: Type)` from a four-shape pack-fn. The
expected output is `len=0 / 1 / 2 / 4`; today's run reports
`len=0` for every shape because `build__void` was first
monomorphised under `probe()`'s mono (N=0) and `args.len` got
constant-folded to 0 inside the cached body. The next commit
adds the same isolation pattern to `monomorphizeFunction`.

Step 5 of the FFI plan (generic `Into(Block)` impl) needs the
`build_block_convert(args: []Type, $ret: Type) -> string` builder,
which trips this leak directly.
This commit is contained in:
agra
2026-05-27 21:44:39 +03:00
parent 952dc0e161
commit ec2a99a1a3
4 changed files with 159 additions and 0 deletions

View File

@@ -0,0 +1,30 @@
// Regression: a generic function (with `$T: Type` type params)
// called from inside a pack-fn mono must NOT inherit the outer
// pack maps during its own body lowering. Before the fix landed
// in `monomorphizeFunction`, the cached mono of a generic with
// an `args`-named param had its `args.len` constant-folded to
// the arity of whichever pack shape triggered the first mono;
// every subsequent shape read the same baked-in constant.
//
// Same root cause as issue-0048 (`lazyLowerFunction`), in a
// different lowering path (`monomorphizeFunction`).
#import "modules/std.sx";
build :: (args: []Type, $ret: Type) -> string {
return concat("len=", int_to_string(args.len));
}
probe :: (..$args) -> string {
return build($args, void);
}
run_all :: () {
print("0: {}\n", probe());
print("1: {}\n", probe(true));
print("2: {}\n", probe(42, "hi"));
print("4: {}\n", probe(1, 2.0, "x", true));
}
#run run_all();
main :: () { print("rt\n"); }

View File

@@ -0,0 +1,122 @@
# 0050 — `monomorphizeFunction` leaks outer pack-fn state into the mono body
## Symptom
A generic function (one with `$T: Type` type params) called from
inside a pack-fn's monomorphisation inherits the outer pack maps
during its OWN body lowering. The familiar fall-out: every
identifier in the generic body whose name happens to match the
outer pack's name gets routed through `lowerFieldAccess`'s
`<pack_name>.len` intercept (or `pack_arg_types` / `pack_arg_nodes`
substitutions) and silently picks up the WRONG value.
Same root cause as issue-0048, just a different lowering path.
0048 fixed `lazyLowerFunction`. The generic-monomorphisation path
(`monomorphizeFunction` in `src/ir/lower.zig` around line 8718)
has the same omission: it saves and nulls `type_bindings` /
`scope` / builder state, but does NOT save/null `pack_arg_nodes`
/ `pack_param_count` / `pack_arg_types` / `inline_return_target`.
When the body of the mono'd function references an identifier
named after the outer pack (typical: `args`), the outer
`pack_param_count["args"]` entry is still present and the
`args.len` lowers to a baked-in constant — the pack arity of
whichever shape triggered the FIRST mono. Subsequent shapes call
the same cached mono and read the same wrong constant.
## Reproduction
```sx
#import "modules/std.sx";
build :: (args: []Type, $ret: Type) -> string {
return concat("len=", int_to_string(args.len));
}
probe :: (..$args) -> string {
return build($args, void);
}
#run print("0: {}\n", probe());
#run print("1: {}\n", probe(true));
#run print("2: {}\n", probe(42, "hi"));
main :: () {}
```
Observed:
```
0: len=0
1: len=0
2: len=0
```
Expected: `len=0`, `len=1`, `len=2`. The slice passing across
the function-call boundary already works post-0048; what's wrong
here is that the callee's `args.len` is being constant-folded at
lower time before the runtime slice ever gets a chance to be read.
Negative control — same shape WITHOUT the `$ret: Type` type-param
(so `build` is not generic, gets lowered via `lazyLowerFunction`
+ the 0048-protected isolation):
```sx
build :: (args: []Type) -> string { ... } // no $ret
probe :: (..$args) -> string { return build($args); }
#run print("0: {}\n", probe()); // → 0
#run print("1: {}\n", probe(true)); // → 1
#run print("2: {}\n", probe(42, "hi")); // → 2
```
This works correctly, which confirms the bug is specifically on
the generic-mono path. The IR for the failing case shows
`int_to_string(i64 0)` baked into `@build__void` — the args.len
fold landed at monomorphisation time, not at the call site.
Suite green at commit `952dc0e` (master, 2026-05-27); the bug
surfaces the moment a builder fn (FFI plan's
`build_block_convert(args: []Type, $ret: Type) -> string`) is
written using the new bare-`$args` + `$ret: Type` shape that
step 5 of the FFI plan calls for.
## Investigation prompt
Apply the same isolation pattern that landed for
`lazyLowerFunction` (commit `0ede097`, issue-0048):
`monomorphizeFunction` in `src/ir/lower.zig` (~line 8718) needs
a save+null+defer-restore block covering:
- `self.pack_arg_nodes`
- `self.pack_param_count`
- `self.pack_arg_types`
- `self.inline_return_target`
Place the block alongside the existing
`saved_func` / `saved_block` / `saved_scope` / `saved_bindings`
saves, before the body lowering begins. Mirror the defer pattern
from `lazyLowerFunction` so all early-return paths restore
correctly.
Verification:
Run the reproduction above; the three probes must print 0 / 1 /
2 respectively. Then run `bash tests/run_examples.sh` (must stay
214/214) and `zig build test`.
A regression test goes in
`examples/NNN-generic-fn-pack-state-leak.sx` once the fix lands.
The shape is exactly the reproduction above.
## Why this matters
Step 5 of the FFI plan (`current/CHECKPOINT-FFI.md` — generic
`Into(Block) for Closure(..$args) -> $R` impl in stdlib) calls a
builder `build_block_convert(args: []Type, $ret: Type) -> string`
from inside the impl body. The impl body itself is mono'd per
call shape (where the closure's pack types and return type are
substituted in), and the builder is called from inside that mono.
With this leak, every call shape sees the same wrong source
string from the builder — the trampoline emission silently
produces zero-arity blocks regardless of the actual closure.
Without the fix, step 5 can't proceed.

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,6 @@
0: len=0
1: len=1
2: len=2
4: len=4
--- build done ---
rt