Guides · 08

defer, recover, and panic

Vyi splits failure into two channels that never overlap:

  • Errors as valuesE!T / ?T, handled with try and catch. 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:

vyi
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.

vyi
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:

vyi
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.

vyi
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:

vyi
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:

vyi-error
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:

text
panic: negative input
  at main.vyi:7:18

The runtime itself panics on a violated invariant. The one you'll meet first is indexing out of bounds — [*]T indexing is bounds-checked:

vyi
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:

vyi
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 panicking panic's reason string as reason: [*]u8 and 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 the recover block is skipped.
  • A return inside 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 recover block 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:

vyi-error
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:

vyi
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 -1sumDigits 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:

vyi
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.

vyi
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.