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

42
examples/233-onfail.sx Normal file
View File

@@ -0,0 +1,42 @@
// `onfail` — cleanup that runs only when an error LEAVES the enclosing block
// (ERR step E1.7). Unlike `defer` (which runs on every exit), `onfail` fires
// on an error exit — a `raise` or a propagating `try` — and is skipped on
// success. On an error exit `defer` and `onfail` run interleaved in reverse
// declaration order. `onfail e { … }` binds the in-flight error tag.
// (Per-attempt-`try` gating and `or`-chain absorption refine this in E2.4b.)
#import "modules/std.sx";
E :: error { Bad }
inner :: (n: s32) -> !E {
if n < 0 { raise error.Bad; }
return;
}
// defer + onfail interleave on the error path; only defers on success.
run :: (n: s32) -> !E {
defer print("defer A\n");
onfail print("onfail B\n");
defer print("defer C\n");
try inner(n); // n<0 → propagates → onfail fires
return;
}
// `onfail e` binds the tag.
classify :: (n: s32) -> !E {
onfail e { if e == error.Bad { print("cleanup: bad\n"); } }
if n < 0 { raise error.Bad; }
return;
}
main :: () -> s32 {
print("[fail]\n");
a := run(-1); // error → defer C, onfail B, defer A
print("[ok]\n");
b := run(7); // success → defer C, defer A (no onfail)
print("[bound]\n");
c := classify(-1); // onfail binding sees Bad
print("[done]\n");
return 0;
}

View File

@@ -0,0 +1,15 @@
// `onfail` rejection (ERR step E1.7): `onfail` is only valid inside a failable
// function. A non-failable function never error-exits, so an `onfail` could
// never fire — use `defer` for unconditional cleanup. The positive cases live
// in `examples/233-onfail.sx`.
#import "modules/std.sx";
non_failable :: () -> s32 {
onfail print("never fires\n"); // error: onfail outside a failable function
return 0;
}
main :: () -> s32 {
return non_failable();
}

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 ──────────────────────────────────────────────────────────── // ── Lowering ────────────────────────────────────────────────────────────
pub const Lowering = struct { 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) 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 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 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) 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 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 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), .raise_stmt => |rs| self.lowerRaise(&rs, node.span),
.assignment => |asgn| self.lowerAssignment(&asgn), .assignment => |asgn| self.lowerAssignment(&asgn),
.defer_stmt => |ds| self.lowerDefer(&ds), .defer_stmt => |ds| self.lowerDefer(&ds),
.onfail_stmt => |ofs| self.lowerOnFail(&ofs, node.span),
.push_stmt => |ps| self.lowerPush(&ps), .push_stmt => |ps| self.lowerPush(&ps),
.multi_assign => |ma| self.lowerMultiAssign(&ma), .multi_assign => |ma| self.lowerMultiAssign(&ma),
.destructure_decl => |dd| self.lowerDestructureDecl(&dd), .destructure_decl => |dd| self.lowerDestructureDecl(&dd),
@@ -8084,17 +8095,42 @@ pub const Lowering = struct {
// ── Defer/Push/MultiAssign ────────────────────────────────────── // ── Defer/Push/MultiAssign ──────────────────────────────────────
fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void { fn lowerDefer(self: *Lowering, ds: *const ast.DeferStmt) void {
// Push deferred expression onto the stack — will be emitted at block exit in LIFO order // Push deferred expression onto the stack — emitted at every block exit, LIFO.
self.defer_stack.append(self.alloc, ds.expr) catch {}; self.defer_stack.append(self.alloc, .{ .body = ds.expr, .is_onfail = false }) catch {};
} }
/// Emit deferred expressions from saved_len..current in reverse (LIFO) order, /// `onfail [e] BODY` (ERR E1.7) — cleanup that runs only when an error
/// then truncate the defer stack back to saved_len. /// 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 { fn emitBlockDefers(self: *Lowering, saved_len: usize) void {
// Guard: if stack was already drained (e.g., by a return that emitted all defers) // 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 (saved_len > self.defer_stack.items.len) return;
if (self.currentBlockHasTerminator()) { 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); self.defer_stack.shrinkRetainingCapacity(saved_len);
return; return;
} }
@@ -8102,11 +8138,43 @@ pub const Lowering = struct {
var i = stack.len; var i = stack.len;
while (i > saved_len) { while (i > saved_len) {
i -= 1; i -= 1;
_ = self.lowerExpr(stack[i]); if (!stack[i].is_onfail) _ = self.lowerExpr(stack[i].body);
} }
self.defer_stack.shrinkRetainingCapacity(saved_len); 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 { fn lowerPush(self: *Lowering, ps: *const ast.PushStmt) void {
// push Context.{...} { body } — allocates a fresh Context on the // push Context.{...} { body } — allocates a fresh Context on the
// stack frame, rebinds the lowering's `current_ctx_ref` to it for // 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 // (3) Emit the failure return. Pure-failable: the return type IS the
// error set, so return the tag value directly. // error set, so return the tag value directly.
if (ret_ty == err_set) { if (ret_ty == err_set) {
self.emitBlockDefers(self.func_defer_base);
const tag_ty = self.builder.getRefType(tag_ref); 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; 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| { if (self.inline_return_target) |iri| {
self.builder.store(iri.slot, coerced); self.builder.store(iri.slot, coerced);
self.builder.br(iri.done_bb, &.{}); self.builder.br(iri.done_bb, &.{});
@@ -15166,15 +15234,15 @@ pub const Lowering = struct {
} else { } else {
// Value-carrying `-> (T..., !)`: the error path leaves the value // Value-carrying `-> (T..., !)`: the error path leaves the value
// slots undefined and carries the tag in the error slot (ERR E2.1). // 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; const fields = self.module.types.get(ret_ty).tuple.fields;
var slots = std.ArrayList(Ref).empty; var slots = std.ArrayList(Ref).empty;
defer slots.deinit(self.alloc); defer slots.deinit(self.alloc);
for (fields[0 .. fields.len - 1]) |vty| { for (fields[0 .. fields.len - 1]) |vty| {
slots.append(self.alloc, self.builder.constUndef(vty)) catch unreachable; 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); const tup = self.buildFailableTuple(ret_ty, slots.items, coerced_tag);
self.emitTupleRet(ret_ty, tup); self.emitTupleRet(ret_ty, tup);
} }
@@ -15310,11 +15378,11 @@ pub const Lowering = struct {
const ok_bb = self.freshBlock("try.ok"); const ok_bb = self.freshBlock("try.ok");
self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{}); self.builder.condBr(is_err, prop_bb, &.{}, ok_bb, &.{});
// Propagation: run the function's defers, then return the caller's // Propagation: run the function's cleanups (defers + onfails, since
// failure carrying this tag (pure caller → `ret(tag)`; value-carrying // this is an error exit), then return the caller's failure carrying
// caller → `ret {undef..., tag}`). // this tag (pure caller → `ret(tag)`; value-carrying → `ret {undef, tag}`).
self.builder.switchToBlock(prop_bb); 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); self.emitErrorReturn(caller_ret, caller_set, err_val);
// Success: a value-carrying callee yields its value slot; a // Success: a value-carrying callee yields its value slot; a

View File

@@ -0,0 +1 @@
0

View File

@@ -0,0 +1,10 @@
[fail]
defer C
onfail B
defer A
[ok]
defer C
defer A
[bound]
cleanup: bad
[done]

View File

@@ -0,0 +1 @@
1

View File

@@ -0,0 +1,5 @@
error: `onfail` is only valid inside a failable function (a return type with `!` or `!Named`) — use `defer` for unconditional cleanup
--> /Users/agra/projects/sx/examples/234-onfail-reject.sx:9:5
|
9 | onfail print("never fires\n"); // error: onfail outside a failable function
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^