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.
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:
- The pack-fn lives in an imported module (e.g.
library/modules/std.sx). - 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 onname - 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:
- Cross-module pack-fn monomorphisation — the new-form path
in
monomorphizePackFnregisters 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 throughemit_llvm.functions[fid]lookup hands back a null. Compare the new-form mono path to the legacyname: ..Typemono path side-by-side — look for any place the latter threads the caller's module ID / FuncId but the former forgets to. - 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. - The
$workaround as a hint — the bug disappears when the pack name is..$parts: []string. Inside the parser /isPackFndiscriminator, 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.zig—monomorphizePackFn(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 setsis_variadic = trueANDis_comptime = false(no$); the new form WITH$sets both true. The mono path probably gates onis_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.