Guides · 08
defer, recover, and panic
Vyi splits failure into two channels that never overlap:
- Errors as values —
E!T/?T, handled withtryandcatch. This is how a function reports failure it expects: a missing file, bad input, a full buffer. Guide 07 covers it. panic— an invariant violation: a bug, not an outcome. A panic carries only a human-readable reason string and unwinds the stack.
This guide covers the panic channel and the cleanup mechanism both channels share:
defer.
defer
defer schedules a statement (or a block) to run when the enclosing function
exits — however it exits:
import { stdout } from "@io"
fn greet() {
defer stdout.write("cleanup\n")
stdout.write("body\n")
}
fn main() !u8 {
greet() // prints "body", then "cleanup"
return Ok(0)
}Three rules define it:
Defers run on every exit path. A normal return, falling off the end of the
body, an error leaving through try, and a panic unwinding through the frame all
run the function's defers. That's the point: cleanup written with defer cannot be
skipped by an exit path you forgot about.
mut cleanups = 0
enum E { Bad }
fn leaf(x: i32) E!i32 {
if x == 0 { return Err(E.Bad) }
return Ok(x)
}
fn mid(x: i32) E!i32 {
defer { cleanups = cleanups + 1 }
con v = try leaf(x) // on Err, this exits mid — the defer still runs
return Ok(v + 1)
}After calling mid(5) and mid(0), cleanups is 2: the defer fired on the
success return and on the try propagation.
Defers run LIFO. The last one registered runs first, so teardown mirrors setup:
import { stdout } from "@io"
fn session() {
stdout.write("open socket\n")
defer stdout.write("close socket\n")
stdout.write("begin session\n")
defer stdout.write("end session\n")
}
// prints: open socket, begin session, end session, close socket
fn main() !u8 {
session()
return Ok(0)
}A defer belongs to the function, not the block. A defer inside an if or a
loop body is registered when execution reaches it, but it runs at function exit —
not when the block ends. (A defer that execution never reaches is never registered.)
The body of a defer is ordinary code with full access to the function's locals, and it evaluates when it runs — at exit — so it sees their final values, not a snapshot.
One more property, straight from the "runs on every exit" rule: a return inside a
defer replaces the function's return value.
import { stdout } from "@io"
fn f() i32 {
defer { return 9 }
return 1
}
fn main() !u8 {
stdout.writeInt(i64(f()))
stdout.write("
")
return Ok(0) // prints 9, not 1
}Used bare this is a curiosity; its real job is powering recover, below.
panic
panic reason aborts the current line of execution and starts unwinding the stack.
The reason is a [*]u8 — a string. String literals already are one, so:
import { stdout } from "@io"
fn checked(x: i32) i32 {
if x < 0 { panic "negative input" }
return x * 2
}
fn main() !u8 {
stdout.writeInt(i64(checked(21)))
stdout.write("
")
return Ok(0)
}There is no bare panic and no structured payload — a panic is for a human reading
a crash, not for a caller to branch on. Anything a caller should handle belongs in
an error union instead. The reason must be a [*]u8:
fn main() !u8 {
panic 5 // error: `panic` reason must be `[*]u8`, got i32
}Panicking unwinds frame by frame, running each frame's defers on the way out. If no
recover stops it, the panic leaves main, the runtime prints the reason and a
stack trace of the panic site, and the process exits with status 101. Call
checked(-21) above and you get:
panic: negative input
at main.vyi:7:18The runtime itself panics on a violated invariant. The one you'll meet first is
indexing out of bounds — [*]T indexing is bounds-checked:
import { stdout } from "@io"
fn main() !u8 {
con s = "abc"
stdout.writeInt(i64(s[7]))
stdout.write("
")
return Ok(0) // compiles; panics at runtime: array index out of bounds
}Standard-library guards panic the same way (for example @mem's copy when the
destination is too small). It's always the same shape: a condition the program
promised would hold, didn't.
recover
recover catches a panic. It is not a statement you can put anywhere — it lives
only inside a defer body, because the only moment a panic can be intercepted
is while the unwind is running that frame's cleanup:
import { stdout } from "@io"
fn boom() i32 {
panic "kaboom"
}
fn safe() i32 {
defer {
recover(reason) {
stdout.write("recovered: ")
stdout.write(reason) // reason is the panic's [*]u8
stdout.write("\n")
return 7
}
}
return boom()
}
fn main() !u8 {
con v = safe() // prints "recovered: kaboom"; v = 7
stdout.write("after\n")
stdout.writeInt(i64(v))
stdout.write("
")
return Ok(0)
}How it works:
recover(reason) { … }binds the panickingpanic's reason string asreason: [*]u8and runs the block — but only when the defer is running because of a panic. On a normal or error return the surrounding defer body still runs and therecoverblock is skipped.- A
returninside the block is the defer-return substitution from above: it sets the function's return value and halts the unwind. The caller sees an ordinary return — it never knows a panic happened. - A
recoverblock that does not return merely observes the panic: the unwind continues outward to the next defer up the stack, and ultimately crashes the process if nothing else stops it. Use this to log a crash without swallowing it.
Outside a defer, recover is rejected:
fn main() !u8 {
recover(reason) { return Ok(1) } // error: usable only inside a `defer` body
return Ok(0)
}And it is panic-only. An error leaving through try is a value, not an unwind —
it runs your defers but never your recover. If you want to handle an error, catch
it; recover existing does not make panics a control-flow tool.
Putting it together
The canonical shape: acquire, defer the release, and — at a boundary where a crash
in the work should not take the whole program down — put a recover in the same
defer, after the cleanup:
import { stdout } from "@io"
import { systemAllocator } from "@mem"
fn digit(b: u8) i32 {
if b < u8('0') || b > u8('9') { panic "not a digit" }
return i32(b) - 48
}
fn sumDigits(input: [*]u8) i32 {
con buf = systemAllocator.alloc(64, 1) catch { panic "out of memory" }
defer {
systemAllocator.free(buf) // cleanup: runs on every exit
recover(reason) { // handler: runs only on a panic
stdout.write("bad input: ")
stdout.write(reason)
stdout.write("\n")
return -1
}
}
mut sum = 0
for b in input { sum = sum + digit(b) }
return sum
}
fn main() !u8 {
con ok = sumDigits("123") // 6
con bad = sumDigits("12x") // prints "bad input: not a digit"; -1
stdout.writeInt(i64(ok + bad))
stdout.write("
")
return Ok(0) // prints 5
}On the happy path the defer frees the buffer and the recover block never runs. On
a panic the unwind reaches the defer, the buffer is still freed, and the recover
substitutes -1 — sumDigits returns normally.
A recover can sit any number of frames above the panic. Every intervening frame's defers run as the unwind passes through, so nothing between the panic site and the recovery point leaks:
import { stdout } from "@io"
mut depth = 0
fn deep(n: i32) i32 {
defer { depth = depth + 1 } // runs in every frame of the unwind
if n == 0 { panic "bottom" }
return deep(n - 1)
}
fn guarded() i32 {
defer { recover(reason) { return 7 } }
return deep(3)
}
fn main() !u8 {
con v = guarded() // v = 7; depth = 4
stdout.writeInt(i64(v * 10 + depth))
stdout.write("
")
return Ok(0) // 74
}Where's my panic? — @trace
The call-frame information behind the crash printout is also available to your
program: the @trace package exposes the live call stack.
import { currentPos } from "@trace"
import { stdout } from "@io"
fn main() !u8 {
con p = currentPos(0) // the (line, col) of this call site
stdout.writeInt(i64(p.line))
stdout.write("
")
return Ok(0) // 4
}trace(skip) returns the whole stack as a [*]Frame (file, line, col per frame) —
useful for building assertion messages and test matchers that point at the caller.
See also: reference/statements.