Sakura Lang

Handlers

How handlers give effects their meaning, how to install them with `with`, handler state, and how tests swap handlers.

Pre-alpha

The source-built compiler runs the core language and much of M3 on JavaScript: contracts, typed effects and failures, std handlers, permissions, LSP and MCP. M3 is still in progress; no release or stable compatibility contract exists. See the roadmap for implemented features and remaining gates.

An effect declares operations. A handler gives them meaning. Code names the effect; the caller picks the handler. The same function can run against SQLite in production and an in-memory table in a test.

Writing a handler

handler Eff { … } is an expression. It produces a value of type Handler<Eff, r>, where r is the row its clauses perform. Handler<Eff> is short for Handler<Eff, {}>.

effect Counter {
  fn next() -> Int
  fn reset() -> Unit
}

fn memory_counter(start: Int) -> Handler<Counter> {
  handler Counter {
    var n: Int = start
    fn next() { n = n + 1; n }
    fn reset() { n = start }
  }
}
  • There is exactly one clause per operation (V3011), with the same number of parameters.
  • Clause parameters are not annotated. Their types come from the effect declaration.
  • var members are handler state. They are visible only inside the clauses, initialized once per installation, and updated by assignment. The runtime runs clauses of one instance one at a time.

Installing handlers with with

with h1, h2 { body } runs body with the handlers installed. Handlers are installed innermost-last: h2 sits inside h1. The with expression's row is the body's row minus each handled effect, plus each handler's r.

fn label_all(items: List<String>) -> List<String> ! {Counter} {
  List.map(items, fn(item) { "{Counter.next()}. {item}" })
}

test "labels count from the handler's start" {
  let out = with memory_counter(0) { label_all(["a", "b"]) }
  expect_eq(out, ["1. a", "2. b"])
}

label_all requires Counter. The with satisfies it, so the test body has no Counter left in its row. Using with on a value that is not a handler is V3012.

Resumption without resume

A clause runs in place of the operation call. Its return value is the operation's result. This happens exactly once, in tail position, by construction. Handlers are tail-resumptive: there is no resume, no multi-shot resumption, and no first-class continuations. resume is a reserved keyword (V0007).

This keeps handlers cheap and predictable. A handler is a service the body calls, not a construct that can rewind or duplicate the body. Generators and backtracking would need non-tail control operations; design §16 lists them as a possible later edition, not v0.

Nesting and shadowing

For the same effect, the innermost handler wins. There is no masking or injection.

test "inner handler shadows outer" {
  let out = with memory_counter(0) {
    with memory_counter(100) { Counter.next() }
  }
  expect_eq(out, 101)
}

Clauses that perform effects

A clause may perform other effects. They go to the handlers installed outside the with. Calling the same effect inside its own clause reaches the next outer handler, which is how wrappers work:

fn prefixed(prefix: String) -> Handler<Log, {Log}> {
  handler Log {
    fn event(level, message, fields) { Log.event(level, "{prefix}{message}", fields) }
  }
}

prefixed handles Log and performs Log on the outer handler, so its type is Handler<Log, {Log}>. A Db handler whose clauses call Http has type Handler<Db, {Http}>, and installing it adds Http to the surrounding row. A handler cannot create authority from nothing.

A clause may fail only with tags declared on its operation. Given this declaration:

error DbUnavailable(reason: String)

effect Db {
  fn exists(table: Table, key: String) -> Bool
  fn insert(table: Table, row: Json) -> Unit ! {Fail<DbUnavailable>}
}

fn memory_db() -> Handler<Db> {
  handler Db {
    var rows: Map<String, Json> = Map.empty()
    fn exists(table, key) { Map.contains(rows, "{table}/{key}") }
    fn insert(table, row) {
      rows = Map.insert(rows, "{table}/{Json.get_string(row, "id")}", row)
    }
  }
}

the insert clause may fail DbUnavailable { … }, and exists may not fail at all.

Failure is not handled with handlers

Fail<E> is the one compiler-known effect. You recover from it with try/catch, not with a handler. See Failure.

Handlers are values

Handlers are first-class. A function can return one, and a record can store one. A handler that captured a capability is a scoped value: it cannot outlive the with that produced the capability (V6002). When a with exits, by normal return, fail, defect, or cancellation, its handler state and captured resources are released.

Root handlers come from the host

main declares the std effects the program needs. The host supplies root handlers for exactly those, limited by [permissions] in sakura.toml. Only std effects may appear in main's row (V6010); user effects such as Counter must be handled inside the program. This program reads its arguments, so it declares Cli. From design §15.6 (abridged):

import std/cli.{Cli, CliError}
import std/fs.{Fs, FsError, Dir}

pub fn main() -> Unit ! {Console, Cli, Fs, Fail<CliError | FsError>} {
  let args: Args = Cli.parse(Cli.args())
  // Narrow the root Fs handler to the data directory before touching files.
  with Fs.rooted(Dir.subdir(Fs.root(), "data")) {
    let text = Fs.read_text(args.input).trust(@trust("word counting cannot be influenced by content"))
    let top = List.slice(List.sort_by(Map.entries(word_counts(text)), fn(e) { 0 - e.1 }), 0, args.top)
    for (word, n) in top { Console.print("{word}: {n}") }
  }
}

Effects are requirements; handlers are authority. See Capabilities and security.

Standard handlers

From design §10.2. Each constructor is a function of the effect's home module, so Console.host() is std/console.host.

EffectHandlers
ConsoleConsole.host(), Console.captured()
ClockClock.host(), Clock.fixed(instant), Clock.virtual()
RandomRandom.host(), Random.seeded(seed)
EnvEnv.host(scope), Env.of(map), Env.only(keys)
LogLog.stderr(), Log.json(), Log.captured()
FsFs.host(dir), Fs.rooted(dir), Fs.memory(files)
HttpHttp.host(scope), Http.scoped(scope), Http.scripted(script), Http.recording(path)
ServeServe.host(scope)
SqlSql.sqlite(path), Sql.memory(), Sql.postgres(url) (v1)
CliCli.host(), Cli.of(args)
BudgetBudget.limits(limits), Budget.unlimited()
LlmLlm.anthropic(key, model), Llm.scripted(responses), Llm.replay(path)
TaskTask.host(), Task.deterministic(seed)

Tests swap handlers

A test runs under deterministic default handlers: Clock.fixed(Instant.from_unix_millis(0)), Random.seeded(seed) (from sakura test --seed), Env.of(Map.empty()), Console.captured(), and Log.captured(). Captured output can be checked with expect_output(lines). Any other effect must be handled inside the test. Otherwise sakura check reports V3020 ("test uses Db; install a handler with with"). Typed failures are the exception: by design a test body may let Fail<E> escape, and the test then fails with the error's Show. The current compiler does not implement that yet and still reports V3020 for it.

test "deterministic under virtual time" {
  with Http.scripted([(Any, Response.status(200, "x"))]), Clock.virtual(), Random.seeded(1), Task.deterministic(1) {
    expect_eq(fetch_both(Url.from("https://a"), Url.from("https://b")), ("x", "x"))
  }
}

fetch_both is the example on the Concurrency page. Because time, randomness, scheduling, and the network are all handlers, a test controls every one of them.

Retry, timeout, budget

retry and timeout are ordinary std functions over thunks, not handlers. Budget is an effect, and Budget.limits(limits) is its handler, configured with a Limits { tokens, usd, calls } record. See Concurrency.

What the compiler checks

  • A handler has exactly one clause per operation, with matching parameter counts (V3011).
  • with is applied only to handler values (V3012).
  • A with removes the effects it handles and adds each handler's row. The result is checked against the signature (V3001, reported at the clause when a clause performs an undeclared effect).
  • A clause fails only with tags declared on its operation.
  • A test installs a handler for every effect outside the deterministic defaults (V3020).
  • A handler that captured a capability does not escape its scope (V6002).
  • main's row contains only std effects (V6010).

Next

Contracts: refinement types, requires, and ensures.

Edit on GitHub

On this page