Status
Accepted
Replaces: ADR-007 (Structured Concurrency Model)
Context
Slug requires a concurrency model that is:
- Simple and explicit, aligned with Slug’s design philosophy
- Predictable, avoiding hidden scheduling or implicit parallelism
- Safe by default, especially for long-running services
- Portable across implementations (Go interpreter, bytecode VM, future runtimes)
- Compatible with recursion and tail-call optimization (TCO)
Early designs used an actor-style model and later an async-gated structured concurrency approach. Experience implementing and using these models revealed an unnecessary distinction between asynchrony and ownership.
In practice:
- All Slug code already executes inside a Task
- Tasks already require a concurrency context for
spawn - Gating
awaitor task management behind an “async context” adds cognitive overhead without adding safety
This ADR refines the model by making nurseries the sole unit of concurrency structure.
Decision
Slug adopts a nursery-based structured concurrency model.
- Concurrency is always available
- Nurseries define ownership, lifetime, and failure boundaries
- There is no concept of “async context”
1. Tasks
A Task represents a single unit of execution.
All running code executes inside a Task, including the program entry point (the root task).
Each Task owns:
- Its lexical environment stack
- Its call stack
- Its current nursery stack
- Cancellation state
- Completion state and result
Tasks are lightweight, cooperative, and fully managed by the runtime.
2. Root Nursery
The root task begins execution with an implicit root nursery.
This guarantees:
spawnis always safe and well-definedawait taskis always legal- No code runs outside structured concurrency
There is no “async mode” or opt-in for concurrency.
3. Nurseries
A nursery is a runtime construct that owns a set of child Tasks.
Nurseries define:
- Task ownership and lifetime
- Failure propagation
- Cancellation scope
- Optional concurrency limits
Nursery blocks
nursery fn() {
...
}
nursery limit 10 fn() {
...
}
Entering a nursery block:
- Pushes a new nursery onto the task’s nursery stack
Exiting a nursery block:
- Joins all remaining child tasks
- Propagates the first unhandled child failure unless already handled
Nurseries are not lexical environments and are not derived from variable scope.
4. Dynamic Nursery Context
Each Task maintains a dynamic nursery stack.
Rules:
nursery {}pushes a new nursery- Exiting the block pops the nursery after joining children
spawnalways registers the new Task with the current nursery- Spawned tasks inherit the parent Task’s current nursery
This ensures correct ownership even when spawning occurs inside:
- Deep call stacks
- Module-level functions
- Higher-order functions
5. Spawning and Awaiting Tasks
Spawning
let t = spawn {
work()
}
spawnalways registers the new task with the current nursery- The returned value is the Task itself (no separate handle)
Awaiting
let result = await t within 15_000
await taskwaits for that task to complete- Awaiting a task consumes it from its owning nursery
Implications:
- Awaited tasks do not re-propagate errors at nursery exit
- Duplicate error handling is prevented
- Nursery child buildup is avoided
await task may be used anywhere, not only inside a nursery block.
6. Error Propagation
- Tasks may fail with runtime errors (including cancellation and timeouts)
- Each nursery records the first unhandled child failure
- On nursery exit, that failure is injected unless already handled
Defer semantics
defer onerrormay handle an error by returning normally- If re-propagation is desired, the handler must explicitly
throw err defer onsuccessruns only if the final scope result is successful
Handled errors do not reappear at nursery exit.
7. Interaction with recur (TCO)
recuris a function-level control flow operation- It does not exit scopes
- It does not run defers
- It does not reset or close nurseries
Nurseries persist across recur iterations and only exit on:
- Return
- Unhandled error
- Cancellation
8. Defer Semantics in Recursive Loops
deferruns on scope exit, not per iteration- In a
recurloop, defers execute once, when the loop finally exits - Per-iteration cleanup must be placed in a nested block or helper function
Consequences
Positive
- Clear and teachable mental model
- No hidden concurrency modes
- Concurrency structure is explicit and visible
- Safe by default for server workloads
- Natural integration with recursion and TCO
- Portable across runtimes and future VMs
Negative
- Requires understanding nursery-based ownership
- Fire-and-forget requires explicit design
- Misuse of
deferin recursive loops requires documentation or linting
Neutral
- No implicit parallelism; concurrency is always explicit
- Long-running services require intentional nursery design