|
| 1 | +# Upvalue Design: Options for Fixing B5 |
| 2 | + |
| 3 | +We have a bug in the VM's upvalue implementation and several meaningfully different ways |
| 4 | +to fix it. Looking for input on which direction to take. |
| 5 | + |
| 6 | +--- |
| 7 | + |
| 8 | +## Background: how upvalues work today |
| 9 | + |
| 10 | +The VM uses a **Lua-style open/closed upvalue** system. When a closure captures a |
| 11 | +variable from an enclosing function, an upvalue cell is created. While the enclosing |
| 12 | +frame is still alive the cell is *open* — it holds a stack slot index and reads/writes |
| 13 | +go directly to the stack. When the frame exits, `close_upvalues` copies the stack value |
| 14 | +into the cell and marks it *closed* (heap-resident). Closures that outlive the frame |
| 15 | +then read from their own copy. |
| 16 | + |
| 17 | +```rust |
| 18 | +enum UpvalueCell { |
| 19 | + Open(usize), // stack slot index — cheap, no allocation |
| 20 | + Closed(Value), // heap copy — frame has exited |
| 21 | +} |
| 22 | +``` |
| 23 | + |
| 24 | +The outer function accesses its own locals via `GetLocal`/`SetLocal` (direct stack |
| 25 | +indexing). Inner closures access captured variables via `GetUpvalue`/`SetUpvalue` |
| 26 | +(through the cell). While the cell is open, both paths ultimately touch the same stack |
| 27 | +slot and stay in sync. |
| 28 | + |
| 29 | +--- |
| 30 | + |
| 31 | +## The bug (B5) |
| 32 | + |
| 33 | +Some stdlib higher-order functions (`map`, `filter`, `any`, …) are implemented as |
| 34 | +`NativeFunc::WithVm`: they receive `&mut Vm` and call closures back inline. Before |
| 35 | +dispatching these, the VM **drains** the arguments off the stack: |
| 36 | + |
| 37 | +```rust |
| 38 | +NativeFunc::WithVm(f) => { |
| 39 | + let call_args: Vec<Value> = self.stack.drain(start..).collect(); |
| 40 | + self.stack.pop(); // callee slot |
| 41 | + f(&call_args, self)? |
| 42 | +} |
| 43 | +``` |
| 44 | + |
| 45 | +The drain is forced by Rust's borrow checker — you can't hold `&vm.stack[start..]` |
| 46 | +(immutable borrow) and `&mut vm` (mutable borrow) simultaneously. (Note: `Simple` |
| 47 | +natives, which don't need `&mut Vm`, already avoid the drain and pass a slice directly.) |
| 48 | + |
| 49 | +`materialize_upvalues_in_args` was added to compensate: before the drain it snapshots |
| 50 | +any open upvalue cells in the argument closures into `Closed` copies, so the HOF can |
| 51 | +call them back safely. Its comment reads: *"Native functions bridge to the tree-walk |
| 52 | +interpreter, which may call closures back via `Vm::call_function` in a fresh VM |
| 53 | +context."* |
| 54 | + |
| 55 | +That comment is the key, as we'll see below. |
| 56 | + |
| 57 | +**The problem:** when two closures capture the same variable, they share a single |
| 58 | +`Rc<RefCell<UpvalueCell>>`. If one of them is passed to a HOF and gets materialized, |
| 59 | +the shared cell flips to `Closed` — taking the *other* closure's cell with it. The |
| 60 | +outer frame is still alive and continues to use `GetLocal`/`SetLocal` on the original |
| 61 | +stack slot. After the HOF returns, the stack slot and the closed cell have diverged: |
| 62 | +mutations made by the HOF-called closure went to the closed copy, while the outer |
| 63 | +frame's stack slot was never updated. |
| 64 | + |
| 65 | +### Reproducer |
| 66 | + |
| 67 | +``` |
| 68 | +fn make_counter() { |
| 69 | + let x = 0; |
| 70 | + let inc = fn(v) { x = x + 1 }; // captures x |
| 71 | + let get = fn() { x }; // captures x — shares the same upvalue cell as inc |
| 72 | +
|
| 73 | + [1, 2, 3].map(inc); |
| 74 | + // inc was called 3 times via SetUpvalue -> closed cell now holds 3 |
| 75 | + // outer frame's stack slot for x was never updated -> still 0 |
| 76 | +
|
| 77 | + print(get()) // prints 3 (reads closed cell) |
| 78 | + print(x) // prints 0 (reads stack slot) <- BUG: should also be 3 |
| 79 | +} |
| 80 | +make_counter() |
| 81 | +``` |
| 82 | + |
| 83 | +The two `print` calls should produce the same value but don't. |
| 84 | + |
| 85 | +--- |
| 86 | + |
| 87 | +## Option C — Gemini's suggestion: pass args as a slice, don't drain |
| 88 | + |
| 89 | +Keep args on the stack while the HOF executes. Open upvalue cells remain valid because |
| 90 | +the slots they reference are still present. |
| 91 | + |
| 92 | +```rust |
| 93 | +// Instead of draining into a Vec... |
| 94 | +let result = native_func(vm, &vm.stack[start..]); |
| 95 | +vm.stack.truncate(start - 1); // clean up after |
| 96 | +``` |
| 97 | + |
| 98 | +**Why this is attractive:** `materialize_upvalues_in_args` disappears entirely. No |
| 99 | +premature closing, no divergence. |
| 100 | + |
| 101 | +**The problem:** it doesn't compile. The same borrow-checker conflict that forced the |
| 102 | +drain in the first place makes this impossible: `&vm.stack[start..]` borrows the stack |
| 103 | +immutably, while `vm` is passed mutably. This is exactly the same constraint `Simple` |
| 104 | +natives face — and they solve it by *not* taking `&mut Vm` at all. For `WithVm`, which |
| 105 | +*needs* `&mut Vm` to call closures back, there is no safe way to also hold a live slice |
| 106 | +into the stack at the same time. (Even if you worked around the borrow checker with |
| 107 | +`unsafe`, the stack `Vec` can reallocate when the callback pushes frames, invalidating |
| 108 | +the raw pointer.) |
| 109 | + |
| 110 | +--- |
| 111 | + |
| 112 | +## Option D — Delete `materialize_upvalues_in_args` entirely |
| 113 | + |
| 114 | +This option emerges from reading `call_function` and `call_callback` side by side: |
| 115 | + |
| 116 | +```rust |
| 117 | +// Spawns a FRESH VM with an empty stack — used by the interpreter bridge |
| 118 | +pub fn call_function(func, args, globals) -> Result<Value, VmError> { |
| 119 | + let mut vm = Self { stack: Vec::new(), ... }; |
| 120 | + vm.call_callback(func, args) |
| 121 | +} |
| 122 | + |
| 123 | +// Runs INLINE on the same VM — used by VmCallable (all stdlib HOFs) |
| 124 | +pub fn call_callback(&mut self, func, args) -> Result<Value, VmError> { |
| 125 | + self.stack.push(Value::unit()); |
| 126 | + self.stack.extend(args); |
| 127 | + self.dispatch_call_with_memo(func, ...)?; |
| 128 | + self.run_to_depth(depth)?; |
| 129 | + Ok(self.stack.pop()...) |
| 130 | +} |
| 131 | +``` |
| 132 | + |
| 133 | +All stdlib HOFs use `VmCallable::call`, which delegates to `call_callback`. That runs |
| 134 | +inline on the **same VM with the same stack**. After the drain, the outer frame's locals |
| 135 | +— including `x` at slot S — are still on the stack. An `Open(S)` upvalue cell is |
| 136 | +perfectly valid: when the HOF calls `inc` back via `call_callback`, `inc` pushes its |
| 137 | +own frame, accesses `self.stack[S]` through the open cell, and everything stays in sync |
| 138 | +without any materialization. |
| 139 | + |
| 140 | +`materialize_upvalues_in_args` is only legitimately needed when callbacks go through |
| 141 | +`call_function` (fresh VM, empty stack) — that's the interpreter bridge path, not the |
| 142 | +stdlib HOF path. It appears the function was written for the bridge case but is being |
| 143 | +applied to all `WithVm` calls, including the stdlib HOFs where it's both unnecessary |
| 144 | +and harmful. |
| 145 | + |
| 146 | +**The fix:** remove the `materialize_upvalues_in_args` call (and possibly the function |
| 147 | +itself, if no other caller needs it). The open/closed mechanism continues to work |
| 148 | +correctly for its intended purpose (frame teardown). |
| 149 | + |
| 150 | +**Scope:** delete ~5 lines at the call site. Verify that no `WithVm` function in the |
| 151 | +codebase calls `call_function` internally (rather than `call_callback`/`VmCallable`), |
| 152 | +since those would lose their safety net. |
| 153 | + |
| 154 | +**Remaining question:** is there any code path where a `WithVm` HOF ends up calling |
| 155 | +back a closure via `call_function` rather than `VmCallable`? If yes, materialization is |
| 156 | +still needed on that path (and should be scoped to it). If no, it can be deleted |
| 157 | +outright. |
| 158 | + |
| 159 | +--- |
| 160 | + |
| 161 | +## Option A — Targeted fix: sync-back after the HOF call |
| 162 | + |
| 163 | +Keep the current architecture. Fix the divergence by writing the closed value back to |
| 164 | +the stack slot — and reopening the cell — after the HOF returns. |
| 165 | + |
| 166 | +**How it works:** |
| 167 | + |
| 168 | +1. `materialize_upvalues_in_args` returns the `(cell, stack_slot)` pairs it closed. |
| 169 | +2. A new `sync_back_materialized_upvalues` method, called after `dispatch_call`, |
| 170 | + writes `cell.value → stack[slot]`, converts the cell back to `Open(slot)`, and |
| 171 | + re-adds it to `open_upvalues`. |
| 172 | + |
| 173 | +```rust |
| 174 | +// call site (simplified) |
| 175 | +let synced = if func.needs_arg_materialization() { |
| 176 | + self.materialize_upvalues_in_args(args) |
| 177 | +} else { |
| 178 | + vec![] |
| 179 | +}; |
| 180 | +let result = self.dispatch_call(func, args); |
| 181 | +self.sync_back_materialized_upvalues(synced); // restore stack + reopen cells |
| 182 | +if let Err(mut e) = result { ... } |
| 183 | +``` |
| 184 | + |
| 185 | +After sync-back the stack slot has the value the HOF-called closure left in the cell, |
| 186 | +the cell is open again, and all subsequent accesses — via `GetLocal` in the outer frame |
| 187 | +or `GetUpvalue` in any sharing closure — are back in sync. |
| 188 | + |
| 189 | +**Scope:** ~30–40 lines changed, all inside `vm.rs`. No changes to the compiler, |
| 190 | +analyser, or opcode set. Low risk. |
| 191 | + |
| 192 | +**Remaining complexity:** `materialize_upvalues_in_args`, `close_upvalues`, |
| 193 | +`open_upvalues`, `UpvalueCell::Open`, and `CloseUpvalue` all remain. The open/closed |
| 194 | +dance still exists and could produce similar bugs in future edge cases. |
| 195 | + |
| 196 | +--- |
| 197 | + |
| 198 | +## Option B — Architectural fix: box captured variables at declaration |
| 199 | + |
| 200 | +Mark captured variables during the analysis/compilation pass. Instead of starting life |
| 201 | +as stack slots and migrating to the heap on frame exit, captured variables are |
| 202 | +heap-allocated (`Rc<RefCell<Value>>`) from the moment of declaration. The outer frame |
| 203 | +and all inner closures hold a reference to the same heap cell from the start. There is |
| 204 | +no open→closed transition. |
| 205 | + |
| 206 | +Non-captured locals are unaffected — they remain cheap stack slots. |
| 207 | + |
| 208 | +**How it works:** |
| 209 | + |
| 210 | +- The analyser already tracks which variables are captured by inner closures. That |
| 211 | + information would be used to tag declarations. |
| 212 | +- The compiler emits `GetUpvalue`/`SetUpvalue` for captured variables in the **outer** |
| 213 | + function too, not just in inner closures. Both sides always go through the heap cell. |
| 214 | +- The `Open` variant of `UpvalueCell` is deleted. Cells always hold a `Value`. |
| 215 | +- `open_upvalues`, `capture_upvalue`, `close_upvalues`, and |
| 216 | + `materialize_upvalues_in_args` are all removed from the VM. |
| 217 | +- Loop iteration isolation (currently handled by `CloseUpvalue`) is replaced by the |
| 218 | + compiler emitting "allocate a fresh cell, copy current value" at the top of each loop |
| 219 | + body for any captured loop variable. |
| 220 | + |
| 221 | +**Scope:** touches the analyser, compiler, and VM. Moderate refactor — probably |
| 222 | +200–400 lines net, spread across several files. |
| 223 | + |
| 224 | +**Benefit:** B5 and the entire class of open/closed divergence bugs become impossible. |
| 225 | +The VM's frame teardown is simpler (no upvalue scanning). `materialize_upvalues_in_args` |
| 226 | +disappears completely. |
| 227 | + |
| 228 | +**Cost:** Every access to a captured variable in the outer scope pays a heap |
| 229 | +dereference instead of a stack index. In practice this only affects variables that are |
| 230 | +actually captured (a small fraction of all locals), but it is a regression on those |
| 231 | +paths. |
| 232 | + |
| 233 | +--- |
| 234 | + |
| 235 | +## Summary table |
| 236 | + |
| 237 | +| | Option D (delete materialize) | Option A (sync-back) | Option B (box at declaration) | Option C (slice, no drain) | |
| 238 | +|--------------------------|-------------------------------|------------------------------|--------------------------------------|------------------------------| |
| 239 | +| Lines changed | ~5, one file | ~30–40, one file | ~200–400, multiple files | Not implementable as stated | |
| 240 | +| Risk | Low (needs audit) | Low | Medium | — | |
| 241 | +| Fixes B5? | Yes | Yes | Yes | Yes (if implementable) | |
| 242 | +| Eliminates bug class? | Removes the cause directly | No — same mechanism remains | Yes — open/closed dance gone | Removes the cause directly | |
| 243 | +| Runtime cost | None | None | Heap deref for captured vars | — | |
| 244 | +| Removes VM complexity | Partially | No | Yes (open_upvalues, close_upvalues…) | Partially | |
| 245 | +| Key prerequisite | Audit all WithVm HOFs | None | Analyser + compiler changes | Rust borrow rules | |
| 246 | + |
| 247 | +--- |
| 248 | + |
| 249 | +## Questions for reviewers |
| 250 | + |
| 251 | +1. Is Option D safe? Are there any `WithVm` HOFs that call back closures via |
| 252 | + `call_function` (fresh VM) rather than `VmCallable`/`call_callback` (inline)? |
| 253 | +2. If Option D is safe, does it fully fix B5, or are there other paths that still |
| 254 | + require materialization? |
| 255 | +3. Is Option B's architectural simplification worth the refactor cost and the |
| 256 | + performance regression on captured-variable access? |
| 257 | +4. Are there other edge cases in the current open/closed mechanism (beyond B5) that |
| 258 | + make Option B more urgent regardless of the B5 fix chosen? |
0 commit comments