Skip to content

Resource Management

Shape provides deterministic resource cleanup through automatic scope-based drop (RAII-style) and the Drop trait. When a binding goes out of scope, its drop() method runs automatically — no explicit cleanup blocks required.

Every let binding that implements the Drop trait is automatically dropped when its enclosing scope exits. This applies to normal block exit, early return, break, continue, and error propagation.

fn query_total(uri: string) -> number {
let conn = db.connect(uri)
let rows = conn.query("SELECT * FROM records WHERE date = today()")
rows.map(|r| r.value).sum()
}
// conn.drop() called automatically when the function exits

There is no special syntax for resource management. Regular let bindings are sufficient. The compiler tracks which bindings implement Drop and emits drop calls at every scope exit point.

You can use ordinary blocks to limit a resource’s lifetime:

fn process(uri: string) {
let result = {
let conn = db.connect(uri)
conn.query("SELECT sum(value) FROM records").first().value
}
// conn is dropped here, at the end of the block
// result holds the query value and is still available
print(f"Total volume: {result}")
}

When a scope contains multiple droppable bindings, they are dropped in reverse declaration order (last declared, first dropped). This naturally matches dependency graphs where later resources depend on earlier ones:

fn pipeline() {
let conn = db.connect("postgres://localhost/analytics")
let tx = conn.begin_transaction()
tx.execute("INSERT INTO results VALUES (42)")
tx.commit()
}
// Drop order: tx first, then conn
// Transaction is finalized before the connection is closed

The reverse-order guarantee holds across all exit paths, including early returns:

fn pipeline() {
let a = acquire("first")
let b = acquire("second")
let c = acquire("third")
if should_abort() {
return // drops: c, b, a (reverse order)
}
run_pipeline(a, b, c)
}
// normal exit also drops: c, b, a

When droppable bindings are declared inside a loop body, they are dropped at the end of each iteration. break and continue both trigger cleanup:

let uris = ["postgres://a", "postgres://b", "postgres://c"]
for uri in uris {
let conn = db.connect(uri)
let count = conn.query("SELECT count(*) FROM records").first().value
if count == 0 {
continue // conn.drop() runs, loop advances
}
if count > 1000000 {
break // conn.drop() runs, loop exits
}
process(conn, count)
// conn.drop() runs here on normal iteration
}

Drop is a builtin trait. You do not need to import it. Any type that implements Drop gets automatic cleanup when its bindings go out of scope.

The trait has one required method:

trait Drop {
drop(self)
}

Implement Drop for your own types to define cleanup behavior:

type FileHandle {
path: string,
fd: int
}
impl Drop for FileHandle {
method drop() {
close_fd(self.fd)
print(f"Closed file: {self.path}")
}
}

Now any FileHandle binding is cleaned up automatically:

fn read_data() -> Vec<Row> {
let f = open_file("/data/records.csv")
let lines = f.read_lines()
lines.map(parse_row)
}
// f.drop() closes the file descriptor when the function returns

A more complete example showing a database wrapper:

type PooledConnection {
pool_id: string,
conn_id: int,
active: bool
}
impl Drop for PooledConnection {
method drop() {
if self.active {
return_to_pool(self.pool_id, self.conn_id)
}
}
}
impl Display for PooledConnection {
method display() {
f"Connection({self.conn_id} @ pool {self.pool_id})"
}
}

When drop() raises an error, the error is logged but does not prevent other pending drops from executing. This ensures that one failing cleanup does not leak other resources:

fn transfer() {
let a = open_resource("alpha")
let b = open_resource("beta")
process(a, b)
}
// if b.drop() errors: error is logged, execution continues
// a.drop() still runs regardless of whether b.drop() succeeded

If both a.drop() and b.drop() fail, both errors are logged. The original return value or error from the scope body is preserved; drop errors never replace it.

Returning from a function runs all pending drops for every scope being exited, before the function actually returns:

fn find_first_match(uri: string, pattern: string) -> string {
let conn = db.connect(uri)
let rows = conn.query(f"SELECT * FROM events")
for row in rows {
if row.name.contains(pattern) {
return row.name // conn.drop() runs before return completes
}
}
"no match"
}

You can use nested blocks to control exactly when resources are dropped:

fn load_and_transform(source_uri: string, dest_uri: string) {
let data = {
let src = db.connect(source_uri)
src.query("SELECT * FROM records")
}
// src is dropped here -- connection released
let dest = db.connect(dest_uri)
dest.bulk_insert("records_backup", data)
// dest is dropped when the function exits
}

Automatic drop integrates with Shape’s async scope blocks. When an async scope exits, drops run before child task cancellation occurs. This guarantees that shared resources are cleaned up while tasks can still observe them.

Drop opcode selection is based on the type’s Drop implementation, not the calling context. When a type’s drop() method is declared async, the compiler emits an async drop opcode (DropCallAsync). When it is sync, a sync drop opcode (DropCall) is emitted. This happens on a per-binding basis — different types in the same function can use different drop opcodes.

A type can provide both sync and async drop methods in the same impl Drop block:

type DbConnection { handle: int, uri: string }
impl Drop for DbConnection {
// Sync fallback -- used when dropped in a sync function
method drop() {
close_sync(self.handle)
}
// Async variant -- used when dropped in an async function
async method drop() {
await flush(self.handle)
await close(self.handle)
}
}

The compiler selects the drop variant based on the type’s available implementations and the calling context:

Type hasAsync contextSync context
Sync drop onlyDropCallDropCall
Async drop onlyDropCallAsynccompile error
Both sync and asyncDropCallAsync (prefers async)DropCall (sync fallback)
No Drop implDropCall (silently skips)DropCall (silently skips)

If a type only has an async drop() and is used inside a sync function, the compiler rejects it with a clear error. Either add a sync method drop() fallback or move the binding into an async function.

When an async scope exits, async drops run before child task cancellation occurs. This guarantees that shared resources are cleaned up while tasks can still observe them.

async scope {
let conn = db.connect("postgres://localhost/analytics")
let stream_a = conn.subscribe("events")
let stream_b = conn.subscribe("updates")
for await event in merge(stream_a, stream_b) {
if event.type == "close" {
break
}
handle(event)
}
}
// Cleanup order:
// 1. stream_b.drop(), stream_a.drop(), conn.drop() (reverse order, async drops awaited)
// 2. Pending async tasks are cancelled

Use blocks within an async scope to release resources before the scope finishes:

async scope {
let results = {
let conn = db.connect("postgres://localhost/analytics")
async let a = conn.query("SELECT * FROM events")
async let b = conn.query("SELECT * FROM updates")
join(a, b)
}
// conn is dropped here, connection released
// results are still available for further processing
process(results)
}

Keep drop() fast and infallible. The drop() method should not perform expensive computation or operations that are likely to fail. If cleanup requires fallible I/O, handle the error gracefully inside drop() rather than letting it propagate.

Use blocks to limit resource lifetimes. When a resource is only needed for part of a function, wrap it in a block so it is dropped as soon as possible:

fn process(uri: string) {
// conn lives only as long as this block
let data = {
let conn = db.connect(uri)
conn.query("SELECT * FROM dataset")
}
// conn is already dropped
// expensive processing happens without holding the connection
transform(data)
}

Rely on declaration order for dependencies. Declare resources in dependency order (connection before transaction, file before cursor). Reverse drop order ensures dependents are cleaned up before their dependencies:

fn transact(uri: string) {
let conn = db.connect(uri)
let tx = conn.begin_transaction()
let cursor = tx.cursor()
cursor.execute(query)
tx.commit()
}
// Drop order: cursor, tx, conn

Implement Drop for any type that holds external resources. Files, connections, locks, handles, and temporary allocations should all implement Drop so that cleanup is automatic and cannot be forgotten.

For more on trait definitions and implementations, see Traits. For async scope semantics and task cancellation, see Async and Structured Concurrency.