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).
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 insrc/ir/lower/control_flow.zig) handledvoidandnoreturn, but for a pure-failable return type (an.error_set) it fell through to the genericelsearm and emittedret const_undef(ret_ty), leaving the error-channel slot undefined. The bare-return;path inlowerReturn(src/ir/lower/stmt.zig) already wroteconstInt(0, ret_ty)("no error") for the same case, so adding an explicitreturn;masked the bug.Fix:
src/ir/lower/control_flow.zigensureTerminator— added an arm that, for a pure-failable (!ret_ty.isBuiltin() and types.get(ret_ty) == .error_set) end-of-body fall-through, emitsret constInt(0, ret_ty), matching the explicit-return;success path. This covers ALL failable functions that fall through, not justmain.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 explicitreturn).lowerValueBody(src/ir/lower/stmt.zig) blindlycoerceToType+ret'd the bare success value to the full failable tuple type, leaving the success error-tag slot uninitialized → phantomcatch/oron SUCCESS (and a dropped value /ret { ... } undeffor string + multi-value returns). Fix: before the genericcoerceToType+ret, mirror the explicit-return EXPR;branch — whenret_tyis a value-failable tuple (!ret_ty.isBuiltin() and types.get(ret_ty) == .tuple and self.errorChannelOf(ret_ty) != null) callself.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) andmonomorphizePackFn(src/ir/lower/pack.zig). A generic value-failable($T) -> T !E { v }instantiated at i64 / string / struct shipped an uninitialized error slot → phantomcatchon success andorsilently yielding the fallback (value corruption). Fix: both sites now DELEGATE the trailing-value return to the sharedlowerValueBody(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 throughlowerFailableSuccessReturn. (The JNI native-method entry wrapper inffi.zigstill 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 (itscatchmust not fire) called from amain :: () -> !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) }) eachcatch-handled (catch must not fire, value correct), plus a realraisestill 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 (eachcatch-handled, catch must not fire, value correct), theor-form yielding the real value not the fallback, plus a generic thatraises 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"); }printsokthenerror: unhandled error reached main: error.and exits 1. - Expected: exit 0 (specs.md §11: "the exit code is
0for void /-> !success"). Adding an explicit trailingreturn;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.)