# 0153 — a re-exported generic value-failable `($R, !E)` loses its `!` error channel ## ✅ RESOLVED (2026-06-21) **Root cause** — `GenericResolver.inferGenericReturnType` (`src/ir/generics.zig`) resolved the generic call's return-type AST (`($R, !E)`) in the CALL-SITE module context. For a re-exported fn the error set name (`LE` / `IoErr`, re-exported as `LE :: lib.LE`) resolved through the call-site alias to a TypeId that is NOT tagged `.error_set`, so the planned result was a tuple whose last field wasn't an error set — `errorChannelOf` (`lower/error.zig:148`) saw a plain tuple and the failable channel was lost. `monomorphizeFunction` already pins the source to the fn's defining module before resolving the return type; `inferGenericReturnType` did not, so the planned call-result type and the instance's real signature disagreed. **Fix** — pin the source to the function's defining module (`fd.body.source_file`) around the return-type resolution in `inferGenericReturnType`, mirroring `monomorphizeFunction`. The binding-build stays in the call-site context (its args are typed there). Now the `!E` resolves to the same `.error_set` TypeId the instance's signature uses. **Verified** — the repro prints `r=42`; regression test `examples/1058-errors-reexport-value-failable-channel.sx` (+ companion `lib.sx`). This also unblocked the B1.2 async surface end-to-end: `examples/1805-concurrency-io-blocking-async.sx` (`sum: 42` / `double: 42` / `clock ok`) + `examples/1806-concurrency-io-cancel.sx` (cancel → `await` raises `.Canceled`). Full suite green. --- ## Symptom A generic function returning a value-failable `($R, !E)` keeps its error channel when called from the module that declares it, but **loses it when the function is reached through a re-export alias** (`get :: lib.get;`). At the consumer the call result is typed as a plain **tuple** (last field is a *non*-`.error_set` type), so: - `try f()` → `error: `try` requires a failable expression; operand has type 'tuple'` - `f() or { default }` → LLVM verification failure — the `or` merge PHI is typed `i1` (the lost-channel discriminant) but carries the `i64` default: ``` PHI node operands are not the same type as the result! %bp = phi i1 [ true, %entry ], [ -1, %or.rhs.0 ] ``` It requires **both** conditions — drop either and it works: - non-generic re-exported value-failable + `or` → **works** - generic value-failable imported **directly** (no re-export) + `or` → **works** - generic value-failable, **direct call** (no UFCS) through a re-export → **fails** too (so it is NOT UFCS-specific) ## Reproduction Co-located minimal repro (two files, no project deps beyond `modules/std.sx`): `issues/0153-...sx` (consumer) + `issues/0153-.../lib.sx` (impl). Run the consumer; expect `r=42`, get the PHI verification failure. ```sx // lib.sx #import "modules/std.sx"; LE :: error { Bad } Box :: struct ($R: Type) { v: R; } get :: ufcs (b: *Box($R)) -> ($R, !LE) { return b.v; } ``` ```sx // main #import "modules/std.sx"; lib :: #import ".../lib.sx"; Box :: lib.Box; // re-export the generic struct, get :: lib.get; // the generic value-failable fn, LE :: lib.LE; // AND its error set (the std.sx facade pattern) main :: () -> i32 { b : Box(i64) = .{ v = 42 }; r := b.get() or { -1 }; // ← PHI i1/i64 mismatch print("r={}\n", r); return 0; } ``` Real-world one-liner (same bug, via the stdlib facade — `await`/`IoErr` are re-exported from `std/io.sx` through `std.sx`): ```sx #import "modules/std.sx"; #import "modules/std/atomic.sx"; main :: () -> i32 { f : Future(i64) = ---; f.value = 42; f.state = .ready; f.canceled = Atomic(bool).init(false); r := f.await() or { -1 }; // ← same PHI mismatch print("r={}\n", r); return 0; } ``` ## Impact Blocks the B1.2 async surface (the LAST blocker after 0151 + 0152). `await` returns `($R, !IoErr)` and is re-exported via `std.sx` (`await :: io_mod.await; IoErr :: io_mod.IoErr;`), so every `context.io.async(...).await() or { … }` / `try …await()` hits this. The async runtime itself is correct (Futures build, `$R` infers, the value is right) — only the call-site failable typing is wrong. ## Investigation prompt A value-failable `(T, !E)` is represented as a **tuple whose LAST field is an `.error_set` TypeId** — that is exactly what `Lowering.errorChannelOf` (`src/ir/lower/error.zig:148`) keys on. The bug is that the call-result type inferred for a re-exported generic fn is a tuple whose last field is NOT an `.error_set`, so `errorChannelOf` returns null (→ "plain tuple"). Suspect: the generic return-type resolution (`inferGenericReturnType` / `buildTypeBindings` in `src/ir/generics.zig` + `monomorphizeFunction` in `src/ir/lower/generic.zig`) resolves the fn's return-type AST `($R, !LE)` in a module context where the error-set name reached through the re-export alias (`LE :: lib.LE`) resolves to a TypeId that is NOT tagged `.error_set` (a duplicate/plain interning of the aliased error type, or the alias is followed to a non-error-set placeholder). The "generic + re-export" co-requirement points at the monomorphized return-type path specifically — a non-generic re-export keeps the channel (its return type isn't re-resolved per-instance), and a direct generic import keeps it (the error set resolves in its own module). Steps: 1. At the consumer call site, dump the inferred call-result TypeId for `b.get()` and inspect its last tuple field's `TypeInfo` — confirm it is NOT `.error_set` (vs the direct-import case, where it IS). 2. Trace where the aliased error-set name (`LE` / `IoErr`) is resolved during the instance's return-type construction; ensure it resolves to the SAME `.error_set` TypeId the declaring module interned (follow the re-export alias to the original error set, don't re-intern a plain type). Verification: run the co-located repro; expect `r=42`. Then restore the B1.2 async examples (`examples/1805-concurrency-io-blocking-async.sx` + `1806-...-io-cancel.sx`) per CHECKPOINT-FIBERS and confirm `sum: 42` / `double: 42` / cancel raises `.Canceled`.