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.
4.3 KiB
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
#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):
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_nodesself.pack_param_countself.pack_arg_typesself.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.