Repository navigation
A function assigning to a module-top-level let silently writes a frame-local; the global never changes #671
Description
Activity
- addedbugSomething isn't workingSomething isn't workingseverity:highMajor feature broken or significantly wrongMajor feature broken or significantly wrongsubsystem:vmBytecode virtual machineBytecode virtual machine
on Aug 30, 2026 A candidate fix exists and the full suite is green under it — 2997 passed, 0 failed
Not committed; recorded so the work is scoped rather than guessed at. Both sites patched together, since either alone is insufficient (measured — see below).
Site 1,
SymbolTable._resolve_upvalue_in— do not bail when there is no enclosing function scope; walk outward to module scope and return aglobalsymbol if found:enclosing = self._enclosing_function_scope(func_scope) if enclosing is None: scope = func_scope.parent while scope: if name in scope.symbols and scope.symbols[name].scope == "global": return scope.symbols[name] scope = scope.parent return None
Site 2,
VM.store_name— mirrorload_name's precedence, so an existing module global that this frame does not shadow is written there rather than captured by the frame:elif name not in locals_ and name in self.module_globals: if isinstance(self.module_globals[name], LiveBinding): cast("LiveBinding", self.module_globals[name]).set(value) else: self.module_globals[name] = value
About twelve lines between them.
Results
result fn setit() { g = 99i }on top-levellet g = 7ig = 99✔let setit = fn() { g = 99i }g = 99✔let bump = fn() { g = g + 1i }1✔ (wasCannot add nil and int)full suite 2997 passed, 8 skipped, 261 subtests, 0 failed (7m18s) Neither site alone is enough, and this is worth recording because it is the thing that makes the bug look unfixable from one end: with only site 1, the closure compiles to a correct global
STORE, the read starts resolving, and the write is still swallowed by the frame. With only site 2, the compiler never emits aSTOREat all — it emitsSTORE_LOCAL_IDX, so the VM change is unreachable.The edge case that had to be checked, and holds
STOREis emitted at 13 sites in the compiler, several of which are legitimately frame-local inside a function —catchvariables, destructuring temps,matchpattern bindings. A naive "always write the global" would clobber a same-named module global from any of them. Probed:let e = "global-e" let v = "global-v" fn risky() { try { throw "boom" } catch e { print("caught: \(e.message)") } } fn temper() { let v = "local-v"; return v }caught: boom after catch, global e = global-e <- not clobbered local-v after temper, global v = global-v <- not clobberedThe
name not in locals_guard is what makes that hold, and aletinside a function still compiles toSTORE_LOCAL_IDXso it shadows correctly.What this still needs before it is a PR
- A source-level assertion, not just behaviour. The project's own doctrine applies here more than usual: this bug is two implementations of "where does this name live" disagreeing, so a behaviour-only test passes on whichever path is already correct. The test should assert that
store_nameandload_namedrive off the same precedence rule. - A regression test per row of the table above, including the negative shadowing cases, each verified to fail against the unfixed tree.
- A decision on how to describe it in the changelog. Programs that currently silently no-op will start mutating their globals. That is the fix, and nobody can be relying on a silent no-op, but it is a behaviour change and should be named as one rather than filed under "fixes".
- A check of whether the opcode freeze constrains anything here. It does not for this shape — no new opcode is needed — but the alternative design (a distinct global-store opcode) would need the seven-step process in
FREEZE_PROPOSAL.md, so it should be ruled out explicitly rather than by omission.
Suite runs and probes were done with
.nodus/cleared between runs; the bytecode cache makes a compiler edit here look inert, as noted above.- A source-level assertion, not just behaviour. The project's own doctrine applies here more than usual: this bug is two implementations of "where does this name live" disagreeing, so a behaviour-only test passes on whichever path is already correct. The test should assert that
- added a commit that references this issue
on Aug 30, 2026 - added 8 commits that reference this issue
on Aug 30, 2026
Summary
A function that assigns to a module-top-level
letsilently writes a frame-local instead. The global keeps its old value, and there is no error, no warning, and no diagnostic.The same happens through a closure (
let setit = fn() { g = 99i }), and with compound assignment (g += 1i).Reads are fine — a function can read a top-level
letand gets the right value. Mutation at top level is fine —g = 9ioutside any function works. Only assignment from inside a function is broken, and it is broken silently.When the right-hand side also reads the variable, the failure surfaces as a type error that points at arithmetic rather than at scoping, because the freshly-created local is uninitialised:
That message is the only signal a user ever gets, and it names the wrong thing. Where the RHS does not read the variable, there is no signal at all.
Reproduction
Verified against
mainat69cd15c(5.7.1 dev source), CLI and disassembler.fn setit() { g = 99i }on top-levellet g = 7ig == 99g == 7, silentlet setit = fn() { g = 99i }g == 99g == 7, silentlet bump = fn() { g = g + 1i }g == 1Cannot add nil and intfn show() { print(g) }(read only)77✓g = 9iat top levelg == 9g == 9✓letinside a function, captured and mutated by a closureThe last row matters: ordinary upvalue capture is correct. An escaping counter closure, two closures sharing one captured variable, two-level nesting, mutation from inside a spawned coroutine, and
+=all behave correctly when the variable is function-scoped. This is specifically about module scope.Evidence
nodus run --dump-bytecodeon the named-function case:The function was given a frame slot for
gand wrote there.Root cause — two sites, and they disagree
This is the recurring shape
CLAUDE.mddocuments: one question — where does this name's value live? — answered independently in two places.Site 1, compiler.
SymbolTable._resolve_upvalue_in(compiler/symbol_table.py) opens with:A function declared at module level has no enclosing function scope, so this bails before ever looking at the module scope — even though the walk immediately below it is written to handle exactly that case (
if symbol.scope == "global": return symbol) and would return the right symbol if reached.resolve_localcannot cover for it: it breaks at the firstfunction-kind scope.So
resolve('g')returnsNone, andCompiler.compile_expr'sAssignbranch then does:Instrumented, the sequence is unambiguous —
gis plainly visible in the module scope and is still not found:Site 2, VM. Fixing the compiler alone is not enough. With site 1 patched, the closure correctly compiles to
LOAD g/STORE g, and the write still does not reach the global, becauseVM.store_name(vm/vm.py) writes into the frame whenever there is one:load_namewalkslocals → module_globals → functions → host_globals.store_namestops atlocals. Read and write are asymmetric, which is why reads work and writes vanish.Measured with only site 1 patched:
let g = 0i; let bump = fn() { g = g + 1i }; bump()stops erroring and prints0— the read is now correct, the write is still lost.Fix direction
Both sites, or neither — patching one leaves the bug reachable through the other, which is the point of the shape.
_resolve_upvalue_inshould not bail when there is no enclosing function scope; it should still walk outward to module scope and return aglobalsymbol when it finds one. (Confirmed to change behaviour: reads start resolving.)store_nameneeds to answer the same questionload_namedoes. ASTOREfor a name the compiler resolved asglobalmust reachmodule_globalsrather than being captured by whatever frame happens to be on the stack.The durable form is to name the resolution rule once and have both
load_nameandstore_namedrive off it, with a source-level test asserting they agree — a behaviour-only test passes on the read path alone, which is how this survived.Worth deciding alongside: whether assigning an undeclared name inside a function should keep silently creating a local at all, or become an error. That fallback is what turns a scoping miss into a silent no-op rather than a diagnostic.
Note for whoever picks this up
The bytecode cache will make a compiler edit look inert. During this investigation the site-1 patch appeared to do nothing until
.nodus/was removed —CLAUDE.mdrecords this trap and it fired exactly as described.rm -rf .nodusbetween runs.Relationship to #156
#156 (DESIGN-006, filed 2026-06-07) says closures cannot assign to outer
letvariables at all. That is no longer true — function-scoped upvalue mutation works, including escaping closures. This issue is the part that is still broken, and it is a different defect with a different failure mode: not a refused assignment, a silent wrong one. #156 is being rewritten to point here.CLAUDE.md's language-quirks section carries the same stale claim and prescribes a map-with-quoted-keys workaround that is unnecessary for the function-scoped case; corrected in the same change.Affected versions
5.7.1 (current) and, on the evidence of #156's age, every earlier version. Not a regression.