Interpreter (internal/interpreter)
A tree-walking evaluator.
Runtime values
Value is a tagged union (single concrete struct) rather than a Go interface hierarchy. This avoids reflect and method-table indirection, which keeps the binary small and predictable under TinyGo.
type Value struct {
Kind ValueKind # KindNull | KindInt | KindFloat | KindString |
# KindBool | KindList | KindMap | KindBytes |
# KindStruct (M13.1)
Int int64
Float float64
Str string
Bool bool
List []Value # KindList: element data
Map []MapEntry # KindMap: insertion-ordered entries
Bytes []byte # KindBytes: raw bytes
Fields []StructField # KindStruct: ordered (Name, Value) per definition
StructName string # KindStruct: matches the StructDef name
ElemTyp *parser.Type # KindList: declared element type (stamped)
KeyTyp *parser.Type # KindMap: declared key type (stamped)
ValTyp *parser.Type # KindMap: declared value type (stamped)
}ZeroFor(t parser.Type) returns the zero value for each declared type and is used when a def omits its init clause: 0, 0.0, "", false, null, an empty [] list (typed by the declaration), or an empty {} map. Value.AsFloat() handles int->float promotion for arithmetic and comparison; Value.Equal() implements == (same-kind comparison, plus the numeric-promotion rule across int and float; deep-equal for lists; order-insensitive key→value equality for maps).
Parameterized Type
parser.Type is a recursive struct: { Kind TypeKind; Element, KeyType, ValType *Type }. Compound types nest naturally (list of list of int). Equality (Type.Equal) is structural.
Value semantics
Lists and maps are value-typed in Jennifer: $ys = $xs; behaves as a copy, function parameters bind by copy, and const is deep. No aliasing is observable from user code.
Eager-copy value semantics. Value semantics rest entirely on eager deep copies at every store site, not on copy-on-write:
- Every binding site takes a private copy before storing:
execDefine/execAssignviaeagerCopy, parameter binding viabindParamValue, andsnapshotForSpawnviaDeepCopyfor the goroutine-boundary crossing. Library builtins whose pattern is "copy then mutate freely" (lists.shuffle,lists.reverse, ...)Copy()first. So no two live bindings ever share a compound backing. - Because of that invariant the mutation sites (
execAppend,execIndexAssign,execFieldAssign) fetch the target viaGetBindingand mutate the binding's own backing in place - no per-write copy - which keeps append-in-a-loop amortised O(N). Value.Copy()is the public deep-copy alias; the engine isDeepCopy().- One optimisation trims a redundant copy: a fresh list / map / struct literal RHS is already private (its evaluator
Copy()s every element into a brand-new container), soexecDefine/execAssignskip the whole-value eager copy for it (rhsFreshLiteral) and only stamp the declared type. Var / index / field reads, const refs, and calls can hand back a reference into a live binding, so those are still eager-copied.
Read-only-parameter borrow. The one carve-out to "parameter binding always copies": when a method never writes a parameter, its argument is bound by alias rather than deep-copied, since a read-only alias is observationally identical to a copy. Resolve runs markBorrowableParams per method - a statement-level write-scan of the body (descending through nested control flow; because no shadowing is allowed, a written name is unambiguously the parameter, and spawn bodies mutate their own deep-copied snapshot so are correctly skipped) - and sets Param.Borrow when the parameter is never written and its type is borrow-safe: a compound whose stampDeclaredType does not recurse into element backing (a flat list / map, or a struct / bytes; a list of list falls back to copying so the stamp cannot mutate the shared backing). Interpreter.bindArg aliases when Param.Borrow && (i.isModule || i.entryGlobalsImmutable || m.GlobalSafe) and copies (bindParamValue) otherwise, per callee method m (methodBorrowCtx). The gate is the soundness boundary: "never writes the parameter" alone is not enough, because if the argument aliases a mutable global and the body mutates that global, a borrowed read would see the change where a copy would not. So borrow is enabled only where no mutable global can be aliased and mutated during the call, which holds in three cases:
- a module (top level is declarations-only, and cross-module arguments are copies at the boundary);
- a single-file script that declares no mutable top-level
def(hasMutableTopLevelGlobalfalse, recorded asentryGlobalsImmutableinRun), so a script's own read-only helpers benefit without being moved into a module; - a specific method proven to mutate no global transitively (
m.GlobalSafe), even in a script that holds mutable globals elsewhere.computeEntryGlobalSafe(run after module load) scans each entry method for a local hazard - a write to a non-local name, aCallValueExpr, a module call (which can re-enter the host viameta.callMain), or a callback builtin that can invoke.jcode (the higher-orderlistslayer,meta.call/meta.callMain,testing.run/runWith/assertThrows, keyed by canonical namespace) - then a fixpoint propagates the hazard along named-call edges. The walker defaults an unrecognised node to hazardous, so a new node never silently escapes;spawnbodies are skipped (they mutate a snapshot). The callback set must be a denylist because a func-value argument is not statically visible (a bare method name is aConstRefExpr, and curried func returns carry no type) - the invariant "any builtin invoking.jcode viaBuiltinCtx.Invoke/CallByName*/CallHostWith*is listed" lives inglobalsafe.go.
The REPL (whose global set grows across inputs) keeps the copying bind. This is the extension of the scalar copy-elision to compound Kinds that bindParamValue was documented as needing a mutation-safety proof for. It removes accidental quadratics in read-only helpers (a markdown block collector that binds the whole line list once per block; the countNodes budget walk; a per-element script helper over a growing list). Pinned by internal/parser/borrow_test.go (write-scan + type gate), internal/interpreter/borrow_test.go (soundness gates + value- semantics parity for module, globals-free script, and mutable-globals script), and globalsafe_internal_test.go / borrow_internal_test.go (the GlobalSafe fixpoint and the entryGlobalsImmutable wiring).
A shared-marker copy-on-write protocol (Value.shared *bool + Share() / Ensure()) was tried here and removed. It was inert: a value receiver plus by-value Environment.Get / GetAt reads meant the flag was set on a throwaway copy and never reached the stored binding, so Ensure never detached and correctness always came from the eager copies above. Reintroducing it write-through (store *Binding so the marker propagates) was considered and rejected - see rejected.md.
Borrow and that COW are near-opposites, which is why one is rejected and the other ships. COW is dynamic and optimistic: share every value by default, then detect a mutation at runtime and deep-copy at that moment - so it needs a live marker on every read/write and it keeps a mutable shared backing around until a write splits it, exactly the shared mutable state the eager-copy model is designed to avoid. Borrow is static and conservative: share only the bindings a resolve-time proof shows are never written, so there is no detach, no runtime machinery, and the shared backing is immutable for the alias's lifetime (as safe to share as a const). COW was also rejected as barely-reachable - because every store copies, two live bindings almost never alias, so its deferred-copy case rarely occurs; borrow targets the opposite, ubiquitous case, since parameter binding is a store site that always copies and read-only helpers are everywhere. Borrow is a second narrow, provably-safe copy elision layered on top of eager copies (like rhsFreshLiteral), not a replacement of the model the way COW would have been.
Aliasing correctness is exercised by internal/interpreter/value_alias_test.go - every "shared then mutated" corner case (nested lists, structs containing lists, function-argument mutation, chained lvalues, etc.). Anyone changing the mutation logic must add coverage there.
Type stamping
After a literal like [1, 2, 3] evaluates, the resulting Value has no ElemTyp. The declared type lives only on the receiving binding's DeclType. To make subsequent operations - index-writes, parameter checks, iteration types - have inner-type info without re-consulting the declaration, the interpreter calls stampDeclaredType(v, declType) at every binding boundary. The helper writes ElemTyp / KeyTyp / ValTyp onto the value and recurses into nested compound elements so deep type tracking is preserved for nested $xs[i][j] = ... writes.
Index access
- Reads (
evalIndex): out-of-bounds list indices and missing map keys are positioned runtime errors. Reads return the slot value by copy via Go's struct semantics, but the inner slice headers still alias - which is fine because reads can't mutate anything. - Writes (
execIndexAssign/execFieldAssign): both route through the unifiedcollectLvalueSteps+applyLvalueWritewalker. The walker descends through the chain (any mix of[index]and.fieldnodes) on a fresh copy of the root binding, writes viawriteIndexedSlot(index leaf) or the per-struct-field type check (field leaf), then commits back throughenv.Assign. Const enforcement fires once against the root binding (deep constness). Map writes to a missing key extend the map (insertion order preserved); writes to an existing key update in place. Struct field writes are type-checked against the declared field type stored in theStructDef.
Structs
User-defined struct types live in Interpreter.structs (map[string]*parser.StructDef), populated at Run time by hoisting every top-level def struct before any other statement executes. This mirrors the method-hoisting pass, and the same duplicate-name check applies (Run rejects two structs with the same name; the REPL silently redefines).
Per-instance values use KindStruct and carry the struct name plus a []StructField (each entry is {Name, Value}). The field slice is stored in declaration order so %v rendering and Equal checks are deterministic. Copy() deep-copies every field so value semantics hold; MatchesDeclared matches by name (p typed Point matches Point-typed declarations only).
The def x as Name; no-init path is handled by zeroStructFor, which recurses through nested struct-typed fields so every leaf field gets its declared zero. Unknown struct names are rejected here and at execDefine time before the init expression is evaluated, so the user sees "unknown struct type" rather than a misleading type-mismatch error.
Module structs resolve by their canonical path, not their stem: zeroStructFor and lookupStructDef take the ModPath carried on the value / declared type and resolve through moduleByPath, falling back to the stem-keyed moduleByNS only when no path is available. This is what keeps field writes and zero-init working when two loaded modules share a file stem (a/util.j and b/util.j) - a stem-keyed lookup is ambiguous there and moduleByNS deliberately returns nil rather than guessing.
Library-provided namespaced structs
Libraries register their own struct types via Interpreter.RegisterNamespacedStruct(libName, structName, fields). The definition lands in i.NSStructs keyed by nsKey{NS, Name}, parallel to NSBuiltins and NSConstants. Users write def x as os.Result; for the type and os.Result{ ... } for the literal; both forms resolve at use time via resolveNamespacePrefix so aliases (use os as o; def x as o.Result;) work the same way as they do for namespaced function calls and constants.
At runtime the value carries both StructName and an optional StructNS tag. MatchesDeclared and Equal compare on the (NS, Name) pair so a library os.Result is a distinct type from a user-defined Result; Display prefixes the namespace (os.Result{exitCode: 0, ...}). Field access, chained lvalues ($r.exitCode, $line.from.x = 5;), value semantics, and deep const all reuse the user-struct machinery - only the type-resolution path differs.
User code may not register namespaced structs; the API is Go-side only. Programs that want to declare their own structs keep using the def struct Name { ... }; bare form.
Iteration
execForEach opens a fresh per-iteration scope so the loop variable binding doesn't leak out and def-rebindings don't accumulate. It borrows that frame from envPool (like execBlock) rather than allocating a fresh map + slot slice each pass; execFor pools its header frame the same way. For lists it walks elements in order; for maps it walks keys in insertion order. The underlying map representation is an ordered slice ([]MapEntry) rather than a Go map[K]V precisely to make this iteration deterministic and testable.
Map hash index. Because Map is an ordered slice, a naive indexInto / writeIndexedSlot linear-scans it with Value.Equal per entry, so building a map with $m[$k] = $v over N keys is O(N^2). Each map Value carries an unexported side index mapIdx map[string]int (encoded scalar key -> position) that turns lookup, update, and the existence check on insert into O(1), so the build is O(N). The index is advisory: it is consulted only when mapIndexUsable() confirms it is complete - non-nil and len(mapIdx) == len(Map) - and every operation falls back to the linear scan otherwise, which is always correct. That length stamp is what makes the index safe under value semantics: a map copied by value shares the slice header until it diverges, a duplicate-key literal has no 1:1 index, and a non-hashable key (float, or any compound) is never indexed - all three fail the stamp and scan. Only hashable scalar keys (string / int / bool / null) are encoded, mirroring Value.Equal exactly so distinct keys never collide; float is excluded (NaN, -0.0, precision). The index is (re)built by DeepCopy (every binding-boundary copy owns a fresh one), by evalMapLit, and lazily on the first indexed write.
Environment
Environment is a parent-linked scope frame. Each frame carries two storage backends, but a given binding lives in exactly one of them:
slots []Binding- the slot-indexed view and the primary storage for resolved code. Sized fromBlock.NumSlotsat frame construction (NewEnvironmentSized) or grown on demand fromDefineAt. A resolved binding (Slot >= 0) is written here and only here - not the name map - so a call / block frame does no per-binding heap allocation (the M21.12 per-frame allocation work). EachBindingcarries its ownName, so the rare name-based readers can find a slot binding by scanning this (small, pooled) slice.vars map[string]Binding- the name-keyed view, now the fallback only: it holds bindings the resolver did not stamp with a slot (Slot < 0) - the REPL (each turn is a fresh parse with no resolver context) and ad-hoc / hand-built ASTs. Resolved frames leave it empty.root *Environment- cached pointer to the outermost ancestor. Set at construction viarootFor(parent, self); bothNewEnvironment*andborrowBlockEnvpopulate it.effectiveGlobal(env)reads this field in O(1) with a defensive parent-chain walk as fallback.releaseBlockEnvclears it on release so pooled envs don't retain refs across borrow cycles.
Binding{Value, DeclType, IsConst, Slot, Name} carries Slot (-1 for name-only Define; non-negative for resolver-stamped DefineAt) and Name (the identifier, so a slot-backed binding is self-describing without a parallel name-map entry).
Storing the declared type lets Assign reject type-mismatching writes (you cannot assign a string to a variable declared as int).
lookupLocal(name) is the shared per-frame lookup: it scans slots by Name first (the resolved common case), then falls back to vars. All the name-based readers below walk the parent chain calling it.
Name-based API (fallback path - REPL, spawn snapshot, ad-hoc AST):
Define(name, val, declType, isConst)- adds to the current frame'svars; errors if the name exists anywhere in the chain (the spec forbids shadowing). SetsBinding.Slot = -1.Assign(name, val)- walks up the chain (lookupLocal) to find the binding; errors if it is a constant, the value's kind doesn't match its declared type, or the name is undefined. Writes back to the slot it came from, or tovarsfor a name-only binding.Get(name)andGetBinding(name)- walk up the chain vialookupLocal.copyBindingsInto(dst)- reconstructs a frame's name->value view (slots byName, thenvars) as detached deep copies; the one placesnapshotForSpawnrebuilds captured scope from slot storage (globals are slot-backed too).
Slot-based API (fast path):
DefineAt(slot, name, val, declType, isConst)- forslot >= 0installs the binding atslots[slot](growing the slice if needed) withBinding.Name = nameand does not touchvars; forslot < 0it takes the name-map fallback (with the enclosing-scope shadow check).GetAt(depth, slot, name)- walksdepthparents then indexesslots[slot]. Falls back tovars[name]at the same depth when the slot is out of range (covers execution paths added to a resolver-less frame).GetBindingAt(depth, slot, name)- metadata companion toGetAt.AssignAt(depth, slot, name, val)- const / type-mismatch checks match the name path; on success writes toslots[slot](the slot is authoritative for a slot-backed binding).
NewEnvironmentSized(parent, numSlots) is the constructor that pre-sizes slots from the resolver's hint, avoiding a grow on every DefineAt in hot loops. NewEnvironment(parent) (no size) is still used by REPL paths and by ad-hoc paths where the slot count isn't known upfront.
execBlock opens a fresh child Environment for each {...} block, so variables declared inside don't leak out. for opens its own scope wrapping init/cond/step/body so the init variable is visible throughout the loop without escaping it.
Resolver / runtime scope alignment
The resolver's static scope stack has to match the runtime's env chain exactly, or (Depth, Slot) addresses land in the wrong place. Three carve-outs where the resolver deviates from "one AST scope = one runtime frame":
trybody runs in the enclosing env, not a fresh frame:execTrycallsexecStmts(body.Stmts, env)directly. The resolver walks try-body statements inline in the current scope to match. Only the catch handler gets a proper scope push (matches the runtime'scatchEnv := NewEnvironment(env)).- For-header init lives in
forEnv, body lives in a nested block frame:execForcreates one env for the header andexecBlocknests another for the body. The resolver tracks them as separate scopes. - Spawn bodies are deliberately unresolved. The runtime's
snapshotForSpawnproduces a two-frame duplex (globals-snap + locals-snap) that doesn't align with the resolver's single-frame view of "the enclosing scope." Rather than invent depth arithmetic to reconcile the two, the resolver skips spawn-body statements entirely and every reference inside falls back to name-based lookup at runtime. Spawn is coarse-grained concurrency dispatch, not hot-loop territory, so the perf regression is bounded.
Method call frames
Three compounded moves cut the recursive-call cost:
- Environment pool.
environment.goexportsborrowBlockEnv/releaseBlockEnvon top of a package-levelsync.Pool. EveryexecBlock, everyevalCall, and everyCallByNameborrows a frame on entry and returns it before returning to the caller. Release zeroes both thevarsmap (delete-in-place) and every used slot entry so the pool doesn't retain compound-value backings live between uses. Jennifer has no closures - no value, no library, no AST node can capture an env pointer past its block's dynamic extent - so the pool is safe by construction. The two envs that outlive their creating call (i.global, the goroutine-root snapshots fromsnapshotForSpawn) stay on the non-pooledNewEnvironmentpath.snapshotForSpawncopies the launching goroutine's own root frame -effectiveGlobal(env), which isi.globalin serial code but the enclosing spawn's detached global snapshot inside a spawn body - never the livei.global, so a nested spawn snapshotting on a background goroutine can't race the main goroutine's global writes. - Pre-resolved callees.
CallExprcarries aMethod *MethodDefpointer (seeinternal/parser/ast.go). DuringResolvethe resolver stamps the pointer when the callee names a hoisted top-level user method.evalCallconsults the pointer first; only when it'snil(REPL turns, hand-built ASTs) does it fall back toi.methods[c.Callee]. Builtins keep the pointernilbecause the namespaced / global registries still need theuse-activation check on every call. - Slot-based parameter binding. The resolver's
resolveMethodassigns parameters to slots0..N-1in the call frame. At runtimeevalCallborrows the call frame viaborrowBlockEnv(effectiveGlobal(env), len(m.Params))and binds each parameter throughDefineAt(idx, ...). No map hashing per parameter; the resolver's slot numbers align with the interpreter's storage layout automatically.
Namespaced-call fast paths
Four more moves compound on top of the method-call frame work:
- Pre-resolved namespaced calls / constants.
QualifiedCallExprcarries an opaqueFn anyfield;QualifiedConstRefExprcarries an opaqueConst anyfield (seeinternal/parser/ast.go, keptanyto avoid a parser -> interpreter import cycle). Becauseparser.Resolveruns beforeprocessImports, the pre-fill can't live in the resolver pass.Interpreter.resolveQualifiedRefsruns fromRunright afterprocessImportsand walks the AST once, stamping the exactBuiltin/Valuethe runtime would otherwise look up per call.evalQualifiedCallandevalQualifiedConstuse the pre-filled pointer via type assertion; unresolvable prefixes stay nil and hit the originalresolveNamespacePrefix + NSBuiltinspath with its original error messages. - Int-int / float-float comparison fast paths.
evalComparisonnow checks both operand Kinds before falling through toAsFloat, mirroring the int-int block that already lived inevalArithmetic. Numeric loops ($i < N) skip two float conversions per iteration. - Immutable-Value copy elision in arg binding.
evalCall's arg loop routes throughbindParamValue(v, declType). For scalar Kinds (int / float / bool / null / string) it returnsvunchanged - bothValue.CopyandstampDeclaredTypewere no-ops for those Kinds. Compound Kinds still go through the full copy + stamp path so value semantics + declared-type propagation stay correct. effectiveGlobalcaching viaEnvironment.root. Environment grew aroot *Environmentfield set at construction time by therootFor(parent, self)helper. BothNewEnvironment*andborrowBlockEnvpopulate it.effectiveGlobal(env)becomes an O(1) field read with a defensive parent-chain walk as fallback for hand-built envs.releaseBlockEnvclearsroot = nilon release so the pool doesn't retain a reference across borrow cycles.
Expression micro-optimizations
Two moves close out the optimization pass:
- Constant folding at parse time.
BinaryExprandUnaryExprgained aFolded Exprfield (internal/parser/ast.go). The resolver runstryFoldBinary/tryFoldUnary(internal/parser/fold.go) after each subtree's operands resolve; when both operands are literal (transitively through their ownFoldedfields via theasLithelper), the operation runs at parse time and the result gets stamped onFolded.evalBinary/evalUnarycheckFoldedfirst and delegate to it, skipping the operand walk + op switch. Runtime errors (division by zero, negative shift count) leave the node unfolded so the runtime hits the same error at the same source position - the fold pass never surfaces a parse-time error the runtime wouldn't have raised. Two exactness carve-outs mirror the runtime: an int/int comparison folds on the exactint64values (not a lossyfloat64), and a mixed int/float comparison is left unfolded entirely so the runtime's exactcompareIntFloatdecides it - promoting the int tofloat64at fold time would lose precision above 2^53 and make9007199254740993 == 9007199254740992.0wrongly fold totrue.!=folds wherever==does (it is the negation). Mixed int/float arithmetic still folds (the int->float promotion there matches the language's arithmetic semantics).
String interpolation
A cooked "..." string with one or more {expr} slots lexes to a single TOKEN_STRING_INTERP whose Parts []lexer.StringPart alternate literal chunks and expression-source slots. readCookedString drives this: \{ / \} decode to literal braces, an unescaped { calls scanInterpSlot (which brace-counts nested {} so a map literal in a slot balances, and copies a nested string literal verbatim via copyNestedString so its braces / quotes do not disturb the count), and a bare } is a positioned lex error. Each expression slot records its absolute source line/col; the parser's buildInterpString sub-lexes it with lexer.TokenizeAt (seeding that position so diagnostics and resolver annotations point at the real spot) and sub-parses it to a single expr - a trailing non-EOF token is a "slot must be a single expression" error, an empty slot is rejected. The result is a parser.InterpStringExpr (Parts []InterpPart, each a literal Lit or an Expr). The resolver descends into each slot's Expr (undefined-variable / shadowing stay parse-time errors), so lint and profile - which walk the same tree - see slot expressions as ordinary code (lint adds the L204 interpolation-slot-complexity check; profile attributes a slot's call to the slot's own position). Evaluation stringifies each slot value with Display (the convert.toString form, so no use convert is needed and any kind interpolates) and concatenates - the "sugar over concat + convert.toString" the milestone describes, kept as a node so the toolchain is not blind to slot code. A raw '...' string never interpolates (it lexes to a plain TOKEN_STRING); fmt re-emits a TOKEN_STRING_INTERP from its captured Raw, so slots survive a format verbatim.
Execution model
Interpreter.Run(prog)callsparser.Resolve(prog)first so the AST carries(Depth, Slot)annotations before any structural check runs. Resolve is idempotent: re-running on an already-resolved program produces the same annotations. Any undefined-variable or shadowing error surfaces here as a positioned parse-time diagnostic, not a runtime error.- Records
Importsintoi.imported, activating library namespaces. Module aliases are not loaded yet - that happens in step 4 - which is why the qualified-ref pre-resolution is deferred until after it. - Collects every
MethodDefintoi.methods(methods are hoisted: callable regardless of source order). During collection it enforces two rules: no duplicate method names, and no method name that collides with a registered builtin whose owning library has been imported (the no-shadowing rule extended to builtins - seeevalCallbelow). - Creates the global
Environment(i.global). Before executing the body,loadModuleImports(prog)loads and initialises everyimport "..."module (see Module loading), so an imported module is fully initialised before the importer's body runs. ThenresolveQualifiedRefs(prog)walks the AST once and pre-fills everyQualifiedCallExpr.Fn/QualifiedConstRefExpr.Constagainst the now-populated namespace AND module-alias tables. A library builtin, a library const, a module-alias const (m.CONST- its boundary-retagged, deep-const value cached on the node), and a module-alias method (m.fn(...)- amoduleMethodTarget{mod, *MethodDef}cached inFn, dispatched throughdispatchModuleMethod->CallMethodWith) all resolve to a direct dispatch instead of a per-access lookup (an unexported / missing name stays unstamped, so its runtime error is unchanged). This pass runs after BOTH import mechanisms (the tables didn't exist during Resolve) and is skipped by the REPL, which builds its namespaces incrementally. Finally executesprog.TopLevelstatements in source order in the global scope. - Method calls execute the body in a fresh call frame whose parent is
effectiveGlobal(env)(an O(1)env.rootfield read; in serial code that'si.global, inside aspawnbody it's the spawn-globals snapshot). The call frame is borrowed from the environment pool pre-sized to the parameter count; parameters bind throughDefineAtinto slots0..N-1; the callee is looked up via the pre-resolvedCallExpr.Methodpointer when set, falling back toi.methodswhen it isn't (REPL turns). Scalar-Kind arguments skip the Copy + declared-type-stamp step viabindParamValue. Top-level variables are visible inside methods (subject to the no-shadowing rule). The body's return value (barereturn;->null;return EXPR;-> the expression's value; falling off the end ->null) propagates back to the caller.
EvalInteractive (the REPL entry point) skips step 1 - each REPL turn is a fresh parse whose scope can't be resolved without the accumulated global context from prior turns. The runtime handles this by leaving resolver annotations at their -1 sentinel and using the name-based Environment API. The perf cost is limited to REPL sessions, which aren't hot loops.
There is no required entry point. A program with only imports and method defs is valid and runs to completion immediately (those methods are simply never called).
Module loading
import "PATH.j" [as NAME]; (an ast.ModuleImportStmt) loads another .j file as a module. internal/interpreter/module.go holds the loader; internal/module holds the path resolver.
EnableModules(baseDir, searchDirs, load, setup) wires the system onto the root interpreter and builds a moduleReg shared across the whole run:
cache map[string]*loadedModule- the run-once table keyed by resolved absolute path.stack []string- canonical paths currently loading, for cycle detection.search []string- the module search path (system module dir, then each-Idir).load func(string) (*parser.Program, error)- lex + preprocess + parse a resolved file. The CLI passesmain.go'sloadModuleProgram; tests pass an equivalent.setup func(*Interpreter)- install the standard library into a module's fresh sub-interpreter. The CLI passesinstallLibraries.
loadModuleImports(prog) runs from Run (step 4) before the body. For each import it calls loadModule(path, at):
module.Resolve(path, baseDir, search)-> canonical absolute path (local.//../and absolute/resolve directly; a bare name walkssearch, where a name found in two search dirs is a hard error). Resolution errors are positioned at the import statement.- Cycle check - if the canonical path is already on
reg.stack, errormodule cycle: A -> B -> C -> Anaming every edge. - Run-once - if it's already in
reg.cache, return the cached*loadedModulewithout re-running. reg.load(canonical)parses the module (parse errors stay positioned in that file).- A fresh sub-interpreter is the module's own scope:
sub := New(); reg.setup(sub); sub.modReg = reg; sub.baseDir = dir(canonical). Sharingregmeans the sub's own imports use the same cache and stack. - Push the canonical path,
sub.Run(modProg)(which recurses into the sub's imports, then runs its body), pop the path, and cache the result.
The recursion is what delivers the guarantees: run-once from the cache, depth-first post-order init from initialising a module before its importer's body, and cycle detection from the load stack. Load errors (a parse error or a throw during a module's top level) propagate out of Run as ordinary errors and fail the program; they are not catchable, because an import is a top-level declaration, not an expression, so it cannot sit inside a try/catch (the parser rejects import in a block).
Module scope and namespacing
A module's top level is declarations-only: checkModuleDeclarationsOnly (run in loadModule after parse) allows only def const in TopLevel - structs, methods, and imports live in their own Program slices - so a mutable def or a free-standing statement is a positioned load-time error. Scripts run through the CLI never reach this check, so a jennifer run program keeps mutable top-level def and free-standing statements.
loadModuleImports binds each import's alias (the as NAME clause, or the file stem via moduleStem) into the importer's moduleAliases (map[string]*loadedModule), collision-checked against library prefixes (nsPrefixes) and other module aliases. Consumer-side resolution rides the existing qualified-reference eval layer, since the parser resolver and resolveQualifiedRefs already defer unknown prefixes to runtime:
evalQualifiedCallchecksmoduleAliasesbefore the library path and, on a hit, dispatchesalias.fn(args)throughcallModuleMethod- arguments evaluated in the consumer's env, thenm.interp.CallByNameWithruns the body against the module's globals and methods. Arity / type mismatches reposition at the consumer's call site; aruntime error,throw, orexitfrom the module body propagates unchanged.evalQualifiedConstreadsalias.CONSTfrom the module's global scope (m.interp.global.GetBinding).
use non-transitivity, run-once sharing, and -race safety all fall out of the fresh-sub-interpreter-per-module model: a module's interpreter holds only immutable constants and read-only methods, so concurrent calls from parallel spawn bodies share no mutable state.
Exports, visibility, and cross-module struct identity
export marks a top-level func / def struct / def const as a module's public surface (parseExported); collectExports records the set on the loadedModule, and callModuleMethod / moduleConst / evalStructLit / the def-type check reject an unexported alias.member with a positioned "not exported from module" error. export is illegal in a script: Run calls rejectExportInScript unless isModule is set (only loadModule, and the jennifer test overlay via SetModuleContext, set it). checkReferentialClosure (at load) rejects an exported struct field or exported function parameter typed as a private module struct; only the module's own bare struct types are checked, so library / namespaced types cross freely.
Cross-module struct identity is boundary translation, not internal tagging. A module's own structs stay bare (StructNS "") inside the module, where all the existing struct machinery works unchanged. retagStructs re-tags them to (module-stem, name) as a value crosses out to an importer (a callModuleMethod return, a moduleConst read) and back to bare on the way in (a callModuleMethod argument), recursing through struct fields, list elements, and map values; library / other-module structs (a different namespace) are untouched. So the consumer can type a module struct (def p as points.Point, or def ps as list of points.Point; the def-check's resolveDeclaredStructNS stamps the type's namespace to the module stem, recursing into list / map / task element types so a collection element type matches the identity retagStructs gives the values), construct one (points.Point{...} in evalStructLit), read its fields, and pass it back - all type-checking - while a.Point and b.Point stay distinct (namespace, name) pairs. Struct identity is keyed by the module's canonical path, carried in the ModPath field on both Value and parser.Type (empty for library / user structs) and compared by Value.Equal / MatchesDeclared; StructNS holds the file stem purely for display, so two module files sharing a basename (a/util.j, b/util.j, or @jennifer/benchmark vs @alice/benchmark) are genuinely distinct types while both still render as benchmark.Point. The boundary retag threads (StructNS, ModPath) so a foreign struct that only shares the stem is left untouched, and method parameter types are stamped alongside def types so a func f(s as mod.Struct) param carries the same identity the passed value does. The retag copies only compound values at the boundary (module calls are not a hot path). Declared-type stamping happens once, single-threaded, before execution: resolveDeclaredTypesOnce (run from Run after loadModuleImports) walks every declared type - top-level, method bodies, and spawn bodies - stamps it, and marks the AST node parser.Type.Resolved. The per-execution resolveDeclaredStructNS in execDefine early-returns on a resolved node, so a def inside a loop, or a shared method / spawn body reached from concurrent goroutines, re-reads the marker instead of re-stamping - no write-write race on the shared type node. The pass is best-effort (an unresolvable type is left for execDefine to error on at its original position), and the marker also sidesteps a latent idempotency gap: re-resolving an already-stamped library alias would otherwise hit the "canonical namespace is aliased" rejection. (An importer alias to a module stem is also recognised on any later pass via moduleByNS.)
Func values across the module boundary
A func value is a handle to a top-level method (Value.Fn *parser.MethodDef). It also carries Value.FnHome *Interpreter - the interpreter that defined the method - stamped when a bare method name is evaluated as a value (i.methods[name] -> FuncVal(m) with FnHome = i). This matters only across a module boundary: a func value is a value like any other, so it is routinely passed into a module (a lists.map predicate, an args.dispatch handler, a webapi authenticator) or returned out of one. Without a home, evalCallValue would run the body in the caller's interpreter, whose namespace table does not carry the func's own use / import aliases - so a body that references strings.upper or constructs mymod.Thing{...} would fail with "unknown namespace" against the wrong interpreter.
So evalCallValue, when fv.FnHome != nil && fv.FnHome != i, dispatches the call to the home: it evaluates the arguments in the caller, then runs home.callMethodWithDepth(fv.Fn, env.depth, args...) - the body executes in its home context, its aliases resolve, and env.depth threads the caller's call-depth counter so recursion bouncing through a cross-boundary func call still trips the catchable guard (as it does through dispatchModuleMethod / meta.callMain). Same-interpreter calls (FnHome == i, or a legacy nil home) keep the ordinary callUserMethod path with no retag.
Struct arguments and the return value cross the boundary through crossRetag(v, from, to), which reuses retagStructs (above) in a two-pass form: to's own structs - tagged with to's identity as from sees them - are un-tagged to bare inside to, then from's own structs - bare inside from - take from's identity so to recognises them as that module's structs. Because a struct belongs to exactly one module, the two passes target disjoint owners, so the same helper serves both directions (arguments crossing in, the result crossing out) and every pairing (host->module, module->host, module->module). The webapi authenticator is the worked case: a host func value returning a webapi.Identity (built through the host's webapi alias, so tagged (webapi-stem, webapi-path) in the host) is called from inside the webapi module and its return is retagged bare, exactly the identity the module's own code expects. FnHome is a shared pointer, copied with the (immutable) func value like Fn, and never mutated. Pinned by cmd/jennifer/funcvalue_home_test.go and args_dispatch_test.go.
Builtins and libraries
Each library lives in its own Go package under internal/lib/<name>/ and registers its functions (and constants) on the interpreter. User-facing reference docs are split per library:
- libraries/io.md -
printf,sprintf, format verbs - libraries/convert.md -
int,float,string,bool,typeOf - libraries/math.md -
math.abs,min,max,sqrt,pow,floor,ceil,round,rand,randInt,randSeed; constantsmath.PI,math.E - libraries/strings.md -
upper,lower,contains,startsWith,endsWith,indexOf,trim/trimLeft/trimRight,replace,repeat,substring,split,chars,join - libraries/os.md -
os.PLATFORM,os.ARCH,os.EOL,os.DIRSEP,os.PATHSEP,os.ARGS,os.getEnv,os.hasFlag,os.flag,os.run,os.spawn,os.wait,os.poll,os.kill - libraries/meta.md -
meta.VERSION(build version),meta.BUILD(toolchain) - libraries/index.md - catalog and organizing principles
len(EXPR)is a language built-in primary, not a library function. See grammar.md.
What follows is the implementation contract, not the user-facing API.
Library functions are Go closures registered with the interpreter:
type BuiltinCtx struct {
Out io.Writer // stdout-like effects write here
In io.Reader // stdin-consuming builtins read here
InREPL bool // true when the call originates from the REPL
}
type Builtin func(ctx BuiltinCtx, args []Value) (Value, error)
# In a library package:
func Install(in *interpreter.Interpreter) {
in.Register("io", "printf", printf)
in.Register("io", "sprintf", sprintf)
in.Register("io", "readLine", readLine)
in.Register("io", "eof", eofFn)
}BuiltinCtx replaces an earlier (out io.Writer, args) signature to give input-consuming builtins symmetric access to stdin and the REPL flag. Interpreter.In defaults to os.Stdin; the REPL sets Interpreter.InREPL = true so readLine / eof refuse rather than racing the line editor for input.
Interpreter.Builtins stores builtinEntry{Lib, Fn} per name. A call to foo(...) resolves in this order:
- User-defined method
fooini.methods. - Builtin
foo- but only if its owning library has beenused. The error otherwise quotes the right library name:foorequiresuse <lib>;.
The no-shadowing check at hoist time uses the same lookup: a user method that collides with an imported library's builtin is rejected.
User-defined constants (via def const NAME as TYPE init EXPR;) live in the same Environment as variables and resolve through evalExpr's ConstRefExpr case (bare-identifier lookup). They participate in the no-shadowing rule like everything else.
Library-provided constants (math.PI, math.E, time.UTC, time.PROGRAM_START, os.PLATFORM, ...) are namespaced and registered through RegisterNamespacedConst. They resolve through QualifiedConstRefExpr - see the "Namespaced libraries" subsection below. The RegisterConst flat-namespace constant API and the bare-IDENT ConstRefExpr fallback for library constants are no longer used by any shipping library; the fallback path remains in the interpreter as exported API surface pending a final cleanup pass.
Namespaced libraries
Domain libraries register through the namespaced API:
in.RegisterNamespaced("os", "platform", platformFn)
in.RegisterNamespacedConst("os", "JENNIFER_OS", interpreter.StringVal("linux"))Both entries are keyed by (namespace, name) in NSBuiltins / NSConstants. The library's name doubles as the namespace prefix (future libraries may decouple them, but today they always match). Registering through the namespaced API also flags the lib in knownNamespaces.
Only libraries flagged in knownNamespaces may be aliased. processImports rejects use NAME as ALIAS; for any library that registered exclusively through Register / RegisterConst (the flat API) with the message library NAME has no namespaced builtins; + "as ALIAS" + aliasing is meaningless here. The flat libraries (io, convert, math, strings, core) all fall into this category - they have no prefix to rename, and silently accepting an as clause would create the misleading impression of an alias-shaped escape hatch.
processImports builds two maps from each use NAME [as ALIAS];:
nsPrefixes[prefix] = canonicalNamespace- the prefix that's active at call sites;prefix == canonicalforuse os;,prefix == aliasforuse os as o;.nsAliasedAway[canonical] = alias- records that the canonical name has been shadowed by an alias, so a lateros.foo()afteruse os as o;errors with adid you mean+ "o" +?hint.
Resolution at a QualifiedCallExpr / QualifiedConstRefExpr goes through resolveNamespacePrefix(prefix):
- If
prefixis innsPrefixes, use the canonical namespace it points at. - Else, if
prefixis innsAliasedAway, emit the "did you mean<alias>?" hint. - Else, if
prefixis the canonical name of a known namespaced lib the program forgot touse, emit arequires+ "use prefix;" reminder. - Else, emit
unknown namespace.
The no-shadowing rule for top-level methods (checkMethodNoShadow) adds one more clause: a method name that matches an active namespace prefix is rejected (func os() {} errors after use os;, but is fine after use os as o; because only o is reserved as a prefix).
The five essential flat libraries (io, convert, math, strings, core) intentionally do not use the namespaced API - their names stay bare for ergonomics.
For the user-facing API of each library, follow the links above. Below are the implementation-only notes worth knowing as a maintainer.
internal/lib/io: printf and sprintf share a formatArgs helper with three shapes - 0 args errors; first-arg-is-string triggers format substitution; single non-string arg writes the value's Display() form (the "just print this value" shortcut). printf writes to Interpreter.Out; sprintf returns a KindString value and ignores the writer.
internal/lib/math: floor/ceil/round accept int (identity) or float and return int. round uses Go's math.Round (half away from zero). math.PI and math.E are registered via RegisterNamespacedConst and resolved through QualifiedConstRefExpr like every other namespaced constant; the namespace prefix is reserved for the rest of the program once use math; runs.
internal/lib/convert: conversions live behind the namespace - convert.toInt(v), convert.toFloat(v), convert.toString(v), convert.toBool(v). A bare type keyword in expression position (int(...), bool(...)) is a positioned parse error whose hint names the matching convert builtin. typeOf is a normal IDENT call. convert.toBool(v) implements canonical-only conversion at all source kinds (0/1 for int, 0.0/1.0 for float, "true"/"false" for string) - non-canonical values produce a positioned error, not silent coercion.
internal/lib/strings: all indices and lengths are rune-based (Unicode code points), implemented via unicode/utf8. len returns the rune count; indexOf returns a rune index (not the byte index Go's strings.Index produces - we translate); substring uses a small byteOffsetForRune helper to convert rune-indexed bounds back to byte slicing on the underlying string. repeat guards against multiplication overflow before calling Go's strings.Repeat to avoid the panic in the standard library. The Go package is named stringslib to avoid colliding with the standard strings package, which it depends on heavily.
Runtime errors
*runtimeError carries optional File/Line/Col and a Kind tag (defaults to "runtime" when the originating site doesn't specialise it). Errors render as runtime error at FILE:L:C: <msg> (or runtime error at L:C: <msg> when the file is unknown). All five Jennifer error types - *lexer.LexError, *preproc.PreprocessError, *parser.ParseError, *runtimeError, and *ErrorSignal - implement a small Position() (file string, line, col int) interface. The CLI uses that interface (no string parsing) to look up the right file and print a caret under the offending source line.
Catchable errors
try { body } catch (NAME) { handler } runs the body and, on an error, binds the thrown value to $NAME in a fresh per-handler scope. Two sentinel paths can produce the catchable error:
*ErrorSignal- raised bythrow EXPR;(execThrow). Carries the thrownValueplus the throw's source position. Uncaught signals reach the CLI through the samepositionedinterface as*runtimeError.*runtimeError- raised by any builtin or language operation (out-of-bounds index, missing map key, type mismatch, etc.). When one reaches an enclosingtry,execTrywraps it viaruntimeErrorToValueinto anErrorstruct (kind,message,file,line,col) and binds it like any other thrown value.
*ExitSignal is not routed through this path - the spec puts process exit outside the recoverable-error scope, so execTry propagates it untouched. blockResult flags (hasReturn, hasBreak, hasContinue) flow through execTry unchanged so the surrounding method / loop sees them.
The canonical Error struct is auto-hoisted into i.structs by both Run and EvalInteractive before any user struct definition runs (canonicalErrorStructDef()). User code may not redefine it - the existing duplicate-struct check fires with struct "Error" is defined more than once.
runtimeError.Kind is the symbolic tag surfaced as $err.kind in the catch block. The current shipping default is "runtime"; specific tags ("out_of_bounds", "type_mismatch", etc.) get filled in per call site as user code grows demand for finer dispatch.
Errors and positions (cross-file)
The pipeline plumbs file information through three layers:
- The lexer attaches the source file path to every token (
Token.File).TokenizeWithFile(source, file)is the entry point; the no-argTokenizeleavesFileblank for unattributed input. - The preprocessor preserves each spliced token's
Filefield when resolvingimport "path.j";, so tokens from an imported file keep that file's path, line, and column. - The parser propagates
Filefrom tokens to every AST node (eachposstruct carriesFile,Line,Col). Synthesized nodes (e.g.BinaryExpr) copy the file from the left operand.
When the interpreter raises a *runtimeError, it pulls file/line/col from the offending node via a small posFor(node) helper. The CLI's printErrorContext type-asserts the positioned interface, and if the reported file differs from the program's main file it loads that file from disk before slicing out the snippet to display.
Concurrency
The spawn keyword, the task of T type kind, and the task library together form Jennifer's first concurrency surface. The user-facing model is docs/user-guide/concurrency.md; this section describes the runtime side.
Goroutine mapping
spawn { ... } is a primary expression: parser.SpawnExpr carries the body as []Stmt. The interpreter handles it through evalSpawn:
- Build a fresh capture environment with
snapshotForSpawn(env). - Allocate a
TaskStatewith a freshly madeDone chan struct{}. - Register the state in the interpreter's per-run task registry.
go i.runSpawn(state, ex, spawnEnv).- Return a
Valueof kindKindTaskwrapping the state pointer.
runSpawn closes state.Done from a defer so all observers (task.wait, task.waitAll, task.waitAny, the exit-time scan) see the close as a happens-before edge before reading state.Result or state.Err. The goroutine itself executes the body via the existing execBlock over the captured env; this is the same path top-level statements take, so the spawn body sees the full interpreter (libraries, structs, method definitions, namespacing).
return EXPR; in the body becomes state.Result. A blockResult with hasReturn=false but no error means an implicit null return (matches method-call semantics). break or continue that escapes its loop inside the body becomes a positioned error ("break outside a loop" / "continue outside a loop") via unhandledLoopFlowError; loop-flow can't cross the spawn boundary, mirroring how it can't cross a method-call boundary.
Value-semantics capture
snapshotForSpawn(env) is the data-race story. It builds a two-frame chain: a "globals" frame holding deep copies of every i.global binding, and a "locals" frame chained on top holding deep copies of every non-global binding visible at the spawn site. The spawn body runs against the locals frame; user-method call frames inside the spawn parent through effectiveGlobal and land on the globals frame.
func (i *Interpreter) snapshotForSpawn(env *Environment) *Environment {
globalSnap := NewEnvironment(nil)
for name, b := range i.global.vars {
globalSnap.vars[name] = b.deepCopy() // globals -> own frame
}
localSnap := NewEnvironment(globalSnap)
for cur := env; cur != nil && cur != i.global; cur = cur.parent {
for name, b := range cur.vars {
if _, seen := localSnap.vars[name]; seen { continue }
localSnap.vars[name] = b.deepCopy()
}
}
return localSnap
}The two-frame shape is what lets user-function calls inside the spawn keep their normal scoping. A user method's call frame inherits from the global surface only - never from the caller's locals (Jennifer's "no inheriting caller scope" model). Inside a spawn, the call frame's parent comes from effectiveGlobal(env):
func effectiveGlobal(env *Environment) *Environment {
cur := env
for cur != nil && cur.parent != nil {
cur = cur.parent
}
return cur
}In serial code env chains to i.global, so effectiveGlobal returns i.global. In a spawn body env chains to the snapshot's globals frame, so effectiveGlobal returns that frame. Both paths honour the no-shadowing rule the same way (parameters never collide with captured locals, only with true globals), and the spawn body's user-method calls are race-free because they never touch the live i.global the parent goroutine may be writing.
Deep-copy reuses the same Value.Copy() path as $ys = $xs; and function-parameter binding, so lists, maps, bytes, and structs copy at any depth.
The one exception is KindTask itself. A task of T value deliberately copies the pointer to the underlying TaskState, not the state - multiple variables pointing at "the same spawn" must observe it together. Without this, def u as task of T init $t; would clone the in-flight goroutine handle and break observation counting.
Task registry and loud-fail
The interpreter carries
type Interpreter struct {
// ...
tasksMu sync.Mutex
tasks []*TaskState
}evalSpawn calls registerTask(state). Each TaskState carries an Observed bool flag that any of the three "I saw this" operations flips: task.wait (both on success return and on rethrow), task.discard, and task.waitAll (drains every survivor before re-raising).
Interpreter.UnwaitedTaskErrors() runs at the end of CLI execution:
func (i *Interpreter) UnwaitedTaskErrors() []error {
i.tasksMu.Lock()
snapshot := append([]*TaskState(nil), i.tasks...)
i.tasksMu.Unlock()
var errs []error
for _, t := range snapshot {
if t == nil || t.Observed { continue }
<-t.Done // happens-before edge
if t.Err != nil { errs = append(errs, t.Err) }
}
return errs
}It deliberately blocks on <-t.Done for every unobserved task. The "no footguns" rationale: a non-blocking scan could miss a late-arriving error and silently exit cleanly. Blocking buys the loud-fail guarantee at the cost of hanging the program when an unobserved goroutine never finishes (a spawn { while (true) {} } without task.discard). The user-guide flags this as a footgun of its own; the runtime trade-off favours soundness.
cmd/jennifer/main.go consumes the slice: after Run(prog) returns, it walks UnwaitedTaskErrors(), prints each one to stderr in spawn error (unwaited): MSG form, and bumps the process exit code if any were present. ExitSignal from a body is special-cased in the loud-fail surface (treat as a normal program-level exit, not a "task error") so user-explicit shutdowns don't print spurious "unwaited" lines.
task library Go layer
internal/lib/task registers five namespaced builtins through the standard RegisterNamespaced path:
| Builtin | Path |
|---|---|
task.wait | block on <-state.Done; MarkObserved; return Result or wrap Err as runtimeError |
task.poll | BoolVal(state.IsDone()) via the non-blocking select on state.Done |
task.discard | MarkObserved; return Null() immediately (does not block) |
task.waitAll | iterate list, wait each, mark all observed; return list-of-results or first error in order |
task.waitAny | []reflect.SelectCase over the list, reflect.Select, return chosen index |
reflect.Select is the one place the runtime drops into reflect; acceptable because the list length is dynamic and select { ... } on a variable arm count has no other Go-level construction. The TinyGo target supports it for chan-receive cases; verified by the package tests passing under both compilers.
MarkObserved is a thin wrapper around setting the flag under the registry mutex (no atomics - the field is read only by the exit-time scan, which already takes the mutex). The pattern is "observation = explicit consent that this task's outcome is yours"; the loud-fail path is the only place reads happen outside the consenting frame.
Type stamping for task of T
parser.TypeTask joins TypeList / TypeMap / TypeBytes / TypeStruct in the Type.Kind enum. Type.Element holds the T for task of T; Type.String and Type.Equal handle recursion the same way as list of T. MatchesDeclared rejects non-task values and (when the declared element type is concrete) walks the wrapped task's ElemTyp to enforce element-type compatibility - so def t as task of int init spawn { return "x"; }; fails at the use site, not deep inside the spawn body.
CLI integration
main.go (batch path), repl.go (interactive path), fmt_test.go::runProgramOutput (golden-test harness), and examples_test.go all tasklib.Install(in) alongside the other libraries. The REPL path also calls UnwaitedTaskErrors() between inputs - a spawn that errored in line N surfaces before the prompt for line N+1, so the REPL session can't accumulate silent failures.
What's deferred
The runtime side has more breathing room than the user-facing surface. The deferred pieces:
- Channels. No
chan Ttype, nosend/recvbuiltins. The spawn/task pair handles the common cases; a channel primitive would add real bookkeeping and is a later candidate. - Cancellation. No way for an outsider to stop a running spawn body. Open design question (cooperative vs hard abort vs structured-concurrency tree).
- Structured concurrency. No automatic scope-bounded termination. The loud-fail registry is the lighter-weight answer.
- Timeouts. Compose with a
time.sleepsentinel +task.waitAny; a higher-level helper may ship later. - Refcounted copy-on-write for
Value. The O(N) deep-copy cost of spawning over a large captured collection is a known cost of the value-semantics model and the same cost that hits$ys = $xs;in serial code. A refcounted copy-on-mutation optimisation in theValueruntime would help both paths; not scheduled.