Expand description
Which locals can live in fusevm frame slots instead of the host’s scope chain.
Every identifier in a node-js program is a name lookup: the compiler emits
CallBuiltin(GETLOCAL) with the name as a string constant, and the host pops
it off the VM stack, borrows the thread-local JsHost, walks the
Rc<RefCell<EnvData>> chain and hashes the name in each scope. An empty
for (let i = 0; i < 5_000_000; i++) {} costs four of those round-trips per
iteration — 178 ns against node’s 6 ns — and none of them is JIT-able, since
fusevm’s block tier declines any region containing a CallBuiltin.
A local that no closure can reach does not need a scope entry at all: it can
live in the frame slot vector fusevm already keeps, addressed by index
(Op::GetSlot / Op::SetSlot). This module decides which names qualify.
The shape of the analysis follows pythonrs’s fn_slots_allowed /
fn_slots, which does the same job against the same VM.
The rules are deliberately conservative, because a name that is slotted in one place and looked up by name in another is a silent wrong answer:
- A name reachable from another chunk keeps its binding. A nested
function, arrow or class body compiles to its own chunk and resolves what
it captures through the environment chain; a
tryblock is likewise its own chunk on its own VM frame, so a slot written outside it is invisible inside. Every identifier mentioned in either is therefore off the table — but only those identifiers, not the whole chunk, so a loop counter still gets a slot in a file that also defines a callback. A directevalcan name anything, so it disables the chunk outright. - One declaration per name. Shadowing (
let xin two sibling blocks) is exactly where a flat name→slot table would be wrong, so a name declared more than once in the chunk is left alone rather than scope-tracked. - No read before the declaration, in source order. This is what keeps
the temporal dead zone and
varhoisting behaving as they do today: a slot reads asundefinedbefore its first write, which is neither node’sReferenceErrorfor aletnor what node-js currently answers. A name whose first mention is a read stays on the name path, where those answers come from. Source order is a conservative stand-in for execution order — a loop can re-enter a block, but it cannot reach a statement the source has not introduced yet. - Simple identifiers only. Destructuring targets,
delete x, and anything that is not anExpr::Identbind through the host. - At the top level of a script,
let/constonly. A top-levelvaris a property of the global object (var g = 1; globalThis.gis1on node v26.7.0), so it has to stay a real binding.
Structs§
- Plan
- What the compiler needs to know about a chunk’s locals.
Functions§
- param_
names - The simple identifier parameters, in order. A destructuring or rest pattern is bound by the body prologue, so it is not seeded here.
- plan
- Plan the slots for a chunk:
paramsare bound into the environment by the caller before the chunk runs (the compiler emits a prologue that copies each into its slot),bodyis the statement list about to be compiled, andtop_levelmarks a script/module body, wherevarstays a global.
Type Aliases§
- Slot
Table - Name → frame slot for one chunk. Empty when the chunk is not eligible.