ERR/E1.7: onfail — cleanup on error-exit, interleaved with defer

`onfail [e] BODY` runs cleanup only when an error LEAVES the enclosing block
(a `raise` or a propagating `try`), and is skipped on success — unlike `defer`,
which runs on every exit. On an error exit, defers and onfails run interleaved
in reverse declaration order; `onfail e` binds the in-flight error tag.

- Cleanup stack: defer_stack now holds CleanupEntry { body, is_onfail, binding }
  (one declaration-ordered stack so defer/onfail interleave). lowerDefer pushes
  a defer entry; lowerOnFail (new `.onfail_stmt` arm) pushes an onfail entry,
  rejecting `onfail` outside a failable function.
- emitBlockDefers (success exits — return / normal block exit) now emits only
  `defer` entries and discards onfails.
- emitErrorCleanup (new; wired at the error exits — lowerRaise pure +
  value-carrying, lowerTry propagation) emits both kinds interleaved in reverse,
  binding the in-flight tag for `onfail e`.

Block-rooted: an error propagating to the function drains all enclosing blocks'
onfails; a block that exits normally discards its onfails. Per-attempt-`try`
gating is moot for now (no compilable `or` chain can absorb a mid-block try
failure yet — E2.4b). Body restrictions beyond the parser's raise-in-onfail
ban are deferred.

Tests: examples/233-onfail.sx (interleave order on error vs success + binding;
deterministic trace), examples/234-onfail-reject.sx (onfail outside a failable
fn rejected; exit 1). Gates: zig build, zig build test, 272/272 examples.
This commit is contained in:
agra
2026-05-31 22:29:40 +03:00
parent 50e5515080
commit 57d8e327cd
7 changed files with 157 additions and 15 deletions

View File

@@ -76,6 +76,16 @@ const Scope = struct {
}
};
/// A pending block-scoped cleanup: `defer` (runs on every block exit) or
/// `onfail` (runs only when an error leaves the block, binding the in-flight
/// tag). Both share one declaration-ordered stack so error-exit cleanup runs
/// them interleaved in reverse order (ERR E1.7).
const CleanupEntry = struct {
body: *const Node,
is_onfail: bool,
binding: ?[]const u8 = null,
};
// ── Lowering ────────────────────────────────────────────────────────────
pub const Lowering = struct {
@@ -121,7 +131,7 @@ pub const Lowering = struct {
current_match_tags: ?[]const u64 = null, // type tags for current match arm (for runtime dispatch)
force_block_value: bool = false, // set by lowerBlockValue to extract if-else values
block_terminated: bool = false, // set when constant-folded if emits a return/br into current block
defer_stack: std.ArrayList(*const Node) = std.ArrayList(*const Node).empty, // block-scoped defer stack
defer_stack: std.ArrayList(CleanupEntry) = std.ArrayList(CleanupEntry).empty, // block-scoped defer + onfail cleanup stack
func_defer_base: usize = 0, // defer stack base for current function (lowerReturn drains to this)
global_names: std.StringHashMap(GlobalInfo) = std.StringHashMap(GlobalInfo).init(std.heap.page_allocator), // #run global name → GlobalId
deferred_type_fns: std.ArrayList([]const u8) = std.ArrayList([]const u8).empty, // functions deferred until all types registered
@@ -1472,6 +1482,7 @@ pub const Lowering = struct {
.raise_stmt => |rs| self.lowerRaise(&rs, node.span),
.assignment => |asgn| self.lowerAssignment(&asgn),
.defer_stmt => |ds| self.lowerDefer(&ds),
.onfail_stmt => |ofs| self.lowerOnFail(&ofs, node.span),
.push_stmt => |ps| self.lowerPush(&ps),
.multi_assign => |ma| self.lowerMultiAssign(&ma),
.destructure_decl => |dd| self.lowerDestructureDecl(&dd),
@@ -8084,17 +8095,42 @@ pub const Lowering = struct {
// ── Defer/Push/MultiAssign ──────────────────────────────────────
fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void {
// Push deferred expression onto the stack — will be emitted at block exit in LIFO order
self.defer_stack.append(self.alloc, ds.expr) catch {};
// Push deferred expression onto the stack — emitted at every block exit, LIFO.
self.defer_stack.append(self.alloc, .{ .body = ds.expr, .is_onfail = false }) catch {};
}
/// Emit deferred expressions from saved_len..current in reverse (LIFO) order,
/// then truncate the defer stack back to saved_len.
/// `onfail [e] BODY` (ERR E1.7) — cleanup that runs only when an error
/// leaves the enclosing block. Recorded on the shared cleanup stack;
/// emitted (interleaved with defers, reverse) at error exits by
/// `emitErrorCleanup`, and discarded — never run — on a success exit.
fn lowerOnFail(self: *Lowering, ofs: *const ast.OnFailStmt, span: ast.Span) void {
// `onfail` is only meaningful inside a failable function — a
// non-failable function never error-exits, so it could never fire.
const ret_ty = self.effectiveReturnType() orelse {
self.diagOnFailNotFailable(span);
return;
};
if (self.errorChannelOf(ret_ty) == null) {
self.diagOnFailNotFailable(span);
return;
}
self.defer_stack.append(self.alloc, .{ .body = ofs.body, .is_onfail = true, .binding = ofs.binding }) catch {};
}
fn diagOnFailNotFailable(self: *Lowering, span: ast.Span) void {
if (self.diagnostics) |diags| {
diags.addFmt(.err, span, "`onfail` is only valid inside a failable function (a return type with `!` or `!Named`) — use `defer` for unconditional cleanup", .{});
}
}
/// Emit cleanups from saved_len..current in reverse (LIFO) order on a
/// SUCCESS exit: only `defer` entries run; `onfail` entries are skipped
/// (and discarded by the truncation). Truncates the stack to saved_len.
fn emitBlockDefers(self: *Lowering, saved_len: usize) void {
// Guard: if stack was already drained (e.g., by a return that emitted all defers)
if (saved_len > self.defer_stack.items.len) return;
if (self.currentBlockHasTerminator()) {
// Block already terminated (e.g., by return) — defers were already emitted
// Block already terminated (e.g., by return) — cleanups were already emitted
self.defer_stack.shrinkRetainingCapacity(saved_len);
return;
}
@@ -8102,11 +8138,43 @@ pub const Lowering = struct {
var i = stack.len;
while (i > saved_len) {
i -= 1;
_ = self.lowerExpr(stack[i]);
if (!stack[i].is_onfail) _ = self.lowerExpr(stack[i].body);
}
self.defer_stack.shrinkRetainingCapacity(saved_len);
}
/// Emit cleanups from `base`..current in reverse order on an ERROR exit
/// (raise / try-propagation): BOTH `defer` and `onfail` entries run,
/// interleaved in reverse declaration order. `err_tag` is the in-flight
/// error tag, bound to each `onfail e`'s binding. Does not truncate — the
/// terminating `ret` + the unwinding block-scope `emitBlockDefers` (which
/// then see the terminator and skip) leave the stack consistent.
fn emitErrorCleanup(self: *Lowering, base: usize, err_tag: Ref) void {
if (base > self.defer_stack.items.len) return;
const tag_ty = self.builder.getRefType(err_tag);
const stack = self.defer_stack.items;
var i = stack.len;
while (i > base) {
i -= 1;
const entry = stack[i];
if (entry.is_onfail) {
if (entry.binding) |name| {
var ofscope = Scope.init(self.alloc, self.scope);
const saved = self.scope;
self.scope = &ofscope;
ofscope.put(name, .{ .ref = err_tag, .ty = tag_ty, .is_alloca = false });
_ = self.lowerExpr(entry.body);
self.scope = saved;
ofscope.deinit();
} else {
_ = self.lowerExpr(entry.body);
}
} else {
_ = self.lowerExpr(entry.body);
}
}
}
fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
// push Context.{...} { body } — allocates a fresh Context on the
// stack frame, rebinds the lowering's `current_ctx_ref` to it for
@@ -15154,9 +15222,9 @@ pub const Lowering = struct {
// (3) Emit the failure return. Pure-failable: the return type IS the
// error set, so return the tag value directly.
if (ret_ty == err_set) {
self.emitBlockDefers(self.func_defer_base);
const tag_ty = self.builder.getRefType(tag_ref);
const coerced = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
self.emitErrorCleanup(self.func_defer_base, coerced);
if (self.inline_return_target) |iri| {
self.builder.store(iri.slot, coerced);
self.builder.br(iri.done_bb, &.{});
@@ -15166,15 +15234,15 @@ pub const Lowering = struct {
} else {
// Value-carrying `-> (T..., !)`: the error path leaves the value
// slots undefined and carries the tag in the error slot (ERR E2.1).
self.emitBlockDefers(self.func_defer_base);
const tag_ty = self.builder.getRefType(tag_ref);
const coerced_tag = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
self.emitErrorCleanup(self.func_defer_base, coerced_tag);
const fields = self.module.types.get(ret_ty).tuple.fields;
var slots = std.ArrayList(Ref).empty;
defer slots.deinit(self.alloc);
for (fields[0 .. fields.len - 1]) |vty| {
slots.append(self.alloc, self.builder.constUndef(vty)) catch unreachable;
}
const tag_ty = self.builder.getRefType(tag_ref);
const coerced_tag = if (tag_ty != err_set) self.coerceToType(tag_ref, tag_ty, err_set) else tag_ref;
const tup = self.buildFailableTuple(ret_ty, slots.items, coerced_tag);
self.emitTupleRet(ret_ty, tup);
}
@@ -15310,11 +15378,11 @@ pub const Lowering = struct {
const ok_bb = self.freshBlock("try.ok");
self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});
// Propagation: run the function's defers, then return the caller's
// failure carrying this tag (pure caller → `ret(tag)`; value-carrying
// caller → `ret {undef..., tag}`).
// Propagation: run the function's cleanups (defers + onfails, since
// this is an error exit), then return the caller's failure carrying
// this tag (pure caller → `ret(tag)`; value-carrying → `ret {undef, tag}`).
self.builder.switchToBlock(prop_bb);
self.emitBlockDefers(self.func_defer_base);
self.emitErrorCleanup(self.func_defer_base, err_val);
self.emitErrorReturn(caller_ret, caller_set, err_val);
// Success: a value-carrying callee yields its value slot; a