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).
136 lines
6.7 KiB
Markdown
136 lines
6.7 KiB
Markdown
# 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
|
|
> `raise`s 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
|
|
|
|
```sx
|
|
#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:
|
|
|
|
```sx
|
|
#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.)
|