-
Notifications
You must be signed in to change notification settings - Fork 5k
Check syntax eagerly in vm.Script constructor #28667
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -3,6 +3,7 @@ | |
| #include "ErrorCode.h" | ||
|
|
||
| #include "JavaScriptCore/Completion.h" | ||
| #include "JavaScriptCore/ParserError.h" | ||
| #include "JavaScriptCore/JIT.h" | ||
| #include "JavaScriptCore/JSWeakMap.h" | ||
| #include "JavaScriptCore/JSWeakMapInlines.h" | ||
|
|
@@ -130,6 +131,17 @@ constructScript(JSGlobalObject* globalObject, CallFrame* callFrame, JSValue newT | |
| SourceCode source = makeSource(sourceString, JSC::SourceOrigin(WTF::URL::fileURLWithFileSystemPath(options.filename), *fetcher), JSC::SourceTaintedOrigin::Untainted, options.filename, TextPosition(options.lineOffset, options.columnOffset)); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
|
|
||
| { | ||
| JSC::ParserError error; | ||
| if (!JSC::checkSyntax(vm, source, error)) { | ||
| ASSERT(error.isValid()); | ||
| auto exception = error.toErrorObject(globalObject, source); | ||
| RETURN_IF_EXCEPTION(scope, {}); | ||
| JSC::throwException(globalObject, scope, exception); | ||
| return {}; | ||
| } | ||
|
Comment on lines
+134
to
+142
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Every followed by execution now incurs two full JSC parses instead of one: in the constructor fully parses the source and discards the AST, then re-parses from scratch at run time. Pre-fix Bun did one parse (at run time); Node.js/V8 does one compile at construction time and reuses cached bytecode — this PR is strictly worse than both for the common case of valid scripts that are executed. Extended reasoning...What the bug is and how it manifests The new call added to (NodeVMScript.cpp lines 134–142) performs a full source parse internally — it calls the JSC parser, builds a complete AST, validates it, and then discards everything. When the script is later executed via , , or , creates a new and re-parses the same source from scratch to produce bytecode. This means every object that is created and then run now incurs two full parses instead of one. The specific code path that triggers it
Why existing code doesn't prevent it / addressing the refutation The refutation suggests "JSC may have internal caching that mitigates the double-parse cost." JSC does have a that caches bytecode keyed by source. However, does NOT produce bytecode — it produces only a parsed AST that is immediately discarded. The sourceProviderCache is populated only by successful compilation (via or similar). Therefore, the cache cannot be populated by , and the second parse in is a full cold parse. The double-parse is confirmed real. The refutation is correct that this is intentional and mirrors the pattern in NodeVM.cpp. This is a known tradeoff accepted by the PR author to achieve eager correctness checking. It is not a hidden defect. What the impact would be For valid scripts (the common case — invalid scripts throw at construction and are never run), every construction + execution now costs ~2× the parsing work compared to pre-fix Bun. For large scripts or hot paths that create many instances (template engines, test runners, repeated patterns), this is a measurable regression. Node.js/V8 avoids this entirely by eagerly compiling in the constructor (producing cacheable bytecode), so V8 does one compile and zero re-parses on execution. Pre-fix Bun did one parse (deferred to run time). This PR makes Bun do two parses for every valid script that gets executed. How to fix it and step-by-step proof The correct fix is to replace + discard with an eager compile that stores the resulting or its bytecode for reuse by subsequent calls — matching Node.js/V8 behavior. This is a larger change outside this PR's scope and is noted as a follow-up in the synthesis description. A concrete step-by-step showing the regression: (1) is called; (2) parses , builds AST, returns , AST is freed; (3) the object is created storing only the string; (4) is called; (5) creates a fresh , parses again from the source string, compiles to bytecode, runs it. Two parses for one script execution — where pre-fix Bun did one and Node.js does zero (after construction-time compile). |
||
| } | ||
|
|
||
| const bool produceCachedData = options.produceCachedData; | ||
| auto filename = options.filename; | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| import { expect, test } from "bun:test"; | ||
| import { Script } from "node:vm"; | ||
|
Comment on lines
+1
to
+2
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🟡 Per root Extended reasoning...What the issue is Both governing CLAUDE.md files for this directory tree state the same rule:
Issue #28666 is not a regression. The PR description itself states "The source was stored but only parsed at execution time" and "Bun silently created the Script object" — i.e., the eager syntax check never existed in any prior Bun release. The pre-PR assertion in Why the convention matters here The repo's documented intent is that compat-gap coverage lives alongside the module's other tests so future readers find all Addressing the objection One reviewer noted that Step-by-step
How to fix Move the five test cases from |
||
|
|
||
| test("vm.Script throws SyntaxError for missing closing paren", () => { | ||
| expect(() => { | ||
| new Script("Math.max(a, b", { filename: "main" }); | ||
| }).toThrow(SyntaxError); | ||
| }); | ||
|
|
||
| test("vm.Script throws SyntaxError for unterminated string", () => { | ||
| expect(() => { | ||
| new Script('"hello', { filename: "main" }); | ||
| }).toThrow(SyntaxError); | ||
| }); | ||
|
|
||
| test("vm.Script throws SyntaxError for invalid token", () => { | ||
| expect(() => { | ||
| new Script("let @x = 1;", { filename: "main" }); | ||
| }).toThrow(SyntaxError); | ||
| }); | ||
|
|
||
| test("vm.Script throws SyntaxError at construction, not at run time", () => { | ||
| let reachedRun = false; | ||
| try { | ||
| const script = new Script("Math.max(a, b", { filename: "main" }); | ||
| reachedRun = true; | ||
| script.runInThisContext(); | ||
| } catch (error: unknown) { | ||
| expect(error).toBeInstanceOf(SyntaxError); | ||
| } | ||
| expect(reachedRun).toBe(false); | ||
| }); | ||
|
coderabbitai[bot] marked this conversation as resolved.
|
||
|
|
||
|
robobun marked this conversation as resolved.
|
||
| test("vm.Script does not throw for valid syntax", () => { | ||
| expect(() => { | ||
| new Script("Math.max(1, 2)", { filename: "main" }); | ||
| }).not.toThrow(); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.