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).
This commit is contained in:
agra
2026-06-25 22:39:49 +03:00
parent 45e69ac1bb
commit df1327e316
18 changed files with 269 additions and 33 deletions

View File

@@ -239,6 +239,17 @@ pub const DiagnosticList = struct {
return false;
}
/// Count of `.err`-level diagnostics (excludes warnings / notes / help).
/// Used to detect whether a NEW error was reported across a span of work,
/// without a warning/note bumping the total `items` length.
pub fn errorCount(self: *const DiagnosticList) usize {
var n: usize = 0;
for (self.items.items) |d| {
if (d.level == .err) n += 1;
}
return n;
}
fn resolveSourceAndFile(self: *const DiagnosticList, d: Diagnostic) struct { source: []const u8, file_name: []const u8 } {
if (d.source_file) |sf| {
if (self.import_sources) |is| {

View File

@@ -1190,6 +1190,14 @@ pub fn ensureTerminator(self: *Lowering, ret_ty: TypeId) void {
self.builder.emitUnreachable();
} else if (ret_ty == .void) {
self.builder.retVoid();
} else if (!ret_ty.isBuiltin() and self.module.types.get(ret_ty) == .error_set) {
// A pure-failable function (`-> !` / `-> !Named`, whose return type IS
// the error set) that falls off the end with no explicit `return;` is
// a SUCCESS exit — the error slot must carry 0 ("no error"), exactly
// like the bare-`return;` path in lowerReturn. Without this the slot is
// left undefined and the caller (or main) reads a garbage tag and
// reports a phantom unhandled error (issue 0190).
self.builder.ret(self.builder.constInt(0, ret_ty), ret_ty);
} else {
// Use const_undef for complex types (string, struct, etc.)
const default_val = if (ret_ty == .string or !ret_ty.isBuiltin())

View File

@@ -181,18 +181,15 @@ pub fn monomorphizeFunction(self: *Lowering, fd: *const ast.FnDecl, mangled_name
if (!self.currentBlockHasTerminator()) self.builder.emitUnreachable();
self.builder.finalize();
} else {
// Lower the function body
// Lower the function body. Delegate the trailing-value return to the
// shared `lowerValueBody` so the generic-instantiation path can't drift
// from the decl path — it handles all three body-return shapes: the
// value-failable success routing (append the success error slot via
// `lowerFailableSuccessReturn`, NOT a bare coerce+ret that leaves the
// error-tag slot uninitialized — issue 0190), the pure-failable
// fall-through, and the missing-value diagnostic.
if (ret_ty != .void) {
const body_val = self.lowerBlockValue(fd.body);
if (!self.currentBlockHasTerminator()) {
if (body_val) |val| {
const val_ty = self.builder.getRefType(val);
const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
self.builder.ret(coerced, ret_ty);
} else {
self.ensureTerminator(ret_ty);
}
}
self.lowerValueBody(fd.body, ret_ty);
} else {
self.lowerBlock(fd.body);
self.ensureTerminator(ret_ty);

View File

@@ -1054,16 +1054,13 @@ pub fn monomorphizePackFn(
self.lowerBlock(fd.body);
if (!self.currentBlockHasTerminator()) self.builder.emitUnreachable();
} else if (ret_ty != .void) {
const body_val = self.lowerBlockValue(fd.body);
if (!self.currentBlockHasTerminator()) {
if (body_val) |val| {
const val_ty = self.builder.getRefType(val);
const coerced = if (val_ty != .void) self.coerceToType(val, val_ty, ret_ty) else val;
self.builder.ret(coerced, ret_ty);
} else {
self.ensureTerminator(ret_ty);
}
}
// Delegate the trailing-value return to the shared `lowerValueBody`
// (mirrors the decl + generic paths) so this pack-fn instance can't
// drift — it routes the value-failable success through
// `lowerFailableSuccessReturn` (appending the success error slot)
// instead of a bare coerce+ret that leaves the error-tag slot
// uninitialized (issue 0190).
self.lowerValueBody(fd.body, ret_ty);
} else {
self.lowerBlock(fd.body);
self.ensureTerminator(ret_ty);

View File

@@ -127,11 +127,37 @@ pub fn lowerBlockValue(self: *Lowering, node: *const Node) ?Ref {
/// `;`-terminated (value discarded) or void — and the body doesn't already
/// terminate via `return`/`raise`. Replaces the old silent default-return.
pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
// Snapshot the ERROR count so the missing-value error below can be
// suppressed when the body ALREADY reported a real error (e.g. an explicit
// `return <pack>` where the pack has no runtime value). Count only `.err`
// diagnostics — a warning/note emitted while lowering the body (e.g. an
// ObjC selector arity warning) must NOT suppress a genuine missing-value
// error, or we'd ship an uninitialized return at exit 0.
const errs_before: usize = if (self.diagnostics) |d| d.errorCount() else 0;
const body_val = self.lowerBlockValue(body);
if (self.currentBlockHasTerminator()) return;
if (body_val) |val| {
const val_ty = self.builder.getRefType(val);
if (val_ty != .void) {
// Value-carrying failable `-> (T..., !)`: a trailing success
// EXPRESSION (no explicit `return`) yields just the value part —
// the compiler must append the success error slot (0). Mirror the
// explicit-`return EXPR;` path; a plain `coerceToType` would leave
// the error-tag slot uninitialized (phantom catch on success).
if (!ret_ty.isBuiltin() and
self.module.types.get(ret_ty) == .tuple and
self.errorChannelOf(ret_ty) != null)
{
const span = blk: {
if (body.data == .block) {
const stmts = body.data.block.stmts;
if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
}
break :blk body.span;
};
self.lowerFailableSuccessReturn(val, ret_ty, span);
return;
}
const coerced = self.coerceToType(val, val_ty, ret_ty);
self.builder.ret(coerced, ret_ty);
return;
@@ -148,17 +174,23 @@ pub fn lowerValueBody(self: *Lowering, body: *const Node, ret_ty: TypeId) void {
}
}
if (self.diagnostics) |diags| {
if (body.data == .block and body.data.block.discarded_semi != null) {
diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
} else {
const span = blk: {
if (body.data == .block) {
const stmts = body.data.block.stmts;
if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
}
break :blk body.span;
};
diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
// Only the body produced no value AND no error was reported while
// lowering it — a genuine "missing trailing value", not the fallout of
// an already-diagnosed failed return. (If a real error fired, surfacing
// the redundant missing-value note would just be noise.)
if (diags.errorCount() == errs_before) {
if (body.data == .block and body.data.block.discarded_semi != null) {
diags.addFmt(.err, body.data.block.discarded_semi.?, "function returns '{s}' but the last expression's value is discarded by this `;` — drop the `;` to return it (or use an explicit `return`)", .{self.formatTypeName(ret_ty)});
} else {
const span = blk: {
if (body.data == .block) {
const stmts = body.data.block.stmts;
if (stmts.len > 0) break :blk stmts[stmts.len - 1].span;
}
break :blk body.span;
};
diags.addFmt(.err, span, "function returns '{s}' but its body produces no value — end it with a trailing expression (no `;`) or an explicit `return`", .{self.formatTypeName(ret_ty)});
}
}
}
self.ensureTerminator(ret_ty);