Attempt-1 narrowed lowerReturn's target to failableSuccessType(ret_ty) for
every value-carrying failable. That fixed the bare-enum success slot but
introduced two defects (attempt-2 review):
F1 — explicit full failable tuple `return (.v, error.X)` panicked. With the
target narrowed to the value type, the trailing error element no longer
resolved against the error set, leaving an `.unresolved` tuple field that
tripped "unresolved type reached LLVM emission" in backend/llvm/types.zig.
F2 — a `-> (Enum, !E)` body with a comptime parameter is inlined
(lowerComptimeCall), so its success `return .red` took the inline-return path,
which the first cut skipped: it stored `{value, undef}` (error slot undef) into
the inline slot, so the success error slot read garbage at runtime.
Fix: choose the return-expr target via failableReturnTarget(ret_ty, value_node)
— a BARE value resolves against failableSuccessType (real enum ordinal), while
an EXPLICIT full failable tuple literal (arity == full-tuple field count) keeps
the full-tuple target and is forwarded as-is. This applies on the inline path
too, and the inline value-failable return now routes through
lowerFailableSuccessReturn (whose emitTupleRet stores `{value, 0}` into the
inline slot + branches), so the success error slot is 0 there as well.
Regression: examples/1056-errors-enum-value-failable-tuple-and-comptime.sx —
F1 explicit-tuple error return + bare-value success in one fn (no panic, slot 0
on success, tag 1 on error); F2 comptime-param enum value-failable read at
runtime on the success path (cast, bare if, == error.X) + error path. Reads the
slot at runtime so an undef is caught, not masked by the `if !e` proof.
examples/1055 + the original 0097 repro still pass. Gate: zig build 0,
zig build test 0, run_examples.sh 453 ok / 0 failed / 0 timed out.
6.2 KiB
0097 — value-failable returning an ENUM corrupts the error slot on the success path
RESOLVED. Root cause was not the field-offset/width miscalculation originally
hypothesized — tuple_init / tuple_get and the backend struct layout were correct. The real
cause was upstream in lowerReturn (src/ir/lower.zig): when lowering the returned expression of
a value-carrying failable -> (T..., !), target_type was set to the full failable tuple
(Color, !E) instead of the success value type Color. A bare enum literal .red resolves
its variant tag against target_type (lowerEnumLiteral → resolveVariantValue); against a tuple
type there is no matching variant, so it returned the silent 0 default AND stamped the result
with the tuple type. lowerFailableSuccessReturn then saw val_ty == ret_ty and took the
forwarding branch, returning the half-built aggregate { value, undef } as-is — the appended
constInt(0, err_ty) was never inserted, leaving the error slot undef (read back as garbage
nonzero) on the success path.
Fix: in lowerReturn, choose the target_type for the returned expression via
failableReturnTarget(ret_ty, value_node): for a value-carrying failable a bare returned
value resolves against failableSuccessType(ret_ty) (the value type / value-tuple) so an enum
literal gets its real ordinal and the success-return path appends the 0 error slot; an
explicit full failable tuple literal (return (v..., e), arity == full-tuple field count)
keeps the full-tuple target so its trailing error element resolves against the error set and is
forwarded as-is. The s32 case was already correct because integer literals don't resolve variants
against target_type.
Two follow-up defects from the first cut of this fix were corrected (attempt-2 review):
- F1 — explicit full tuple return panicked. Narrowing the target to the value type for all
value-failables broke
return (.blue, error.Nope): the trailing error element no longer resolved against the error set, leaving an.unresolvedtuple field that tripped the "unresolved type reached LLVM emission" panic insrc/backend/llvm/types.zig. The arity-awarefailableReturnTargetkeeps the full-tuple target for the explicit form, so it lowers and forwards as before. - F2 — comptime-param inline return still corrupted. A
-> (Enum, !E)body with a comptime parameter is inlined (lowerComptimeCall), so its successreturn .redtook the inline-return path (if (self.inline_return_target)), which the first cut skipped — it stored{value, undef}(error slotundef) into the inline slot. That path now applies the same target narrowing and routes a value-carrying failable throughlowerFailableSuccessReturn(whoseemitTupleRetstores{value, 0}into the inline slot + branches), so the success error slot is0there too.
Regression: examples/1055-errors-enum-value-failable-error-slot.sx (bare-enum success slot)
and examples/1056-errors-enum-value-failable-tuple-and-comptime.sx (F1 explicit-tuple error +
bare-value success in one fn; F2 comptime-param enum value-failable read at runtime on the success
path — cast, bare if, == error.X, plus the error path). Both read the slot at runtime so an
undef is caught, not masked by the if !e proof. Fail on pre-fix code, pass after. Verified
zig build, zig build test, and bash tests/run_examples.sh (453 ok) all green.
Below preserved as a record of the original problem.
Symptom
A value-failable function -> (EnumType, !ErrSet) writes a garbage nonzero tag into the error
slot on the SUCCESS path. Per specs.md the error channel must be 0 on success ("0 in the
error slot means no error"). Every runtime read of the slot on success (cast(s64) err, bare
if err, err == error.X, and therefore error_tag_name(err)) reports a false error. Only the
path-sensitive compile-time proof if !err reads correctly (it is tied to the SSA value, not a
runtime load of the slot), which is why it masks the bug.
- Observed (enum value): success path → error slot reads nonzero (garbage
undef), not0. - Expected: success path → error slot reads
0;if erris false;err == error.Xis false.
Reproduction (only imports modules/std.sx)
#import "modules/std.sx";
Color :: enum { red; green; blue; }
E :: error { Nope }
pick :: (s: string) -> (Color, !E) {
if s == "red" { return .red; } // SUCCESS path
raise error.Nope;
}
main :: () -> s32 {
c, e := pick("red"); // SUCCESS -> error slot MUST be 0
print("error e (int) = {}\n", cast(s64) e); // EXPECT 0 ; BUG prints 1
if e { print("bare-if e: ERROR (WRONG)\n"); } else { print("bare-if e: ok\n"); }
if e == error.Nope { print("e == Nope (WRONG)\n"); } else { print("e != Nope (ok)\n"); }
if !e { print("guard !e: value c (int) = {}\n", cast(s64) c); } // c = 0 = .red (CORRECT)
return 0;
}
Actual (buggy):
error e (int) = 1
bare-if e: ERROR (WRONG)
e == Nope (WRONG)
guard !e: value c (int) = 0
Expected (now produced):
error e (int) = 0
bare-if e: ok
e != Nope (ok)
guard !e: value c (int) = 0
Contrast — the IDENTICAL shape with an s32 value is CORRECT
pick :: (n: s32) -> (s32, !E) { if n > 0 { return n; } raise error.Nope; }
// v, e := pick(5); → error slot = 0 (correct); bare-if e: ok
The split is enum-value-specific because only an enum literal (return .variant) resolves its
tag against target_type. An integer literal does not, so the s32 path never got mis-stamped with
the failable-tuple type and never took the false forwarding branch.
Root cause (confirmed at ground truth)
return .red in pick lowered the enum literal with target_type = (Color, !E) (the whole
failable tuple). The LLVM IR on the success path was:
ret { i64, i32 } { i64 0, i32 undef } ; error slot UNDEF, not 0 (.blue gave i64 0 too — value lost)
vs. the s32 case which already produced ret { i32, i32 } { i32 7, i32 0 }. After narrowing the
return target to the value type, the enum success path produces ret { i64, i32 } zeroinitializer
(value 0 = .red, error slot 0), and .blue correctly carries ordinal 2.