|
| 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