// SPDX-License-Identifier: MIT OR Apache-2.0

Tutorial — Resource Cleanup with consume{} (Plan 110)

Plan 110.8.2. Tutorial chapter introducing consume X = ... { body } scope-block pattern for resource cleanup.

Why Cleanup Matters

When working with resources (files, database connections, locks), you need to ensure they’re always released, even when errors happen. Forgetting to release leads to:

  • Resource leaks — locked files, hung database connections.
  • Deadlocks — Mutex held during panic, others waiting forever.
  • Data corruption — Transaction not rolled back, half-written state.

Nova provides consume X = expr { body } scope-block to make cleanup automatic and reliable.

First Example — File Reading

fn read_config(path str) Fail[IoError] -> Config {
    consume f = File.open(path)? {
        ro raw = f.read_all()!!
        return Config.parse(raw)!!
    }
    // f.@cleanup (File.close) automatically called here.
}

What happens:

  1. File.open(path)? opens the file. If it fails, ? propagates the error — f never bound, no cleanup needed.
  2. f accessible inside { ... } body.
  3. After body completes (success OR error), f.@cleanup(outcome) is called automatically — closes the file.
  4. If body errors, error re-propagates AFTER cleanup.

Why !! and not ? inside the body. Both operators unwrap, but they propagate differently (D85): ? is return-style and is legal only in a function that returns Result/Option — inside a function that declares Fail[E] it is E_TRY_IN_FAIL_FN. !! is throw-style: it throws through the Fail effect, which is exactly what these functions declare. The ? on the initializer line stays, and is a different thing: consume X = expr? { body } is the D196 unwrap-init form.

Why return rather than a bare tail expression. The binding form consume X = expr { body } is a STATEMENT — its value is unit (D188 desugaring). Only the re-consume form consume X { body } is an expression. Returning from inside the body still runs @cleanup first.

The Cleanup[E] Protocol

Any type can be used in consume X = ... { body } by implementing the Cleanup[E] protocol:

type Cleanup[E] protocol {
    @cleanup(outcome ScopeOutcome) Fail[E] -> ()
}

type ScopeOutcome
    | Success
    | Failure(str)
    | Panic(str)
  • E is the type of errors @cleanup itself can throw (e.g., IoError if close can fail).
  • Success — body completed normally.
  • Failure(msg) — body threw an error (including cancel).
  • Panic(msg) — body panicked (programming bug).

Implementing Cleanup for Your Type

Example: Database Transaction

type Transaction { conn DbConn, id int }

fn Transaction consume @cleanup(outcome ScopeOutcome) Fail[DbError] -> () {
    match outcome {
        Success      => @conn.commit(@id)!!
        Failure(_)   => @conn.rollback(@id)!!
        Panic(_)     => @conn.rollback_emergency()
    }
}

Usage:

fn process_order(db Db, order Order) Fail[DbError] -> () {
    consume tx = db.begin() {
        db.insert_order(order)!!
        db.notify_warehouse(order.id)!!
    }
    // Success → commit; failure → rollback (automatic).
}

Example: Infallible Cleanup (Mutex Lock)

For resources where cleanup CAN’T fail, use Cleanup[never]:

fn MutexGuard consume @cleanup(_outcome ScopeOutcome) -> () => @unlock()
//                                                       ^^^^ no Fail[E]

Caller doesn’t need Fail[E]:

fn increment_counter(state State) -> () {        // no Fail!
    consume _l = state.mutex.lock() {             // Cleanup[never]
        state.value += 1
    }
}

Outcome Discrimination

@cleanup body can branch on outcome:

fn HttpRequest consume @cleanup(outcome ScopeOutcome) -> () {
    match outcome {
        Success      => @metrics.inc("http.success")
        Failure(msg) => {
            if msg.starts_with("cancel: ") {
                @metrics.inc("http.cancel")
            } else {
                @metrics.inc("http.error")
            }
        }
        Panic(_)     => @metrics.inc("http.panic")
    }
    @release_pool_slot()
}

Nesting Scopes

Scopes nest naturally — inner exits before outer (LIFO):

fn deep_work(addr str) Fail[NetError] -> () {
    consume conn = pool.acquire()? {
        consume tx = conn.begin()? {
            consume stmt = tx.prepare(sql)? {
                stmt.execute(args)!!
            }
            // stmt.@cleanup fires first.
        }
        // tx.@cleanup fires (commit or rollback).
    }
    // conn.@cleanup fires last (release to pool).
}

Mixed consume{} + defer

Both work together. defer fires within its scope BEFORE @cleanup:

fn process() -> int {
    mut counter = 0
    consume r = Resource.new() {
        defer { counter += 100 }    // fires when body ends
        counter += r.id
    }
    // Order: defer body (counter += 100) → r.@cleanup
    counter
}

Initialization Forms (D196)

Init expression supports:

// Direct method call
consume tx = db.begin() { ... }

// Result unwrap via ?
consume tx = db.try_begin()? { ... }   // Result[Tx, DbError] → Tx

// Option unwrap via !!
consume tx = maybe_tx()!! { ... }       // Option[Tx] → Tx

// Conditional (both branches same type)
consume r = if local { LocalRes.new() } else { LocalRes.connect()? } { ... }

Forms that don’t work:

// ❌ Option without unwrap
consume tx = maybe_tx() { ... }
// → D196-wrapped-init-needs-unwrap

// ❌ Different types in branches
consume r = if cond { ResA.new() } else { ResB.new() } { ... }
// → D196-divergent-consumable

Comparison with Other Languages

// Rust — implicit, no syntax marker
let f = File::open(path)?;
f.read_all()
// Drop fires automatically
# Python with statement
with open(path) as f:
    f.read()
// Nova consume{}
consume f = File.open(path)? {
    f.read_all()?
}

Nova advantages:

  • Visible: cleanup is explicit, not a magic Drop.
  • Cancel-shield: cleanup protected from cancel storm (D188 R3).
  • Outcome-aware: resource discriminates success/failure/panic.
  • Async-capable: you can await inside @cleanup (D191).

What’s Next

See also

Last updated August 17, 2026