Files
sx/issues/0050-monomorphizeFunction-pack-state-leak.md
agra c21b683b08 docs(issues): mark 17 already-fixed issues RESOLVED with verified banners
Each banner was re-verified against the current binary (repro now behaves
correctly) and cites the actual fix location in current src/** plus the covering
regression example. Closes the stale-but-fixed backlog: 0019, 0042-0056, 0131.
No compiler change.
2026-06-21 09:25:52 +03:00

127 lines
4.9 KiB
Markdown

# 0050 — `monomorphizeFunction` leaks outer pack-fn state into the mono body
> **RESOLVED.** Root cause: `monomorphizeFunction` saved/nulled `type_bindings`/`scope`/builder state but left the outer pack-fn maps live, so a generic callee with an `args`-named param had its `args.len` constant-folded (via `lowerFieldAccess`'s `<pack>.len` intercept) to the first mono's arity and baked into the cached IR.
> Fix: `monomorphizeFunction` in `src/ir/lower/generic.zig` now saves+nulls+defer-restores `pack_arg_nodes` / `pack_param_count` / `pack_arg_types` / `inline_return_target` (lines 51-64), mirroring the `lazyLowerFunction` isolation from issue-0048.
> Covered by regression test `examples/0524-packs-generic-fn-pack-state-leak.sx` (probes print 0/1/2/4).
## 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.