14. Procs: the unit of failure

Everything in this part so far has been about work that succeeds. A scope joins its children, a channel carries their answers, a select waits on several things at once — and when a child failed, chapter 10 sent the error up to the join and chapter 6’s machinery read it. That is the right answer for a computation that can fail. It is the wrong answer for a service that can fail, and the shelf is about to show why:

struct Doc { title: str, words: int }
fn shard() -> !int {
    var docs = List[Doc]()
    (mut docs).push(Doc { title: "regions", words: 900 })
    (mut docs).push(Doc { title: "moves", words: 640 })
    Corrupt
}
fn main() -> !int {
    let index = spawn proc shard()
    let m = index.monitor()
    select {
        exit(reason) from m => { print("the shard is down: {reason}") },
        timeout(1.s) => { print("no word from the shard") },
    }
    print("the shelf is still serving")
    0
}
$ lupin shard.lu
the shard is down: error(Corrupt)
the shelf is still serving

Two documents were allocated and then the shard gave up. Nobody freed them, nobody unwound anything, and main did not handle an error — it was told one, by name, and went on serving. A proc is the thing that died: a failure domain, with its own memory, whose death is an event other procs can subscribe to instead of an error they have to catch.

14.1 Armstrong’s argument, one page

The design above is not new and this book will not pretend it is. It is Joe Armstrong’s, from the 2003 thesis that came out of building the AXD301 switch at Ericsson, and it is worth compressing to one page because the argument is short and every clause of it does work.

Start from the observation that software fails in ways you did not enumerate. Not the errors in your row — those are chapter 6’s, and they are values because you thought of them. The other kind: the assertion that should have held, the index that was not there, the invariant two threads broke between them. You cannot handle what you did not predict, so the only sound response is to stop the part of the system that is now in an unknown state, and restart it from a known one.

That is only sound if “the part” is a real boundary. If the failing code shares mutable state with the code that survives, restarting it does not restore a known state — it leaves half-updated data behind and a supervisor that believes it fixed something. So the argument’s real content is a constraint on architecture, and Armstrong states it as an alignment: the unit of service, the unit of failure, and the unit of ownership are the same unit. Draw those three boundaries on top of each other and “let it crash” is not recklessness, it is arithmetic. Draw them apart and no amount of supervision helps.

Wolf spells that unit proc. What crosses between procs is messages; what does not cross is a writable reference to anything. The rule is chapter 10’s D14 rule, unchanged and pointed at a bigger boundary: a proc’s function is an ordinary function taking ordinary arguments, and what it may receive is what a channel may carry.

Three ways to stop

A proc’s death is reported, not thrown, and the report is a value with a shape worth knowing before anything else. Here are the three shapes on one page:

fn ok() -> int { 3 }
fn bad() -> !int { Corrupt }
fn parked() -> int {
    let idle = channel[int](0)
    let v = idle.recv() else |_| { return 0 }
    v
}
fn reason_of(p: Proc, label: str) {
    let m = p.monitor()
    select {
        exit(reason) from m => { print("{label}: {reason}") },
        timeout(1.s) => { print("{label}: no word") },
    }
}
fn main() -> !int {
    reason_of(spawn proc ok(), "returned")
    reason_of(spawn proc bad(), "failed")
    let k = spawn proc parked()
    k.kill()
    reason_of(k, "killed")
    0
}
$ lupin reasons.lu
returned: normal(3)
failed: error(Corrupt)
killed: killed

normal(3) carries the value the proc’s function returned — the result rides along inside the reason, which is why a proc that computes something does not need a channel to hand it back. error(Corrupt) is a failure the proc reported about itself, with the tag from its own error row. killed is what happens when somebody else decides. Three shapes, three predicates (is_normal, is_error, is_killed), and a supervisor that only asks the first one treats the other two the same, which is usually the policy it wants.

Read the signature of reason_of before moving on, because it is the whole ergonomics of this chapter. A proc handle is a Proc: an ordinary value, passed to an ordinary function, with nothing in the signature announcing that a failure domain is involved. monitor is a method on it that hands back a channel of exit messages, and select’s exit(reason) from m arm is how you read one. There is no callback, no registry, and no supervisor type in the language — which is the point of §15.2.

Per-proc accounting, closed

Now the part of the argument Armstrong’s thesis leaves open, and states that it leaves open. Erlang’s isolation is per-process, but its accounting is not: a process that grows a huge mailbox or a huge heap takes memory from the node, and the node is shared. The folklore remedies — poll message_queue_len, set max_heap_size, watch the node — are all measurements after the fact, because the runtime has no structural place to put “this process’s memory.”

Wolf has one, and chapter 8 built it. A proc owns regions. Ask the interpreter what exists after a proc is spawned:

wolf> struct Doc { title: str, words: int }
defined type `Doc`
wolf> fn shard() -> !int { var xs = List[Doc](); (mut xs).push(Doc { title: "regions", words: 900 }); Corrupt }
defined fn `shard`
wolf> let s = spawn proc shard()
wolf> :mem
regions:
  #0 `program` arena state=open objects=0
  #1 `proc:shard` arena state=open objects=0
tasks and procs:
  proc#0 `program` root=task0 state=live
  proc#1 `shard` root=task1 state=live
  task#0 `main` Running
  task#1 `proc:shard` Ready

Region #1 is the shard’s, created with the shard and named after it. Every allocation the shard makes without naming a region lands there, which means “how much memory does this service hold” is a question about one region rather than a question about a heap. §14.2 is what happens to that region when the shard dies.

The design target

The thesis reports a number about AXD301 worth carrying into your own programs: around 92% of its functions contained no concurrency at all. The claim is not that 92% of the code was straightforward; it is that 92% of it was sequential, written and tested as ordinary functions, with the concurrency confined to a thin layer that started things and watched them.

Treat that as a target rather than a curiosity. The chapters in this part give you seven or eight constructs, and the measure of a design that uses them well is how little of the program has to know they exist. The shelf’s shard above is an ordinary function that builds a list and returns an error; nothing in it is concurrent, and it became a failure domain because one line somewhere else said spawn proc.

The one thing both tools refuse

spawn proc f() takes an argument list, always, and the compiler and the interpreter agree about it at the same character:

fn shard() -> !int { 0 }
fn main() -> !int {
    let index = spawn proc shard
    0
}
error[E0201]: expected `(…)` proc arguments
 --> ./s3.lu:6:33
  |
6 |     let index = spawn proc shard
  |                                 ^
  |
$ lupin noparens.lu
noparens.lu: E0201: expected `(`, found an end of line [gram.expr.conc] at 77..77
$ echo $?
2

The rule is small and the reason it is grammar rather than inference is not. A proc is spawned by calling something, so the arguments it will own are written at the spawn site, in one place, where a reader looking for “what does this service start with” finds them. There is no spawn proc f that means “spawn f with whatever is around.”

Exercise 14-1 (comprehension · lupin) — A proc’s function returns 3. Before running, predict both fields of the line this prints:

fn worker() -> int { 3 }
fn main() -> !int {
    let w = spawn proc worker()
    let m = w.monitor()
    select {
        exit(reason) from m => {
            print("normal={reason.is_normal()} killed={reason.is_killed()}")
        },
        timeout(1.s) => { return 1 },
    }
    0
}

Exercise 14-2 (design) — A log-search service has three concerns: an HTTP listener, a query planner, and one index shard per disk. Argue where the proc boundaries go. For each boundary you draw, name the failure it isolates and the state that dies with it; for one boundary you chose not to draw, name what shared fate you accepted.

14.2 Crash means bulk-free

A proc’s death frees its regions. That sentence is the reason this chapter is in a book that spent a whole part on memory, and it is observable: ask what exists, kill the shard, ask again.

wolf> struct Doc { title: str, words: int }
defined type `Doc`
wolf> fn shard() -> !int { var xs = List[Doc](); (mut xs).push(Doc { title: "regions", words: 900 }); Corrupt }
defined fn `shard`
wolf> let s = spawn proc shard()
wolf> :mem
regions:
  #0 `program` arena state=open objects=0
  #1 `proc:shard` arena state=open objects=0
tasks and procs:
  proc#0 `program` root=task0 state=live
  proc#1 `shard` root=task1 state=live
  task#0 `main` Running
  task#1 `proc:shard` Ready
wolf> let m = s.monitor()
wolf> select { exit(reason) from m => { print("shard down: normal={reason.is_normal()}") }, timeout(1.s) => { print("timeout") }, }
shard down: normal=false
wolf> :mem
regions:
  #0 `program` arena state=open objects=0
tasks and procs:
  proc#0 `program` root=task0 state=live
  proc#1 `shard` root=task1 state=error
  task#0 `main` Running
  task#1 `proc:shard` Done

Region #1 is not closed, not emptied, and not queued for anything. It is gone, and no line of the program freed it. The proc is still in the forest, because a supervisor may want to ask about it, and its state is error — the record of the death outlives the memory the death released.

That is the whole cleanup story for a crashed proc, and it is worth stating what is absent from it. No destructor ran. No finalizer queue drained. No garbage collector noticed. There is no traversal of the shard’s data proportional to how much data it had, because a region is freed as one act regardless of how many objects are in it (§8.8). Erlang’s answer here is the same shape — a dead process’s heap goes back whole — and wolf gets it in a language with no garbage collector at all, because the granule was already in the type system.

Kill and cancel are different verbs

Which raises the question chapter 10 §10.4 deliberately left standing. A cancelled task runs its defers on the way out. What does a proc do?

It depends on which verb you used, and the difference is decided rather than incidental:

fn shard() -> !int {
    defer print("the shard closed its cursor")
    let queries = channel[str](0)
    let q = queries.recv()?
    print("serving {q}")
    0
}
fn reason_of(p: Proc, label: str) {
    let m = p.monitor()
    select {
        exit(reason) from m => { print("{label}: {reason}") },
        timeout(1.s) => { print("{label}: no word") },
    }
}
fn main() -> !int {
    let polite = spawn proc shard()
    polite.cancel()
    reason_of(polite, "cancelled")
    let rude = spawn proc shard()
    rude.kill()
    reason_of(rude, "killed")
    0
}
$ lupin verbs.lu
the shard closed its cursor
cancelled: cancelled
killed: killed

One cursor was closed, out of two shards that both registered a defer to close one. cancel is chapter 10’s cooperative cancellation raised to proc granularity: it is delivered at a blocking point, the blocked recv hands back an error, the ? returns through the frame, and returning is what runs a defer. The exit reason is cancelled, which is a fourth shape and the one that means “asked to stop, and did.”

kill is not that. It destroys the proc where it stands and frees its regions, and the defer does not run.

The asymmetry looks harsh until you ask what the alternative would be. A kill has to be safe to issue against a proc in any state, including a proc that is spinning in a loop with no blocking point, and including a proc whose invariants are already broken — which is the usual reason somebody reached for kill. Running that proc’s cleanup code would mean running arbitrary user code inside a failure domain that is being destroyed for misbehaving. So the language declines, and the compensation is the region: nothing leaks, because the memory was never the defer’s job.

The design consequence is a rule for your own code. Anything that must be released even if its owner is killed does not belong to the proc that might be killed. It belongs to the other side of a channel, or to the supervisor above it — which is chapter 15.

verb        delivered at      defers run   regions      exit reason
cancel      a blocking point  yes          freed        cancelled
kill        immediately       no           freed        killed

Both rows free the regions. That is the column that makes the rest of the table a choice about code rather than a choice about leaks.

Exercise 14-3 (comprehension · lupin)build_then_crash allocates a hundred integers into a region, then returns an error. Predict what the monitor reports and, separately, what happened to the hundred integers — then say which line of code freed them.

Exercise 14-4 (comprehension · lupin)sleeper registers a defer and then blocks forever on an empty channel; the owner kills it. Two prints are written in this program: defer-skipped in the proc and released in the owner. Predict which of them appear, and in what order:

fn sleeper() -> int {
    defer print_raw("defer-skipped")
    let ch = channel[int](0)
    let v = ch.recv() else |_| { return 1 }
    v
}
fn main() -> !int {
    let w = spawn proc sleeper()
    let m = w.monitor()
    w.kill()
    select {
        exit(reason) from m => {
            if reason.is_killed() { print_raw("released") } else { print_raw("wrong") }
        },
        timeout(1.s) => { print_raw("timeout") },
    }
    0
}

Exercise 14-5 (comprehension · lupin) — The same shape at task granularity. One sibling blocks on a channel with a defer registered; the other fails. Predict the output — and then state, in one sentence each, why this defer runs when 14-4’s did not:

fn fail_fast() -> !int { Boom }
fn race_them() -> !int {
    let ch = channel[int](0)
    scope s {
        s.spawn(fn() {
            defer print("sibling cleanup ran")
            let v = ch.recv()?
            v
        })
        s.spawn(fn() { fail_fast() })
    }
    0
}
fn main() -> !int {
    let r = race_them() else |_| { 42 }
    if r == 42 { print("caught") }
    0
}

14.3 Mailboxes

A proc that owns state serves it to everybody else through a queue, and in wolf that queue is chapter 12’s channel with no additions:

struct Doc { title: str, words: int }
fn shelver(inbox: channel[Doc]) -> int {
    var words = 0
    for d in inbox { words += d.words }
    words
}
fn main() -> !int {
    let inbox = channel[Doc](4)
    let s = spawn proc shelver(inbox)
    let m = s.monitor()
    inbox.send(Doc { title: "regions", words: 900 })
    inbox.send(Doc { title: "moves", words: 640 })
    inbox.close()
    select {
        exit(reason) from m => { print("the shelver finished: {reason}") },
        timeout(1.s) => { print("the shelver is still working") },
    }
    0
}
$ lupin shelver.lu
the shelver finished: normal(1540)

The mailbox is inbox, the receive loop is for d in inbox, and the answer comes back in the exit reason because the proc’s whole job was to produce one number. Everything in that program is vocabulary you already have. words is a local of one proc, so nothing synchronizes it and no lock appears anywhere; the close is the sender’s promise that there is no third document; and the count is the same under every schedule, which the explorer confirms because addition does not care what order it happens in.

Two message kinds want two channels and a select:

fn desk(shelving: channel[int], queries: channel[int], out: channel[int]) -> int {
    var served = 0
    while served < 2 {
        select {
            n from shelving => { out.send(n) },
            n from queries => { out.send(0 - n) },
        }
        served += 1
    }
    served
}
fn main() -> !int {
    let shelving = channel[int](1)
    let queries = channel[int](1)
    let out = channel[int](2)
    let d = spawn proc desk(shelving, queries, out)
    shelving.send(900)
    queries.send(1)
    let x = out.recv() else |_| { return 1 }
    let y = out.recv() else |_| { return 1 }
    print("the desk handled two kinds of message: {x + y}")
    0
}
$ lupin desk.lu
the desk handled two kinds of message: 899

That is the whole mailbox design: typed channels, one per conversation, multiplexed by select. What it does not have is a feature Erlang programmers will look for immediately, so here is why it is missing.

Why there is no selective receive

Erlang’s receive matches patterns against the mailbox and takes the first message that matches, leaving the rest queued. It is genuinely expressive: a call-and-response over one mailbox is four lines, because the reply is picked out by pattern while unrelated traffic waits its turn.

The cost is a scan. Every selective receive may walk the queue, so a receive that matches nothing walks all of it — and a message class that never matches is never removed. The failure mode is a mailbox that grows silently until the node dies, and it is common enough that Erlang folklore has a name for it and Erlang tooling has a counter for it. The expensive version is worse: a proc whose queue is long spends time proportional to the queue on each receive, so the system gets slower exactly as it falls behind, which is the shape of every queueing collapse.

Wolf’s answer is to make the topology explicit instead. One channel per conversation, each with a capacity you chose, and pattern matching happens on the channel rather than on the contents of one queue. The reply channel in the program above is that trade, made once: the desk answers on out because out is the answer’s route, not because somebody wrote a pattern that happens to match answers.

You pay for it in expressiveness. A protocol that Erlang writes as one mailbox and three patterns, wolf writes as three channels; the three channels are declared, bounded, and visible to the scheduler, and none of them can grow without limit while nobody looks. Less clever per receive, and every queue in the program has a name and a bound.

Handlers are atomic and non-blocking

One posture, stated plainly because it decides the shape of every service you write. A proc’s receive loop handles one message at a time to completion. There is no reentrancy, no interleaving of two handlers, and therefore no lock inside a proc: the proc is the lock, and its state is private by construction.

Which means a handler that blocks stops the service. A recv in the middle of handling a message is a service that has stopped serving everybody else in order to wait for one thing, and the fix is never a timeout inside the handler — it is to make the waiting somebody else’s job. Spawn a task for the slow part and let it send the result back as another message, or spawn a proc per conversation. The handler’s contract is that it finishes.

Exercise 14-6 (fingers · lupin) — Build a counting service: a proc that reads commands from a channel, where 0 means “reply with the total” and any other value adds to it. Drive it with 5, 2, then a report, and print what comes back.

Exercise 14-7 (extension · lupin) — Grow the protocol: -1 resets the counter. Report the total, reset, add 3, and report again. Predict both numbers first, then answer: what ordering guarantee makes your prediction safe, and which chapter taught it?

Exercise 14-8 (design) — Erlang mailboxes offer selective receive: a proc can pluck the first message matching a pattern, leaving the rest queued. Wolf’s mailbox is a FIFO channel plus select over multiple channels. State one protocol that selective receive expresses more directly, then argue wolf’s side: what does a skipped-over message cost in Erlang that wolf’s design refuses to pay?

What a proc costs

Three facts, which are contract rather than implementation detail.

A proc is not an OS process. Isolation at v1 is in-process: procs share an address space, and what separates them is the type system’s refusal to let a writable reference cross, not a page table. The consequence is honest and worth knowing — a proc that reaches the unsafe tier (chapter 9) and corrupts memory can corrupt another proc’s memory, because the membrane there is an audited unsafe block rather than hardware. Safe wolf has no such reach, and the isolation is exactly as strong as the audit surface §9.7 taught you to grep for.

A proc costs a region and a task. The region is created with the proc and freed with it; the task is chapter 10’s task, scheduled the same way, and a proc’s root task blocks and wakes like any other. Nothing about spawn proc is a different scheduler.

A proc’s death is ordered with respect to its monitors. Everything the proc did happens before the exit message its monitor receives, which is what makes the two :mem dumps in §14.2 evidence rather than a race: by the time the reason arrives, the memory is already gone, and no monitor can observe a half-freed proc.

Exercise 14-9 (comprehension + schedule play · lupin) — Two client tasks each send two increments to the counting proc; the scope joins, then main asks for the total. Run it under seeds 0, 1, 5, 9. Predict first: does the total vary with the schedule, and why not — and name the thing that does vary between those runs even though no output shows it.