Skip to content

vm.Script: parse once and reuse the unlinked code in every context it runs in - #37950

Closed
robobun wants to merge 1 commit into
mainfrom
farm/b63639a2/vm-script-pin-unlinked-code
Closed

robobun wants to merge 1 commit into
mainfrom
farm/b63639a2/vm-script-pin-unlinked-code

Conversation

@robobun

@robobun robobun commented Aug 12, 2026

Copy link
Copy Markdown
Collaborator

Depends on oven-sh/WebKit#418 (this PR points WEBKIT_VERSION at that PR's preview build; it should land together with the real bump once that is merged).

Problem

  • const s = new vm.Script(src) followed by s.runInContext(vm.createContext({})) for N contexts parses src more than once. Both run paths call JSC::evaluate(globalObject, script->source(), ...) (src/jsc/bindings/NodeVMScript.cpp, runInContext / scriptRunInThisContext), which creates a fresh ProgramExecutable per run and gets its code through JSC's CodeCache. That works while the cache holds the entry, but CodeCacheMap is sized for a 16 MB working set (workingSetMaxBytes, CodeCache.h) and prunes whenever more than that has been added since the last prune, so a ~20 MB bundle is re-parsed by whichever context comes after any other sizable compilation. Each re-parse also produces a fresh tree of UnlinkedFunctionExecutables, so memory grows per context instead of being shared.
  • On top of that the constructor parsed once more: it ran JSC::checkSyntax to throw SyntaxError eagerly, threw the result away, and the first run parsed again (the comment in the constructor already named compile-once as the follow-up).
  • cachedData was accepted (decoded and linked once to compute cachedDataRejected) but the runs never used it; they still went to the CodeCache and parsed.
  • Numbers on the unfixed build, release, a 20 MB (function(exports, require, module){...}) source with 117,978 functions, parses counted with BUN_JSC_reportParseTimes=1:
    • construction: 1 parse (~420 ms); first run: parses again; a second context after a 16.5 MB script went through the cache: 1 full parse of the program (~370-600 ms, evaluate 514-790 ms) plus a re-parse of the wrapper body on invoke, and UnlinkedFunctionExecutable goes from 118k to 236k live cells (two copies of the bundle).
    • with cachedData: construction still parses once (checkSyntax), and the first run parses again.

Fix

  • CodeCache: let a SourceProvider pin the unlinked code for its source WebKit#418 adds SourceProvider::pinnedUnlinkedCode() / pinUnlinkedCode(): on a CodeCache miss JSC asks the provider before parsing, and hands the provider whatever it parses. Base implementations are no-ops.

  • vm.Script now creates its SourceCode over a NodeVMScriptSourceProvider (NodeVMScript.h) that forwards those two hooks to the owning NodeVMScript, which keeps the UnlinkedProgramCodeBlock in a WriteBarrier keyed by SourceCodeKey::hash() (source plus code type / strictness / code generation mode, so a lookup under different flags misses and gets replaced). The Script is the GC root; the provider only holds a back-pointer, cleared in ~NodeVMScript because executables and stack traces from earlier runs can keep the provider alive longer than the Script.

  • The constructor's checkSyntax is replaced by NodeVMScript::compile(), which goes through CodeCache::getUnlinkedProgramCodeBlock and therefore pins the parse it does; syntax errors come out of the same ParserError and are decorated exactly as before. Accepted cachedData pins the decoded block instead, so such a Script never parses its source. The run paths are unchanged: JSC::evaluate still creates a ProgramExecutable per run (global declaration instantiation has to happen per context), but initializeGlobalProperties now links the pinned block.

  • Net effect: one parse per Script (zero with accepted cachedData), regardless of how many contexts it runs in, what else goes through the CodeCache, or useCodeCache; the nested unlinked function executables / code blocks are shared by every context, linked code stays per context as before.

  • Equivalents for the other two entry points (not done here):

    • ShadowRealm.prototype.evaluate(string) builds a new SourceCode per call (evalInRealm, ShadowRealmPrototype.cpp) so there is nothing to pin to; the WebKit hook already covers eval code blocks, so a Bun-side handle (for example a vm.Script method that evaluates the pinned source in a given realm via IndirectEvalExecutable + the realm's wrapRemoteValue) would give ShadowRealms the same one-parse behavior.
    • vm.compileFunction returns a bare function per call and already goes through getUnlinkedProgramCodeBlock on a per-call StringSourceProvider (NodeVM.cpp constructAnonymousFunction), so it too would only need a handle object owning a pinning provider. Until then, new vm.Script("(function (exports, require, module) {...})") + runInContext is the way to instantiate one compiled function per context with a single parse.
  • Verified:

    • test/js/node/vm/vm.test.ts, new describe "Script compiles its source once for every context it runs in": a fixture keeps everything 6 context runs produce alive and reports how many UnlinkedProgramCodeBlocks later runs added. With BUN_JSC_useCodeCache=0 (which makes every run miss the cache, i.e. the big-source behavior in miniature) and with accepted cachedData, the unfixed build reports 5 and this build 0; the default-cache case and a per-context global declaration check pass on both.

    • Parse counts (BUN_JSC_reportParseTimes=1, debug builds of the same revision with and without this change, a 5 MB source with ~30k functions run in 3 contexts; the probe is in the details block):

      phase unfixed this PR
      new Script(src) 1 parse 1 parse
      first runInContext (evaluate) 1 re-parse of the program 0
      first invoke of the wrapper body parsed once body parsed once (inherent, once per Script)
      another 16.5 MB script compiled in between that script parsed twice parsed once
      next context (evaluate) 1 full parse, 9.0 s 0
      next context (invoke) body re-parsed, 1.5 s 0
      new Script(src, { cachedData }) 1 parse (8.4 s) 0 parses (2 ms)
    • test/js/node/vm/ (231 pass) and all 97 node test/parallel/test-vm-* files pass against the preview WebKit. script-leak.test.ts times out on this (slow, ASAN) machine with and without the change; its 10,000-script loop measured directly ends at 34 MB RSS growth with this change versus 138 MB without, because the construction-time compile turns every run into a CodeCache hit and the cache prunes its now-dead entries sooner.

  • Related: node:vm: reject invalid cachedData instead of crashing #32839 rewrites the cachedData decode block this change adds one line to (textual overlap only); test: add a regression test for JSC CodeCache key collisions #35778 is about CodeCache key collisions between different sources, which this per-Script pin does not interact with (a pin only ever answers lookups for its own provider).

Background

  • JSC compiles a script in two stages. Parsing produces realm-independent "unlinked" cells: an UnlinkedProgramCodeBlock for the top level, with an UnlinkedFunctionExecutable per function in it, each of which lazily gets an UnlinkedFunctionCodeBlock the first time it is called. Running the code in a particular global object "links" those into per-realm CodeBlocks / FunctionExecutables. Only the unlinked half is what a second context can share, and sharing it is exactly what the CodeCache does when it hits; this change just makes the hit unconditional for a vm.Script.
  • ProgramExecutable::initializeGlobalProperties is the only place program code enters execution: it fetches the unlinked block from the CodeCache and performs global declaration instantiation (hoisting vars and functions onto the context's global) against it. Since the executable is created inside Interpreter::executeProgram, there is no way to pass it a block directly, which is why the hook lives in the cache lookup and is expressed through the SourceProvider (the object JSC already consults there for disk-cached bytecode).
  • Bun.gc() calls Heap::deleteAllUnlinkedCodeBlocks, which drops every function's cached bytecode; a probe that calls Bun.gc(true) between contexts therefore sees function bodies re-parsed on the next call even with this change (and even in the main realm). The test only collects after all runs, and counts program blocks, which that call does not touch.
Raw probe output (parse counts per phase; "sibling" = a 16.5 MB script compiled between context 0 and 1)

The probe constructs one vm.Script, runs it in N fresh contexts (calling the returned wrapper each time and keeping the results alive), and counts the Parsed ... lines JSC prints with BUN_JSC_reportParseTimes=1 between markers it writes to stderr.

### release build, unfixed, 20 MB / 117,978 functions
## sibling 16.5MB compile between run0 and run1
run 0: evaluate 181 ms, invoke 423 ms
run 1: evaluate 514 ms, invoke 309 ms
run 2: evaluate 0 ms, invoke 19 ms
live after 3 runs: UnlinkedProgramCodeBlock=5 UnlinkedFunctionExecutable=236314 UnlinkedFunctionCodeBlock=40 FunctionExecutable=354459
parses per phase (count, total parse ms):
  construct                           1       422 ms
  run0-evaluate                       2         0 ms
  run0-invoke                         2       143 ms
  sibling                             2       631 ms
  run1-evaluate                       1       371 ms
  run1-invoke                         2        90 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms


# BEFORE, debug binary (unfixed, 536251ca9), 5MB source

## mode: plain
construct: 6632 ms
run 0: evaluate 330 ms, invoke 3065 ms
run 1: evaluate 0 ms, invoke 765 ms
run 2: evaluate 0 ms, invoke 506 ms
live after 3 runs: UnlinkedProgramCodeBlock=3 UnlinkedFunctionExecutable=30531 UnlinkedFunctionCodeBlock=10 FunctionExecutable=91055
parses per phase (count, total parse ms):
  construct                           1      5525 ms
  run0-evaluate                       2         1 ms
  run0-invoke                         2      1332 ms
  run1-evaluate                       0         0 ms
  run1-invoke                         0         0 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

## mode: --sibling
construct: 6607 ms
run 0: evaluate 331 ms, invoke 3006 ms
run 1: evaluate 10385 ms, invoke 4172 ms
run 2: evaluate 0 ms, invoke 669 ms
live after 3 runs: UnlinkedProgramCodeBlock=4 UnlinkedFunctionExecutable=30532 UnlinkedFunctionCodeBlock=8 FunctionExecutable=60877
parses per phase (count, total parse ms):
  construct                           1      5505 ms
  run0-evaluate                       2         1 ms
  run0-invoke                         2      1335 ms
  sibling                             2     26695 ms
  run1-evaluate                       1      9011 ms
  run1-invoke                         2      1518 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

## mode: --cachedData
construct: 9564 ms (cachedDataRejected=false)
run 0: evaluate 311 ms, invoke 3507 ms
run 1: evaluate 0 ms, invoke 1001 ms
run 2: evaluate 0 ms, invoke 512 ms
live after 3 runs: UnlinkedProgramCodeBlock=4 UnlinkedFunctionExecutable=30575 UnlinkedFunctionCodeBlock=9 FunctionExecutable=91109
parses per phase (count, total parse ms):
  construct                          45      8465 ms
  construct-with-cachedData           1      8445 ms
  run0-evaluate                       2         1 ms
  run0-invoke                         2      1759 ms
  run1-evaluate                       0         0 ms
  run1-invoke                         0         0 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

# AFTER, debug binary (fixed, 99d435d50 + preview-pr-418), 5MB source

## mode: plain
construct: 9119 ms
run 0: evaluate 0 ms, invoke 4323 ms
run 1: evaluate 0 ms, invoke 998 ms
run 2: evaluate 0 ms, invoke 558 ms
live after 3 runs: UnlinkedProgramCodeBlock=3 UnlinkedFunctionExecutable=30531 UnlinkedFunctionCodeBlock=9 FunctionExecutable=91055
parses per phase (count, total parse ms):
  construct                           1      6865 ms
  run0-evaluate                       1         1 ms
  run0-invoke                         2      2397 ms
  run1-evaluate                       0         0 ms
  run1-invoke                         0         0 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

## mode: --sibling
construct: 13062 ms
run 0: evaluate 1 ms, invoke 4432 ms
run 1: evaluate 0 ms, invoke 553 ms
run 2: evaluate 0 ms, invoke 589 ms
live after 3 runs: UnlinkedProgramCodeBlock=3 UnlinkedFunctionExecutable=30531 UnlinkedFunctionCodeBlock=8 FunctionExecutable=60877
parses per phase (count, total parse ms):
  construct                           1     10606 ms
  run0-evaluate                       1         2 ms
  run0-invoke                         2      1998 ms
  sibling                             1     28453 ms
  run1-evaluate                       0         0 ms
  run1-invoke                         0         0 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

## mode: --cachedData
construct: 2 ms (cachedDataRejected=false)
run 0: evaluate 0 ms, invoke 13344 ms
run 1: evaluate 1 ms, invoke 1792 ms
run 2: evaluate 0 ms, invoke 833 ms
live after 3 runs: UnlinkedProgramCodeBlock=3 UnlinkedFunctionExecutable=30574 UnlinkedFunctionCodeBlock=10 FunctionExecutable=91108
parses per phase (count, total parse ms):
  construct                          44      9504 ms
  construct-with-cachedData           0         0 ms
  run0-evaluate                       1         1 ms
  run0-invoke                         2      9648 ms
  run1-evaluate                       0         0 ms
  run1-invoke                         0         0 ms
  run2-evaluate                       0         0 ms
  run2-invoke                         0         0 ms

…runs in

The constructor used to checkSyntax() and throw the parse away, and every
runInContext()/runInThisContext() went back through JSC's CodeCache, so a
script larger than the cache's working set was re-parsed per context and
each context got its own copy of the unlinked function tree. The Script
now parses once through the CodeCache and keeps the UnlinkedProgramCodeBlock;
its SourceProvider hands that block back to the CodeCache on every miss
(oven-sh/WebKit#418). Accepted cachedData is pinned the same way instead of
being decoded and then ignored.
@coderabbitai

coderabbitai Bot commented Aug 12, 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: 12 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: a0e3b3da-51c1-4239-b325-53cfaad2ac85

📥 Commits

Reviewing files that changed from the base of the PR and between 165dc9f and 99d435d.

📒 Files selected for processing (4)
  • scripts/build/deps/webkit.ts
  • src/jsc/bindings/NodeVMScript.cpp
  • src/jsc/bindings/NodeVMScript.h
  • test/js/node/vm/vm.test.ts

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

@robobun

robobun commented Aug 12, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status: closed, superseded by #38040 (with oven-sh/WebKit#420). The constructor compile-once part continues in #37998.

Reproduced with BUN_JSC_reportParseTimes=1: on the unfixed build a 20 MB vm.Script is parsed at construction, again on the first run, and again for the next context once another large script has gone through the CodeCache.

@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 depends on an unmerged WebKit change (WEBKIT_VERSION points at a preview build that the description says should be replaced when oven-sh/WebKit#418 lands) and adds GC-lifecycle-sensitive machinery — a ref-counted SourceProvider holding a raw back-pointer into a GC cell, cleared in the destructor, plus a new WriteBarrier field — a human look is still warranted before landing.

What was reviewed

  • GC rooting: m_pinnedUnlinkedCode is a WriteBarrier visited in visitChildrenImpl; the provider's m_script back-pointer is nulled in ~NodeVMScript so a provider outliving the Script (via executables/stack traces) can't reach a freed cell.
  • Constructor flow reordering: checkSyntax → compile() preserves the SyntaxError/decorateParseErrorStack path; filename/filenameProvided/lineOffset are copied out before WTF::move(options).
  • cachedData + produceCachedData restructuring: the new cachedData.isEmpty() && produceCachedData gate matches the old else if semantics.
  • Tests spawn subprocesses with bunEnv, drain stdout/stderr concurrently, and the BUN_JSC_useCodeCache=0 case would fail on the unfixed build per the description.
Extended reasoning...

Overview

This PR makes vm.Script parse its source once and reuse the resulting UnlinkedProgramCodeBlock across every context it runs in. It touches four files: scripts/build/deps/webkit.ts (points WEBKIT_VERSION at a preview build of oven-sh/WebKit#418), src/jsc/bindings/NodeVMScript.{h,cpp} (new NodeVMScriptSourceProvider subclassing StringSourceProvider with the new pinnedUnlinkedCode/pinUnlinkedCode virtual hooks; NodeVMScript gains a WriteBarrier<UnlinkedProgramCodeBlock> + key-hash field, a destructor that nulls the provider's back-pointer, and a compile() method replacing checkSyntax), and test/js/node/vm/vm.test.ts (four new tests under a describe.concurrent block).

Security risks

None identified. This is a compilation-caching optimization inside node:vm; it does not change what code runs or in which realm, and the run paths (JSC::evaluate → per-context ProgramExecutable) are unchanged. The pin is keyed by SourceCodeKey::hash() so a lookup under different parse flags misses.

Level of scrutiny

High. Three factors put this outside auto-approval territory: (1) it is a coordinated cross-repo change — the WebKit side (oven-sh/WebKit#418) is not yet merged and WEBKIT_VERSION is set to a preview tag that the PR description says should be replaced with the real bump; a human needs to sequence the two landings. (2) It adds GC-lifecycle-sensitive state: a ref-counted SourceProvider holds a raw NodeVMScript* back-pointer to a GC cell, relying on the cell's destructor to null it before the cell's storage is reused; and a new WriteBarrier field must be visited. Both look correct (the field is visited, the destructor nulls the pointer, NodeVMScript is already JSDestructibleObject with a destroy hook), but this class of change is exactly what the repo's review guidelines flag for careful human review. (3) The new virtual-method contract with JSC's CodeCache is defined in the paired WebKit PR, so correctness depends on code not visible in this diff.

Other factors

The bug-hunting pass found nothing. The constructor reordering was checked for use-after-move (filename, filenameProvided, lineOffset are copied to locals before WTF::move(options)); the produceCachedData branch was checked against the old else if semantics (now cachedData.isEmpty() && produceCachedData, equivalent because the old branches were mutually exclusive on cachedData.isEmpty()). Tests follow harness conventions (subprocess spawn, {...bunEnv, ...}, concurrent pipe drain, describe.concurrent) and per the description the BUN_JSC_useCodeCache=0 case fails on the unfixed build (5 blocks added) and passes here (0). Given the cross-repo dependency and GC-sensitive surface, deferring to a human is the right call.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author
Updated 6:05 PM PT - Aug 12th, 2026

@robobun, your commit 99d435d is building: #93680

@robobun

robobun commented Aug 13, 2026 •

Copy link
Copy Markdown
Collaborator Author
Updated 7:05 PM PT - Aug 12th, 2026

❌ @robobun, your commit 99d435d has some failures in Build #93680 (All Failures)


🧪   To try this PR locally:

bunx bun-pr 37950

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

bun-37950 --bun

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

On hold. A JSC maintainer is replacing the SourceProvider hook in oven-sh/WebKit#418 with a first-class entry point for executing an already-parsed UnlinkedProgramCodeBlock (and the eval/function equivalents) against a given global; this PR will be rebased onto that API once it is posted (the Script will keep the block from its single parse and call the new entry point per run instead of going through the CodeCache). Not iterating further against the #418 preview in the meantime.

The part that does not depend on any JSC change, compiling once in the constructor instead of checkSyntax() plus a second parse on the first run (or on produceCachedData), is being split out into its own PR; link to follow here.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

The constructor compile-once part is #37998.

@robobun

robobun commented Aug 13, 2026

Copy link
Copy Markdown
Collaborator Author

Superseded by #38040 together with oven-sh/WebKit#420, which is the first-class JSC entry point this PR was on hold for: the Script keeps the UnlinkedProgramCodeBlock from its one compile and hands it to JSC::evaluate on every run, instead of pinning it through the SourceProvider hook from oven-sh/WebKit#418 (also closed). #38040 replaces the constructor's checkSyntax as well, so it covers all of this branch.

The constructor-only part, which needs no WebKit change, continues in #37998 until #38040 lands. Closing this one.

@robobun robobun closed this Aug 13, 2026
dylan-conway added a commit that referenced this pull request Aug 18, 2026
…text it runs in (#38040)

### What does this PR do?

`new vm.Script(src)` compiles `src` once, and every `runInContext` /
`runInThisContext` / `runInNewContext` links that one
`UnlinkedProgramCodeBlock` into the target context, instead of going
back to JSC's `CodeCache` for each run. The cache keeps 16 MB of source
in total, so for anything large (or once enough other scripts have gone
through) every context used to re-parse and regenerate the whole script
and get its own copy of the unlinked cells; jest-style workloads (one
big script, one context per test file) hit exactly this.

- Uses the entry point added in oven-sh/WebKit#420
(`JSC::evaluate(globalObject, source, block, …)`), which is included in
the WebKit build main already pins; this PR no longer changes
`WEBKIT_VERSION`.
- The `Script` cell holds the block in a `WriteBarrier` and passes it to
`evaluate()`. It is recompiled (and replaced) only if a context whose
`CodeGenerationMode` differs — i.e. one with a debugger attached — runs
it; JSC also ignores a block that does not match and falls back to the
cache, so a mismatch can never run the wrong bytecode. Global
declaration instantiation still happens per context inside JSC exactly
as before.
- The constructor's `checkSyntax` (a parse whose result was thrown away)
is replaced by that compile, so construct + run parses once instead of
twice; construction still throws the SyntaxError with the same
decoration. `sourceMapURL` no longer needs its own re-parse either,
since the constructor's compile populates the directive.
- `decorateParseErrorStack` no longer fabricates a `<url>:-1` header for
compile errors that have no position (parser stack overflow already did
this before; codegen out-of-memory now surfaces at construction too).
Those errors now look like Node's: a plain `RangeError`.
- `cachedData` keeps its current meaning (accept / reject check;
execution uses the compiled source), and `produceCachedData` /
`createCachedData()` are unchanged — they now find the constructor's
compile in the CodeCache instead of parsing again. Executing the decoded
`cachedData` block directly, and serializing the held block for
`createCachedData()`, are possible follow-ups; the former needs the
Script to own the `cachedData` bytes for as long as functions decoded
from them can run, so it is deliberately not part of this change.

This replaces #37950 (+ oven-sh/WebKit#418), which hooked the
`SourceProvider` into the CodeCache miss path: there a Script whose
source is already cached under another provider never gets to pin
anything, and pinning the block decoded from `cachedData` would have run
bytecode that borrows the `cachedData` buffer. Here the Script simply
owns what it compiled and hands it to JSC.

Trade-off: the unlinked program block (and the
`UnlinkedFunctionExecutable` shells under it) now live as long as the
`Script` object, rather than as long as the cache happens to keep them;
function bytecode inside is still dropped by
`deleteAllUnlinkedCodeBlocks` / memory pressure as before, and dead
Scripts are unaffected (`script-leak.test.ts` ends with the same 1942
live blocks before and after). Scripts that are constructed but never
run now pay bytecode generation at construction (36 → 38 ms for the 2.7
MB script below), which is also what Node does.

Numbers — local release builds on the same machine
(`build:release:local`; "before" is main as of Aug 5 with unmodified
WebKit), 2.7 MB script with 30k functions run in 3 fresh contexts, parse
counts from `BUN_JSC_reportParseTimes=1` (a constant 3–4 unrelated small
parses per phase, identical on both sides, are subtracted).
`BUN_JSC_useCodeCache=0` stands in for the block having been evicted:

| phase | before | after |
| --- | --- | --- |
| `new Script` (30k top-level functions) | 36 ms, 1 parse (checkSyntax,
discarded) | 38 ms, 1 parse + bytecode, kept |
| each further context, top-level functions, cache evicted | 31–44 ms, 1
full parse, +30k `UnlinkedFunctionExecutable` per context (90k after 3)
| 6–7 ms, 0 parses, stays at 30k |
| each further context, functions inside a wrapper, cache evicted |
38–59 ms, program + wrapper body re-parsed per context | 1–2.5 ms, 0
parses |
| construct + first context, cache warm (wrapper case) | 33 + 40 ms | 39
+ 25 ms |
| 2000 × `new Script(90 KB) + runInThisContext()` | 32–43 ms (two parses
each) | 18–19 ms (one parse each) |

### How did you verify your code works?

- New tests in `test/js/node/vm/vm.test.ts`: with
`BUN_JSC_useCodeCache=0`, running one Script / two Scripts with the same
source / a Script constructed with accepted `cachedData` in 6 contexts
adds no `UnlinkedProgramCodeBlock` (the current build adds 5 / 10 / 5);
each context still gets its own `var` / function bindings; source
positions are identical in every context; a position-less compile error
gets no header.
- `test/js/node/vm/` and Node's `test/parallel/test-vm-*` +
`sequential/test-vm-*` (100 files) against a debug build with the WebKit
change, plus `BUN_JSC_validateExceptionChecks=1` on `vm.test.ts`. A
probe of the whole `vm.Script` surface (SyntaxError decoration,
`sourceMapURL`, positions with `lineOffset` through all four run
methods, thrown-error decoration in a second context, `cachedData`
accept / reject, `produceCachedData`, `createCachedData`, `timeout`)
prints byte-identical output on main and on this branch.
- `script-leak.test.ts` passes on release; on a debug build with a
locally built WebKit it hits the 5 s timeout, but so does unrelated
`eval` in that configuration (18× slower than the prebuilt debug
WebKit), so that is the local build configuration, not this change.

Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
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