Drop the outer slot mapping when a let shadows it in a register - #64
Conversation
A pointer-represented `let` whose copy is elided binds the name to a
register holding the value's address, with no local slot of its own. That
branch updated `variables` and `variable_types` but left `local_slots`
alone, so when the name shadowed an outer `var` of the same name the outer
variable's slot mapping stayed live for the duration of the block.
The `Expr::Id` read path re-emits `LocalAddr { dst: reg, slot }` for any
`local_slots` hit, to keep the register correct across calls that may have
clobbered it. With the stale mapping in scope that re-emit overwrote the
binding's address register with the *outer* variable's address, so every
read of the shadowing name loaded the outer value. jit and stack don't use
that name-to-slot re-materialization and were correct; asm matched vm
because it runs the same bytecode.
Drop the stale entry wherever a `let` or `var` binds a name to a register
instead of a slot. Block scope already saves and restores `local_slots`,
so the outer variable's mapping is back at block exit.
Fixes #56.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa
Review of the first commit found that dropping only the slot mapping left the other name-keyed state behind, which turned a miscompile into a compiler panic: `Expr::Id`, `get_var_address` and `translate_assign` all check `captured_vars` before `local_slots` and then unwrap the slot, so a `let` shadowing a lambda-captured name hit `unwrap()` on `None`. Give both code generators a `shadow_outer_binding` helper that forgets every name-keyed fact about the outer binding, and call it from each binding branch of `let` and `var` — after the initializer is translated, so an initializer that reads the outer binding still resolves to it. The vm/asm generator clears `local_slots`, `reg_promoted`, `reference_vars` and `captured_vars`; the stack generator clears `captured_vars` and `captured_slots` (its reference bindings live in the variable map itself, so they already shadowed correctly). Block scope now saves and restores all of them, so the outer binding's state comes back at block exit. This also fixes two cases that were already broken on main: a binding shadowing a lambda-captured name (wrong value on stack, out-of-bounds access on vm/asm) and one shadowing a reference parameter (same out-of-bounds). Both get golden tests, and both were verified to fail on main and pass here on all four backends. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa
|
Addressed the review in 0c17f9d. The regression it caught was real. Dropping only The fix is now the general one. Both code generators get a That also covers the review's third finding: a binding shadowing a reference parameter used to read through the reference. Two new golden tests, The
|
Fixes #56.
A
letbinding inside awhilebody that shadowed an outer structvarof the same name read the outer variable on thevmandasmbackends.Root cause
A pointer-represented
letwhose copy is elided (let q = mk(7)whereq: P) binds the name to a register holding the value's address — it gets no local slot of its own. That branch insrc/vm_codegen.rsupdatedvariablesandvariable_typesbut leftlocal_slotsalone, so when the name shadowed an outervar q: Pthe outer variable's slot mapping stayed live for the duration of the block.The
Expr::Idread path re-emitsLocalAddr { dst: reg, slot }for anylocal_slotshit, to keep the register correct across calls that may have clobbered it. With the stale mapping in scope that re-emit overwrote the binding's address register with the outer variable's address, so every read of the shadowing name loaded the outer value. In the bytecode for the issue's repro:jitandstackdon't use that name-to-slot re-materialization and were correct;asmmatchedvmbecause it runs the same bytecode. Thefor/var/ non-struct / non-shadowing variants listed in the issue all avoided this branch, which is why they agreed across backends.Fix
Drop the stale
local_slotsentry wherever aletorvarbinds a name to a register instead of a slot — the two register branches inExpr::Letand the scalar branch inExpr::Var. Block scope already saves and restoreslocal_slots, so the outer variable's mapping is back at block exit and a post-loopprint(q.x)still reads99.Testing
New golden test
tests/cases/structs/shadowed_let_in_while.lytecovers the one-iteration repro, a three-iteration version whose shadowed reads feed only an assignment, one where the read also feeds a call, the outer variable after the loop, and the same shape underfor. Pre-fix it prints the issue's99and297onvm; post-fix all four backends agree.cargo test --workspace: 380 lib tests plus the golden suite onjit,vm,asm, andstack, all passing.🤖 Generated with Claude Code
https://claude.ai/code/session_01DeNzkFkzMbdH3XPzitDmVa