Files
sx/issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.md
agra d3944570b9 lang: generic $R type-arg resolution + receiver-driven ufcs overload (issues 0156, 0157)
0156 Part 1: a single-type generic $R (parsed as comptime_pack_ref)
used as a type-arg in a pack-fn body (Box($R), size_of(Box($R))) hit a
missing arm in resolveTypeWithBindings -> .unresolved -> LLVM panic.
Fix: mirror resolveTypeArg's comptime_pack_ref arm (look up
type_bindings, else a loud diagnostic). Regression: examples/generics/0216.
(Part 2 -- deferred .. spread crashes -- reframed OPEN/non-blocking.)

0157: a user generic ufcs method whose name collides with a stdlib
re-export resolved via last-wins fn_ast_map with no receiver filtering,
so the wrong overload won, $R never bound, and .unresolved reached LLVM.
Fix: selectUfcsGenericByReceiver enumerates all module authors, keeps
the receiver-binding ones, picks the most receiver-specific (concrete >
bare $T), dedups re-exports, and flags a genuine tie as a deterministic
'ambiguous -- qualify' diagnostic. Regression: examples/generics/0217.
2026-06-21 18:43:49 +03:00

7.6 KiB

0157 — UFCS generic method whose name collides with a stdlib re-export leaves $R unresolved → LLVM panic

RESOLVED. Root cause: a bare-ufcs call (recv).name(args) resolved name via a single last-wins fn_ast_map[name] with NO receiver-type filtering — a user cancel :: ufcs (t: *Task($R)) colliding with the stdlib re-export cancel :: ufcs (f: *Future($R)) picked the wrong one, $R never bound, and .unresolved reached LLVM → panic. Fix (src/ir/lower/call.zig): for every generic-ufcs dispatch, selectUfcsGenericByReceiver enumerates ALL module authors of the name (program_index.module_decls — covers namespaced-imported modules, not just flat-visible ones), keeps those whose receiver binds all type-params, and picks the most receiver-SPECIFIC one (concrete *Task($R) over a bare (x: $T)), deduping re-exports by fd identity; two distinct equally-specific binders → a deterministic "ambiguous, qualify the call" diagnostic; none bind → "cannot infer" (never an .unresolved into codegen). Regression test: examples/generics/0217-generics-ufcs-method-name-collides-stdlib.sx (user cancel(*Box($R)) vs stdlib cancel → resolves to the user method).

Residual (acceptable, no worse than pre-fix): when the receiver-matching author isn't in module_decls (synthetic), the call falls back to the last-wins fd0 if it binds. Determinism is guaranteed for all enumerable authors. The same missing-unbound-param guard also covers the qualified struct-method path (call.zig ~960) — left as-is (separate, not hit here).

Symptom

One-line: a user-defined generic UFCS method whose name collides with a stdlib re-exported generic UFCS (cancel, re-exported by std.sx from io.sx), called via UFCS on a different generic struct, leaves that struct's type parameter $R unresolved; the .unresolved TypeId then reaches LLVM emission and panics.

  • Observed: compiler panic during codegen — thread … panic: unresolved type reached LLVM emission — a type resolution failure was not diagnosed/aborted at src/backend/llvm/types.zig:196 (.unresolved => @panic(...)), reached via fieldLLVMTypetoLLVMTypeInfodeclareFunction.
  • Expected: the UFCS call resolves to the user's cancel (receiver type *Box($R) ≠ the stdlib cancel's *Future($R)), $R binds to i64, the program prints 1. If the call were genuinely ambiguous/unresolvable, a diagnostic must be emitted (e.g. "cannot infer generic type parameter 'R'", which the non-UFCS form cancel(c) already produces) — never a raw codegen panic.

Reproduction

issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.sx:

#import "modules/std.sx";

Box :: struct ($R: Type) { value: R; flag: i64; }

// Name collides with std.sx's re-exported `cancel :: io_mod.cancel`
// (generic ufcs over `*Future($R)`).
cancel :: ufcs (b: *Box($R)) { b.flag = 1; }

main :: () -> i64 {
    x : Box(i64) = ---; x.value = 7; x.flag = 0;
    (@x).cancel();          // expected: prints 1
    print("{}\n", x.flag);  // actual: $R unresolved -> LLVM panic
    return 0;
}

Run: ./zig-out/bin/sx run issues/0157-...sx → panics.

Isolation already done (the trigger is the NAME, nothing else)

Bisected from the B1.4a async-task work (a user cancel :: ufcs (t: *Task($R)) on std/sched.sx). All of these are IRRELEVANT to the crash — it reproduces or not based solely on whether the method name collides with a std.sx re-export:

  • Renaming cancel to any name NOT exported by std.sx (drop, m, zz_cancel99, …) → compiles & runs, prints 1. This is the whole bug: same body, same struct, same call site — only the name differs.
  • Body is irrelevant: { b.flag = 1; } (ignores $R), { b.value = b.value; } (touches $R), and -> $R { return b.value; } all crash under the name cancel.
  • Struct shape is irrelevant: single field { value: R; } and two fields { value: R; flag: i64; } both crash; field order doesn't matter.
  • Construction is irrelevant: an explicit x : Box(i64) = --- local crashes just as a heap *Box(i64) returned from another generic ufcs does. No closures / allocator / fibers needed.
  • The sibling stdlib name wait (also re-exported by std.sx from io.sx, generic ufcs over *Future($R) returning $R) does NOT crash when user-redefined over *Box($R) — it resolves and runs. So only some colliding names trip it; cancel (a void-returning generic ufcs) does.
  • The non-UFCS spelling cancel(c) instead of c.cancel() produces a clean diagnostic — error: cannot infer generic type parameter 'R' for 'cancel' from this call's arguments — rather than the panic. So the UFCS path is silently skipping the inference-failure diagnostic the non-UFCS path emits, and falling through to codegen with $R = .unresolved.

std.sx re-exports the colliding name at line ~101: cancel :: io_mod.cancel; (and io.sx:127 cancel :: ufcs (f: *Future($R))).

Investigation prompt (paste into a fresh session)

Fix issue 0157. A user-defined generic UFCS method whose name collides with a stdlib re-exported generic UFCS (std.sx re-exports cancel :: io_mod.cancel, a generic ufcs (f: *Future($R)) from io.sx) is mis-resolved when called via UFCS on a different generic struct. Repro: ./zig-out/bin/sx run issues/0157-ufcs-generic-method-name-collides-stdlib-unresolved.sxpanic: unresolved type reached LLVM emission at src/backend/llvm/types.zig:196. Renaming the user method to a non-colliding name makes it work, and the non-UFCS call form (cancel(c)) already emits the correct diagnostic cannot infer generic type parameter 'R' for 'cancel'.

Suspected area: UFCS method/overload resolution + generic-arg inference (look in src/ir/lower.zig / the call-lowering + UFCS-candidate-selection path, and the generic-instantiation inference that binds $R from the receiver argument — grep for where UFCS rewrites recv.f(args) into the candidate set and where a generic callee's type params are inferred from actual arg types). The bug: when an overload set for the UFCS name contains BOTH the stdlib cancel(*Future($R)) and the user cancel(*Box($R)), the resolver appears to bind $R against the wrong candidate (or fails to bind it and proceeds anyway) for the receiver *Box(i64), leaving Box's $R = .unresolved. The fix likely needs to either (a) pick the candidate whose receiver type unifies with the actual receiver (*Box(i64) → user cancel) BEFORE inferring type params, or (b) when inference fails for the chosen candidate, emit the SAME "cannot infer generic type parameter 'R'" diagnostic the non-UFCS path emits — never fall through to codegen with an .unresolved field type.

Verification: the repro must now print 1 (the user cancel runs) — OR, if the overload truly is meant to be ambiguous, must emit a clean diagnostic instead of the LLVM panic. Then move the repro into the feature suite per CLAUDE.md (examples/generics/... or wherever name-collision UFCS belongs) and re-run zig build test. Also re-enable the BLOCKED B1.4a work: the suspending fiber-task layer (go/wait/cancel) is already implemented in library/modules/std/sched.sx; its example examples/concurrency/1813-concurrency-fiber-async-suspend.sx (a cancel UFCS over *Task($R)) is what surfaced this — once 0157 is fixed, seed examples/concurrency/expected/1813-...exit, capture goldens with -Dname=...1813...sx -Dupdate-goldens, and verify the full suite.

Status: OPEN