Viscosity
Review
Part IV · A bytecode virtual machine · Day 15

57. Closures and upvalues

In the VM, locals live in stack slots that are reused the moment a call returns. A closure needs one of them to stay alive. The fix is a small object that points into the stack, and moves off it at the last moment.

About 18 minutes, including the exercises and quiz
By the end you will be able to
  • Explain why a closure in a stack VM can't just remember a slot number.
  • Read the (isLocal, index) pairs after OP_CLOSURE and say where each upvalue comes from.
  • Trace an upvalue from open (pointing at a stack slot) to closed (holding the value itself).
  • Say when the VM closes upvalues, and why the open list is kept sorted.

The counter, again

You met this program in chapters 6 and 41.

fn makeCounter() {
  let n = 0;
  fn count() {
    n = n + 1;
    return n;
  }
  return count;
}
let a = makeCounter();
print a(); // 1
print a(); // 2

The tree-walker got this almost for free: every call made an Environment on the heap, and count kept a reference to it.

In the VM, n is a stack slot at a fixed offset from the frame pointer (chapter 54). When makeCounter returns, OP_RETURN sets the stack top back to the frame’s base, and the next call writes over the slot.

So count can’t just remember “slot 2”. By the time a() runs, slot 2 belongs to someone else.

Upvalues: found at compile time

Intuition

A coat-check ticket. During the party it leads to a hook on the rack. When the cloakroom closes, your coat moves to a locker, and the attendant writes the locker number on the same ticket. You hold one ticket throughout; only where it leads changes.

Each closure gets a list of small objects, one per variable it uses from an enclosing function: its upvalues. The body says “my upvalue 0”, and the upvalue knows where the value is.

The compiler already tracks every function’s locals (chapter 54), so it can find these variables as it goes. When a name isn’t a local of the current function, namedVariable tries resolveUpvalue before falling back to a global:

function resolveUpvalue(c, name) {
  if (!c.enclosing) return -1;
  const local =
    resolveLocal(c.enclosing, name);
  if (local !== -1) {
    c.enclosing.locals[local]
      .isCaptured = true;
    return addUpvalue(c, local, true);
  }
  const up =
    resolveUpvalue(c.enclosing, name);
  if (up !== -1) {
    return addUpvalue(c, up, false);
  }
  return -1;
}

Two cases. If the name is a local of the function directly around this one, the upvalue captures that local’s slot (isLocal true). Otherwise the function recurses outward. If some outer function has it, the enclosing function gets an upvalue of its own, and this function’s upvalue copies that one (isLocal false). addUpvalue appends { index, isLocal } to the function’s list, unless an equal entry is already there, and returns its position.

When the function is done, the compiler writes that list into the bytecode as byte pairs after OP_CLOSURE. The companion’s disassembler (chapter 46) shows them:

== makeCounter ==
0000    2 OP_CONSTANT         0 '0'
0002    6 OP_CLOSURE          1 <fn count>
0004    |                     local 1
== count ==
0000    4 OP_GET_UPVALUE      0
0002    | OP_CONSTANT         0 '1'
0004    | OP_ADD
0005    | OP_SET_UPVALUE      0

local 1 means: capture slot 1 of the running frame, which is n (slot 0 holds the function itself). Inside count, n became OP_GET_UPVALUE 0 and OP_SET_UPVALUE 0. With three levels, say inner inside middle inside outer, where only inner uses outer’s x, the middle function passes it along: middle gets local 1, and inner gets upvalue 0, meaning “take my enclosing closure’s upvalue 0”.

Definition

An upvalue is a closure’s handle on a variable declared in an enclosing function. The compiler resolves upvalues at compile time and emits one (isLocal, index) pair per upvalue after OP_CLOSURE: capture slot index of the running frame, or reuse upvalue index of the running closure. The body reaches them with OP_GET_UPVALUE and OP_SET_UPVALUE.

Open: pointing into the stack

At run time, OP_CLOSURE builds the closure and fills in its upvalues, one pair at a time:

case OP.CLOSURE: {
  const cl = newClosure(readConstant());
  push(cl);
  const ups = cl.upvalues;
  for (let i = 0; i < ups.length; i++) {
    const isLocal = readByte();
    const index = readByte();
    ups[i] = isLocal
      ? captureUpvalue(frame.slots + index)
      : frame.closure.upvalues[index];
  }
  break;
}

frame.slots + index turns a frame-relative slot into an absolute stack index. The upvalue object (ObjUpvalue) has just three fields: slot, closed and nextOpen. While makeCounter runs, n really is in slot 2, so the upvalue records slot: 2, and reading through it reads the stack. That’s an open upvalue.

There is one subtlety. Two closures made in the same call must share n, not each get a copy. So the VM keeps every open upvalue on a list, openUpvalues, and captureUpvalue looks there first:

captureUpvalue(slot: number) {
  let prev = null;
  let u = this.openUpvalues;
  while (u && u.slot > slot) {
    prev = u;
    u = u.nextOpen;
  }
  if (u && u.slot === slot) return u;
  const made = heap.newUpvalue(slot);
  made.nextOpen = u;
  if (prev) prev.nextOpen = made;
  else this.openUpvalues = made;
  return made;
}

The list is sorted by slot, highest first. The search stops once it passes the slot it wants, and links a new upvalue in at that spot.

Definition

An open upvalue is an upvalue whose variable is still on the stack: its slot is the stack index of the variable, and reads and writes go straight to stack[slot]. The VM keeps all open upvalues on one list sorted by slot, and reuses an existing one when a second closure captures the same slot, so the closures share the variable.

Closed: moving to the heap

Now step through what happens when makeCounter returns:

Step throughAn upvalue, open then closed
fn makeCounter() {
let n = 0;
fn count() {
n = n + 1;
return n;
}
return count;
}
let a = makeCounter();
print a();
The value stack on the left, an upvalue object in the middle, the count closure on the rightVALUE STACKHEAP<script>0makeCounter1free2free3frame: makeCounter (slots from 1)
CALL makeCounter: a new frame starts at stack slot 1. Its slot 0 holds the function being called.
1/6

The move happens at the last possible moment. OP_RETURN calls closeUpvalues(frame.slots) before it drops the frame:

closeUpvalues(last: number) {
  while (this.openUpvalues &&
         this.openUpvalues.slot >= last) {
    const u = this.openUpvalues;
    u.closed = this.stack[u.slot];
    u.slot = -1;
    this.openUpvalues = u.nextOpen;
  }
}

Every open upvalue at or above the frame’s base points into the dying frame. The loop copies each value into the upvalue’s own closed field, marks it closed, and unlinks it. Because the list is sorted, the first upvalue below last ends the loop: everything after it is lower still. From then on, readUpvalue sees slot === -1 and returns closed instead of stack[slot].

A function’s return isn’t the only time a local dies. A local declared in a block dies at the block’s }. That’s why resolveUpvalue set isCaptured: when endScope discards a local, it emits OP_POP for an ordinary one and OP_CLOSE_UPVALUE for a captured one, which closes it (closeUpvalues(sp - 1)) and then pops it. So a closure made in a loop body that captures a body local gets a fresh upvalue on every pass. The extra exercise at the end puts that to work.

Definition

A closed upvalue holds its variable’s value in its own closed field, on the heap, and no longer refers to the stack (slot is -1). The VM closes upvalues when the variable’s slot is about to be discarded: at OP_RETURN for every slot in the returning frame, and at OP_CLOSE_UPVALUE for a captured local leaving a block. Closures that shared the open upvalue share the closed one too.

Under the hood

Lua’s implementation uses the same scheme. Its luaF_findupval walks a list of open upvalues kept in stack order and reuses a match, and luaF_closeupval moves each value into the upvalue and repoints the upvalue’s value pointer at that copy[2]. In C, a pointer can aim at a stack slot or at the upvalue’s own field, so a read never has to ask which. TypeScript can’t point into the middle of an array, so the companion stores an index and uses -1 for “closed”. Drip’s VM follows clox from Crafting Interpreters[1].

Compare the cost with chapter 41. The tree-walker allocated an environment for every call. The VM allocates nothing extra for a call, and one small upvalue only for a variable a closure really captures.

Your turn

ExerciseCapture and close

A tiny model of the VM’s upvalues. vm.stack is an array of values and vm.open is an array of open upvalues, each { slot, closed } (unsorted here, to keep it short). Write capture(vm, slot): return the open upvalue for slot if there is one, otherwise make { slot, closed: null }, add it to vm.open and return it. Write closeFrom(vm, last): close every open upvalue whose slot is >= last (copy the value, set slot to -1) and remove it from vm.open. read is given.

function capture(vm, slot) {
  // reuse an open upvalue for slot,
  // or make and remember a new one
}

function closeFrom(vm, last) {
  // close upvalues with slot >= last
}

function read(vm, u) {
  return u.slot === -1
    ? u.closed
    : vm.stack[u.slot];
}
Extra practiceA closed upvalue per pass

On the VM, this loop saves three closures, and every one prints 4. Fix it without adding a function: change only the loop body so that f1(), f2() and f3() print 1, 2 and 3.

let f1;
let f2;
let f3;
for (let i = 1; i <= 3; i = i + 1) {
  fn show() { print i; }
  if (i == 1) f1 = show;
  if (i == 2) f2 = show;
  if (i == 3) f3 = show;
}
f1();
f2();
f3();

What you learned

  • In a stack VM, a local’s slot is reused once its call returns, so a closure needs an upvalue: a small object standing between it and the variable.
  • The compiler resolves upvalues at compile time and emits (isLocal, index) pairs after OP_CLOSURE: capture a local of the enclosing frame, or reuse the enclosing closure’s upvalue.
  • An open upvalue points at a live stack slot. The VM keeps open upvalues on one sorted list and reuses them, so closures share variables.
  • A closed upvalue has copied its value into itself. Closing happens at OP_RETURN and at OP_CLOSE_UPVALUE, just before the slot dies.

Next: the VM has been allocating closures, upvalues, strings and instances, and freeing none of them. Time for a garbage collector.

Check your understanding

0/6
  1. 1.

    In the VM, why doesn’t n survive on its own after makeCounter returns?

  2. 2.

    A closure is created with the pairs local 1 and upvalue 0. What does it get?

  3. 3.

    Two closures made in the same call capture the same local, and one of them changes it. What does the other see?

  4. 4.

    What does closeUpvalues(last) do to each open upvalue whose slot is at least last?

  5. 5.

    Where does the compiler emit OP_CLOSE_UPVALUE?

  6. 6.

    Why is the open-upvalue list sorted by stack slot?

References

  1. [1] Robert Nystrom (2021). Crafting Interpreters. Free web edition at craftinginterpreters.com (© 2015–2021); source, text and test suite at github.com/munificent/craftinginterpreters. github.com/munificent/craftinginterpreters (checked 2026-10-31)
  2. [2] Roberto Ierusalimschy, Luiz Henrique de Figueiredo, Waldemar Celes (2026). lfunc.c: auxiliary functions to manipulate prototypes and closures (Lua source code). The Lua programming language, official source repository (github.com/lua/lua), Lua.org, PUC-Rio. github.com/lua/lua/blob/master/lfunc.c (checked 2026-10-31)