Skip to content

Commit 3f76f86

Browse files
timfennisclaude
andcommitted
Fix: B5 — delete materialize_upvalues_in_args
All WithVm HOFs call back closures via VmCallable::call -> call_callback, which runs inline on the same VM stack. Open upvalue cells remain valid after the arg drain because they reference the outer frame's stack slots, not the arg slots. Materialization was only ever needed for call_function (fresh VM, interpreter bridge) which no WithVm HOF uses. Deleting the function and its call site fixes the shared-cell divergence bug where inc and get capturing the same variable would disagree after a HOF call. Adds reproducer bug0012 and design doc UPVALUE_DESIGN.md. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent c482fd8 commit 3f76f86

5 files changed

Lines changed: 279 additions & 64 deletions

File tree

ndc_vm/REVIEW.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -155,7 +155,7 @@ opcode that the analyser failed to reject). Should return `VmError` instead.
155155

156156
---
157157

158-
### B5 — `materialize_upvalues_in_args` can close a still-live upvalue cell
158+
### ~~B5 — `materialize_upvalues_in_args` can close a still-live upvalue cell~~ ✅ Fixed
159159
**File:** `vm.rs:533` | **Axes:** Bug
160160

161161
Closes upvalue cells referenced by argument closures while the originating frame is

ndc_vm/UPVALUE_DESIGN.md

Lines changed: 258 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,258 @@
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?

ndc_vm/src/value/function.rs

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -149,19 +149,6 @@ impl Function {
149149
}
150150
}
151151
}
152-
153-
/// Returns true if this function requires upvalue materialization before
154-
/// being called as a native. Only `WithVm` natives need this — they
155-
/// receive `&mut Vm` and can invoke closures passed as arguments, so any
156-
/// open upvalues in those closures must be closed first. `Simple` natives
157-
/// never call back into the VM and can skip the materialization scan.
158-
pub fn needs_arg_materialization(&self) -> bool {
159-
match self {
160-
Self::Native(f) => matches!(f.func, NativeFunc::WithVm(_)),
161-
Self::Memoized { function, .. } => function.needs_arg_materialization(),
162-
_ => false,
163-
}
164-
}
165152
}
166153

167154
impl fmt::Debug for Function {

ndc_vm/src/vm.rs

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -248,13 +248,6 @@ impl Vm {
248248
.resolve_callee(args)
249249
.map_err(|msg| VmError::new(msg, span))?
250250
{
251-
// Native functions bridge to the tree-walk interpreter, which may call
252-
// closures back via `Vm::call_function` in a fresh VM context. In that
253-
// context, Open upvalue cells (stack-slot references) are invalid.
254-
// Materialize them now while the current stack is still live.
255-
if func.needs_arg_materialization() {
256-
self.materialize_upvalues_in_args(args);
257-
}
258251
if let Err(mut e) = self.dispatch_call(func, args) {
259252
e.span.get_or_insert(span);
260253
return Err(e);
@@ -550,49 +543,6 @@ impl Vm {
550543
.retain(|c| matches!(*c.borrow(), UpvalueCell::Open(_)));
551544
}
552545

553-
/// Materializes (closes) any Open upvalues in the immediate arguments.
554-
/// This is necessary when closures are about to be passed to functions
555-
/// that may call them in a different VM context (e.g., stdlib HOFs
556-
/// that bridge back to the tree-walk interpreter). We only materialize
557-
/// direct function arguments, not nested closures.
558-
fn materialize_upvalues_in_args(&mut self, arg_count: usize) {
559-
let start = self.stack.len() - arg_count;
560-
561-
// Capture the stack state before any mutations
562-
let current_stack_len = self.stack.len();
563-
let mut materialized = Vec::new();
564-
565-
// Identify closures and their open upvalues that need materializing
566-
for i in start..current_stack_len {
567-
if let Value::Object(obj) = &self.stack[i] {
568-
if let Object::Function(Function::Closure(closure)) = &**obj {
569-
for (j, cell) in closure.upvalues.iter().enumerate() {
570-
if let UpvalueCell::Open(slot) = *cell.borrow() {
571-
materialized.push((i, j, slot));
572-
}
573-
}
574-
}
575-
}
576-
}
577-
578-
// Now materialize them
579-
for (arg_idx, upvalue_idx, slot) in materialized {
580-
if slot < self.stack.len() {
581-
let value = self.stack[slot].clone();
582-
if let Value::Object(obj) = &self.stack[arg_idx] {
583-
if let Object::Function(Function::Closure(closure)) = obj.as_ref() {
584-
let mut cell_borrow = closure.upvalues[upvalue_idx].borrow_mut();
585-
*cell_borrow = UpvalueCell::Closed(value);
586-
}
587-
}
588-
}
589-
}
590-
591-
// Remove cells we just closed from open_upvalues; they're no longer open.
592-
self.open_upvalues
593-
.retain(|c| matches!(*c.borrow(), UpvalueCell::Open(_)));
594-
}
595-
596546
fn dispatch_call(&mut self, func: Function, args: usize) -> Result<(), VmError> {
597547
// Memoized functions check the cache first. On a hit we short-circuit
598548
// without pushing a new frame. On a miss we dispatch the inner
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
// B5: materialize_upvalues_in_args closes upvalue cells belonging to HOF arguments
2+
// while the originating frame is still live. When two closures (inc, get) capture
3+
// the same variable x, they share one Rc<RefCell<UpvalueCell>>. Passing inc to
4+
// map closes that shared cell; subsequent SetUpvalue calls in inc write to the
5+
// closed copy, but GetLocal in make_counter still reads the original stack slot.
6+
// After the fix, both get() and x should agree.
7+
//
8+
// expect-output: 3
9+
// expect-output: 3
10+
fn make_counter() {
11+
let x = 0;
12+
let inc = fn(v) { x = x + 1 };
13+
let get = fn() { x };
14+
15+
[1, 2, 3].map(inc);
16+
17+
print(get()) // reads via closed upvalue cell -> 3
18+
print(x) // reads via GetLocal (stack slot) -> 0 with bug, 3 after fix
19+
}
20+
make_counter()

0 commit comments

Comments
 (0)