Files
sx/issues/0097-enum-value-failable-error-slot-corruption.md
agra d8076b9333 lang: rename signed integer types sN -> iN
Surface rename of the signed integer family: s1..s64 become i1..i64
(u1..u64, usize, isize unchanged). 'string' keeps the s-prefix arm in
name classification; width parsing moves to the i-prefix arm next to
isize.

Internal TypeId tags follow the surface (.s8/.s16/.s32/.s64 ->
.i8/.i16/.i32/.i64), as do mono-key mangle fragments (ptr_i64,
tu_i64_bool) and all display/diagnostic formatting (i{d}).

Migrated in the same sweep: stdlib + examples + issue repros + FFI C
companions (shared symbol names like ffi_id_i64), expected
stdout/stderr/ir snapshots, specs.md, readme.md, CLAUDE.md/AGENTS.md,
implementation_plan.md, docs/, issue writeups. Vendored stb_image and
historical flow state left untouched.

zig build test: 426/426; examples suite: 595/595.
2026-06-12 09:31:53 +03:00

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 (lowerEnumLiteralresolveVariantValue); 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 i32 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 .unresolved tuple field that tripped the "unresolved type reached LLVM emission" panic in src/backend/llvm/types.zig. The arity-aware failableReturnTarget keeps 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 success return .red took the inline-return path (if (self.inline_return_target)), which the first cut skipped — it stored {value, undef} (error slot undef) into the inline slot. That path now applies the same target narrowing and routes a value-carrying failable through lowerFailableSuccessReturn (whose emitTupleRet stores {value, 0} into the inline slot + branches), so the success error slot is 0 there 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(i64) 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), not 0.
  • Expected: success path → error slot reads 0; if err is false; err == error.X is 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 :: () -> i32 {
    c, e := pick("red");                            // SUCCESS -> error slot MUST be 0
    print("error e (int) = {}\n", cast(i64) 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(i64) 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 i32 value is CORRECT

pick :: (n: i32) -> (i32, !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 i32 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 i32 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.