Repository navigation
DESIGN-006: Closures cannot assign to outer let variables — no upvalue mutation #156
Description
Activity
- addedseverity:mediumUX broken but workaround existsUX broken but workaround existssubsystem:vmBytecode virtual machineBytecode virtual machinecycle:v5.0v5.0 cyclev5.0 cycletier:4-deferred-to-v5Tier 4: deferred to v5.0Tier 4: deferred to v5.0
on Jun 7, 2026 Triage 2026-08-17 (against v5.0.2). Still reproduces, with the exact error this issue quotes:
$ nodus run c1.nd Type error at c1.nd:2:34: Cannot add nil and intfrom
let count = 0i let inc = fn() { count = count + 1i } inc()Unchanged through 5.0.2. Note this is the same defect as #177 — see the note there.
- removedtier:4-deferred-to-v5Tier 4: deferred to v5.0Tier 4: deferred to v5.0
on Aug 29, 2026 Half of this is fixed; the other half is real and now has a root cause. Superseded by #671.
Re-verified against
mainat69cd15c(5.7.1 dev source) by running each case, not by reading the code.The title and summary are no longer true
"Closures cannot assign to outer
letvariables — no upvalue mutation." Upvalue mutation works, and has for some time:fn make_counter() { let n = 0i return fn() { n = n + 1i; return n } }a: 1 b: 2Also verified working: two closures sharing one captured variable (
acc: 12), a two-level nested closure (two-level: 100), mutation from inside a spawned coroutine (from coroutine: 42), and compound assignment (n += 5itwice →10).STORE_UPVALUEexists in the frozen opcode set and is exercised.So the general claim, and the "significant VM change" framing in the fix direction, are both stale. The
MAKE_CLOSURE/ Cell-boxing machinery this issue asks for is built.But this issue's own reproduction still fails — because it is top-level
let count = 0i fn increment() { count = count + 1i } increment() print(count) // 0That is not a closure over a function-local.
countis a module-scopelet, and that case is still broken — silently, exactly as described here. The behaviour you documented was real; the diagnosis generalised one scope too far.The distinction is sharp:
assignment works? letinside a function, mutated by a closureyes letat module top level, assigned from any functionno — silent no-op letat module top level, read from a functionyes letat module top level, mutated at top levelyes Root cause, which this issue did not have
Two sites, and they disagree — the shape
CLAUDE.mdcatalogues.-
SymbolTable._resolve_upvalue_inreturnsNoneimmediately when there is no enclosing function scope, so a module-levelletis invisible from a top-level function — even though the walk directly beneath it handlesscope == "global"correctly and would return the right symbol if reached.Assignthen falls back toself.symbols.define(name), creating a local. The disassembly showsSTORE_LOCAL_IDX 0inside the function againstSTORE gat top level. -
VM.store_namewrites into the current frame'slocalswhenever a frame exists. So even with site 1 patched — confirmed by experiment — the compiler emits a correct globalSTORE, the read starts resolving, and the write is still swallowed.load_namewalkslocals → module_globals → functions → host_globals;store_namestops atlocals. Read and write are asymmetric, which is precisely why reads work and writes vanish.
Where this leaves the two fix options proposed here
- Option 1, "implement upvalue mutation — significant VM change, v5": done, and it was not what this needed.
- Option 2, "detect the shadowing at compile time and emit a clear error": still open, and A function assigning to a module-top-level
letsilently writes a frame-local; the global never changes #671 raises it as a question worth deciding — whether assigning an undeclared name inside a function should keep silently creating a local at all. That fallback is what converts a scoping miss into a silent no-op instead of a diagnostic.
The suggested error text here ("use a map to share mutable state between functions") should not be adopted as-is: the map workaround is unnecessary for the function-scoped case, which works natively.
CLAUDE.md's language-quirks section carries the same stale advice and is being corrected in the same change.Closing as superseded
#671 carries the surviving defect with the reproduction, the disassembly, both root-cause sites, and the note that the bytecode cache makes a compiler edit here look inert. Keeping both open would be two issues for one bug; closing this one rather than rewriting it keeps the history of what was believed and when.
-
- added a commit that references this issue
on Aug 30, 2026
Summary
Attempting to assign to an outer
letvariable inside a nested function creates a new local, silently shadowing the outer. Error message when arithmetic fails is indirect: "Cannot add nil and int" (no reference to shadowing).Reproduction
Workaround:
Expected behavior
Either upvalue mutation should work (like Python
nonlocal), or the attempt to assign an outer variable should produce a clear error: "Cannot assign to outer variable 'count' — use a map to share mutable state between functions."Fix direction
Two options:
Deferred to v5.
Affected versions
v4.0.0 (current when filed).