Files
sx/issues/0153-reexport-generic-value-failable-loses-error-channel.md
agra 68c1991e11 issue 0153 RESOLVED: pin generic return-type resolution to the fn's defining module
inferGenericReturnType resolved a 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 NOT tagged .error_set, so the planned result was a tuple whose last
field wasn't an error set — errorChannelOf saw a plain tuple and the value-
failable's ! channel was lost (try/or rejected it / built a malformed i1 PHI).

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 disagreed with the instance's real signature. Fix:
pin the source to fd.body.source_file around the return-type resolution
(binding-build stays in the call-site context — its args are typed there).

Regression test examples/1058-errors-reexport-value-failable-channel.sx
(+ companion lib.sx). Suite green 732/0.
2026-06-21 05:55:14 +03:00

6.1 KiB

0153 — a re-exported generic value-failable ($R, !E) loses its ! error channel

RESOLVED (2026-06-21)

Root causeGenericResolver.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 + orworks
  • generic value-failable imported directly (no re-export) + orworks
  • 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.

// 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; }
// 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):

#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.