Skip to content

node:vm: compile compileFunction sources in the parsingContext realm - #38297

Open
robobun wants to merge 3 commits into
mainfrom
farm/4de948a1/vm-compile-function-parsing-context-realm
Open

robobun wants to merge 3 commits into
mainfrom
farm/4de948a1/vm-compile-function-parsing-context-realm

Conversation

@robobun

@robobun robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • vm.compileFunction(code, params, { parsingContext }) throws a compile error from the caller's realm: err instanceof SyntaxError is true and err instanceof vm.runInContext("SyntaxError", parsingContext) is false. Node (v26.3.0) gives false / true. Same for the parser's RangeError on a body too deep to parse and for the Failed to compile function fallback.
  • On success the returned function is a caller-realm function as well: Object.getPrototypeOf(fn) is the caller's Function.prototype; in Node it is the context's. Because of that, the body could get out of the context through its own function object: compileFunction("return new (arguments.callee.constructor)('return globalThis')()", [], { parsingContext })() returns the caller's globalThis (with process on it) on bun 1.4.0; Node returns the context's.
  • The compile error is formatted by the caller's Error.prepareStackTrace; Node runs the context's.
  • Cause: vmModuleCompileFunction (src/jsc/bindings/NodeVM.cpp, the constructAnonymousFunction call near line 1625) passes its own globalObject, the realm vm.compileFunction itself lives in, as the realm to compile in; options.parsingContext only reaches the call as the scope chain.
  • Inside constructAnonymousFunction that realm argument is what ParserError::toErrorObject creates the error from and what JSFunction::selectStructureForNewFuncExp takes the function's structure from, so both come out in the wrong realm. The fallback throwVMError used the same global.
  • Found by comparing against Node while working in this file; there is no issue for it.

Fix

  • Pass options.parsingContext as the realm argument of constructAnonymousFunction and throw the fallback from it too. It is always set (CompileFunctionOptions::fromJS defaults it to the caller's global), so without the option the two globals are the same object and nothing changes.
  • Matches Node: node_contextify.cc enters the parsing context before ScriptCompiler::CompileFunction, so the error and the function are created in it. It is also what JSC does on its own: the parse error of a lazily compiled function is created in the realm of the scope it is linked against (ScriptExecutable::newCodeBlockFor), and a function's structure realm normally equals the realm of its scope chain, which this restores.
  • Nothing else in constructAnonymousFunction moves realm: ProgramExecutable::create only takes the VM from the argument, ProgramCodeBlock::create and JSFunction::create already received the parsing context's scope, decorateParseErrorStack ignores it, and createBuffer always builds cachedData from the default global, so it stays a Buffer of the caller's realm like Node's Buffer::Copy(env, ...). The cachedData key and getBytecode still read one and the same global on the produce and consume sides.
  • The context's Error.prepareStackTrace runs with no further change because computeErrorInfo (FormatStackTraceForJS.cpp) dispatches on the error object's own realm; a throwing one is still swallowed by the existing tryClearException, as in the caller-realm case.
  • Removed the unreachable second if (!function) after fetcher->owner(...).
  • Independent of node:vm: report compileFunction body errors as SyntaxErrors and reject bodies that close the function #38239, which reworks how the error is built inside constructAnonymousFunction: it creates the error in whatever realm it is handed, and this PR only changes what is handed in.
  • Scope: node:vm has three compile entry points that create their compile error from the caller's global instead of the target context's. This PR is the compileFunction one (NodeVM.cpp), the only one of the three where the compiled code object itself is mis-realmed too. runInContext / runInNewContext (NodeVMScript.cpp, Node's kParsingContext) is node:vm: throw runInContext/runInNewContext compile errors from the context's realm #38317, and new SourceTextModule(code, { context }) (NodeVMSourceTextModule.cpp, createModuleRecord) is left for a separate change; each derives its target realm from a different option and lives in a different constructor, so they are kept as separate changes. node:vm: allocate prepareStackTrace call sites in the vm realm #29998 covers the related prepareStackTrace call-site allocations.
  • Not changed here: the setGlobalScopeExtension(functionScope) call in the diff is the pre-existing line, now made through the new local. That it installs the contextExtensions chain on the parsing context for good is a separate pre-existing bug, fixed by node:vm: stop leaking compileFunction contextExtensions into later global lookups #38302.
  • Verified with the new compileFunction() > parsingContext block in test/js/node/vm/vm.test.ts: 8 tests, 6 fail on main and on bun 1.4.0, the 2 that pin unchanged behavior (default realm, cachedData) pass either way, all 8 pass with the fix, and the same assertions hold under node v26.3.0.
  • Also run with the fix: the rest of test/js/node/vm/ (script-leak.test.ts times out locally under ASAN independent of this change), test/js/node/test/parallel/test-vm-basic.js, test-vm-module-basic.js, test-vm-no-dynamic-import-callback.js, test/regression/issue/isArray-proxy-crash.test.ts.

Background

  • Realm: a global object together with its own copies of the intrinsics (Error, SyntaxError, Function.prototype, ...). Each vm context is a realm of its own (a NodeVMGlobalObject), and instanceof against another realm's constructor is false. In JSC an object's realm is recorded in its Structure, so the realm of a new error or function is decided by which global object its structure is taken from.
  • parsingContext: the compileFunction option naming the context to compile in. Bun keeps it in CompileFunctionOptions::parsingContext and builds the function's scope chain from it; before this change that scope chain was its only use.
  • constructAnonymousFunction: wraps params and body into a (function (...) {...}) program, compiles it, and returns the function expression. Its first parameter is the global object the error or the function is created from; the header comment now says so.
  • Error.prepareStackTrace: the V8-style hook on a realm's Error constructor that formats .stack. Bun looks it up on the Error of the realm the error object was created in, which is why the fix also changes which hook runs.

@coderabbitai

coderabbitai Bot commented Aug 14, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro

Run ID: 8d12c1ac-808c-4e06-9564-d28ddfe27564

📥 Commits

Reviewing files that changed from the base of the PR and between 7cf6296 and 7b8bf9d.

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

Walkthrough

vmModuleCompileFunction now compiles functions and reports compilation errors in options.parsingContext. Tests cover realm-specific constructors, globals, cached data, stack formatting, and behavior without a parsing context.

Changes

Parsing context realm

Layer / File(s) Summary
Realm-aware compilation
src/jsc/bindings/NodeVM.cpp, src/jsc/bindings/NodeVM.h
vmModuleCompileFunction uses options.parsingContext for function creation and compilation errors. The API comment documents this realm behavior.
Function and error realm validation
test/js/node/vm/vm.test.ts
Tests verify parsing-context error constructors, function prototypes, globals, constructor isolation, caller-realm fallback, and cached-data behavior.
Context stack formatting
test/js/node/vm/vm.test.ts
Tests verify context-specific Error.prepareStackTrace handling and preservation of the original syntax error when stack formatting throws.

Suggested reviewers: cirospaciari, jarred-sumner, sosukesuzuki

🚥 Pre-merge checks | ✅ 4
✅ Passed checks (4 passed)
Check name Status Explanation
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Title check ✅ Passed The title clearly and concisely identifies the main change to compile compileFunction sources in the parsingContext realm.
Description check ✅ Passed The description clearly explains the problem, fix, scope, background, and verification results, including the relevant test coverage.

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

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author

Reproduced on bun 1.4.0 and on main with the compileFunction() > parsingContext block added to test/js/node/vm/vm.test.ts (6 of its 8 tests fail without the change in NodeVM.cpp; the same assertions pass under node v26.3.0). Fix is in this PR (#38297): the source is compiled in options.parsingContext's realm, so the compile error and the returned function are that context's objects.

@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.

LGTM — swaps the realm argument of constructAnonymousFunction to options.parsingContext, matching Node's node_contextify.cc.

What was reviewed:

  • options.parsingContext is always non-null (defaulted in CompileFunctionOptions::fromJS and the vmModuleCompileFunction fallback), and captured to a local before WTF::move(options).
  • createBuffer for cachedData still routes through defaultGlobalObject()->JSBufferSubclassStructure(), so the buffer stays a caller-realm Buffer (test covers it); lexicallyScopedFeatures and getBytecode now both read the same global on produce and consume.
  • Realm move is a hardening, not a weakening: the function's structure/prototype now lives in the sandbox realm it was already scoped to, so fn.constructor no longer hands back the caller's Function.
  • Dead second if (!function) after fetcher->owner() correctly removed.
Extended reasoning...

Overview

Changes the first argument of constructAnonymousFunction in vmModuleCompileFunction from the caller's globalObject to options.parsingContext, so compile errors and the returned function are created in the parsing context's realm (matching Node). Also routes the Failed to compile function fallback throwVMError through the same realm, removes an unreachable duplicate null check, and adds a header comment on constructAnonymousFunction documenting the realm parameter. Seven new tests cover the SyntaxError/RangeError realm, the returned function's prototype realm, Error.prepareStackTrace dispatch, the cachedData Buffer realm, and the no-option default.

Security risks

node:vm is isolation-adjacent, so I checked realm direction carefully. The function's scope chain was already parsingContext before this change; only its structure (prototype) moved. That means Object.getPrototypeOf(fn).constructor now resolves to the sandbox's Function instead of the caller's — a hardening, not an escape vector. Compile errors are now created in the context realm, so the context's Error.prepareStackTrace runs; a throw from it is already swallowed by the existing tryClearException (test asserts this). No new caller-realm objects are exposed to the context. cachedData remains a host-realm Buffer because WebCore::createBuffer takes its structure from defaultGlobalObject().

Level of scrutiny

Moderate: C++ JSC bindings in a compat-sensitive path, but the effective change is a one-argument swap plus dead-code deletion. options.parsingContext is guaranteed non-null (fromJS sets it to the caller's global first thing; the !fromJS branch in vmModuleCompileFunction re-defaults it), so there's no new null-deref path. The local parsingContext is captured before WTF::move(options), so the post-move throwVMError use is safe. No new GC roots are needed — the same pointer was already held across the call as functionScope.

Other factors

The PR description traces every use of the realm parameter inside constructAnonymousFunction (ProgramExecutable::create, selectStructureForNewFuncExp, toErrorObject, decorateParseErrorStack, createBuffer, getBytecode) and I spot-checked createBuffer/createUninitializedBuffer and the lexicallyScopedFeatures/SourceCodeKey derivation to confirm produce and consume stay symmetric. Tests are specific, assert both the positive (context realm) and negative (not.toBeInstanceOf(Error)) contract, restore mutated globals in finally, and pin unchanged behavior for the no-option case and cachedData. The bug-hunting pass found nothing.

Comment thread src/jsc/bindings/NodeVM.cpp Outdated

@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 which realm vm.compileFunction compiles in — sandbox-boundary code where the fix itself closes an arguments.callee.constructor escape — a human look at the realm semantics is still worthwhile.

What was reviewed:

  • options.parsingContext is captured into a local before WTF::move(options) and is always non-null (defaulted to the caller's global in CompileFunctionOptions::fromJS and the fallback branch).
  • createBuffer for cachedData resolves through defaultGlobalObject, so the Buffer stays a caller-realm Buffer (test pins this); the SourceCodeKey's lexicallyScopedFeatures now reads from the same global that setGlobalScopeExtension writes, so produce/consume keys match.
  • The removed second if (!function) was unreachable — fetcher->owner() cannot null it.
Extended reasoning...

Overview

Swaps the realm argument of constructAnonymousFunction in vmModuleCompileFunction from the caller's globalObject to options.parsingContext, so the compiled function's structure and any parse-error object are created in the parsing context's realm. Also routes the throwVMError fallback through the same global, deletes an unreachable duplicate null-check, and adds a one-line comment on the constructAnonymousFunction declaration. Eight new tests cover error realm, function realm, the arguments.callee.constructor escape, Error.prepareStackTrace dispatch, cachedData round-trip, and unchanged default behavior.

Security risks

This is node:vm realm-boundary code. The change is a security improvement (closes a documented escape from a parsingContext sandbox back to the caller's globalThis), but any mistake in which realm's intrinsics or prepareStackTrace runs could reintroduce a leak. I traced the affected paths inside constructAnonymousFunction (toErrorObject, selectStructureForNewFuncExp, createBuffer, getBytecode) and each behaves as the PR description states, but sandbox-boundary changes warrant maintainer sign-off.

Level of scrutiny

High. node:vm isolation is security-relevant even though vm is not a hard sandbox; the change alters which realm's user hooks (Error.prepareStackTrace) run and which Function.prototype the returned function inherits.

Other factors

The C++ diff is effectively a one-argument swap with strong test coverage that fails on main and passes with the fix (per the description). parsingContext is captured before WTF::move(options), is guaranteed non-null, and setGlobalScopeExtension was already being called on it before this PR — the read side (globalScopeExtension() inside constructAnonymousFunction) is now aligned with the write side. The comment-cop bot's note was addressed in 41b0956. No outstanding reviewer comments.

@robobun

robobun commented Aug 14, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 6:03 AM PT - Aug 14th, 2026

❌ @robobun, your commit ade2b23 has 2 failures in Build #95596 (All Failures):


🧪   To try this PR locally:

bunx bun-pr 38297

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

bun-38297 --bun

@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 which JSC realm constructAnonymousFunction creates its output in — cross-realm semantics in the node:vm sandbox layer — a maintainer look would still be worthwhile.

What was reviewed:

  • parsingContext is always non-null (CompileFunctionOptions::fromJS defaults it to the caller's global; the !fromJS fallback re-assigns it), and is captured into a local before WTF::move(options).
  • Traced downstream uses of the swapped globalObject argument: createBuffer routes through defaultGlobalObject() so cachedData stays a caller-realm Buffer; decorateParseErrorStack marks the parameter unused; lexicallyScopedFeatures and getBytecode now both read the same global's scope extension (previously they could disagree).
  • Removed second if (!function) was provably dead — the first one returns.
Extended reasoning...

Overview

The PR changes one argument at one call site in vmModuleCompileFunction (src/jsc/bindings/NodeVM.cpp): constructAnonymousFunction and the fallback throwVMError now receive options.parsingContext instead of the caller's globalObject. It also removes an unreachable duplicate if (!function) block, adds a one-line doc comment on the header declaration explaining the realm parameter, and adds an 8-test describe("parsingContext") block to test/js/node/vm/vm.test.ts. The net effect is that the compiled function's structure and any compile-time error are created in the parsingContext realm, matching Node.

Security risks

node:vm is not a security boundary per Node's own documentation, but this change is realm-isolation-adjacent: the added test the body cannot reach the caller's realm through its own function object demonstrates that before the fix, arguments.callee.constructor on the compiled function was the caller's Function, letting body code reach the caller's globalThis. The fix closes that. I did not find a way this change weakens isolation — it tightens it. The cachedData Buffer stays in the caller's realm because WebCore::createBuffer unconditionally uses defaultGlobalObject(...)->JSBufferSubclassStructure(), which I verified in JSBuffer.cpp:471.

Level of scrutiny

This is a small, focused change with an unusually thorough PR description that traces every downstream consumer of the swapped argument (ProgramExecutable::create, toErrorObject, selectStructureForNewFuncExp, decorateParseErrorStack, createBuffer, getBytecode). I spot-checked the non-obvious claims (createBuffer's default-global routing, decorateParseErrorStack's UNUSED_PARAM) and they hold. The tests are Node-verified and cover error realm, function realm, the callee-constructor escape, cachedData realm invariance, and Error.prepareStackTrace dispatch (including the throwing case). That said, cross-realm object creation in JSC bindings is subtle territory — the lexicallyScopedFeatures derivation, setGlobalScopeExtension, and SourceCodeKey interaction all now key off a NodeVMGlobalObject rather than the Zig global — and a maintainer familiar with NodeVM's realm handling should confirm the trace.

Other factors

No CODEOWNERS entry covers these paths. The comment-cop bot's feedback (paragraph-long comment) was addressed in commit 41b0956. No outstanding human review comments. The bug-hunting system found nothing. The two sibling entry points with the same class of bug (runInContext/runInNewContext in NodeVMScript.cpp, SourceTextModule in NodeVMSourceTextModule.cpp) are explicitly scoped out with linked follow-up PRs, which is a reasonable split since each derives its target realm differently.

…realm

vmModuleCompileFunction passed the caller's global object to
constructAnonymousFunction, so a compile error (and the compiled function's
prototype) came from the caller's realm while the source was scoped to
options.parsingContext. Node compiles inside the parsing context, so the
SyntaxError is that context's SyntaxError and the function is that context's
Function. Pass the parsing context as the compile realm instead; cachedData
is still a Buffer of the caller's realm, as in Node.
@robobun
robobun force-pushed the farm/4de948a1/vm-compile-function-parsing-context-realm branch from ade2b23 to 7b8bf9d Compare August 14, 2026 13:11
@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

On the "context extensions visible on the long-lived parsing context" note in the review summary: that is pre-existing. The setGlobalScopeExtension(functionScope) call only moved to go through the new local; before this PR it was made on the same object (options.parsingContext) and was never cleared either. This PR does not change that behavior; #38302 fixes it separately. A test for it belongs there, since it fails and passes independently of the realm change here.

@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 change is small and well-reasoned, and the new tests are thorough. Because it changes which realm compile errors and the returned function are created in — vm sandbox-boundary behavior — and CI shows two macOS-only failures (vite-build.test.ts, astro-post.test.js) that haven't been triaged as unrelated, a human look would still be worthwhile.

What was reviewed

  • options.parsingContext is always non-null (defaulted to the caller's global in both fromJS and the fallback branch), and is captured into a local before WTF::move(options), so no null deref / use-after-move.
  • createBuffer for cachedData resolves its structure via defaultGlobalObject(), so the Buffer stays a caller-realm Buffer as the test asserts; the SourceCodeKey for produce/consume both read globalScopeExtension() from the same parsingContext.
  • Confirmed setGlobalScopeExtension was already called on options.parsingContext before this change — only the compile-realm argument moved.
Extended reasoning...

Overview

The PR changes vmModuleCompileFunction in src/jsc/bindings/NodeVM.cpp to pass options.parsingContext (instead of the caller's globalObject) as the realm argument to constructAnonymousFunction and to the fallback throwVMError. It also removes an unreachable duplicate if (!function) block and adds a one-line contract comment on the constructAnonymousFunction declaration in NodeVM.h. Eight new tests in test/js/node/vm/vm.test.ts cover the error realm, function realm, arguments.callee.constructor escape, the no-option default, cachedData cross-context reuse, and the context's Error.prepareStackTrace (including a throwing one).

Security risks

This is realm-boundary code for node:vm. The change tightens isolation to match Node: previously a body compiled with parsingContext could reach the caller's globalThis (and process) via arguments.callee.constructor; now it cannot. I did not find a way this weakens isolation — the only realm that shifts is the one the compile output/error is created in, and createBuffer for cachedData still routes through defaultGlobalObject() so the host-realm Buffer subclass is used regardless. Still, cross-realm object creation in JSC is subtle and this is exactly the kind of boundary a maintainer should sign off on.

Level of scrutiny

Medium-high. The native diff is only a few lines, but it changes which JSGlobalObject flows into ParserError::toErrorObject, JSFunction::selectStructureForNewFuncExp, getBytecode, and throwVMError. The PR description audits each of these downstream uses and the tests pin them, but the two macOS-only CI failures (third-party integration tests) should be confirmed as pre-existing flakes before merge — Vite/Astro are unlikely to pass parsingContext, but it's worth a glance.

Other factors

CompileFunctionOptions::fromJS unconditionally sets this->parsingContext = globalObject before reading options, and the caller's fallback branch does the same, so parsingContext cannot be null. The local JSGlobalObject* parsingContext = options.parsingContext; is captured before WTF::move(options), avoiding use-after-move. The comment-cop bot's remark was addressed (comment trimmed to one line). The dead second null-check was correctly removed. No prior automated review from me on this PR.

@robobun

robobun commented Aug 14, 2026

Copy link
Copy Markdown
Collaborator Author

Triage of the two macOS failures from the earlier builds (astro-post.test.js SIGABRT, vite-build.test.ts exiting 134): both were the mimalloc fork-handler abort on macOS that #38291 fixed on main after this branch was cut. PR builds run the branch head as it is, so they kept hitting it while the same lane passed on main. The branch is now rebased onto main (no code change in the diff itself), and the darwin lane is re-running on the rebased head. Neither test loads node:vm.

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