Skip to content

Commit 276d25c

Browse files
timfennisclaude
andcommitted
Refactor: M1, M2, RD1–RD4 from code review
- M1: extract compile_for_iterations helper, removing ~120 lines of duplicated loop scaffolding - M2: make VmIterator::deep_copy a required method; add missing impls for MinHeapIter, MaxHeapIter, StringIter - RD1: comment explaining synthetic unit push in no-else branch - RD2: remove dead `let memo =` binding in dispatch_call - RD3: descriptive panic messages in Closure opcode handling - RD4: rename max_local field to num_locals (matching its accessor) - Remove REVIEW.md (all actionable items resolved) Co-Authored-By: Claude Opus 4.6 (1M context) <[email protected]>
1 parent 418ae09 commit 276d25c

7 files changed

Lines changed: 393 additions & 477 deletions

File tree

ndc_vm/R1_INVESTIGATION.md

Lines changed: 230 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,230 @@
1+
# R1 — `SetUpvalue` stack-effect mismatch with `SetLocal`
2+
3+
## Summary
4+
5+
`SetUpvalue` **peeks** the top of the stack (clone, no pop), while `SetLocal` **pops** it.
6+
The compiler treats them identically via `emit_set_var`, assuming both consume the top
7+
value. This causes a stack leak: one phantom value per upvalue assignment. The leak is
8+
currently masked by `Return`, which truncates the entire frame's stack, so it doesn't
9+
produce wrong results — but it does cause unbounded stack growth inside loops that
10+
reassign captured variables.
11+
12+
---
13+
14+
## Context: how this VM works
15+
16+
This is a stack-based bytecode VM for a custom language. Functions are closures. Variables
17+
live either in **local stack slots** (within the current call frame) or in **upvalue cells**
18+
(captured variables from an enclosing scope). Upvalue cells are `Rc<RefCell<UpvalueCell>>`,
19+
where `UpvalueCell` is either `Open(stack_slot_index)` or `Closed(Value)`.
20+
21+
The compiler emits `SetLocal` for local variable writes and `SetUpvalue` for captured
22+
variable writes. Both are emitted from the same helper:
23+
24+
```rust
25+
// compiler.rs:532
26+
fn emit_set_var(&mut self, var: ResolvedVar, span: Span) {
27+
match var {
28+
ResolvedVar::Local { slot } => self.chunk.write(OpCode::SetLocal(slot), span),
29+
ResolvedVar::Upvalue { slot } => self.chunk.write(OpCode::SetUpvalue(slot), span),
30+
ResolvedVar::Global { .. } => unreachable!("globals are native, never assigned"),
31+
};
32+
}
33+
```
34+
35+
The compiler **assumes both instructions have the same stack effect**: consume (pop) the
36+
top-of-stack value and store it.
37+
38+
---
39+
40+
## The bug
41+
42+
### `SetLocal` (vm.rs:195) — pops
43+
44+
```rust
45+
OpCode::SetLocal(slot) => {
46+
let slot = *slot;
47+
let value = self.stack.pop().expect("stack underflow");
48+
if frame.slot(slot) < self.stack.len() {
49+
// Reassignment: write to existing slot, stack shrinks by 1
50+
self.stack[frame.slot(slot)] = value;
51+
} else {
52+
// Declaration: slot IS the top, pop+push is a no-op
53+
self.stack.push(value);
54+
}
55+
}
56+
```
57+
58+
For **reassignment** (the case relevant here — declarations go through a separate compiler
59+
path that only ever emits `SetLocal`), `SetLocal` pops the value and writes it into an
60+
existing lower slot. **Net stack effect: -1.**
61+
62+
### `SetUpvalue` (vm.rs:312) — peeks
63+
64+
```rust
65+
OpCode::SetUpvalue(slot) => {
66+
let slot = *slot;
67+
let value = self.stack.last().expect("stack underflow").clone();
68+
let mut cell = frame.closure.upvalues[slot].borrow_mut();
69+
match &mut *cell {
70+
UpvalueCell::Open(stack_slot) => self.stack[*stack_slot] = value,
71+
UpvalueCell::Closed(stored) => *stored = value,
72+
}
73+
}
74+
```
75+
76+
Clones the top value and writes it into the upvalue cell. The original value **remains on
77+
the stack**. **Net stack effect: 0.**
78+
79+
### Compiled assignment pattern
80+
81+
A simple assignment `x = expr` where `x` is an upvalue compiles to:
82+
83+
```
84+
compile_expr(expr) — pushes result [... result]
85+
SetUpvalue(slot) — peeks, writes to cell [... result] ← value stays!
86+
Constant(unit) — pushes () [... result, ()]
87+
```
88+
89+
The surrounding code (statement in a block) then pops the expression result:
90+
91+
```
92+
Pop — pops () [... result] ← leaked!
93+
```
94+
95+
Compare to the local case:
96+
97+
```
98+
compile_expr(expr) — pushes result [... result]
99+
SetLocal(slot) — pops, writes to slot [...]
100+
Constant(unit) — pushes () [... ()]
101+
Pop — pops () [...] ← clean
102+
```
103+
104+
### Evidence from disassembly
105+
106+
`add_one` function from `tests/programs/005_functions/004_closures.ndc`:
107+
108+
```
109+
== add_one ==
110+
0000 GetGlobal(81) +
111+
0001 GetUpvalue(0) n
112+
0002 Constant(0) 1
113+
0003 Call(2) n + 1 stack: [result]
114+
0004 SetUpvalue(0) n = ... stack: [result] ← leaked!
115+
0005 Constant(1) () stack: [result, ()]
116+
0006 Pop stack: [result] ← leaked value remains
117+
0007 GetGlobal(81) +
118+
0008 GetUpvalue(1) invoked
119+
0009 Constant(2) 1
120+
0010 Call(2) invoked + 1 stack: [result, result2]
121+
0011 SetUpvalue(1) invoked = ... stack: [result, result2] ← leaked!
122+
0012 Constant(3) () stack: [result, result2, ()]
123+
0013 Pop stack: [result, result2] ← two leaked values
124+
0014 GetUpvalue(0) n stack: [result, result2, n]
125+
0015 Return ← truncates entire frame, hiding the leak
126+
```
127+
128+
### Why it doesn't produce wrong results (yet)
129+
130+
`Return` (vm.rs:175) does `self.stack.truncate(frame_pointer - 1)`, which wipes the
131+
entire frame's stack slots regardless of how many phantom values accumulated. So the
132+
leaked values are cleaned up and never observed by the caller.
133+
134+
### Why it's still a real bug
135+
136+
1. **Unbounded stack growth in loops.** A `for` loop inside a closure that reassigns a
137+
captured variable leaks one value per iteration. For long-running loops this is a
138+
memory leak proportional to iteration count.
139+
140+
2. **Fragile invariant.** The compiler's stack-depth tracking is wrong for upvalue
141+
assignments. Any future optimisation that relies on knowing the stack depth (e.g.
142+
stack-slot reuse, register allocation, or stack-depth assertions) will break.
143+
144+
3. **Unnecessary clone.** `SetUpvalue` clones the top value even though the compiler
145+
doesn't use the original afterward. The clone is wasted work (and an `Rc` refcount
146+
bump for heap-allocated values).
147+
148+
---
149+
150+
## The question: what should the fix be?
151+
152+
There are two possible approaches. I'd like your opinion on which is cleaner.
153+
154+
### Option A: Make `SetUpvalue` pop (match `SetLocal`)
155+
156+
Change `SetUpvalue` to pop instead of peek:
157+
158+
```rust
159+
OpCode::SetUpvalue(slot) => {
160+
let slot = *slot;
161+
let value = self.stack.pop().expect("stack underflow");
162+
let mut cell = frame.closure.upvalues[slot].borrow_mut();
163+
match &mut *cell {
164+
UpvalueCell::Open(stack_slot) => self.stack[*stack_slot] = value,
165+
UpvalueCell::Closed(stored) => *stored = value,
166+
}
167+
}
168+
```
169+
170+
**No compiler changes needed.** The compiler already assumes both instructions pop. This
171+
is the minimal fix.
172+
173+
**Concern:** When the upvalue cell is `Open(stack_slot)`, we pop from the top and write
174+
to a *different* stack position. This is fine — we're moving the value, not duplicating
175+
it. But it means `SetUpvalue` for an open upvalue does: pop top → write to stack[slot].
176+
Is that clean, or is it weird that we pop from one stack position and write to another?
177+
(Note: `SetLocal` does the exact same thing for reassignment — pops top, writes to a
178+
lower slot — so this is consistent.)
179+
180+
### Option B: Keep `SetUpvalue` as peek, emit `Pop` in the compiler
181+
182+
Add `OpCode::Pop` after every `SetUpvalue` emission in `emit_set_var`:
183+
184+
```rust
185+
fn emit_set_var(&mut self, var: ResolvedVar, span: Span) {
186+
match var {
187+
ResolvedVar::Local { slot } => self.chunk.write(OpCode::SetLocal(slot), span),
188+
ResolvedVar::Upvalue { slot } => {
189+
self.chunk.write(OpCode::SetUpvalue(slot), span);
190+
self.chunk.write(OpCode::Pop, span);
191+
}
192+
ResolvedVar::Global { .. } => unreachable!("globals are native, never assigned"),
193+
};
194+
}
195+
```
196+
197+
**No VM changes needed.** The peek semantics stay, but the compiler compensates.
198+
199+
**Concern:** This adds an extra instruction to every upvalue assignment. It also means the
200+
two instructions have *different* documented stack effects, which is a footgun for anyone
201+
reading the opcode definitions. Every call site of `emit_set_var` would need to be
202+
audited to confirm the extra `Pop` is correct in context (it should be, since the
203+
compiler already assumes the value is consumed).
204+
205+
### Option C: Something else?
206+
207+
Is there a third approach I'm missing? For example, should assignments be
208+
expression-valued (returning the assigned value) rather than statement-valued (returning
209+
unit)? That would change the semantics but might simplify the compiler. Currently NDC
210+
assignments always produce `()` as their expression value.
211+
212+
---
213+
214+
## Call sites that emit `SetUpvalue` (via `emit_set_var`)
215+
216+
For completeness, here are all the compiler locations where `emit_set_var` can emit
217+
`SetUpvalue`:
218+
219+
1. **Simple assignment** (`x = expr`, compiler.rs:174-178):
220+
`compile_expr → SetUpvalue → Constant(unit)`
221+
222+
2. **Op-assignment with dynamic binding** (`x += expr`, compiler.rs:237):
223+
`compile_binding → GetUpvalue → compile_expr → Call(2) → SetUpvalue`
224+
followed by `Constant(unit)` at line 293.
225+
226+
3. **Op-assignment with no in-place op** (`x += expr`, compiler.rs:244):
227+
Same pattern as (2).
228+
229+
In all three cases, the compiler expects `SetUpvalue` to consume the top value, then
230+
pushes `Constant(unit)` as the expression result.

0 commit comments

Comments
 (0)