11. Scopes as values

Chapter 10 ended on a sentence with a consequence in it: what a function needs in order to spawn is not a keyword but a scope. A scope is an ordinary value, so it can be a parameter, and that one fact is the whole of this chapter.

11.1 The scope as a capability

The shelf wants a helper that starts a reader. Not a helper that starts a reader and waits for it — a helper that starts one and returns, leaving the task running:

struct Doc { title: str, words: int }

fn tally_into(s: Scope, out: channel[int], d: Doc) {
    s.spawn(fn() { out.send(d.words) })
}
fn main() -> !int {
    let out = channel[int](3)
    var total = 0
    scope readers {
        tally_into(readers, out, Doc { title: "regions", words: 900 })
        tally_into(readers, out, Doc { title: "moves", words: 640 })
        tally_into(readers, out, Doc { title: "channels", words: 1200 })
    }
    for _ in 0..3 { total += out.recv() else |_| { return 1 } }
    print("{total} words, three readers, one scope")
    0
}
$ lupin serve.lu
2740 words, three readers, one scope

tally_into starts a task that outlives the call. That is a real extension of a lifetime, and the language’s whole position on it is the first parameter: you extended readers’s lifetime, readers is written at the call site, and the brace those three tasks die at is nine lines below in the same function. The pattern is Nathaniel J. Smith’s “nursery as a capability”, credited, and it survives translation into a language with modes because a Scope parameter is a mode-free ordinary argument — nothing in that signature is a lifetime and nothing in it is a promise about duration. It is a name for a brace.

Compare the two ways this could have gone. A function that takes no scope and spawns internally is externally sequential: whatever it starts, it has joined before it returns, and a caller need not know concurrency happened. A function that takes a Scope is the honest spelling of the other contract — work that outlives this call, dying at a brace you own. Both are legitimate designs, and exercise 11-8 argues them properly. What wolf has removed is the third option, which is the function that starts work outliving its own return without saying so. There is no signature for it.

The audit follows from the syntax, as it did for mutation. In chapter 7, one search for (mut gave you a file’s complete mutation surface, because a mode is written at both ends. Here, one search for Scope in parameter lists plus one for scope blocks gives you a codebase’s complete set of functions that can start a task — with no false negatives, because spawn is a method on a value and a value has to come from somewhere. Two capabilities, two searches, the same reason.

Exercise 11-1 (fingers · lupin) — A function cannot spawn unless somebody hands it a scope. Write launch(s, ch, n) that spawns into a caller’s scope, and a main that calls it three times inside one scope block. The Scope parameter is the entire mechanism — nothing else in the signature says “concurrent.”

Exercise 11-2 (comprehension · lupin) — Take 11-1 and change one character: make the channel a rendezvous, channel[int](0). Predict precisely what happens and why — the answer involves which side of the scope’s closing brace the receives sit on.

Exercise 11-3 (comprehension · lupin) — Using only the text of 11-1’s program, answer: which functions in it are able to spawn tasks, and what single search over a large codebase would find every function with that ability? (Chapter 7 asked the same question about mutation.)

11.2 The background refresher

Here is the shape that makes people reach for a detached thread. The shelf’s published snapshot goes stale, so something has to rebuild it periodically — for as long as the shelf is serving, and not one moment longer:

fn refresh_into(s: Scope, stop: channel[int], log: channel[str]) {
    s.spawn(fn() {
        var rounds = 0
        var running = true
        while running {
            select {
                _ from stop => { running = false },
                timeout(1.ms) => { rounds += 1; log.send("republished round {rounds}") },
            }
        }
        log.send("the refresher left with {rounds} rounds behind it")
    })
}
fn main() -> !int {
    let stop = channel[int](1)
    let log = channel[str](8)
    scope shelf {
        refresh_into(shelf, stop, log)
        for _ in 0..2 { print(log.recv() else |_| { return 1 }) }
        stop.close()
    }
    log.close()
    for line in log { print(line) }
    0
}
$ lupin refresher.lu
republished round 1
republished round 2
the refresher left with 2 rounds behind it

Four things in that program are worth naming, and none of them is a library.

The shutdown signal is a channel close. stop.close() is the only message the refresher ever receives, and it receives it by having its select arm become ready — a closed channel is permanently ready, which is chapter 12’s rule and the reason this loop needs no flag shared between two tasks. Closing is a broadcast to every receiver at once, and it cannot be sent twice by accident.

The refresher’s lifetime is shelf’s. Not “until someone remembers to stop it” — the brace after stop.close() will not complete while the refresher lives, so if the close were removed the program would hang at that brace rather than exiting with the task still running. Try it: the failure mode of forgetting to shut a background task down is a deadlock you find in the first test run, not a process that never exits in production.

The log is drained after the join. log.close() is on the far side of the brace, and it is correct there for §10.3’s reason: the join has already proved that every send which will ever happen has happened, so closing cannot cut anyone off.

select with a timeout arm is the periodic loop. There is no sleep in this program. A sleeping task is a task that cannot be told anything; a task blocked in a select with a timeout arm is doing the same waiting and remains reachable, which is why the refresher notices the close immediately rather than after its next interval.

The cost of this pattern, stated plainly: refresh_into takes a parameter it would not need in a language with detached spawn, and every call site carries it. That is the bill. What it buys is on the same page — the answer to “can this program exit with work still running” is readable from the braces, the refresher has an owner, and the shutdown path is a line of code rather than a convention.

One shape this chapter deliberately does not build is the daemon: work whose lifetime is the process, supervised, restarted when it dies. That wants a failure domain rather than a scope, and chapter 15 gives it one.

Coming from Go: the honest comparison is with context.Context, which is Go’s answer to the same problem and a good one. A ctx threads through call sites, carries cancellation, and is conventionally the first parameter — which is, structurally, the same admission a Scope parameter makes. Two differences are worth stating. A ctx propagates a cancellation request while the goroutine’s lifetime remains its own, so ctx.Done() tells a goroutine it should stop and nothing makes it; a scope’s brace waits. And a ctx is a library convention the compiler does not know about, so forgetting to pass one compiles, while forgetting to pass a Scope does not. Go’s design works and is used at enormous scale; the difference is where the enforcement lives.

Exercise 11-4 (extension · lupin) — Build a worker pool: three workers share one jobs channel and one results channel; main feeds six jobs and closes. Each worker is the same four lines. Why does the pool need no “shut down workers” message?

Exercise 11-5 (comprehension + schedule play · lupin) — Shrink the pool to two workers and four jobs, and tag each result with the worker that produced it. Before running: is the assignment of jobs to workers part of the program, or part of the schedule? Run under seed 1 and seed 2024 and defend your answer with the outputs.

11.3 The structured dump

A task tree that the language guarantees is a task tree a tool can print. Build one in the interpreter and ask what exists:

wolf> let counted = channel[int](2)
wolf> fn tally(out: channel[int], n: int) { out.send(n) }
defined fn `tally`
wolf> scope shelf { shelf.spawn(fn() { tally(counted, 900) }); shelf.spawn(fn() { tally(counted, 640) }) }
wolf> :mem
regions:
  #0 `program` arena state=open objects=0
tasks and procs:
  proc#0 `program` root=task0 state=live
  task#0 `main` Running
  task#1 `task@37` Done
  task#2 `task@80` Done
wolf> :quit

That is not a stack sample. Read it as a tree and every edge in it was declared by the program: proc#0 is the process’s own failure domain with task#0 at its root, and tasks 1 and 2 exist because a scope block spawned them. Their state is recorded because the runtime is the thing that changed it. The dump’s contents are the implementation’s business and may grow; the dump’s existence is contract, which is a distinction worth keeping in mind when you rely on one.

The lesson here is borrowed and worth crediting. Java’s structured concurrency work (JEP 444 and its successors) made the same observation about thread dumps: a dump of a thread pool is a list of stacks with no relationships in it, because the relationships were never written down, while a dump of structured tasks is a tree that tells you which work belongs to which request. Wolf gets the tree for the same reason — spawn needs a scope — and the payoff is the same. When something hangs, “who was waiting for whom” is a question with a printed answer.

The names in the third and fourth lines are the machine’s, taken from the spawn site. Names are part of the contract for this reason: a dump whose entries are task@37 and task@80 tells you the shape, and a dump whose entries say reader and refresher tells you the program.

Exercise 11-6 (spelunking · lupin REPL) — Turn on the trace and run a scope with two children, then read the scheduler’s own account of it. From the trace alone, reconstruct the task tree — which tasks exist, who owns them, and in what order they completed.

Exercise 11-7 (comprehension · lupin REPL) — In 11-6’s trace, find every SchedDecision line and read its “picked 0 of N ready” suffix. At which event did the scheduler actually have a choice, and what does that tell you about how many different traces that one-line program could produce?

Exercise 11-8 (design) — A library offers fetch_all(urls: List[str]) -> List[Response] and wants to fetch concurrently. Two candidate signatures:

fn fetch_all(urls: List[str]) -> List[Response]
fn fetch_all(s: Scope, urls: List[str]) -> List[Response]

The first hides an internal scope; the second borrows the caller’s. Argue for each: who controls cancellation and lifetime in each design, and which caller is each one honest to?