Files
sx/issues/0049-new-form-variadic-cross-module-llvm-emit-crash.md
agra 64dcbca06a ffi issue-0049: new-form variadic cross-module LLVM crash — xfail lock-in
Migrating stdlib's `path_join` to the new variadic syntax
(`(..parts: []string) -> string`) surfaces a latent compiler bug:
`resolveParamType` and `packVariadicCallArgs` treat the new-form
declaration the same as the legacy `parts: ..string` and wrap the
element type in `sliceOf` regardless of whether it already is one.
The new form's `[]string` becomes `[][]string`; the call-site
marshal pack emits `[N x string]` (correct) but the callee stores
its slice param into a `[]([]string)`-typed slot. The shape
mismatch propagates as null/undef Refs that crash
`LLVMBuildExtractValue` inside `emitStrCmp` during emission.

`examples/121-ios-sim-bundle.sx` (existing) and the new focused
`examples/174-new-form-variadic-cross-module.sx` both fail today
with the segfault. The next commit fixes `resolveParamType` +
`packVariadicCallArgs` so both flip green. Stdlib's `format` /
`print` / `open` and the example fixtures stay on the legacy form
in this commit — they migrate in the follow-up cleanup commit.
2026-05-27 21:29:08 +03:00

6.1 KiB

0049 — new-form variadic ..name: []Type defined in an imported module crashes LLVM emit

Symptom

A pack-fn declared with the new variadic syntax ..name: []Type (the form the FFI plan migrates to, replacing the legacy name: ..Type) crashes LLVM IR emission with a null-operand LLVMBuildExtractValue inside emitStrCmp when:

  1. The pack-fn lives in an imported module (e.g. library/modules/std.sx).
  2. The caller is in a different module than the definition.

The same function written with the legacy name: ..Type syntax compiles and runs cleanly. The same new-form definition placed locally in the caller's module (or shadowing the imported name) also compiles cleanly. The two together — new form + import boundary — are what trip the emit.

Segmentation fault at address 0x0
???:?:?: 0x... in __ZN4llvm5Value11setNameImplERKNS_5TwineE (.../libLLVM.dylib)
???:?:?: 0x... in _LLVMBuildExtractValue (.../libLLVM.dylib)
/Users/agra/projects/sx/src/ir/emit_llvm.zig:3570:48: in emitStrCmp (sx)
        const rhs_ptr = c.LLVMBuildExtractValue(b, rhs, 0, "str.rp");
                                                ^
/Users/agra/projects/sx/src/ir/emit_llvm.zig:1805:45: in emitInst (sx)
            .str_eq => |bin| self.emitStrCmp(bin, true),

So an emitted .str_eq op has a rhs Ref that resolves to a null LLVM Value. The .str_eq is somewhere downstream of the new-form pack-fn's monomorphisation — most likely an any_to_string / format-side string comparison that the migrated call path threads back. The rhs was either never materialised in the imported-mono's IR, or was registered against a stale function/module slot that emit_llvm resolves to null.

Reproduction

Modify library/modules/std.sx:

- path_join :: (parts: ..string) -> string {
+ path_join :: (..parts: []string) -> string {

Body unchanged. Then:

// repro.sx
#import "modules/std.sx";

main :: () {
    p := path_join("a", "b");
    print("{}\n", p);
}

zig build && ./zig-out/bin/sx run repro.sx → segfault as above.

Negative controls (all compile and run fine):

// Same body, OLD form — works:
path_join :: (parts: ..string) -> string { ... }

// New form but defined LOCALLY in the caller:
path_join :: (..parts: []string) -> string { ... }
#import "modules/std.sx";  // imported anyway, just no path_join call
main :: () { p := path_join("a", "b"); ... }

// New form with `$`-prefixed pack name — works EITHER locally or imported:
path_join :: (..$parts: []string) -> string { ... }

So the bug is specifically:

  • new-form variadic (..name: []Type)
  • WITHOUT the $ prefix on name
  • defined in a module that gets imported (not in the caller's own file)

Suite state when the bug first surfaced: commit 0ede097 (master, 2026-05-27, just after the issue-0048 fix landed and the suite was green at 213/213). The only delta on top is the path_join :: (parts: ..string)path_join :: (..parts: []string) edit in library/modules/std.sx.

Investigation prompt

The FFI plan migrates all stdlib variadic decls from the legacy form to the new ..name: []Type form (path_join, format, print, plus the foreign open decl, plus the example fixtures). Per the FFI cadence rule the migration is supposed to be a mechanical textual change with identical semantics. This bug blocks that.

The fault location (emitStrCmp line 3570 with null rhs) is the crash point, not the root cause. The root cause is one of:

  1. Cross-module pack-fn monomorphisation — the new-form path in monomorphizePackFn registers the mono'd function in the current module, but if the mono'd body uses a Ref that resolves through a stale module/function context, the LLVM pass through emit_llvm.functions[fid] lookup hands back a null. Compare the new-form mono path to the legacy name: ..Type mono path side-by-side — look for any place the latter threads the caller's module ID / FuncId but the former forgets to.
  2. Synthesised slot names — the new-form pack-fn body uses synthesised __pack_<name>_<i> idents for per-position arg substitution (per CHECKPOINT-FFI step-2b). If these are re-emitted in the caller's IR against the imported function's body without re-resolving against the caller's scope, they'd appear as undef in the final LLVM pass.
  3. The $ workaround as a hint — the bug disappears when the pack name is ..$parts: []string. Inside the parser / isPackFn discriminator, the $ prefix routes the function through the heterogeneous-pack mono path; the new-form WITHOUT $ likely routes through a near-but-not-identical path. Diff the two routes — what does the $ version do that the no-$ version skips when the call crosses an import?

Where to start:

  • src/ir/lower.zigmonomorphizePackFn (around line 8460), materialisePackSlice (around line 8261), buildPackSliceValue (around line 8225). Trace which gets called for the new-form no-$ path.
  • src/ir/lower.zig:lowerPackFnCall — call-site mono dispatch. Look for a $-prefix branch.
  • src/parser.zig:parseParam (variadic handling) — confirm what AST shape the two forms produce. The new form sets is_variadic = true AND is_comptime = false (no $); the new form WITH $ sets both true. The mono path probably gates on is_comptime.

Verification step:

After the fix, run with the path_join edit re-applied:

git diff library/modules/std.sx   # confirm new form lands
./zig-out/bin/sx run examples/121-ios-sim-bundle.sx
bash tests/run_examples.sh

Both should be green, no segfault.

A regression test goes in examples/NNN-new-form-variadic-cross-module.sx once the fix lands. The migration of path_join, format, print, and open then proceeds.

Why this matters

The FFI plan calls for the legacy name: ..Type form to be dropped entirely (current/CHECKPOINT-FFI.md references the 'args: ..Any' is in the plan to change to '..args: []Any' migration). Every stdlib consumer plus the chess game needs the new form. With this bug, stdlib can't be migrated — moving any stdlib variadic to the new form breaks every program that imports it. The migration is gated on this fix.