From 57d8e327cdf0043008cde78edf98aedad37e5d5b Mon Sep 17 00:00:00 2001 From: agra Date: Sun, 31 May 2026 22:29:40 +0300 Subject: [PATCH] =?UTF-8?q?ERR/E1.7:=20onfail=20=E2=80=94=20cleanup=20on?= =?UTF-8?q?=20error-exit,=20interleaved=20with=20defer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `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. --- examples/233-onfail.sx | 42 ++++++++++++ examples/234-onfail-reject.sx | 15 ++++ src/ir/lower.zig | 98 +++++++++++++++++++++++---- tests/expected/233-onfail.exit | 1 + tests/expected/233-onfail.txt | 10 +++ tests/expected/234-onfail-reject.exit | 1 + tests/expected/234-onfail-reject.txt | 5 ++ 7 files changed, 157 insertions(+), 15 deletions(-) create mode 100644 examples/233-onfail.sx create mode 100644 examples/234-onfail-reject.sx create mode 100644 tests/expected/233-onfail.exit create mode 100644 tests/expected/233-onfail.txt create mode 100644 tests/expected/234-onfail-reject.exit create mode 100644 tests/expected/234-onfail-reject.txt diff --git a/examples/233-onfail.sx b/examples/233-onfail.sx new file mode 100644 index 0000000..905d985 --- /dev/null +++ b/examples/233-onfail.sx @@ -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; +} diff --git a/examples/234-onfail-reject.sx b/examples/234-onfail-reject.sx new file mode 100644 index 0000000..4cb88ef --- /dev/null +++ b/examples/234-onfail-reject.sx @@ -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(); +} diff --git a/src/ir/lower.zig b/src/ir/lower.zig index 95890ce..3a9d667 100644 --- a/src/ir/lower.zig +++ b/src/ir/lower.zig @@ -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 diff --git a/tests/expected/233-onfail.exit b/tests/expected/233-onfail.exit new file mode 100644 index 0000000..573541a --- /dev/null +++ b/tests/expected/233-onfail.exit @@ -0,0 +1 @@ +0 diff --git a/tests/expected/233-onfail.txt b/tests/expected/233-onfail.txt new file mode 100644 index 0000000..f63da9a --- /dev/null +++ b/tests/expected/233-onfail.txt @@ -0,0 +1,10 @@ +[fail] +defer C +onfail B +defer A +[ok] +defer C +defer A +[bound] +cleanup: bad +[done] diff --git a/tests/expected/234-onfail-reject.exit b/tests/expected/234-onfail-reject.exit new file mode 100644 index 0000000..d00491f --- /dev/null +++ b/tests/expected/234-onfail-reject.exit @@ -0,0 +1 @@ +1 diff --git a/tests/expected/234-onfail-reject.txt b/tests/expected/234-onfail-reject.txt new file mode 100644 index 0000000..3fd1398 --- /dev/null +++ b/tests/expected/234-onfail-reject.txt @@ -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 + | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^