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.

11.1 The scope as a capability

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

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 argument: 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 specification puts it exactly there — a scope handle is an ordinary value, and “lifetime extension is visible at every call site” ([conc.task.scope]). The pattern is Nathaniel J. Smith’s “nursery as a capability”, and it survives translation into a language with modes because a scope handle is a mode-free ordinary argument: nothing about it is a lifetime and nothing about it is a promise about duration. It is a name for a brace.

The parameter’s type is Scope, the prelude’s name for a scope handle ([conc.proc.handle]), and it is the whole of what the signature says about concurrency: this function is handed a brace, and whatever it starts dies there. Nothing in tally_into’s body could spawn without it, because spawn is a method on that value.

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 handle is the explicit 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: a function that starts work outliving its own return without saying so.

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. The capability to spawn is written at both ends too: as a type in the signature and as a value at the call. One search for : Scope gives you every function that can start work in a caller’s brace, one for .spawn( every task start, and one for scope every brace they die at, with no false negatives, because spawn is a method on a value and the value has to come from a scope block or from a caller who has one.

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 handed-over scope is the entire mechanism: nothing else in the call 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 exactly as long as the shelf is serving:

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 are in that program, 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. 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, not after its next interval.

The cost of this pattern: refresh_into takes a parameter it would not need in a language with detached spawn, and every call site carries it. 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, not 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 comparison to make is with context.Context, 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 handed-over scope makes. There are two differences. 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 hand over the scope does not — the body has nothing to call spawn on. 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

Read it as a tree, not a stack sample, 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.

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? Say also what the second signature fails to tell a reader, and where they have to look instead.