Skip to content

node:vm: resolve global let/const/class bindings from compileFunction bodies - #38305

Open
robobun wants to merge 5 commits into
mainfrom
farm/a0e3ef13/vm-compile-function-global-lexical-scope
Open

robobun wants to merge 5 commits into
mainfrom
farm/a0e3ef13/vm-compile-function-global-lexical-scope

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • vm.compileFunction("return x")() throws ReferenceError: x is not defined when x was declared with let, const or class by a script in that context (vm.runInThisContext, or vm.runInContext when compiling with parsingContext). var declarations and global properties resolve fine. Node returns the binding's value.
  • A sloppy-mode assignment to such a binding from the compiled body leaves the binding untouched and creates a property on the global object (the sandbox, for a context) instead.
  • Both work as soon as any contextExtensions entry is passed, even [{}].
  • Cause: vmModuleCompileFunction (src/jsc/bindings/NodeVM.cpp:1597 on main) creates the function with the global object itself as its scope, so its scope chain is [global object]. Script-level lexical bindings live in the global lexical environment, which ordinary scope chains visit before the global object, so the compiled body never consults it. The contextExtensions branch builds its with-scopes on parsingContext->globalScope() (the lexical environment), which is why that path works.
  • Present since compileFunction was added in Implement vm.compileFunction and fix some node:vm tests #18285.

Fix

  • Create the function on options.parsingContext->globalScope(), giving the chain [global lexical environment -> global object], and stack the contextExtensions with-scopes on that same base. parsingContext is always set by this point (CompileFunctionOptions::fromJS initializes it to the caller's global, and so does the fallback in vmModuleCompileFunction), so the old parsingContext ? parsingContext : globalObject never chose anything.
  • This is the chain every other entry into a context's code gets: JSC's Function constructor creates functions on globalObject->globalScope() (FunctionConstructor.cpp), Interpreter::executeProgram runs scripts in it, module environments chain to it, and JSScope::abstractAccess has a dedicated GlobalLexicalVar resolution for it. It also matches Node, where a compiled function resolves script-level bindings like any other function of the context.
  • Also drop the options.parsingContext->setGlobalScopeExtension(functionScope) call that followed. It installed the chain realm-wide, where JSScope::resolve consults it for every lookup that misses on the global object. On main that is inert without extensions (it installed the global object, which the lookup had just checked) and is the extension leak node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302 fixes with extensions. Combined with the first change alone, it would have installed the lexical environment, so after any plain compileFunction() call the unresolved identifiers of functions created directly on the global object (JSC builtins, Bun's internal modules) would have started resolving to the user's script-level let/const bindings; a reference to an undeclared bindings in node:wasi's getState() made this observable (see the details below). The chain is already what the function is created with, so the call contributed nothing to the function itself. This is the same one-line deletion as node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302, whose tests pass on this branch; whichever lands second rebases onto an identical deletion.
  • contextExtensions keep shadowing lexical bindings (the with-scopes still sit in front of the base), and lookups through a with-scope are linked as Dynamic regardless of the realm-wide hook, which is why the function does not need it. Both are pinned by the tests.
  • Verified with test/js/node/vm/vm.test.ts: the global lexical bindings of ... cases run the same four tests against the caller's context and against a parsingContext (visibility of var/let/const/class, a binding declared after the function was compiled and first called, assignment updating the binding without creating a global property, and extensions shadowing bindings while staying invisible to the context and to later compiles). All 8 fail on bun 1.4.0 and pass here. The rest of vm.test.ts, the other test/js/node/vm/ files, node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302's tests, and the vendored test-vm-basic.js, test-vm-module-basic.js, test-vm-no-dynamic-import-callback.js, test-vm-function-declaration.js, test-vm-context.js, test-vm-strict-assign.js, test-vm-not-strict.js, test-vm-cached-data.js and test-vm-createcacheddata.js pass with the debug build.
  • Not done here: constructAnonymousFunction still takes the realm and the scope as separate parameters (node:vm: compile compileFunction sources in the parsingContext realm #38297 changes which realm is passed). Once node:vm: compile compileFunction sources in the parsingContext realm #38297, node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302 and this land, the realm, the base scope and the with-scope chain can all be derived from parsingContext in one place; that is a follow-up rather than something to fold into three concurrent one-line PRs.

Background

  • Global lexical environment (JSGlobalLexicalEnvironment, returned by JSGlobalObject::globalScope()): the JSC object holding a realm's script-level let/const/class bindings. Unlike var and function declarations, these are not properties of the global object. Its parent scope is the global object, so an ordinary scope chain ends with ... -> global lexical environment -> global object.
  • Scope of a JSFunction: the JSScope* it is created with. Free identifiers in the body are resolved by walking outwards from it, so a function created directly on the global object only sees global properties. JSC and Bun create their builtins and internal modules that way, since those reference no user bindings.
  • contextExtensions: objects compileFunction places in front of the chain as with-style scopes (JSWithScope), so their properties shadow whatever is behind them. V8 builds the same chain for Node.
  • Global scope extension (JSGlobalObject::setGlobalScopeExtension): a realm-wide hook that JSScope::resolve checks after a lookup has reached the global object and missed. JSC's own users (evaluateWithScopeExtension, the debugger) install it around a single evaluation and clear it afterwards.
Repro
const vm = require("node:vm");
vm.runInThisContext("let lexical = 42; var hoisted = 43;");
vm.compileFunction("return hoisted")();                                // 43 everywhere
vm.compileFunction("return lexical")();                                // bun 1.4.0: ReferenceError, node: 42
vm.compileFunction("return lexical", [], { contextExtensions: [{}] })(); // 42 everywhere

const context = vm.createContext({});
vm.runInContext("let fromContext = 7;", context);
vm.compileFunction("return fromContext", [], { parsingContext: context })(); // bun 1.4.0: ReferenceError, node: 7
vm.compileFunction("fromContext = 8", [], { parsingContext: context })();
vm.runInContext("fromContext", context);                                // bun 1.4.0: 7 (and context.fromContext === 8), node: 8

With this change every line matches node v26.3.0.

Why the setGlobalScopeExtension call had to go in the same change

The first revision of this PR only changed the base scope and argued the remaining setGlobalScopeExtension call was unobservable because every lookup walks the lexical environment before reaching the global object. That is true for user code, but not for functions created directly on the global object. node:wasi's WASI#getState()/setState() reference an undeclared bindings (a separate bug), which makes the difference visible:

const vm = require("node:vm");
const { WASI } = require("node:wasi");
const w = new WASI({ version: "preview1" });
vm.runInThisContext("let bindings = 'user-script-let';");
w.setState({ env: {}, FD_MAP: new Map(), bindings: "written-by-builtin" }); // ReferenceError on 1.4.0 and here
vm.compileFunction("");
w.setState({ env: {}, FD_MAP: new Map(), bindings: "written-by-builtin" }); // 1.4.0 and here: ReferenceError again
vm.runInThisContext("bindings");                                          // 1.4.0 and here: 'user-script-let'

With only the base-scope change, the second setState succeeded and overwrote the user's let bindings. Removing the call keeps the realm in the state it is in before any compileFunction call, which is also what the which only the compiled function sees assertions in the new tests check (they fail on 1.4.0 because of the extension leak, and would fail on the first revision for the same reason).


[review] gate passed · iteration 1 · 2 files touched

fails on main (without fix)
ASAN without fix: 8 failed, 62 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (ed9368854)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [21.64ms]
(pass) vm > runInContext() > can return a value [15.29ms]
(pass) vm > runInContext() > can return a complex value [15.48ms]
(pass) vm > runInContext() > can return the last value [14.67ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [15.97ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [13.45ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [15.25ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [15.67ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [15.50ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [14.95ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [14.09ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [14.82ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release without fix: 8 failed, 62 skipped
bun test v1.4.0-canary.1 (b7a043103)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.32ms]
(pass) vm > runInContext() > can return a value [0.20ms]
(pass) vm > runInContext() > can return a complex value [0.20ms]
(pass) vm > runInContext() > can return the last value [0.19ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.19ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [0.14ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [0.27ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [0.19ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.15ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.13ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.14ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.13ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.16ms]
(pass) vm > runInContext() > new Big
... (truncated)
passes on PR (with fix)
ASAN with fix: 62 skipped
$ BUN_DEBUG_QUIET_LOGS=1 bun scripts/build.ts --profile=debug --quiet test "--reporter=junit" "--reporter-outfile=/tmp/mechgate.xml" test/js/node/vm/vm.test.ts
bun test v1.4.0 (ed9368854)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [22.63ms]
(pass) vm > runInContext() > can return a value [15.38ms]
(pass) vm > runInContext() > can return a complex value [16.08ms]
(pass) vm > runInContext() > can return the last value [15.19ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [16.97ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.20ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [16.16ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [16.70ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [15.68ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [15.84ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [15.54ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [15.41ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release with fix: 62 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped)
  target       linux-x64-gnu
  build type   Release
  build dir    ./build/release
  revision     ed93688544
  features     baseline

22 deps, 123 codegen, 1176 objects in 738ms

ninja: Entering directory `/workspace/bun/build/release'
[1/1238] gen ErrorCode+*.h
[2/1238] gen bindgenv2
[3/1238] fetch libjpeg-turbo
[libjpeg-turbo] up to date
[4/1238] install /workspace/bun
bun install v1.4.0-canary.1 (b7a043103)

Checked 107 installs across 153 packages (no changes) [53.00ms]
[5/1238] fetch zlib
[zlib] up to date
[6/1238] install /workspace/bun/packages/bun-error
bun install v1.4.0-canary.1 (b7a043103)

Checked 1 install across 2 packages (no changes) [3.00ms]
[7/1238] fetch tinycc
[tinycc] up to date
[8/1237] install /workspace/bun/src/node-fallbacks
bun install v1.4.0-canary.1 (b7a043103)

Checked 129 installs across 147 packages (no changes) [12.00ms]
[9/1237] gen .bind.ts → GeneratedBindings.cpp
[10/1237] gen JSBuffer.lut.h
Generating /workspace/bun/build/release/codegen/JSBuffer.lut.h from /workspace/bun/src/jsc/bindings/JSBuffer.cpp
[11/1237] gen ProcessBindingConstants.lut.h

... (truncated)
diff hotspot
src/jsc/bindings/NodeVM.cpp |  8 ++----
 test/js/node/vm/vm.test.ts  | 68 +++++++++++++++++++++++++++++++++++++++++++++
 2 files changed, 70 insertions(+), 6 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                         reads  edits  tests
src/jsc/bindings/NodeVM.cpp      7      6      0
test/js/node/vm/vm.test.ts       4      4      0

root cause · written by the author bot

vm.compileFunction without contextExtensions built the compiled function's scope chain directly on the global object, bypassing the JSGlobalLexicalEnvironment where top level let, const and class bindings declared by scripts live, so those identifiers resolved as undefined even though var bindings were found and the contextExtensions path, which already stacked its with scopes on the lexical environment, worked. The fix bases the function's scope on parsingContext->globalScope() in both paths, matching what JSC's own Function constructor does, and removes the realm-wide setGlobalScopeExtens…

vm.compileFunction created the function with the global object itself as
its scope unless contextExtensions were given, so script-level let, const
and class bindings (which live in the global lexical environment, in front
of the global object) were unresolvable from the compiled body, and sloppy
assignments to them created global properties instead. Start the scope
chain at parsingContext->globalScope(), as the contextExtensions path, the
Function constructor and program evaluation already do.
@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Warning

Review limit reached

@robobun, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 7 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: a8f318eb-8eae-484c-83e6-5c718215a6ba

📥 Commits

Reviewing files that changed from the base of the PR and between b555e06 and ed93688.

📒 Files selected for processing (2)
  • src/jsc/bindings/NodeVM.cpp
  • test/js/node/vm/vm.test.ts

Comment @coderabbitai help to get the list of available commands.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 AM PT - Aug 14th, 2026

❌ @robobun, your commit ed93688 has some failures in Build #95769 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 38305

That installs a local version of the PR into your bun-38305 executable, so you can run:

bun-38305 --bun

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced on bun 1.4.0 and on main: vm.runInThisContext("let x = 42") followed by vm.compileFunction("return x")() throws ReferenceError: x is not defined; the same with runInContext + parsingContext. Node v26.3.0 returns the binding's value in both cases.
  • Fix in this PR: the compiled function is created on the context's global lexical environment (parsingContext->globalScope()) instead of the global object, and the scope chain is no longer also installed realm-wide with setGlobalScopeExtension (that deletion is shared with node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302; see the PR description for why it is needed here too).
  • Tests: test/js/node/vm/vm.test.ts, global lexical bindings of ... cases. All 8 fail on bun 1.4.0 (USE_SYSTEM_BUN=1 bun test test/js/node/vm/vm.test.ts -t "global lexical bindings") and pass with this branch, as do the other vm tests listed in the description.
  • CI (build 95769, final): 177 of 179 jobs passed, including the new tests on every platform that ran; no test failed. The build is marked failed only because the two darwin 14 aarch64 - test-bun jobs expired without an agent ever picking them up (infra, not this diff). The flagged tests are unrelated to node:vm and all passed on retry. Ready for review.
  • Related open PRs in the same function: node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302 (same one-line deletion, plus its own tests) and node:vm: compile compileFunction sources in the parsingContext realm #38297 (compiles in the parsingContext realm). Either merge order works; the later one gets a trivial rebase.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The fix is small and the reasoning checks out, but since it changes JSC scope-chain construction for vm.compileFunction (and shifts what setGlobalScopeExtension receives in the no-extensions case), a human look from someone comfortable with JSC scope semantics would be worthwhile.

What was reviewed:

  • Confirmed options.parsingContext is always non-null here (CompileFunctionOptions::fromJS initializes it, and the fallback branch re-initializes it), so the removed ternary was dead and the new dereference is safe.
  • The contextExtensions branch already based its with-scope chain on parsingContext->globalScope(), so the two paths now share one base — no behavior change on that branch.
  • setGlobalScopeExtension(functionScope) now receives the global lexical environment instead of the global object in the no-extensions case; both look inert (already walked before the extension is consulted), and #38302 removes the call.
  • New tests use randomProps to avoid polluting/colliding in the caller's global lexical scope across the describe.each matrix, and pin the contextExtensions shadowing order.
Extended reasoning...

Overview

Two files touched: a ~5-line change in src/jsc/bindings/NodeVM.cpp inside vmModuleCompileFunction, and 8 new test cases in test/js/node/vm/vm.test.ts (a describe.each over this-context and parsingContext, each covering read, late declaration, sloppy assignment, and contextExtensions shadowing). The C++ change replaces the function's initial scope (options.parsingContext ? options.parsingContext : globalObject, i.e. the global object) with options.parsingContext->globalScope() (the global lexical environment), and reuses that as the base for the contextExtensions with-scope chain instead of recomputing it.

Security risks

None identified. This adjusts which JSC scope object sits at the base of the compiled function's scope chain; it does not widen what a compiled function can reach beyond what vm.runIn*Context already exposes for the same context. The global lexical environment's parent is the global object, so resolution still terminates in the same place.

Level of scrutiny

Medium-high. The diff is tiny and the intent is a straightforward Node compat fix, but it lives in JSC bindings and changes scope-chain construction, where mistakes surface as subtle resolution or TDZ differences rather than crashes. It also changes the argument to the persistent setGlobalScopeExtension side effect in the no-extensions path — the PR description argues (convincingly) that this is inert and #38302 removes the call, but that is exactly the kind of nuance a JSC-familiar reviewer should sanity-check.

Other factors

  • Null-safety of the new unconditional dereference is established: CompileFunctionOptions::fromJS sets parsingContext = globalObject before doing anything else, and the caller's options = {}; options.parsingContext = globalObject; fallback covers the false-return path. The contextExtensions branch already dereferenced it unconditionally, so no new failure mode is introduced.
  • The fix mirrors how JSC's own FunctionConstructor builds function scopes (per the PR description), and the contextExtensions path was already doing the right thing — this just brings the no-extensions path in line with it.
  • Tests are well-structured: they use randomProps() so runInThisContext let declarations don't collide across cases (they can't be cleaned up), assert exact values, cover all four lexical declaration forms, and verify assignments hit the binding rather than creating a global property. The two contextExtensions cases pin existing behavior against regression.
  • No CODEOWNERS entry covers these files. No prior human review comments to address.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

No code changes from this review. For whoever looks at the JSC side, the two points it singles out, with the places to check:

  • globalScope() as the base of a function's chain is the normal case in JSC, not a new arrangement: FunctionConstructor.cpp creates new Function(...) results on globalObject->globalScope(), Interpreter::executeProgram runs scripts in it, CyclicModuleRecord.cpp parents module environments to it, and globalFuncEval (indirect eval) evaluates in it. Only builtins are created directly on the global object.
  • setGlobalScopeExtension now receiving the lexical environment (no-extensions case): JSScope::resolve consults the extension only after the walk has reached the global object, and every chain that reaches the global object has just passed through the lexical environment, so the extension cannot resolve anything the walk did not. Before this change it pointed at the global object itself, which was inert for the same reason. The remaining reads of globalScopeExtension() in our bindings only set the with-scope taint bit, which is the same either way. node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302 deletes the call.

With the function now created on the global lexical environment, leaving
setGlobalScopeExtension in place would have made functions that are
created directly on the global object (builtins and internal modules)
resolve their unresolved identifiers against script-level let/const
bindings after any compileFunction call. The chain is already what the
function is created with, so only hand it to the function.
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
Comment thread src/jsc/bindings/NodeVM.cpp Outdated
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Updated after a closer look at the setGlobalScopeExtension call I had left in place: my earlier claim that it was unobservable was wrong. With the function now created on the lexical environment, that call installed the lexical environment realm-wide, so after any plain compileFunction() call, functions created directly on the global object (builtins, internal modules) would resolve their unresolved identifiers to the user's script-level let/const bindings. node:wasi's getState()/setState() reference an undeclared bindings and demonstrated it (repro in the description).

Changes since the first revision:

  • The setGlobalScopeExtension call is removed (same deletion as node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302; its tests pass on this branch, and either merge order rebases cleanly).
  • The "declared later" test now also calls the function before the declaration exists, and the contextExtensions test asserts the extensions stay invisible to the context and to later compiles. All 8 cases fail on 1.4.0 and pass here.
  • The explanatory comment next to functionScope is gone; the reasoning lives in the description and the tests.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. Because it changes JSC scope-chain semantics for vm.compileFunction (removing the realm-wide setGlobalScopeExtension and rebasing the function's scope on the global lexical environment) and overlaps with #38297 and #38302 in the same function, a human look would still be worthwhile.

What was reviewed:

  • Verified options.parsingContext is always non-null before ->globalScope() — CompileFunctionOptions::fromJS sets it unconditionally, and the fromJS-returns-false branch resets it to globalObject.
  • Checked that dropping setGlobalScopeExtension does not lose the with-scope taint for contextExtensions: constructAnonymousFunction reads the taint from globalObject, not parsingContext, so the deleted call never set the bit it reads anyway; the JSWithScope chain on the function itself carries the dynamic-lookup behaviour.
  • Tests use randomProps() for the "this context" variant so the persistent global lexical bindings created via runInThisContext don't collide across test runs; the describe.each matrix covers both entry points and pins the extension-leak regression.
Extended reasoning...

Overview

Two files changed. In src/jsc/bindings/NodeVM.cpp, vmModuleCompileFunction now seeds the compiled function's scope chain with options.parsingContext->globalScope() (the JSGlobalLexicalEnvironment) instead of the global object itself, reuses that same base for the contextExtensions with-scope stack, and drops the trailing setGlobalScopeExtension(functionScope) call. In test/js/node/vm/vm.test.ts, a describe.each block adds 8 tests (4 cases × {this context, parsingContext}) covering visibility of var/let/const/class, late declaration after compile+call, assignment writing back to the lexical binding rather than creating a global property, and contextExtensions shadowing without leaking to the realm.

Security risks

None introduced. The change narrows realm-wide state mutation (removing a persistent setGlobalScopeExtension) rather than adding any. The compiled function's own scope chain now matches what new Function(...) and script evaluation already get in JSC, so no new resolution surface is exposed to user code that wasn't already reachable.

Level of scrutiny

High. Although the native diff is ~5 lines, it changes identifier resolution for every vm.compileFunction result and removes a realm-wide side effect. The PR timeline shows the author's own first revision was subtly wrong (leaving setGlobalScopeExtension in place would have let builtins created directly on the global object resolve into user let/const bindings after any compileFunction() call). That kind of second-order effect is exactly what a JSC-familiar maintainer should sanity-check. The change also intentionally overlaps with #38302 (same one-line deletion) and sits adjacent to #38297 (which realm constructAnonymousFunction compiles in), so a human should confirm the intended merge order and that the combined result is what everyone expects.

Other factors

  • parsingContext non-null: confirmed both code paths set it before dereference, so the removed ?: was dead as claimed.
  • The LexicallyScopedFeatures read in constructAnonymousFunction keys off globalObject->globalScopeExtension(), not parsingContext's, so the deleted call was never what set that bit for the code path that reads it; the JSWithScope objects in the function's own chain make the lookups dynamic regardless.
  • Tests are hermetic via randomProps() for the runInThisContext variant (which necessarily mutates the real global lexical environment). The last test's trailing assertions (typeof extensionOnly in a fresh compileFunction and via run) pin the exact regression the setGlobalScopeExtension deletion prevents.
  • The comment-cop bot flagged earlier revisions for a paragraph-long code comment; the final revision removed it, so that feedback is addressed.

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reviewed this PR and didn't find any bugs. The scope-chain change and the setGlobalScopeExtension removal both check out against JSC's own FunctionConstructor/globalScope() usage, and the 8 new tests cover the variant matrix well. Given it changes node:vm scope-resolution semantics in the JSC bindings and overlaps with #38297/#38302 in the same function, a human look would still be worthwhile.

What was reviewed:

  • Verified options.parsingContext is always non-null at the deref (CompileFunctionOptions::fromJS sets it unconditionally, as does the fallback branch).
  • Confirmed the removed setGlobalScopeExtension was the only writer in Bun's codebase; remaining readers only derive a taint bit for SourceCodeKey, so no dangling consumer.
  • Checked that contextExtensions still work without the realm-wide hook — the with-scopes are on the function's own chain, and the new tests pin both shadowing and non-leakage.
Extended reasoning...

Overview

The PR changes three effective lines in vmModuleCompileFunction (src/jsc/bindings/NodeVM.cpp): the compiled function's base scope becomes parsingContext->globalScope() (the global lexical environment) instead of the global object itself, the contextExtensions with-scope chain is stacked on that same base (deduplicating an identical expression), and the trailing setGlobalScopeExtension(functionScope) call is removed. Eight new tests in test/js/node/vm/vm.test.ts exercise both the this-context and parsingContext variants across visibility, late declaration, assignment, and contextExtensions shadowing.

Security risks

node:vm is sandbox-adjacent, so scope-chain changes matter for isolation. This change does not weaken isolation: it makes the compiled function see its own context's script-level lexical bindings (which it should, per Node), and it removes a realm-wide side effect that was leaking one call's with-scope chain to every subsequent lookup in the realm. The new tests explicitly assert the negative contract — that contextExtensions are invisible to the context and to later compiles — so the security posture improves.

Level of scrutiny

This deserves careful human review despite its small size. It changes scope-resolution semantics in the JSC bindings, where the interaction between JSGlobalLexicalEnvironment, JSWithScope, the realm-wide globalScopeExtension hook, and the TaintedByWithScopeLexicallyScopedFeature bit is subtle. The PR description is thorough and the reasoning cross-references the right JSC sources (FunctionConstructor.cpp, JSScope::resolve), but a maintainer familiar with the JSC integration should confirm the claim that with-scope lookups link as Dynamic regardless of the realm-wide hook, and sanity-check the interaction with the two concurrent PRs (#38297, #38302) touching the same function.

Other factors

  • options.parsingContext cannot be null at the new deref: CompileFunctionOptions::fromJS sets this->parsingContext = globalObject before doing anything else, and the caller's fallback branch does the same.
  • The removed setGlobalScopeExtension was the only writer in Bun's codebase; the five readers in NodeVM.cpp/NodeVMScript.cpp/NodeVMSourceTextModule.cpp only derive a SourceCodeKey taint bit and now consistently see null, which is a fix (the bit was previously leaking to unrelated compilations after any compileFunction call).
  • Tests use randomProps() to avoid polluting the shared this-context lexical environment across test runs, follow the existing file's conventions, and assert exact values. All bot review comments (comment-cop about a long code comment) are resolved — the comment was removed in 0958eca.
  • CI for the latest commit is still building.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant