Spec §8 — Contexts
A context is a dynamically-scoped value: established for the dynamic extent of a body, readable from anything that runs within that extent, invisible outside it. Contexts are Vilan’s answer to ambient parameters (the current reactive owner, the current turn, the enclosing nursery) without global mutable state and without threading a parameter by hand through every signature.
Contexts are compiled away. The implementation threads each context’s value as a hidden parameter through exactly the functions and closures that transitively read it; there is no runtime storage, no async-local machinery, and a program that never creates a context pays nothing. Everything in this chapter is checked at compile time.
8.1 The model
std::context::Context<T> is the handle (appendix §A.4, a lang item:
the compiler keys on its operations). A context is created once, at
module level, and referred to by that name:
let flavor: Context<i32> = Context::new();
flavor.run(value, body)(establish):valueis the context’s value for the dynamic extent ofbody(the closure’s execution and everything it calls, transitively).runyieldsbody’s value.flavor.get()(the strict read): yields the establishedT.flavor.get_safe()(the safe read): yieldsOption<T>,Some(value)under an enclosingrun,Noneotherwise.
Context::new, run, get, and get_safe are intrinsics: the
threading pass rewrites their call sites away. They must be applied
directly to the context’s name (a receiver that is not a named
context is rejected). A context is not otherwise useful as a value:
moving one through a parameter or a field severs the link between its
runs and its reads; the reads can then never be covered. run’s
body argument must be a closure literal (or an injected closure value,
§8.5).
Context<T>’s value type is inferred from its first run, exactly as
a List<T>’s element type is inferred from push.
import std::context::Context;
let flavor: Context<i32> = Context::new();
fun describe(): str {
i"flavor {flavor.get()}"
}
fun main() {
let inner = flavor.run(7, || {
print(describe()); // flavor 7 — dynamic extent reaches callees
flavor.run(9, || describe())
});
print(inner); // flavor 9 — the nearest run wins
}
Establishment nests: a read observes the nearest enclosing run.
Two runs of different contexts are independent.
8.2 Reads: strict and safe
The two read forms differ in what they demand of the program, not in what they return at a covered site:
- A strict
get()demands coverage (§8.3): the compiler proves every path that reaches it passes through an enclosingrun. Code holding only strict reads carries the bareT. - A safe
get_safe()never demands coverage: uncovered paths supplyNone. Code reachable without aruncarries the value asOption<T>; at a covered→safe boundary the bare value wraps inSomeautomatically.
Strictness is a property of code regions, propagated caller-ward: a function that (transitively) reaches a strict read is strict; a function whose only demands are safe reads is safe. One function may be strict for one context and safe for another. Each context threads independently.
8.3 Coverage
For every strict read, the compiler checks, over the whole call graph,
that the reading code cannot be entered without the value. It is a
compile error (“context … is read here, but this code can be reached
without an enclosing run”) when a strict read is reachable from:
- the program’s top level or the entrypoint
main(the uncovered roots:mainis semantically the outermost, run-less extent); - a module-level initializer;
- any caller chain that does not pass through a
runof that context.
A call dispatched on a generic bound is covered by instantiation:
the compiler resolves each call chain’s recorded type bindings — through
generic forwarding functions, recursively — and a needy candidate
implementation demands coverage only of the callers whose instantiation
actually selects it. A chain the compiler cannot resolve (an
unrecorded binding, a function taken as a value or itself reachable
through dispatch) is covered conservatively: the site is treated as
reaching every candidate, so a needy candidate demands coverage of the
caller even if another candidate would have been selected. A call
dispatched on a concrete receiver’s type (an inherited trait
default) is covered by that receiver: only the members the receiver’s
type selects demand coverage — except a self call inside a shared
default body, whose receiver is unknowable at the site and which stays
conservative. Dead code, a function with no callers at all, is
exempt (it cannot run uncovered); a function called from top level, or
taken as a value, is entered without the value and is never covered,
whatever its other callers.
The error anchors at your code. A strict read you wrote reports at
that read — anywhere in your workspace: an imported sibling module, or
a member package your workspace root’s [project] packages declares
(membership is that declaration, never where a directory happens to
live). A strict read that sits inside library code — a
standard-library helper (effect, map, and or all reach the
ambient owner through one read in std::reactive), or an external
dependency package’s function (a git dependency, or a path dependency
that is not a declared member) — reports at each of your calls that
enters the library on an uncovered path, one error per call (a call
inside a covering run is never blamed), with a secondary note
pointing at the library-internal read it reaches, in the library’s own
file. Library-internal calls never carry labels of their own.
The error also shows the whole uncovered path, not just its endpoint: every call of yours between the program’s entry and the anchor carries its own label (“the context requirement flows through this call”; at a call dispatched on a generic bound, “may flow through” — the site might select an implementation that never reads). The labels run from the outermost frame down toward the read, and a deep chain elides its tail behind an explicit “… N more uncovered calls on this path”. A covered call is clean: providing the context stops the trace.
A function that reads a context cannot be used as a value
(“… reads context …, so it can’t be used as a value”): an indirect
call would bypass the hidden parameter. Wrap it in a closure literal at
the use site: the closure captures the channel correctly (§8.4).
Safe reads never fence. In uncovered positions they read None; this
is what lets library code ask “is there an ambient X?” from anywhere
(the standard library’s spawn registration does exactly this, §7.7).
8.4 Closures capture at creation
A closure created inside a covered region captures the context value at its creation site, and the capture is what its body reads, regardless of where the closure is later called:
import std::context::Context;
let flavor: Context<i32> = Context::new();
fun main() {
let snap = flavor.run(3, || {
|| flavor.get() // captures 3 at creation
});
print(flavor.run(8, || snap())); // 3 — not 8
}
This is the stability rule §7.5 relies on: ambient values do not shift
across suspensions or across deferred invocation, because the closure’s
channel was fixed when it was made. (Contrast dynamic-binding systems
where the call site’s environment decides; in Vilan only run’s
extent and creation sites decide.)
8.5 Injected closures: the context clause
Capture-at-creation has one structural consequence: a closure literal
passed to an establishing function is created before the extent
exists, so it would capture the caller’s (often absent) value: exactly
wrong for helpers like nursery(body) or run_with_owner(owner, body)
whose entire purpose is to run the body under a fresh value.
A context clause on a closure type solves this by deferring the
binding to the call site:
fun with_flavor<T>(body: (|| T) context flavor): T {
flavor.run(7, body)
}
A parameter (or let binding) whose closure type carries
context <name> declares an injected closure:
- A closure literal supplied to that position does not capture at creation; it takes its own hidden parameter, bound anew at each call.
- Each call through the injected value demands the context from the caller like a strict read, and supplies the caller’s value.
- An unannotated local binding holding a closure literal, passed into a clause position, adopts the clause (as if the literal were written inline).
Because the binding is deferred, an injected value may only flow where
the threading can follow it: it can be called, forwarded to a
parameter with the same clause, or passed as run’s body. Any
other use (storing it in a field, returning it, putting it in a
collection) is a compile error (“an injected (context-typed) closure
can only be called, forwarded …, or passed to run”).
A clause may name several contexts (context (a, b)); the clause must
name context bindings, and it composes with the closure-type markers of
§7.4 ((sync || T) context turn_scope is the reactive layer’s shape).
8.6 Interactions
- Async (§7.5): captures are fixed at creation, so a context value
is stable across every suspension of the extent by construction. An
awaiting
runbody holds its value across its whole chain. - Spawns (§7.7): every spawn site is an implicit safe read of the standard library’s ambient nursery: inside a nursery’s extent the value is present and the task registers; outside, the read is absent and the spawn stays free-floating.
- Platforms: the threading pass runs before emission for every target; contexts work identically on process and browser platforms (no host storage is involved).
8.7 The standard library’s ambient values (informative)
std builds its ambient machinery on this one mechanism: owner_scope
(the reactive disposal owner: run_with_owner, comp),
turn_scope (the current write-batching turn: turn, batch), and
ambient_nursery (std::task: nursery registration and the
cancellation signal). Their semantics are library contracts, not
language rules; they are specified by their reference pages.