Resource Management
External resources — file handles, network sockets, database connections, locks — need to be released at a precise, known point. Dovetail provides the use keyword for that: it binds acquisition and release together so the release always happens when control leaves the scope, whether the scope completes normally, returns early, or fails.
13.1 The use Keyword
Section titled “13.1 The use Keyword”use is a prefix operator for acquiring a resource inside an async function. It binds the acquired value to a name and guarantees the resource is released when the surrounding scope finishes.
async function copyFile(src: String, dst: String): Async<Unit, IoError> = let input = use File.open(src) let output = use File.create(dst) let bytes: Array<Uint8> = await input.readAll() await output.writeAll(bytes)The usual form is let <name> = use <expression>, though the binding is optional — use <expression> on a line of its own opens the scope and discards the value, which is how Async.scope() below is written. The resource expression is evaluated first; the resulting value is bound to the name; everything that follows in the enclosing block runs with the resource alive; when that follow-up finishes, the resource is released.
In copyFile above, both files are closed automatically when the function finishes — and they are closed even if readAll or writeAll fails partway through.
The resources in this part are all asynchronous — external resources almost always have asynchronous acquire and release steps, and use is designed around that. use itself is not limited to them: the Usable trait it desugars through has a synchronous form too, used for things like a secret that must be zeroized after use.
Fibers forked inside a use block
Section titled “Fibers forked inside a use block”A use block is also a fiber scope: any fiber forked inside it belongs to the block, and the resource is not released until that fiber is gone. So a fiber cannot end up reading from a socket the block already closed.
async function serveOne(): Async<Unit, NetError> = let listener: TcpListener = use Tcp.bind("127.0.0.1:8080") // The block will not release the listener until this fiber has finished. let worker: Fiber<Unit, Never> = await acceptOne(listener).fork() await worker.join()A block that ends normally waits for the fibers forked with fork; one that ends by failure interrupts them first. Fibers forked with forkBackground are interrupted either way — use that for work that is only meaningful while the resource is open, such as a loop that ends only when the resource closes. See Part 12 for the full comparison.
Acquisition runs to completion too
Section titled “Acquisition runs to completion too”Acquiring is uninterruptible. A resource becomes real somewhere inside the acquire — the moment the host hands back a socket, say — and the runtime cannot see when; it can only take ownership once the acquire returns. An interrupt landing in between would leave a live resource with nothing left that could close it, so interrupts are held until the acquire finishes and then delivered.
The trade is that an acquisition which parks indefinitely can no longer be cut short. Keep acquisitions bounded and do the waiting inside the block, which stays interruptible. When an acquisition is the wait — accepting a connection, taking a lock — it opts back in for that part alone with .interruptible(), wrapping the park and never the hand-off, so the fiber can still be cancelled while it waits but the resource still reaches its owner atomically.
Release runs to completion
Section titled “Release runs to completion”Releasing is uninterruptible. A finalizer that awaits — a close that flushes, a TLS shutdown that sends close_notify — cannot be cut in half by an interrupt arriving part-way through, which would leave the resource neither open nor closed. An interrupt that arrives during a release is deferred and delivered the moment the release finishes, so it is never lost.
The trade is that a finalizer which blocks forever now blocks its fiber forever. Keep release paths bounded: prefer a best-effort close over one that waits indefinitely for a peer.
13.2 LIFO Release Order
Section titled “13.2 LIFO Release Order”Multiple use bindings in the same block release in last-in, first-out order, matching the natural nesting:
async function copy(src: String, dst: String): Async<Unit, IoError> = let input = use File.open(src) // released second let output = use File.create(dst) // released first await pipe(input, output)This matches how you would write the cleanup by hand: outer resources outlive inner ones.
13.3 Release on Failure
Section titled “13.3 Release on Failure”The release step runs whether the body succeeds, fails with a typed error, or produces an async Cause.Panicked (for example through Async.failPanic). A language-level panic or failed assert traps and aborts execution; it does not unwind resource scopes. There is no finally block to remember and no try/catch to thread around the work — the keyword itself binds acquisition and release together.
async function process(path: String): Async<Result, ProcessError> = let file = use File.open(path) await analyze(file) // even if this fails, the file is closedThis is the main reason to reach for use: it eliminates the entire class of “I forgot to close that on the error path” bugs.
13.4 Scopes Without a Resource
Section titled “13.4 Scopes Without a Resource”A use block does two things at once: it opens a scope, and it attaches a resource to it. Async.scope() does only the first — it is a resource that acquires nothing, so use on it opens a scope and nothing else.
async function handleOne(conn: TcpStream): Async<Unit, NetError> = use Async.scope() ...Write it bare, with no binding: a scope is never a value, so there would be nothing worth naming. The scope covers the rest of the enclosing block, exactly like any other use, because it is one. To bound something narrower, give it a block of its own — a loop body, an if arm, or a helper function.
What a scope bounds is the two things that attach to one: fibers forked with fork / forkBackground, and finalizers registered with attachToScope. Without a scope of their own, both fall back to the fiber’s root scope and live as long as the fiber.
13.5 Attaching to a Scope You Already Have
Section titled “13.5 Attaching to a Scope You Already Have”attachToScope is the other half of use. It acquires a resource and hands its release to the innermost open scope, instead of opening a block of its own:
async function serve(certPath: Path, keyPath: Path): Async<Unit, ServerError> = use Async.scope() let cert = await Certificate.load(certPath).attachToScope() let key = await ServerKey.load(keyPath).attachToScope() await listen(cert, key) // key released, then cert, at the end of the scopeReach for it when the resource’s lifetime is a scope that already exists and the acquisition should not swallow the rest of the block as its continuation: a resource acquired in one arm of an if, a helper acquiring on its caller’s behalf, a loop acquiring a number of resources known only at runtime. When the lifetime is the rest of the block, use says so more directly.
Note the spelling: always await, never use. A use on it would open a new scope and release at the end of the block, which is exactly what it exists not to do.
Which scope it attaches to
Section titled “Which scope it attaches to”Whichever is innermost when it runs — the same rule fork follows. A scope is never a value, so there is nothing to pass and nothing to pass wrongly. That scope may be an Async.scope(), or an ordinary use block’s, or, if neither is open, the fiber’s own root scope, in which case the resource is released when the fiber ends.
A plain use block is a perfectly good attach target, and it has to be. Everything releases in reverse acquisition order, whichever spelling acquired it:
let socket = use Tcp.connect(host, port)let file = await File.openInputStream(path).attachToScope()// file closed first, then socketIf the attach could skip the enclosing use and land further out, the file would be closed after the socket it was read into — a resource outliving what it was derived from.
The one thing to watch
Section titled “The one thing to watch”An attachToScope with no scope of its own lands on the fiber’s root scope. That never dangles, and for a server that forks a fiber per request it is exactly right. But a long-running loop that attaches once per turn holds every one of them until the fiber ends. Give each turn a scope and it releases before the next acquires:
while more() do await handleOne() // whose own body opens `use Async.scope()`13.6 Composing Resources
Section titled “13.6 Composing Resources”The Resource<T, E> type is itself awaitable. An async function that returns a Resource<T, E> can use await to combine other resources into a single composite resource. Nothing is acquired by the function call itself — what comes back is a description of the combined acquire/release lifecycle, ready to be handed to a use site.
async function dbTransaction(): Resource<Transaction, DbError> = let conn = await openConnection() let txn = await Transaction.begin(conn) txnopenConnection and Transaction.begin each return a Resource<...>. Inside dbTransaction, await threads the acquired values through the body, and the function returns a single Resource<Transaction, DbError>. The caller never sees the connection — only the transaction it cares about — but the connection is still acquired and, crucially, still released. When the surrounding use scope ends, the transaction is closed first and then the connection underneath it, LIFO.
A caller uses that composite resource as if it were any other:
async function runMigration(steps: Array<Migration>): Async<Unit, DbError> = let txn = use dbTransaction() await applyAll(steps, txn)This is the main reason to write a function that returns a Resource rather than use-ing one inline: you can package a multi-step setup — connection plus transaction, listener plus accepted socket, lock plus loaded snapshot — into a single named resource and hand it around, exposing only the value the caller actually needs. The LIFO release contract carries across the composition boundary, so callers never have to know how many pieces the resource is made of.
13.7 Error Conversion at the Use Site
Section titled “13.7 Error Conversion at the Use Site”If the resource’s failure type differs from the surrounding function’s error type, the compiler will convert it automatically — as long as a From instance exists between them:
newtype FileError = Stringnewtype AppError = String
implement From<FileError> for AppError = public function from(e: FileError): AppError = AppError("io: " ++ e.value)
async function loadConfig(path: String): Async<Config, AppError> = let file = use openConfigFile(path) // raises FileError, converted via From await parseConfig(file)No explicit .mapError(...) is needed at the use site — the conversion is wired in for you.
13.8 Scope Context
Section titled “13.8 Scope Context”standard.io.ScopeContext<T> lets consumers share a context identity while each
executing scope supplies its own value. Create the identity with
await ScopeContext<T>.make() and inject it into the objects that need it.
await context.get() returns the nearest binding as Option<T>.
use context.scope(value) installs a binding for the rest of the block. Its
ScopeContextScope<T> result implements Usable<Unit, Never>; it is not a
Resource, and does not provide resource composition, attachToScope, or
forkAndUse. Constructing the description does not install a binding.
Nested scopes can shadow a binding. On exit, the previous binding is restored,
including on typed failure, async Cause.Panicked, or interruption. Fatal Wasm
traps do not unwind scopes. Forked children inherit the bindings
present when they are created, including background fibers, race branches, and
producer fibers. A child’s changes do not affect its parent or siblings, and joining
does not merge them back. Children and finalizers finish before the binding is
removed. A fresh Async.run() starts without bindings.
Each execution of make() creates a distinct identity, even for the same T.
Lookup happens when get() executes. Consumers choose how to handle missing values;
there is no implicit default or required-lookup failure.
Complete example (checked in CI) — inject one context into a repository and supply a different value within each execution scope:
package scopecontext
import standard.io.Asyncimport standard.io.ScopeContext
record Repository = connections: ScopeContext<String>
module Repository = async function connection(self): Async<Option<String>, Never> = await self.connections.get()
function main(): Unit = let program: Async<Unit, Never> = async do let connections = await ScopeContext<String>.make() let repository = Repository { connections = connections } assert (await repository.connection()).isNone use connections.scope("request connection") let child = await repository.connection().fork() assert (await child.join()).require == "request connection" program.run()
test "repository inherits its scoped connection" = main()A context owns its binding, not the bound resource. For a database connection,
acquire the connection with use first, then open its context scope. The connection
is released after context users finish. Mutable values remain shared references;
context inheritance does not make a connection safe for concurrent operations.
13.9 Scoped Test Clocks
Section titled “13.9 Scoped Test Clocks”standard.io.TestClock freezes time for a scope and its descendant fibers. With
no test clock installed, time reads and sleeps use the real clock. Installation
uses a private ScopeContext<TestClock>: nested installations shadow the outer
clock, and independent scopes can use different clocks.
TestClock.install(initial) is a reusable TestClockScope description implementing
Usable<TestClock, Never>. Each use creates a fresh clock at initial and returns
its controller. Read time through Instant.now and the existing date/time
extensions; call await clock.advance(duration) to move it forward. Async.sleep
and timeout use the same virtual timeline, as do consumers of those APIs such
as logging. Direct low-level standard.wasi.clock calls still use host time.
Complete example (checked in CI):
package testclock
import standard.io.Asyncimport standard.io.TestClockimport standard.io.TimeExtensionimport standard.time.Instantimport standard.time.Duration
async function delayedTime(): Async<Instant, Never> = await Async<Unit, Never>.sleep(Duration.ofSeconds(5i64)) await Instant.now
function main(): Unit = let program: Async<Unit, Never> = async do let clock = use TestClock.install(Instant.ofEpochSecond(0i64)) let child = await delayedTime().fork() await clock.advance(Duration.ofSeconds(5i64)) assert (await child.join()).epochSecond == 5i64 program.run()
test "sleep completes in virtual time" = main()Advancement first lets runnable fibers using this clock complete or suspend, so an immediately forked task can register its sleep. It then visits each due deadline in order, waking equal-deadline sleepers in registration order and letting relevant work settle before proceeding. A ten-second advance therefore handles a task that sleeps five seconds and then another five. Wall time and elapsed time move together, with nanosecond precision; there is no separate wall-clock reset.
advance(Duration.zero()) settles relevant runnable work without moving time.
Nonpositive virtual sleeps complete immediately; use Async.yieldNow() when you
need an explicit scheduling yield. Unrelated runnable fibers do not block clock
advancement, but an endlessly runnable fiber using the clock does. Host I/O is not
simulated: synchronize explicitly with external events. The test runner’s
@timeout remains a real-time watchdog.
Concurrent advances execute FIFO, computing their relative targets when they
become active. Canceling an advance removes its request without rolling back time
or work already completed. Invalid durations, invalid nanosecond fields, and time
arithmetic outside the representable range produce Cause.Panicked without
partially applying the invalid request. Negative advancement is invalid.
Normal scope lifetime rules apply. Background children are interrupted and drained;
structured children must finish. Time is never advanced automatically during
shutdown. Drive sleeping cleanup explicitly, or interrupt outstanding sleepers
before leaving the scope. A stalled runtime identifies virtual waiters in its
diagnostic. Bindings remain installed through cleanup, and clock handles become
invalid after installation closes. Using a handle in a different Async.run()
also produces an async defect; each runtime starts without clock bindings.
Summary
Section titled “Summary”useacquires a resource inside an async function and guarantees release when the enclosing scope exits — on success, typed failure, or asyncCause.Panicked.- A
useblock is a fiber scope: fibers forked inside it finish (or are cancelled) before the resource is released. - Acquisition and release are both uninterruptible by default: an interrupt arriving during either is deferred until it completes, so a resource is never stranded half-owned or left half-closed. An acquisition that is a wait opts its park back in with
.interruptible(); a release never can. - Syntax is
let x = use expr, or bareuse exprwhen the value is not wanted; multipleusebindings in a block release in LIFO order. use Async.scope()opens a scope that acquires nothing, for bounding forked fibers and attached resources.await r.attachToScope()acquiresrand releases it when the innermost open scope closes, rather than opening a scope of its own. Everything releases in reverse acquisition order, whichever spelling acquired it.- An async function returning
Resource<T, E>can useawaitto compose other resources into a single composite resource. Frominstances are used automatically to convert a resource’s error type into the surrounding function’s error type.
For helpers that accept an unknown resource type through a Usable bound, see
Generic Resource Helpers.
Discarded resources
Section titled “Discarded resources”An intermediate expression returning Resource produces a warning. A resource
value describes deferred acquisition and release; discarding it does not acquire
the resource. Use let value = use resource in a compatible context, or return
or compose the resource for later acquisition. use resource without a binding
also counts as acquisition and does not produce this warning.
Write let _ = resource only when discarding the description is intentional.
This acknowledges discard; it does not acquire the resource or run its work.
