Skip to content

Build Node renderer VM contexts with DONT_CONTEXTIFY when available - #4830

Open
alexeyr-ci2 wants to merge 2 commits into
mainfrom
alexeyr/vm-context-optimization
Open

Build Node renderer VM contexts with DONT_CONTEXTIFY when available#4830
alexeyr-ci2 wants to merge 2 commits into
mainfrom
alexeyr/vm-context-optimization

Conversation

@alexeyr-ci2

@alexeyr-ci2 alexeyr-ci2 commented Jul 31, 2026

Copy link
Copy Markdown
Collaborator

Why

buildVM creates each bundle's sandbox with vm.createContext(contextObject). Contextifying a sandbox installs V8 named/indexed property interceptors on the context's global object, so every global lookup inside a server bundle (React, process, Buffer, ...) traps through those interceptors instead of hitting V8's ordinary global fast paths. SSR bundles are very global-heavy, so this is on the hot path for every render.

vm.constants.DONT_CONTEXTIFY (Node 20.18+ / 22.8+) creates a context with an ordinary global object instead.

Caveat, stated up front: upstream documents this only qualitatively — "speed up the global access if they don't need the interceptor behavior" (nodejs/node#54394, repeated in the 20.18.0 / 22.8.0 release notes and the vm docs). That PR added no benchmark, and there is no DONT_CONTEXTIFY benchmark anywhere in nodejs/node. The mechanism is real and documented; the magnitude for our SSR workload is unmeasured. This PR lands the mechanism — the number still has to come from a local M1 A/B run.

Implementation

packages/react-on-rails-pro-node-renderer/src/worker/vm.ts — one branch in buildVM:

const dontContextify: typeof vm.constants.DONT_CONTEXTIFY | undefined = vm.constants?.DONT_CONTEXTIFY;
const context =
  dontContextify === undefined
    ? vm.createContext(contextObject)
    : Object.assign(vm.createContext(dontContextify), contextObject);

Three things worth calling out for review:

  • The constant replaces the sandbox object; it is not a second argument. The signature is vm.createContext([contextObject[, options]]) where contextObject is Object | vm.constants.DONT_CONTEXTIFY | undefined, and the call returns the new context's global rather than the object passed in — hence the Object.assign afterwards. That returned object is reference-equal to globalThis inside the context, so the per-request renderingRequest / sharedExecutionContext / runOnOtherBundle injection and finally cleanup in runInVM are unaffected.
  • Feature-detected, not version-gated, and ?. is load-bearing. vm.constants itself only exists from Node 20.12/21.7, and this package declares engines.node >= 18.19.0, so the more obvious 'DONT_CONTEXTIFY' in vm.constants throws TypeError: Cannot use 'in' operator ... in undefined on the floor version.
  • DONT_CONTEXTIFY is a Symbol at runtime, not a number. @types/node (20.19.25 here) declares it as number and declares vm.constants as always-present — both wrong. Only presence is ever checked, and the annotation aliases the declared type (typeof vm.constants.DONT_CONTEXTIFY | undefined) rather than naming number, so it self-corrects if DefinitelyTyped is fixed. Without the annotation the value infers as plain number, the fallback branch narrows to never and reads as dead code.

Test plan

packages/react-on-rails-pro-node-renderer/tests/vm.test.ts — a parameterized test over both flavors. CI's Node always takes the vanilla branch, so the fallback case stubs vm.constants to undefined to exercise the older-Node path that would otherwise never run.

Each flavor asserts which context type was actually built, then that behavior is unchanged: injected globals reach the bundle, global = this still aliases globalThis, per-request state is visible during execution and cleared afterwards (so it cannot leak into a later request sharing a pooled context), and nothing escapes into the worker's own realm.

Separately verified out-of-band that both flavors are observationally identical for every pattern this module relies on — including Module.wrap bundle evaluation with require/__dirname, stack-frame filenames (source-map keying), and host-side reads of globals defined by the bundle. The only difference is globalThis === context, which is the point of the change.

Results (packages/react-on-rails-pro-node-renderer):

  • tests/vm.test.ts + tests/vmSourceMapSupport.test.ts: 91/91 pass, including the existing vm.createContext synchronous-throw recovery test.
  • Full package suite: 415 passed / 8 failed. All 8 failures are pre-existing and unrelated — verified against a clean tree at the same commit, which fails identically (13 suites / 8 tests, missing @fastify/formbody and @fastify/multipart). The only delta is the 2 new passing tests.
  • type-check: clean for vm.ts (the one error is the same pre-existing @fastify/formbody resolution failure in src/worker.ts).
  • eslint: clean.

Changelog

No entry. There is no user-visible behavior change, and the performance claim is unquantified — I'd rather add a changelog entry once the A/B run produces a number than claim an improvement we haven't measured. Happy to add one if a maintainer prefers.

Labels

Labels: benchmark — classification only. Per AGENTS.md, benchmark* labels do not trigger hosted benchmark suites (hosted benchmark selection is disabled after the false-positive regressions in #4038#4044).

Benchmarks: local M1 A/B required — this is a Node renderer hot-path change and the whole premise is a perf claim, so it needs benchmarks/run-local-benchmark-comparison.rb on a quiet machine before this is worth merging on performance grounds.

Not requesting hosted CI yet; say the word and I'll comment +ci-run-hosted.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • Bug Fixes

    • Improved rendering compatibility across supported Node.js versions.
    • Ensured consistent global behavior and injected settings in isolated rendering contexts.
    • Preserved request-state cleanup and isolation between worker executions.
  • Tests

    • Added coverage for modern and legacy Node.js context creation behavior.

`vm.createContext(contextObject)` contextifies the sandbox, which installs V8
named/indexed property interceptors on the context's global object. Every global
lookup inside a server bundle (`React`, `process`, `Buffer`, ...) then traps
through those interceptors instead of hitting V8's ordinary global fast paths --
and SSR bundles are very global-heavy.

`vm.constants.DONT_CONTEXTIFY` (Node 20.18+ / 22.8+) creates a context with an
ordinary global object instead. It is passed *instead of* the sandbox object and
returns the new context's global, so the globals are assigned onto it afterwards.
That object is reference-equal to `globalThis` inside the context, so the
per-request `renderingRequest`/`sharedExecutionContext`/`runOnOtherBundle`
injection and cleanup in `runInVM` are unaffected.

Feature-detected rather than version-gated: `vm.constants` itself only exists
from Node 20.12/21.7 and this package supports Node >= 18.19, so
`'DONT_CONTEXTIFY' in vm.constants` would throw a TypeError on the floor version.

Note that upstream documents the win only qualitatively ("speed up the global
access if they don't need the interceptor behavior", nodejs/node#54394) and
publishes no benchmark numbers. This lands the mechanism; the magnitude for SSR
workloads still needs a local M1 A/B run.

Verified both flavors are observationally identical for everything this module
relies on -- injected globals, `global = this` aliasing, module-wrapped bundle
evaluation, stack-frame filenames, host reads of bundle-defined globals,
per-request injection/cleanup, and no leakage into the worker realm. The new test
covers both branches; the fallback is stubbed since CI's Node always takes the
vanilla path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 31, 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 051d10f0-0ef4-4a2f-a89a-a1c4f13df21e

📥 Commits

Reviewing files that changed from the base of the PR and between 13d759e and 4de1521.

📒 Files selected for processing (1)
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
🚧 Files skipped from review as they are similar to previous changes (1)
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts

Walkthrough

VM creation now uses vm.constants.DONT_CONTEXTIFY when supported and preserves the older contextified-sandbox fallback. Tests cover both modes and verify equivalent global behavior, request-state cleanup, and worker-realm isolation.

Changes

VM context compatibility

Layer / File(s) Summary
VM context creation and validation
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts, packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
VM setup selects DONT_CONTEXTIFY when available and assigns the configured context properties afterward. Older Node versions continue using vm.createContext(contextObject). Parameterized tests verify both modes, injected globals, context identity, request-state cleanup, and worker-realm isolation.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the main VM context implementation change.
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.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch alexeyr/vm-context-optimization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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

@greptile-apps

greptile-apps Bot commented Jul 31, 2026

Copy link
Copy Markdown

Greptile Summary

The PR changes Node renderer VM construction to use an ordinary DONT_CONTEXTIFY global when the runtime supports it.

  • Retains the contextified-sandbox fallback for older Node versions.
  • Adds parameterized tests covering context selection, injected globals, request-state cleanup, and host-realm isolation.

Confidence Score: 4/5

The PR should not merge until the DONT_CONTEXTIFY path preserves supported additional-context behavior for keys that collide with initialized global properties.

The new Object.assign target contains non-writable intrinsic globals, so an arbitrary additional-context key accepted by the existing configuration API can now abort VM construction only on newer Node runtimes.

Files Needing Attention: packages/react-on-rails-pro-node-renderer/src/worker/vm.ts

Important Files Changed

Filename Overview
packages/react-on-rails-pro-node-renderer/src/worker/vm.ts Adds feature-detected DONT_CONTEXTIFY construction, but copying arbitrary additional-context keys onto initialized globals can make previously valid configurations fail.
packages/react-on-rails-pro-node-renderer/tests/vm.test.ts Adds useful behavioral coverage for both context flavors, but does not cover collisions between additional-context keys and non-writable global intrinsics.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
  A[Build prepared context object] --> B{DONT_CONTEXTIFY available?}
  B -->|No| C[Contextify prepared sandbox]
  B -->|Yes| D[Create ordinary VM global]
  D --> E[Assign prepared properties onto global]
  C --> F[Evaluate bundle]
  E --> F
Loading

Reviews (1): Last reviewed commit: "Build Node renderer VM contexts with DON..." | Re-trigger Greptile

// branch can go once 20.18/22.8 are the floor.
const dontContextify: typeof vm.constants.DONT_CONTEXTIFY | undefined = vm.constants?.DONT_CONTEXTIFY;
const context =
dontContextify === undefined

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Intrinsic global assignment failure

When additionalContext contains an allowed key matching a non-writable intrinsic such as Infinity, NaN, or undefined, this Object.assign writes onto the already initialized VM global and throws, preventing the bundle from loading on DONT_CONTEXTIFY-capable Node versions even though the fallback path accepts the same configuration.

Knowledge Base Used: Pro Node Renderer Worker: Request Handling, VM Execution, and Cluster Lifecycle

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 13d759eea2

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

const context =
dontContextify === undefined
? vm.createContext(contextObject)
: Object.assign(vm.createContext(dontContextify), contextObject);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Prevent frozen globals from retaining request state

On Node versions that take this DONT_CONTEXTIFY branch, a bundle or render request that runs Object.freeze(globalThis) can now freeze the actual context global; the old contextified sandbox throws on that freeze. runInVM injects per-request renderingRequest, sharedExecutionContext, and runOnOtherBundle onto this same object and clears them in finally by assignment, so once the global is frozen the cleanup throws/fails and those request-specific values remain visible to later requests that reuse the pooled VM.

Useful? React with 👍 / 👎.

@github-actions

github-actions Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

size-limit report 📦

Path Size
react-on-rails/client bundled (gzip) 63.61 KB (0%)
react-on-rails/client bundled (gzip) (time) 63.61 KB (0%)
react-on-rails/client bundled (brotli) 54.61 KB (0%)
react-on-rails/client bundled (brotli) (time) 54.61 KB (0%)
react-on-rails-pro/client bundled (gzip) 64.96 KB (0%)
react-on-rails-pro/client bundled (gzip) (time) 64.96 KB (0%)
react-on-rails-pro/client bundled (brotli) 55.77 KB (0%)
react-on-rails-pro/client bundled (brotli) (time) 55.77 KB (0%)
registerServerComponent/client bundled (gzip) 135.45 KB (0%)
registerServerComponent/client bundled (gzip) (time) 135.45 KB (0%)
registerServerComponent/client bundled (brotli) 81.77 KB (0%)
registerServerComponent/client bundled (brotli) (time) 81.77 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) 127.9 KB (0%)
wrapServerComponentRenderer/client bundled (gzip) (time) 127.9 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) 75 KB (0%)
wrapServerComponentRenderer/client bundled (brotli) (time) 75 KB (0%)

Comment on lines +133 to +140
beforeEach(() => {
// `vm.constants` is frozen, but `vm`'s own `constants` property is writable, so swapping
// the whole object forces the module down the older-Node fallback path.
if (!preferVanilla) {
jest.replaceProperty(vm, 'constants', undefined as unknown as typeof vm.constants);
}
resetVM();
});

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.

Global mutation isn't restored — likely leaks into every test that runs after it in this file.

This beforeEach stubs the real Node vm.constants (a process-wide singleton) to undefined via jest.replaceProperty, but nothing ever restores it: there's no .restore() call, no afterEach, and no jest.restoreAllMocks(). The package's jest config (packages/react-on-rails-pro-node-renderer/package.json) sets clearMocks: true and resetMocks: true, but not restoreMocks: true. resetMocks/clearMocks only reset mock call state/implementations for jest.fn()/jest.spyOn() — they do not restore properties swapped in via jest.replaceProperty. Only restoreMocks (or an explicit jest.restoreAllMocks()/.restore()) does that.

Given describe.each runs its cases in declaration order, the false (fallback) case runs second and permanently sets vm.constants = undefined for the rest of this test file's execution — there are ~800 more lines of tests after this block (buildVM and runInVM, VM security..., spec/dummy node, VM Pool Management, etc.). All of them will now silently run against the fallback (contextified-sandbox) code path instead of the new DONT_CONTEXTIFY path, even on a Node version that supports it. That undercuts the PR's own testing claim that the flavors were verified "observationally identical" across the suite — after this test runs once, essentially nothing else in the file ever exercises the new branch again.

Suggested fix: capture the handle and restore it, e.g.

let constantsOverride: ReturnType<typeof jest.replaceProperty> | undefined;

beforeEach(() => {
  if (!preferVanilla) {
    constantsOverride = jest.replaceProperty(vm, 'constants', undefined as unknown as typeof vm.constants);
  }
  resetVM();
});

afterEach(() => {
  constantsOverride?.restore();
  constantsOverride = undefined;
});

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

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@packages/react-on-rails-pro-node-renderer/tests/vm.test.ts`:
- Around line 133-140: Restore the property replacement created in the
beforeEach setup for vm.constants when preferVanilla is false, using the
returned replacement handle’s restore method or the test suite’s mock
restoration mechanism. Ensure subsequent tests see the original vm.constants
value and do not remain on the older-Node fallback path.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 85ccd6f0-7d59-40c2-a861-6243878bc82f

📥 Commits

Reviewing files that changed from the base of the PR and between 2bc4dab and 13d759e.

📒 Files selected for processing (2)
  • packages/react-on-rails-pro-node-renderer/src/worker/vm.ts
  • packages/react-on-rails-pro-node-renderer/tests/vm.test.ts

Comment thread packages/react-on-rails-pro-node-renderer/tests/vm.test.ts
@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review

Overview

Adds a feature-detected branch in buildVM (packages/react-on-rails-pro-node-renderer/src/worker/vm.ts) that uses vm.constants.DONT_CONTEXTIFY (Node 20.18+/22.8+) to build server-bundle VM contexts with an ordinary global object instead of a contextified sandbox, avoiding V8's property-interceptor overhead on every global lookup inside SSR bundles. Falls back to the existing vm.createContext(contextObject) path on older Node. Includes a parameterized test exercising both flavors.

Code quality

  • The core change in vm.ts is small, well-scoped, and the accompanying comment correctly explains the non-obvious parts: DONT_CONTEXTIFY replaces the sandbox arg rather than extending it, the return value is the new global (hence the Object.assign), and vm.constants itself needs ?. because it postdates this package's engines.node >= 18.19.0 floor (verified: package.json line 7).
  • The explicit typeof vm.constants.DONT_CONTEXTIFY | undefined annotation is a reasonable workaround for @types/node mistyping the constant as number/always-present, and self-corrects if upstream types are fixed.
  • No new globals are injected and no docs need updating (per the existing js-configuration.md cross-reference comment in the surrounding code), consistent with the change being purely about how the context is built, not what's in it.

Bug: test isolation (see inline comment)

The added test in tests/vm.test.ts stubs the real vm.constants singleton to undefined via jest.replaceProperty to force the fallback branch, but never restores it (no .restore(), no afterEach, no jest.restoreAllMocks()). The package's jest config has resetMocks/clearMocks but not restoreMocks, and those two do not undo a replaceProperty swap. Since describe.each runs the false (fallback) case second, this mutation leaks for the rest of the file's execution (~800 lines / a dozen+ later tests), silently forcing all of them onto the legacy contextified-sandbox path regardless of the actual Node version running CI. Left as-is, the suite only ever exercises the new DONT_CONTEXTIFY path once, in the very first test case. Left a suggested fix inline.

Correctness/security

  • Isolation semantics are unaffected: per Node's docs, DONT_CONTEXTIFY only changes whether global property access goes through interceptors — the new context is still a distinct V8 context with no Node-specific globals (require, process, fs, etc.) unless explicitly injected, same as the contextified path. No security regression from this change.
  • The per-request injection/cleanup path in runInVM (renderingRequest, sharedExecutionContext, etc.) is untouched and, per the new test, still behaves correctly under both flavors.

Process notes (informational, not blockers)

  • Appropriately gated behind the benchmark label per AGENTS.md (hosted benchmark suites are disabled), with a clear ask for a local M1 A/B run before merging on performance grounds — the PR is transparent that the magnitude of the win is unmeasured.
  • Skipping a changelog entry until the perf number is measured is reasonable given "unquantified" is explicitly called out, though this should get an entry once benchmarked since perf improvements normally require one per .claude/docs/changelog-guidelines.md.

Summary: the production change looks correct and low-risk. The main actionable issue is the test-isolation bug that silently defeats most of the new test file's coverage of the new code path — worth fixing before merge so CI is actually validating what the PR claims.

The parameterized VM-flavor test stubs `vm.constants` to `undefined` to force the
older-Node path, but `jest.replaceProperty` was never restored. This package's
jest config sets `clearMocks`/`resetMocks` (and `resetModules`) but not
`restoreMocks`, and none of those undo a property replacement.

Because `describe.each` runs the fallback case second, `vm.constants` stayed
`undefined` for the rest of the file -- every later test in vm.test.ts silently
ran the contextified fallback regardless of the Node version, so the new
DONT_CONTEXTIFY branch was exercised exactly once and never again. That
undercut the coverage this PR claims.

Verified with a temporary probe appended after the flavor block: it asserts
`vm.constants` is still defined and failed with `Received: undefined` before this
change, and passes after.

Reported independently by claude-review and CodeRabbit on #4830.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4de1521cff

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +144 to +148
constantsOverride = jest.replaceProperty(
vm,
'constants',
undefined as unknown as typeof vm.constants,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Badge Guard vm.constants replacement on old Node

When this test is run on the supported Node 18 floor, vm.constants does not exist at all, while jest.replaceProperty only works for properties that already exist. That makes the fallback-flavor beforeEach throw before it can exercise the old-Node path, so pnpm --filter react-on-rails-pro-node-renderer test is broken for a runtime the package still advertises via engines.node >=18.19.0. Use Object.defineProperty/manual save-and-restore, or only call replaceProperty when the property exists.

Useful? React with 👍 / 👎.

@claude

claude Bot commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Review: Build Node renderer VM contexts with DONT_CONTEXTIFY when available

Overview
Replaces the sandbox object passed to vm.createContext() with vm.constants.DONT_CONTEXTIFY (Node 20.18+/22.8+) when available, then Object.assigns the original context object (source-map hooks, injected globals, additionalContext) onto the returned ordinary global. Falls back to the existing contextified-sandbox path on older Node.

Correctness
Traced this against documented vm semantics and the surrounding buildVM/runInVM code:

  • vm.createContext(DONT_CONTEXTIFY) returns the new context's real globalThis rather than contextifying the passed object, so Object.assign(...) onto it is the right way to inject globals — this matches how vm.runInContext and later context.renderingRequest = ... / context.sharedExecutionContext = ... assignments in runInVM are used identically regardless of which branch built the context.
  • contextObject is never referenced again after this block, so there's no aliasing hazard between the host-side object and the two different context-identity outcomes (same object for the fallback branch, a distinct object for the DONT_CONTEXTIFY branch).
  • vm.constants?.DONT_CONTEXTIFY combined with the === undefined check correctly covers both "no vm.constants" (Node < 20.12, i.e. this package's actual engines.node >= 18.19.0 floor) and "vm.constants exists but lacks the key" (Node 20.12–20.17 / 21.7) — both collapse to the same fallback branch, so the single "mock vm.constants to undefined" test case covers both scenarios without needing a second variant.
  • The explicit typeof vm.constants.DONT_CONTEXTIFY | undefined annotation is doing real work: without it, TS would infer number from the (incorrect) DefinitelyTyped declaration and narrow the fallback branch to never. Worth a one-line comment noting why the annotation exists, since a future reader who "simplifies" it by removing the annotation reintroduces dead code silently (tsc won't flag never narrowing as an error, just as unreachable-looking code).

Tests
tests/vm.test.ts parameterizes over both flavors on a single Node version (mocking vm.constants to force the fallback branch) and asserts: correct context identity per flavor, injected globals reach the bundle, global === globalThis still holds, per-request state (renderingRequest) is visible during execution and cleared after, and nothing leaks into the worker's own realm. This is solid coverage for the actual risk surface of this change (global injection/cleanup semantics), not just "does it not throw."

Security
No change to isolation guarantees as far as I can tell — DONT_CONTEXTIFY removes the property-interceptor wrapping on the context's own global, it doesn't merge realms. The existing vm module isn't a security boundary against untrusted code either way, and this PR doesn't change what code runs in the sandbox (still only trusted server bundles), so no new attack surface.

Performance — this is the part that actually gates merge
The PR body is upfront that the perf claim is currently unmeasured — upstream's DONT_CONTEXTIFY PR shipped without a benchmark, and this PR "lands the mechanism" pending a local M1 A/B run via benchmarks/run-local-benchmark-comparison.rb. Per AGENTS.md's own policy (hosted benchmark selection is disabled repo-wide after #4038-#4044, local M1 A/B is the trusted source for Node-renderer/hot-path changes), this shouldn't merge on performance grounds until that run has actually happened and the number is posted — right now the PR is asking reviewers to approve a hot-path change on the strength of a qualitative upstream release note. Not a blocker on code correctness, but please don't merge before the A/B results land.

Minor/optional suggestions

  • Consider a config escape hatch (same pattern as supportModules/stubTimers in configBuilder.ts) to force the fallback path at runtime, in case DONT_CONTEXTIFY turns out to have some edge-case behavioral difference that only surfaces under production load after rollout. Not required given the fallback is already automatic per-Node-version and the test coverage is solid — just a cheap insurance policy for a change to a very hot, very global path.
  • The inline comment above the branch (lines 317-325) is long relative to the code it explains; the content is legitimately non-obvious V8/Node behavior though, so I'd keep it rather than trim it for its own sake.

Overall: the implementation looks correct and carefully reasoned, with test coverage that matches the actual risk. Hold merge for the local benchmark results per the PR's own stated gating criteria.

Comment on lines +326 to +330
const dontContextify: typeof vm.constants.DONT_CONTEXTIFY | undefined = vm.constants?.DONT_CONTEXTIFY;
const context =
dontContextify === undefined
? vm.createContext(contextObject)
: Object.assign(vm.createContext(dontContextify), contextObject);

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.

This looks correct: vm.createContext(DONT_CONTEXTIFY) returns the new context's real globalThis rather than contextifying the sandbox object, so Object.assign onto it is the right way to inject the source-map hooks / supportModules globals / additionalContext. contextObject isn't referenced again after this block, so there's no aliasing hazard between the two branches despite one reusing the same object identity as the context and the other not.

One nit: the explicit typeof vm.constants.DONT_CONTEXTIFY | undefined annotation on dontContextify is load-bearing (per the PR description, without it TS infers number from the incorrect DefinitelyTyped declaration and narrows this ternary's fallback branch to never). Worth a short inline comment saying why the annotation is there — otherwise it reads like a removable type restatement, and a future "cleanup" that drops it reintroduces dead code that tsc won't flag as an error.

Also flagging (from the top-level review) that this is a hot-path change whose performance claim is explicitly unmeasured per the PR body — should hold on merging until the local M1 A/B benchmark run referenced in the PR description actually happens.

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants