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.
123 lines
4.3 KiB
Markdown
123 lines
4.3 KiB
Markdown
# 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.
|