Files
sx/issues/0050-monomorphizeFunction-pack-state-leak.md
agra ec2a99a1a3 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.
2026-05-27 21:44:39 +03:00

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_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.