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.
This commit is contained in:
85
issues/0156-comptime-pack-captured-into-closure.md
Normal file
85
issues/0156-comptime-pack-captured-into-closure.md
Normal file
@@ -0,0 +1,85 @@
|
||||
# 0156 — deferred `..` spread (pack captured into a closure / tuple spread) crashes the backend
|
||||
|
||||
> **Two bugs were conflated under this number.** Investigation split them:
|
||||
>
|
||||
> **Part 1 — `$R` (single-type generic) in a type-arg slot inside a pack-fn body
|
||||
> → LLVM panic — ✅ FIXED.** The parser tags every `$name` expression as
|
||||
> `comptime_pack_ref`, so a single-type binding (`$R` from `Closure(..$args) ->
|
||||
> $R`) used as `Box($R)` / `size_of(Box($R))` reached `resolveTypeWithBindings`
|
||||
> (the resolver `instantiateGenericStruct` runs each type-arg through) as a
|
||||
> `comptime_pack_ref` it had no arm for → catch-all `else` → `.unresolved` →
|
||||
> `src/backend/llvm/types.zig:196` panic. Fix: mirror `resolveTypeArg`'s
|
||||
> `comptime_pack_ref` arm in `resolveTypeWithBindings` (`src/ir/lower.zig`) —
|
||||
> look up `type_bindings`, else emit a loud "pack used where a single type is
|
||||
> required" diagnostic (never a silent default type). Regression test:
|
||||
> `examples/generics/0216-generics-typearg-in-pack-fn-body.sx` (`size_of(Box($R))`
|
||||
> in a pack-fn → `r: 42`).
|
||||
>
|
||||
> **Part 2 — deferred `..` spread crashes — OPEN, NON-BLOCKING (below).**
|
||||
|
||||
## Part 2 — Symptom (OPEN)
|
||||
|
||||
A comptime variadic pack is **comptime state**, not a runtime value: a spread
|
||||
`f(..args)` is expanded at the spread site from `pack_arg_nodes` (the original
|
||||
call-site arg AST, referencing the *caller's* locals). Trying to make a `..`
|
||||
spread cross a **deferred / value boundary** crashes instead of either working
|
||||
or diagnosing:
|
||||
|
||||
- **pack captured into a closure** then spread later — `() => { ... worker(..args) ... }`
|
||||
— **SEGFAULTs at runtime** (the deferred body re-expands `args[i]` from the
|
||||
spawner's locals, which are gone by the time the closure runs on another
|
||||
stack), or panics in the backend when types don't resolve.
|
||||
- **spreading a concrete TUPLE** — `t := .{40, 2}; w(..t)` — **panics**
|
||||
(`unresolved type reached LLVM emission`): `..` only accepts a comptime pack,
|
||||
not a runtime aggregate, and the unsupported case degrades to `.unresolved`
|
||||
rather than a diagnostic.
|
||||
|
||||
Expected: either (a) a `..` spread of a concrete tuple/array is a real feature
|
||||
that lowers to N positional args, and capturing a pack into a closure
|
||||
materializes it; or (b) both are rejected with a clean diagnostic at the spread
|
||||
site. Never a segfault / `.unresolved`-reaches-backend.
|
||||
|
||||
## Reproduction (Part 2)
|
||||
|
||||
```sx
|
||||
#import "modules/std.sx";
|
||||
main :: () {
|
||||
w := (a: i64, b: i64) -> i64 => a + b;
|
||||
t := .{40, 2};
|
||||
out : i64 = 0; po := @out;
|
||||
captured :: () => { po.* = w(..t); }; // tuple spread inside a closure
|
||||
captured();
|
||||
print("out: {}\n", out); // panics: unresolved type reached LLVM emission
|
||||
}
|
||||
```
|
||||
|
||||
(Pack-into-closure variant — segfault: see the original repro shape in this
|
||||
issue's history; `runner :: ufcs (io, worker: Closure(..$args)->i64, ..$args)`
|
||||
with `captured :: () => { po.* = worker(..args); }` segfaults at runtime.)
|
||||
|
||||
## Why it is NON-BLOCKING for the fiber async work (B1.4a)
|
||||
|
||||
The fiber `async`/`await` layer does NOT need a `..` spread to cross the fiber
|
||||
boundary. Deferred async is expressed as a **nullary thunk** that captures its
|
||||
inputs at the call site (where they are live) — `async(io, work: Closure() ->
|
||||
$R)`, used `context.io.async(() => a + b)`. The user's lambda captures `a`/`b`;
|
||||
`async` spawns the already-bound nullary closure as a fiber. No pack crosses the
|
||||
deferral. This is the idiomatic deferred-async shape (cf. `go func(){...}()`),
|
||||
proven end-to-end (`.sx-tmp/pnullary.sx` → `log: 1 2 3 42 100`). So Part 2 is
|
||||
filed for its own session, not a B1.4a blocker.
|
||||
|
||||
## Investigation prompt (Part 2)
|
||||
|
||||
Decide the intended semantics of `..` on a concrete value first (consult
|
||||
`specs.md` §packs). If a `..` spread of a runtime tuple/array SHOULD lower to N
|
||||
positional args: implement it in the pack-spread call lowering (`src/ir/lower/pack.zig`
|
||||
`lowerPackElems` / the `.spread_expr` handling) for a concrete-aggregate operand
|
||||
(emit a GEP+load per element), and make closure capture of a pack materialize
|
||||
the pack's monomorphized element values into the env. If `..` is intentionally
|
||||
comptime-pack-only: emit a diagnostic at the spread site when the operand is a
|
||||
runtime value or a captured pack ("cannot spread a runtime value / a captured
|
||||
pack; `..` applies to a comptime pack only"), and ensure the capture-analysis
|
||||
pass rejects a `comptime_pack_ref` capture cleanly — never let `.unresolved`
|
||||
reach the backend (the segfault path must become a diagnostic). Verify: the
|
||||
Part-2 repro above either prints `out: 42` or emits one clean diagnostic — never
|
||||
a segfault / panic.
|
||||
20
issues/0156-comptime-pack-captured-into-closure.sx
Normal file
20
issues/0156-comptime-pack-captured-into-closure.sx
Normal file
@@ -0,0 +1,20 @@
|
||||
// issue 0156 Part 2 (OPEN, non-blocking) — a deferred `..` spread crashes the
|
||||
// backend instead of working or diagnosing. `..` is comptime-pack-only; spreading
|
||||
// a concrete tuple `w(..t)` panics (`unresolved type reached LLVM emission`), and
|
||||
// capturing a comptime pack into a closure then spreading it segfaults at runtime
|
||||
// (the deferred body re-expands the pack from the now-gone caller locals).
|
||||
//
|
||||
// Part 1 (the `$R`-single-type-arg-in-a-pack-fn LLVM panic) was a SEPARATE bug,
|
||||
// now FIXED — see examples/generics/0216-generics-typearg-in-pack-fn-body.sx.
|
||||
//
|
||||
// Not a fiber-async blocker: deferred async uses a nullary thunk that captures
|
||||
// its inputs at the call site, so no `..` spread crosses the deferral.
|
||||
#import "modules/std.sx";
|
||||
main :: () {
|
||||
w := (a: i64, b: i64) -> i64 => a + b;
|
||||
t := .{40, 2};
|
||||
out : i64 = 0; po := @out;
|
||||
captured :: () => { po.* = w(..t); }; // tuple spread inside a closure → panics
|
||||
captured();
|
||||
print("out: {}\n", out); // want: out: 42 (or a clean diagnostic)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
# 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
|
||||
`fieldLLVMType` → `toLLVMTypeInfo` → `declareFunction`.
|
||||
- **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`:
|
||||
|
||||
```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.sx`
|
||||
> → `panic: 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
|
||||
Reference in New Issue
Block a user