Files
sx/issues/0190-void-failable-fallthrough-uninitialized-error-slot.md
agra df1327e316 fix: initialize the error-channel slot on every failable implicit success return (issue 0190)
A failable function that returned by IMPLICIT success (no explicit
`return`) left its error-tag slot uninitialized, so a caller's `catch` /
`or` (or `main`) read a garbage tag and reported a phantom unhandled
error — and for value-carrying failables the success value was dropped.
The "no error" sentinel was only written on the explicit-`return;` path.

Unified all function-body-return lowering so the failable-success slot
is always written:
  - void `-> !` fall-through: `ensureTerminator` (control_flow.zig) now
    emits `ret constInt(0)` for a pure-failable end-of-body.
  - value-failable trailing-expression success: `lowerValueBody`
    (stmt.zig) routes through `lowerFailableSuccessReturn`.
  - generic + pack-fn instances: `monomorphizeFunction` (generic.zig) and
    `monomorphizePackFn` (pack.zig) now DELEGATE their body-return to
    `lowerValueBody` instead of hand-rolling a `coerce`+`ret` that drifted
    (covers generic/pack value-failables).

Also fixes the missing-value diagnostic guard added here: it now counts
`.err`-level diagnostics (new `DiagnosticList.errorCount`) rather than the
total list length, so a warning/note emitted while lowering the body
(e.g. an ObjC selector arity warning) can no longer suppress a genuine
"body produces no value" error — which previously shipped an
uninitialized return at exit 0.

Regressions: examples/errors/1061 (void fall-through), 1062 (value-failable
trailing expr), 1063 (generic value-failable trailing expr).
2026-06-25 22:39:49 +03:00

6.7 KiB

0190 — void failable (-> !) implicit fall-through leaves the error slot uninitialized

Status: RESOLVED

Root cause: ensureTerminator (the unified implicit fall-through / epilogue synthesis in src/ir/lower/control_flow.zig) handled void and noreturn, but for a pure-failable return type (an .error_set) it fell through to the generic else arm and emitted ret const_undef(ret_ty), leaving the error-channel slot undefined. The bare-return; path in lowerReturn (src/ir/lower/stmt.zig) already wrote constInt(0, ret_ty) ("no error") for the same case, so adding an explicit return; masked the bug.

Fix: src/ir/lower/control_flow.zig ensureTerminator — added an arm that, for a pure-failable (!ret_ty.isBuiltin() and types.get(ret_ty) == .error_set) end-of-body fall-through, emits ret constInt(0, ret_ty), matching the explicit-return; success path. This covers ALL failable functions that fall through, not just main.

Sibling fix (value-failable trailing success expression): adversarial review found the SAME uninitialized-error-slot bug on a value-carrying failable (-> T !E / -> Tuple(A,B) !E) whose body ends in a trailing success EXPRESSION (no explicit return). lowerValueBody (src/ir/lower/stmt.zig) blindly coerceToType+ret'd the bare success value to the full failable tuple type, leaving the success error-tag slot uninitialized → phantom catch/or on SUCCESS (and a dropped value / ret { ... } undef for string + multi-value returns). Fix: before the generic coerceToType+ret, mirror the explicit-return EXPR; branch — when ret_ty is a value-failable tuple (!ret_ty.isBuiltin() and types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) call self.lowerFailableSuccessReturn(val, ret_ty, span) so the success error slot is set to 0. The pure-failable fall-through (above) and the missing-value error case (-> i64 !E { }) are untouched.

Generic + pack-instance fix (unification): the same trailing-value body-return was hand-rolled (coerceToType+ret, no failable-success routing) in TWO more places — monomorphizeFunction (src/ir/lower/generic.zig) and monomorphizePackFn (src/ir/lower/pack.zig). A generic value-failable ($T) -> T !E { v } instantiated at i64 / string / struct shipped an uninitialized error slot → phantom catch on success and or silently yielding the fallback (value corruption). Fix: both sites now DELEGATE the trailing-value return to the shared lowerValueBody (the same helper the decl path uses) instead of re-implementing it, so the value-failable success routing, the pure-failable fall-through, and the missing-value diagnostic are all handled in one place and can't drift again. With this, every body-return path that can carry a failable channel (decl, generic, pack-instance, closure/lambda) routes the trailing-success value through lowerFailableSuccessReturn. (The JNI native-method entry wrapper in ffi.zig still hand-rolls its body-return, but a JNI export crosses the C-ABI boundary where the error channel is forbidden by the ERR E5.1 FFI-boundary rule, so it can never be value-failable.)

Regression tests:

  • examples/errors/1061-errors-void-failable-fallthrough.sx — a -> ! callee that succeeds by fall-through (its catch must not fire) called from a main :: () -> ! that also falls through (exit 0).
  • examples/errors/1062-errors-value-failable-trailing-expr.sx — value-failable trailing-expression successes (-> i64 !E { 99 }, -> string !E { "hi" }, -> Tuple(i64,i64) !E { .(1,2) }) each catch-handled (catch must not fire, value correct), plus a real raise still firing the caller's catch.
  • examples/errors/1063-errors-generic-value-failable-trailing-expr.sx — a generic value-failable ($T) -> T !E { v } instantiated at i64 / string / struct (each catch-handled, catch must not fire, value correct), the or-form yielding the real value not the fallback, plus a generic that raises still firing the caller's catch.

Full suite green (examples: 813 ran, 0 failed).

Symptom

A -> ! (void failable) function that exits by implicit fall-through (no explicit return;) does not initialize its error-channel slot, so a caller (or main) reads a non-zero garbage tag and reports a phantom unhandled error.

  • Observed: main :: () -> ! { print("ok\n"); } prints ok then error: unhandled error reached main: error. and exits 1.
  • Expected: exit 0 (specs.md §11: "the exit code is 0 for void / -> ! success"). Adding an explicit trailing return; makes it exit 0.

This is the silent-uninitialized-slot failure mode: the success path should write "no error" into the channel just like an explicit return; does, but the fall-through path skips it.

Reproduction

#import "modules/std.sx";

main :: () -> ! {
    print("ok\n");
}

Run: ./zig-out/bin/sx run repro.sx → prints ok, then error: unhandled error reached main: error., exit 1 (should be 0).

A non-main void failable shows the same uninitialized slot downstream:

#import "modules/std.sx";

noop :: () -> ! { }                 // falls through, no `return;`
main :: () {
    noop() catch (e) { print("phantom: {}\n", e); }   // fires spuriously
}

Workaround (confirms root cause): an explicit return; at the end of the -> ! body initializes the slot and the phantom error disappears.

Investigation prompt

The error channel for a -> ! function is the last slot of the return aggregate (specs.md §12 ABI). An explicit return; lowers to a write of the "no error" sentinel into that slot; the implicit fall-through exit path (end of body with no return) apparently omits that write, leaving the slot whatever was on the stack.

Likely area: the function-epilogue / failable-return lowering in src/ir/lower/ (the path that synthesizes the implicit return for a body that falls off the end — search for where a void/-> ! function's trailing fall-through is lowered, and where the error slot's "no error" sentinel is written on the explicit-return; path). The fix: the implicit fall-through of a failable function must initialize the error slot to "no error" exactly like return; does.

Verification: the two repros above must exit 0 / not fire the catch; examples/errors/1026-errors-failable-main.sx (which currently passes only because it ends in return;) must keep passing. Add a regression example: a -> ! function (and a main :: () -> !) that succeeds by fall-through with no explicit return;.

(Found by adversarial review during the tuple-syntax-cutover docs pass, commit 989e18b7. Pre-existing — independent of the tuple change.)