Skip to content

vm.Script: compile through the CodeCache in the constructor instead of checkSyntax - #37998

Closed
robobun wants to merge 4 commits into
mainfrom
farm/b63639a2/vm-script-compile-once
Closed

robobun wants to merge 4 commits into
mainfrom
farm/b63639a2/vm-script-compile-once

Conversation

@robobun

@robobun robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator

Split out of #37950 (on hold until the replacement JSC API lands); this part needs no WebKit change.

Problem

  • new vm.Script(src) parsed src twice: the constructor ran JSC::checkSyntax to throw SyntaxError eagerly and discarded the result (src/jsc/bindings/NodeVMScript.cpp, constructScript), then the first runInContext / runInThisContext (JSC::evaluate) or produceCachedData (getBytecode) parsed it again, because neither finds anything in JSC's CodeCache. For a 20 MB bundle that is ~0.4 s (release) of duplicated work per Script; the constructor comment already described compile-once as the follow-up.

Fix

  • The constructor compiles through vm.codeCache()->getUnlinkedProgramCodeBlock() instead of checkSyntax(). Syntax errors come out of the same ParserError and are decorated exactly as before; the difference is that the parsed code is now in the CodeCache under the key the first run and getBytecode look up (same provider, same code generation mode, and the key is built before parsing so the strictness bit matches too), so both hit instead of parsing.
  • Scripts that are constructed and never run leave a cache entry behind, which the cache's normal pruning handles; a run used to create the same entry anyway. Measured side effect: the 10,000-script loop from script-leak.test.ts ends at 34 MB RSS growth instead of 139 MB, because a lookup that hits lets the cache shrink and prune the dead entries sooner.
  • Verified:
    • test/js/node/vm/vm.test.ts, new describe "Script parses its source once": counts JSC's Parsed lines (BUN_JSC_reportParseTimes=1) around construct+run and around construct with produceCachedData; both report 2 on the unfixed build (release and debug) and 1 here. A per-context global declaration check guards that runs still instantiate globals per context.
    • test/js/node/vm/ (230 pass; script-leak.test.ts hits its 5 s timeout on this slow ASAN machine with and without the change) and all 97 node test/parallel/test-vm-* files pass.
    • bench/snippets/node-vm-script-contexts.mjs is the measurement script used for vm.Script: parse once and reuse the unlinked code in every context it runs in #37950 (per-phase wall time and parser runs for one Script across N contexts, with --evict and --cached-data variants); on a 1 MB source it shows the first context's evaluate going from 1 parser run to 0 with this change, everything else unchanged.

Background

  • CodeCache is JSC's in-memory map from source text (plus parse flags) to the parsed, realm-independent UnlinkedProgramCodeBlock. ProgramExecutable::initializeGlobalProperties, which every JSC::evaluate goes through, asks it first and only parses on a miss; checkSyntax never touches it, which is why the old constructor's work could not be reused. What the still-parked vm.Script: parse once and reuse the unlinked code in every context it runs in #37950 adds on top is keeping the block alive per Script so later contexts do not depend on the cache keeping the entry.

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

fails on main (without fix)
ASAN without fix: 2 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 (1edc535e4)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [27.78ms]
(pass) vm > runInContext() > can return a value [17.44ms]
(pass) vm > runInContext() > can return a complex value [17.96ms]
(pass) vm > runInContext() > can return the last value [17.96ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [16.83ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [14.29ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [15.82ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [18.84ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [16.15ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [16.06ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [15.43ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [15.96ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

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

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [0.65ms]
(pass) vm > runInContext() > can return a value [0.41ms]
(pass) vm > runInContext() > can return a complex value [0.34ms]
(pass) vm > runInContext() > can return the last value [0.29ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [0.39ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [0.27ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [0.34ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [0.35ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [0.33ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [0.29ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [0.30ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [0.35ms]
(pass) vm > runInContext() > new Float32Array() in VM context doesn't crash [0.27ms]
(pass) vm > runInContext() > new Float64Array() in VM context doesn't crash [0.30ms]
(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 (1edc535e4)

test/js/node/vm/vm.test.ts:
(pass) vm > runInContext() > can do nothing [22.43ms]
(pass) vm > runInContext() > can return a value [22.74ms]
(pass) vm > runInContext() > can return a complex value [25.84ms]
(pass) vm > runInContext() > can return the last value [24.60ms]
(pass) vm > runInContext() > new ArrayBuffer() in VM context doesn't crash [26.23ms]
(pass) vm > runInContext() > new SharedArrayBuffer() in VM context doesn't crash [23.77ms]
(pass) vm > runInContext() > new Uint8Array() in VM context doesn't crash [26.42ms]
(pass) vm > runInContext() > new Int8Array() in VM context doesn't crash [17.20ms]
(pass) vm > runInContext() > new Uint16Array() in VM context doesn't crash [18.36ms]
(pass) vm > runInContext() > new Int16Array() in VM context doesn't crash [16.78ms]
(pass) vm > runInContext() > new Uint32Array() in VM context doesn't crash [15.32ms]
(pass) vm > runInContext() > new Int32Array() in VM context doesn't crash [22.23ms]
(pass) vm > runInContext() > new Float32Arr
... (truncated)

release with fix: 62 skipped
$ bun scripts/build.ts --profile=release
[configured] bun-profile → bun (stripped) in 1402ms (unchanged)
ninja: Entering directory `/workspace/bun/build/release'
[1/16] cc obj/packages/bun-usockets/src/crypto/openssl.c.o
[2/16] gen cpp.rs (cppbind)
[2/16] cargo bun_bin → libbun_rust.a (--target x86_64-unknown-linux-gnu)

  nightly-2026-07-20-x86_64-unknown-linux-gnu unchanged - rustc 1.99.0-nightly (9f36de775 2026-07-19)

�[1m�[92m   Compiling�[0m bun_core v0.0.0 (/workspace/bun/src/bun_core)
�[1m�[92m   Compiling�[0m bun_errno v0.0.0 (/workspace/bun/src/errno)
�[1m�[92m   Compiling�[0m bun_ptr v0.0.0 (/workspace/bun/src/ptr)
�[1m�[92m   Compiling�[0m bun_boringssl_sys v0.0.0 (/workspace/bun/src/boringssl_sys)
�[1m�[92m   Compiling�[0m bun_safety v0.0.0 (/workspace/bun/src/safety)
�[1m�[92m   Compiling�[0m bun_zlib_sys v0.0.0 (/workspace/bun/src/zlib_sys)
�[1m�[92m   Compiling�[0m bun_cares_sys v0.0.0 (/workspace/bun/src/cares_sys)
�[1m�[92m   Compiling�[0m bun_zstd v0.0.0 (/workspace/bun/src/zstd)
�[1m�[92m   Compiling�[0m bun_picohttp v0.0.0 (/workspace/bun/src/picohttp)
�[1m�[92m   Compiling�[0m bun_brotli v0.0.0 (/workspace/bun/src/brotli)
�[1m�[92m   Compil
... (truncated)
diff hotspot
bench/snippets/node-vm-script-contexts.mjs | 110 +++++++++++++++++++++++++++++
 src/jsc/bindings/NodeVMScript.cpp          |   9 ++-
 test/js/node/vm/vm.test.ts                 |  69 ++++++++++++++++++
 3 files changed, 183 insertions(+), 5 deletions(-)

gate history · 1 passed · 0 rejected · iteration 1

evidence per changed file
file                                        reads  edits  tests
bench/snippets/node-vm-script-contexts.mjs      0      2      0
src/jsc/bindings/NodeVMScript.cpp               6     14      0
test/js/node/vm/vm.test.ts                      5     10      0

…f checkSyntax

The constructor parsed the source with checkSyntax() to throw SyntaxError
eagerly and threw the result away, so the first run (or produceCachedData)
parsed it again. Compiling through the CodeCache keeps the eager error
and leaves the code where JSC::evaluate and getBytecode look for it.
@coderabbitai

coderabbitai Bot commented Aug 13, 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: 4 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: 7cafbddc-7e48-49e6-94e9-e6664b05e3ca

📥 Commits

Reviewing files that changed from the base of the PR and between e27343a and 1edc535.

📒 Files selected for processing (3)
  • bench/snippets/node-vm-script-contexts.mjs
  • src/jsc/bindings/NodeVMScript.cpp
  • test/js/node/vm/vm.test.ts

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

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: ready for review; CI green at the current head (build 93978). No WebKit dependency.

Relationship to #38040 (which replaced #37950 on top of oven-sh/WebKit#420): #38040 contains this same constructor change as part of holding the compiled block for every run. Either this lands first as the part that needs no WebKit bump (and #38040's constructor hunk becomes a small rebase), or this gets closed when #38040 lands; what is unique here is the parse-count tests and bench/snippets/node-vm-script-contexts.mjs, both offered to #38040 in its thread.

Reproduced by counting JSC's parse log (BUN_JSC_reportParseTimes=1): construct + first run, and construct with produceCachedData, each parse the source twice on the unfixed build (release and debug) and once with this change. test/js/node/vm/ and the 97 node test-vm-* files pass.

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 12:05 AM PT - Aug 13th, 2026

❌ @robobun, your commit 1edc535 has some failures in Build #93978 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37998

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

bun-37998 --bun

@github-actions

Copy link
Copy Markdown
Contributor

This PR may be a duplicate of:

  1. vm.Script: parse once and reuse the unlinked code in every context it runs in #37950 - Contains the identical JSC::checkSyntax → vm.codeCache()->getUnlinkedProgramCodeBlock() replacement in constructScript() (via NodeVMScript::compile), plus the same new describe block in test/js/node/vm/vm.test.ts.

🤖 Generated with Claude Code

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Not a duplicate in the sense of competing: this is the subset of #37950 that needs no JSC change, split out on request. #37950 is on hold until the replacement JSC API lands and will be rebased on top of this (it adds keeping the parsed block alive per Script; this PR only stops the constructor's parse from being thrown away).

Comment thread test/js/node/vm/vm.test.ts Outdated
Comment thread src/jsc/bindings/NodeVMScript.cpp Outdated
Comment thread src/jsc/bindings/NodeVMScript.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.

Thanks for addressing the earlier parsesDuring nit — the fixture now surfaces stderr on failure and stdout is ignored. I re-reviewed and found no bugs, but since this swaps checkSyntax for a direct CodeCache::getUnlinkedProgramCodeBlock call in the constructor path every vm.Script goes through, a maintainer familiar with JSC's CodeCache keying should confirm.

What was reviewed:

  • The new call passes globalObject->defaultCodeGenerationMode() where the two existing getUnlinkedProgramCodeBlock sites in NodeVM.cpp pass {}; the parse-count test proves they match in the default case, so a mismatch under a non-default mode would only fall back to the old double-parse, not misbehave.
  • The throwaway ProgramExecutable::create result is stack-reachable through the call and neither existing call site treats create as throwing.
  • Only parseError.isValid() is checked (return value discarded); a null-without-error return would just leave the cache unpopulated, same as before.
  • The existing SyntaxError-at-construction tests (arrow decoration, lineOffset, prepareStackTrace) cover the error path unchanged, and the per-context-globals test guards the run side.
Extended reasoning...

Overview

The functional change is one line in src/jsc/bindings/NodeVMScript.cpp: constructScript replaces JSC::checkSyntax(vm, source, parseError) with vm.codeCache()->getUnlinkedProgramCodeBlock(vm, JSC::ProgramExecutable::create(globalObject, source), source, globalObject->defaultCodeGenerationMode(), parseError), so the eager syntax check populates JSC's CodeCache instead of discarding its parse. The rest is a new describe.concurrent block in test/js/node/vm/vm.test.ts that counts Parsed lines under BUN_JSC_reportParseTimes=1, and a new bench snippet.

Security risks

None identified. This changes how a script is parsed at construction, not whether or what is parsed. The SyntaxError path (parseError.isValid() → toErrorObject → decorateParseErrorStack) is byte-identical to before, and the extensive existing tests for compile-time SyntaxError decoration remain green.

Level of scrutiny

High — this is native C++ in the JSC bindings, on a path every new vm.Script() executes. The change is small and uses a pattern that already appears twice in NodeVM.cpp (getBytecode and the compileFunction path both call getUnlinkedProgramCodeBlock), but confirming that the CodeCache key produced here matches what JSC::evaluate and getBytecode look up in all configurations (not just the default the test exercises) requires JSC-internal knowledge. The throwaway ProgramExecutable is a slightly unusual shape: it's created solely so the CodeCache call has an executable argument, then dropped — a maintainer should confirm nothing in the CodeCache retains a reference to it that would matter.

Other factors

My earlier inline comment on parsesDuring (undrained stdout pipe, exitCode asserted before stderr) was addressed in 6275679: stdout is now "ignore" and a non-zero exit or missing marker throws with the full stderr. The comment-cop bot fired on the constructor comment length and it's now one line. The new tests are well-designed (warmup to isolate the measured parse, per-context-globals guard, produceCachedData variant), and the author reports all 97 node test-vm-* files pass. Given the JSC-internals nature, a human sign-off is still appropriate.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

#37950 is now closed in favor of #38040 (with oven-sh/WebKit#420). #38040 also replaces the constructor's checkSyntax with the CodeCache compile, and then keeps the resulting block for every run, so it includes the change made here.

This PR stays open as the interim version with no WebKit dependency; it can be closed once #38040 lands.

@robobun

robobun commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator Author

#38040 landed on main (1b881a9), and it contains this constructor change as part of holding the compiled block for every run, which is also what the merge conflict here is: main replaced the same lines. Closing as agreed. If anyone wants the two pieces that were unique to this PR, the parse-count tests in test/js/node/vm/vm.test.ts and bench/snippets/node-vm-script-contexts.mjs, they are on the farm/b63639a2/vm-script-compile-once branch.

@robobun robobun closed this Aug 18, 2026
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.

2 participants