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:
30
examples/175-generic-fn-pack-state-leak.sx
Normal file
30
examples/175-generic-fn-pack-state-leak.sx
Normal 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"); }
|
||||
122
issues/0050-monomorphizeFunction-pack-state-leak.md
Normal file
122
issues/0050-monomorphizeFunction-pack-state-leak.md
Normal 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.
|
||||
1
tests/expected/175-generic-fn-pack-state-leak.exit
Normal file
1
tests/expected/175-generic-fn-pack-state-leak.exit
Normal file
@@ -0,0 +1 @@
|
||||
0
|
||||
6
tests/expected/175-generic-fn-pack-state-leak.txt
Normal file
6
tests/expected/175-generic-fn-pack-state-leak.txt
Normal file
@@ -0,0 +1,6 @@
|
||||
0: len=0
|
||||
1: len=1
|
||||
2: len=2
|
||||
4: len=4
|
||||
--- build done ---
|
||||
rt
|
||||
Reference in New Issue
Block a user