Skip to content

[JSC] A sloppy direct eval in a parameter expression declares its vars in the called function - #877

Draft
robobun wants to merge 1 commit into
mainfrom
robobun/17898b84/eval-var-in-parameter-expressions
Draft

robobun wants to merge 1 commit into
mainfrom
robobun/17898b84/eval-var-in-parameter-expressions

Conversation

@robobun

@robobun robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator

Draft on purpose: for WebKit/WebKit first, not for a merge into the fork. No user report.

Problem

  • A sloppy direct eval in a parameter default declares its vars outside the function. function f(p = eval("var q = 1")) {} f(); typeof q is "number" (V8: "undefined").
  • The value can overwrite a global const. An enclosing let gives a false SyntaxError: Can't create duplicate variable in eval.
  • Interpreter::executeEval (interpreter/Interpreter.cpp:1585) declares in the first VarScope up the chain. A function makes its own after its parameters (bytecompiler/BytecodeGenerator.cpp:1354).

Fix

  • The parser records a direct eval in a parameter list. For such a sloppy function, the scope above its parameters becomes a VarScope. An arrow function gets an empty one.
  • ECMA-262 keeps these vars in the function (FunctionDeclarationInstantiation, step 20).
  • Verified: four new JSTests/stress/direct-eval-in-parameter-expression-*.js fail on main. 9 test262 expected failures pass.
  • Self-reviewed: 15 concerns raised, 13 addressed (Notes).

Background

  • VarScope: the environment type for a function's vars.
  • Considered an environment for every sloppy function with an eval: +32 B per call. Also a third stop in the scope walks: every declaring eval pays.

Downsides

  • An arrow function with a parameter eval: +32 B and +243 instructions per call (JIT off). A parameter eval that declares a var: 4,028 to 7,936 instructions per call. jsc text: +1,108 B.
  • Right by accident on main, wrong now: Known gaps rows 1 (delete arguments in a top-level arrow function) and 7.
Notes

Route. This PR does not ask for a merge into the fork as it stands.

  • WebKit/WebKit main (b14e295) has the same code: the walk in Interpreter::executeEval (Interpreter.cpp:1504 there) and the VarScope made after the parameters (BytecodeGenerator.cpp:1314). Its JSTests/test262/expectations.yaml and expectations-linux.yaml list the same 9 rows. I did not build upstream.
  • The Source/ part of this patch applies to that commit with git apply: every hunk, offsets only. Two things are fork-only, and a copy for upstream drops them: the declaredNamesScopesChanged() line in pushVarScopeForEvalInParameters, and the run("bytecode-optimizer", ...) mode in the first line of the four tests.
  • I cannot file the bugs.webkit.org entry or the WebKit/WebKit PR. I could not search Bugzilla for an existing report: bugs.webkit.org does not resolve from the machine that this ran on. A search of the open PRs of WebKit/WebKit and of this repository finds none for it.
  • History for the report. The 8 arrow function rows are expected failures since the test262 import WebKit/WebKit@3b81c1e5a64e (2020-09-10, bug 216379). @sosukesuzuki: the staging row is there since your WebKit/WebKit@39e54abe1ea2 (bug 288361). The nearest fixes in the same code are WebKit/WebKit@ab0917349c9f (the duplicate declaration checks of EvalDeclarationInstantiation, bug 167837) and WebKit/WebKit@c0dd368287d5 (the rest parameter and the VariableEnvironment, bug 268274).
  • What I expect to happen to this PR: it closes when a sync brings an upstream fix and the four tests of this branch pass on that main. Or it becomes a cherry-pick of the upstream commit, as [JSC] Array profiles should not turn ordinary arrays into ArrayStorage (cherry-pick WebKit/WebKit#75376) #785 was.
  • The question for a maintainer: does the fork take this before upstream does? If it does, drop the two expectations*.yaml hunks: no job of this repository runs test262, and they would be one more difference from upstream.
  • The Bun side waits for the answer. It is a WEBKIT_VERSION bump and one test file, test/js/bun/jsc/direct-eval-parameter-expressions.test.ts, on the branch robobun/17898b84/webkit-direct-eval-parameter-expressions of oven-sh/bun. On Bun 1.4.3, 16 of its 17 tests fail. The other one is a control.
  • That test on a Bun with this change: a local debug build of the branch of Upgrade WebKit to 33ce3bf11a71 bun#46279, which pins the merge base of this PR. With the pinned WebKit, 16 of 17 fail. With --webkit-version=autobuild-preview-pr-877-644dc4b0, the preview build of this PR, 17 of 17 pass.
  • On the same two builds, test/bundler/bundler_bytecode_portable.test.ts gives the same hashes for the cache bytes of its whole corpus (21 MB for its libraries). One entry, libraries.js, differs from the committed snapshot on this machine, in the same way with both. repl-transform.test.ts, vm.test.ts, webkit-upgrade-33ce3bf11a.test.ts and bun-jsc.test.ts (492 tests) fail only by timeouts of the debug build: 2 with the preview build, 5 with the pinned one.

How it was found. A run of the same sloppy programs in Bun and in Node. There is no user report, and a search of the oven-sh/bun issues for the symptom finds none. The reason to change it is ECMA-262 conformance and parity with Node.

What changes. Sloppy global code. main is the merge base 5e3554f. V8 is Node v26.3.0 with the program as a script (vm.runInThisContext).

Program main This PR V8
function f(p = eval("var q = 1")) {} f(); typeof q number undefined undefined
var g = (p = eval("var q = 1")) => 0; g(); typeof q number undefined undefined
({ m(p = eval("function q() {}")) {} }).m(); typeof q function undefined undefined
function f(d, p = d && eval("var q = 1")) { return typeof q } [f(true), f(false)] number,number number,undefined number,undefined
(function () { var x = "outer"; (function (p = eval("var x = 'inner'")) {})(); return x })() inner outer outer
the same with let x = "outer" SyntaxError outer outer
const K = "const"; (function () { function f(p = eval("var K = 'new'")) {} f() })(); K new const const
var x = 1; let y = 2; function f(p = eval("var x = 10, y = 20")) {} f(); [x, y] SyntaxError 1,2 1,2
(function (exports, require, module) { function f(p = eval("var module = 1")) {} f(); return typeof module })({}, null, {}) number object object
(function named(p = eval("var named = 1")) { return named })() SyntaxError 1 1
(function () { ((p = eval("var arguments = 'ab'")) => 0)(); return arguments.length })(1, 2, 3) 2 3 3
var w = { x: "with" }; with (w) { (function (p = eval("var x = 'inner'")) {})() } w.x inner with with
(function (p = eval("var arguments = 1")) {})() SyntaxError SyntaxError no error
  • Each SyntaxError is Can't create duplicate variable in eval.
  • The last row does not change. A function has its own arguments next to its parameters, so the specification makes it a SyntaxError. V8 does not follow it there.

Why the var left the function. With a parameter list that is not simple, the generator pushes the scope of the parameters, runs the parameter expressions, and then makes the VarScope as a child of that scope (initializeDefaultParameterValuesAndSetupFunctionScopeStack). During the parameter expressions the chain is: the scope of the parameters, the scope for this (a lexical scope), the name scope of a function expression, then the code around the function. executeEval and JSScope::resolveScopeForHoistingFuncDeclInEval both walk to the first VarScope. So they ended in an enclosing function or on the global object. The store of the value is a separate lookup by name (JSScope::abstractAccess). It reads the symbol table of each scope, and it expects a var from an eval only in a scope with usesSloppyEval. An enclosing function with no eval of its own has no such flag. So the lookup passes the new var and reaches a binding further out. That is how the const row gets its value.

How it works now.

  • Parser::parseInner reads ASTBuilder::evalCount() right after it parsed the parameter list of the function that it compiles. The builder counts every direct eval call that it builds, so the count is never zero when a parameter expression has one.
  • The count is an upper bound. Three things inside a parameter expression also go through the builder: the head of a nested arrow function (it is parsed as a parenthesized expression first), and the field initializers and the static blocks of a nested class. A function with an eval only there gets the VarScope too. Its results do not change. A nested function's body and the parameters of a nested function that is not an arrow function go through a SyntaxChecker and do not count.
  • A function that is not an arrow function and uses eval already makes a scope for this and new.target before its parameters (initializeArrowFunctionContextScopeIfNeeded). With the new ScopeType::FunctionEvalVarScope, the symbol table of that scope keeps the type VarScope and gets usesSloppyEval. The instructions of such a function do not change.
  • An arrow function reads this from the code around it and has no such scope. pushVarScopeForEvalInParameters emits one create_lexical_environment with an empty symbol table of the same kind, and a mov.
  • usesSloppyEval on the symbol table makes JSScope::abstractAccess ask for var injection checks for names that resolve through the scope. A closure made in the parameters before the eval runs therefore sees the var (test262 language/expressions/function/scope-param-elem-var-open.js).
  • emitCallDirectEval has an ASSERT: sloppy function code that calls eval has a VarScope of its own on the scope stack. It is not a RELEASE_ASSERT. If the flag ever misses an eval, a release build does what main does today, and does not abort on valid code.
  • Nothing changes in Interpreter::executeEval, in JSScope, in an opcode, in a JIT tier or in the bytecode cache format (cachedTypesFormatRevision stays 13). The flag is read while the function is compiled and is not stored.

Known gaps. main, this PR and V8 as above.

Program main This PR V8
1. var g = (p = eval("var arguments = 1"), d = delete arguments) => [d, typeof arguments]; g() true,undefined false,number true,undefined
2. (function () { return ((p = eval("var arguments = 1"), d = delete arguments) => [d, typeof arguments])() })() false,number false,number true,object
3. (function () { return (() => { eval("var arguments = 1"); return [delete arguments, typeof arguments] })() })() false,number false,number true,object
4. (function (a = 1) { eval("var a = 's'"); return a })() 1 1 s
5. function f(a = 1, r = () => a) { eval("var a"); a = 5; return [a, r()] } f() 5,5 5,5 5,1
6. (function () { var f = function (a = class { [eval("var q = 1; 'k'")]() {} }) { return typeof q }; return [f(), typeof q] })() number,number number,undefined undefined,undefined
7. (function () { var x = "outer"; var f = function (a = class { [eval("var x; 'k'")]() {} }) { return String(x) }; return [f(), x] })() outer,outer undefined,outer outer,outer

Tests. Four files in JSTests/stress.

  • direct-eval-in-parameter-expression-var-scope.js: 18 kinds of function, 7 places for the eval in the parameter list for a function and for an arrow function, 7 things the eval declares, 3 things that are not a var of the function.
  • direct-eval-in-parameter-expression-bindings.js: 42 checks of which binding a name is. Who sees the var, the body's own declaration, a var per call, the bindings of the code around the function (also a global var, let and const with a function between), the name of the function.
  • direct-eval-in-parameter-expression-conflicts.js: 44 checks. The parameter names and arguments that stay a SyntaxError, this, new.target, super and arguments inside the eval, an eval that throws, strict code, nested functions and classes, an eval in the body.
  • direct-eval-in-parameter-expression-tiers.js: a loop of testLoopCount calls where 1 call in 16 runs the eval, and two new realms where a closure of the parameters is compiled before the first eval of the realm declares a var.
  • Each runs in the default modes and in three more: the bytecode optimizer with --validateBytecodeOptimizerStaticScopes=1, --forceDebuggerBytecodeGeneration=true, and the type and control flow profilers. That is 20 modes for each file. The 80 runs pass on a release build and on a build with assertions (run-jsc-stress-tests). On main all 80 fail.
  • Time for one run on a release build, one process at a time, best of 3: 10 to 130 ms. In the same measurement the existing es6-default-parameters.js takes 27 to 298 ms. A single run can take longer on this machine: the longest of the 80 runs took 235 ms.
  • V8 gives the expected value in every check but three. Those are the rows where a function's parameter eval declares var arguments.

Other runs (Linux x64, jsc of the merge base 5e3554f against jsc of this PR). These are runs on one machine by hand: no job of this repository runs test262.

  • test262 test/language, test/annexB and test/staging, 48,132 runs, release builds: 9 runs change, all from fail to pass (80 failures to 71). They are the 8 language/eval-code/direct/arrow-fn-*-arrow-func-declare-arguments-assign*.js files and staging/sm/eval/redeclared-arguments-in-param-expression-eval.js. Their rows are removed from expectations.yaml and expectations-linux.yaml.
  • The same 9, and no other run, change in 10,693 runs of the eval, function code, arrow function, new.target and class directories with JSC_forceDebuggerBytecodeGeneration=true. There the build of this PR has assertions.
  • The 6,119 files of JSTests/stress that are not new, with default options, release builds, 8 at a time: 8 files differ. They are out of memory kills and a timeout under the parallel load, and two tests that print a memory size or the order of threads. Each gives the same result on both builds when it runs alone.
  • Bytecode (--dumpGeneratedBytecodes=true, JIT off) of the same files: 6,066 dumps are identical. The other 53 dumps are not stable: they differ between runs of one binary (worker threads, random constants, the order of a switch table). Every one of the 662 code blocks that both builds dumped there has the same number of instructions.
  • Bytecode cache (--diskCachePath, JIT off) of the same files: 6,101 files write a cache, 52.4 MB in total. The caches of the two builds differ in 3 files, by 5 bytes. That is one byte for each function with a direct eval in its parameter list: 3 in es6-default-parameters.js, 1 in IIFE-es6-default-parameters.js, 1 in bytecode-optimizer-static-scopes-eval-with-global.js. Nine more files differed in the parallel run. The caches of three sampling-profiler-*.js files differ between two runs of one binary as well. The other six are identical on both builds when they run alone.
  • The build of this PR with assertions, for the new ASSERT: JSTests/stress with default options has 4 aborts in 6,123 files. ensure-crash.js and module-loader-promise-then-tampered.js abort on the release builds of main too. The other two are mustCrash! tests that rely on a debug ASSERT, and they abort on an assertion build of an older main as well. The 10,693 test262 runs of the directories above, without the debugger option, give the same results as the release build. The five bytecode-optimizer-static-scopes-*.js tests, es6-default-parameters.js and IIFE-es6-default-parameters.js pass in all of their 44 runs.
  • es6.yaml, ChakraCore.yaml, LayoutTests/jsc-layout-tests.yaml, mozilla-tests.yaml, modules.yaml, typeProfiler.yaml, controlFlowProfiler.yaml, heapProfiler.yaml and complex.yaml with --quick, release build of this PR: 8,234 runs pass.
  • 1,209 generated programs (kinds of function, parameter lists and ways to read the var, crossed, and about 80 single cases) against V8: main differs in 693 of them, by the result in 22 and by a global that it leaves behind in 677. This PR differs in 4, by the result: three like the last row of the first table, and the class computed key of Known gaps.

Costs, measured. Release builds (-O2, no assertions) of the merge base and of this PR. Machine instructions are user-space instructions from perf_event_open, with --useJIT=0, as the difference between runs of N and 2N iterations.

What main This PR
size text of jsc 44,961,990 B 44,963,098 B (+1,108)
sizeof(FunctionParameters) 80 B 80 B
Bytecode of (p = eval("var q")) => 0 14 instructions, 2 environments 16, 3
Bytecode of ({} = eval("var q; ({})")) => 0 13, 1 15, 2
Bytecode of async (p = eval("var q")) => { await 1 } 57, 2 59, 3
Bytecode of (p = (a = eval("var x = 1")) => 0) => 0 (the flag is an upper bound) 11, 2 13, 3
Bytecode of (p = class { static f = eval("1") }) => 0, and of the static block form 18, 3 20, 4
Bytecode of 14 other shapes (functions, methods, generators and async functions with a parameter eval, functions with an eval in the body only) same instructions
Bytecode cache of function h(p = eval("var q")) {} h(), 848 B 1 byte differs (0x08 to 0x01: the scope type and usesSloppyEval)
Heap per call, (x, p = eval("1")) => p 159.13 B 192.06 B
Heap per call, (x, {} = eval("({})")) => 1 224.19 B 256.32 B
Heap per call, function (x, p = eval("1")) {}, and 5 shapes with no eval in the parameters identical
Call of (x, p = eval("1")) => p 2,561 2,804
Call of (x, {} = eval("({})")) => 1 2,505 2,778
Call of (x, p = eval("var v = 1")) => p 3,472 7,755
Call of function (x, p = eval("var v = 1")) { return p } 4,028 7,936
Call of function (x, p = 1) { eval("var v = 1"); return p } 7,572 7,540
Call of function (x, p = eval("1")) { return p }, median of 9 (range) 3,128 (3,109 to 3,137) 3,131 (3,118 to 3,133)
Call of function (p = 1) { eval("1"); return p }, median of 9 (range) 3,085 (3,068 to 3,087) 3,076 (3,062 to 3,083)
Call of function (a, b = 2) { return a }, median of 9 620 620
Read of a global in a closure from the parameters of function (p = 1, f = () => gv) { eval("1"); return f } 627 627
The same closure from function (p = eval("1"), f = () => gv) { return f } 627 651
The same, after an eval declared a var in some function of the realm 627 2,353
Compile and call 20,000 functions function fN(a, b = 2) { return a + b } 1,072.7 M 1,074.5 M
Compile and call 20,000 functions function fN() { return eval('1') } 1,099.4 M 1,097.2 M
  • Rows with no note are the minimum of 3 runs. The runs of one binary spread by up to 81 instructions for a call with an eval that declares a var, and by up to 18 for the other calls.
  • The last two rows are whole runs, minimum of 5. The runs of one binary spread by 2.1 M to 7.8 M, so there is no difference above the noise.
  • The closure rows are the cost of the var injection check that makes the closure see a var that the eval declares later. A closure from the parameters of a function with an eval in its body only does not get it.
  • On main a parameter eval at the top level declared its var on the global object, which does not fire the realm's VarInjection watchpoint. Now the var is in a function, and the watchpoint fires as for a body eval. A checked read of a global costs 2,113 instructions after a body eval on both builds. After a parameter eval it costs 651 on main and 2,112 with this PR.
  • The binary of Bun is not measured: the only Bun build with this change is a debug build.
  • valgrind, perf and bloaty are not installed on this machine.

Self-review. 15 concerns, 13 addressed.

  1. An earlier revision also removed the arguments guard of JSLexicalEnvironment::deleteProperty. That changed delete arguments in generator and async function bodies. Removed from the patch (Known gaps).
  2. The route: such a fix goes to WebKit/WebKit first. This PR is a draft with the Route note, and the Bun side waits.
  3. main moved under the branch (Upgrade to upstream WebKit 33ce3bf11a71 #860). Rebased on 5e3554f. Every number in this description is from that base.
  4. A RELEASE_ASSERT on every sloppy eval site. Now an ASSERT.
  5. The parser flag is an upper bound, and two comments and this description said that it is exact. Reworded. The shapes are in the cost table.
  6. "Same bytecode" for functions that are not arrow functions was shown by a dump that does not print a scope type. Now shown by the bytes of the bytecode cache.
  7. The async rows of the test could not fail: a shared variable kept the value of an earlier call. It is reset before each call, and the rejection row settles through drainMicrotasks().
  8. No async generator method and no profiler mode in the tests. Added.
  9. No JIT code in the tests ran with the realm's VarInjection watchpoint intact. Added the two realms of the tiers test.
  10. No test for a class heritage or a class computed key in a parameter expression, and none for a global const behind a function. Added rows for both, and Known gaps rows 6 and 7.
  11. The Problem section gave the mildest form. Rewritten: the value can overwrite a binding further out.
  12. The reverse case and the order relative to [JSC] With parameter expressions, a var arguments in the function body is a binding of its own #715 and [JSC] In a generator or async function, arguments and each parameter have one binding, for the body's declarations and for direct eval #782 were not stated. Added to Known gaps.
  13. The Bun test had no REPL case, and its bytecode case did not show that the cache was used. Both added on the Bun branch.
  14. Not taken: stronger test262 files. The 30 test262 files for step 20 pass on main, because none reads the outer binding afterwards. That is a change for tc39/test262, not for this PR.
  15. Not taken: a search of bugs.webkit.org for an existing report. It was not reachable (Route).

…s in the called function

A sloppy direct eval declares its vars in the first VarScope up the scope
chain (Interpreter::executeEval). While the parameter expressions of a
function run, the function has no VarScope there: the bytecode generator
makes the "var" environment after the parameters, as their child. So
`function f(p = eval("var x = 1")) {}` declared `x` in the code around the
function, in an enclosing function or on the global object, and stored the
value to whatever `x` meant there.

The specification gives the function an environment of its own for these
vars, outside the environment of the parameters
(FunctionDeclarationInstantiation, step 20).

- The parser records on FunctionParameters that it built a direct eval
  call while it parsed the parameter list.
- For such a sloppy function, the scope that holds |this| and new.target
  for arrow functions is a VarScope. It is already above the parameters.
- An arrow function has no such scope. It gets one empty VarScope before
  its parameters.
- emitCallDirectEval() asserts that sloppy function code has a VarScope of
  its own on the scope chain.

A function whose parameter list has no direct eval call gets the same
bytecode as before.
@robobun

robobun commented Oct 22, 2026 •

Copy link
Copy Markdown
Collaborator Author

Status

  • Reproduced in the jsc shell of main (5e3554f) and in Bun 1.4.3: function f(p = eval("var q = 1")) {} f(); typeof q gives "number". Node v26.3.0 gives "undefined".
  • This draft is for WebKit/WebKit first. The Route note in the description has the question for a maintainer.
  • The Bun side waits. Its test is on the branch robobun/17898b84/webkit-direct-eval-parameter-expressions of oven-sh/bun. On a local Bun build with the preview build of this PR, 17 of its 17 tests pass. With the WebKit of main, 16 of 17 fail.

@github-actions

Copy link
Copy Markdown

Preview build of 644dc4b: autobuild-preview-pr-877-644dc4b0

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant