Skip to content

[JSC] With parameter expressions, a var arguments in the function body is a binding of its own - #715

Open
robobun wants to merge 3 commits into
mainfrom
robobun/869fd8d4/var-arguments-parameter-expressions
Open

robobun wants to merge 3 commits into
mainfrom
robobun/869fd8d4/var-arguments-parameter-expressions

Conversation

@robobun

@robobun robobun commented Sep 22, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • In a sloppy mode function with parameter expressions, a store to the body's var arguments also changes the arguments of the parameter expressions. function f(a = () => arguments.length) { var arguments = 7; return a(); } returns undefined. The spec and V8 return the argument count.
  • Cause: the BytecodeGenerator constructor (bytecompiler/BytecodeGenerator.cpp:812) keeps arguments in the parameter scope and skips the body's var arguments. The body then uses the parameter scope's binding.

Fix

  • The loop over varDeclarations() creates the var like any other var. initializeDefaultParameterValuesAndSetupFunctionScopeStack() already copies a parameter binding's value into a var of the same name.
  • hoistSloppyModeFunctionIfNecessary() loses its fallback to the parameter scope. A function named arguments in a block now goes to the body's var, as in V8.
  • Correct because FunctionDeclarationInstantiation step 28 gives the body a second binding that starts with the first one's value.
  • Verified: JSTests/stress/var-arguments-with-parameter-expressions.js (new) fails on main and passes in every run-jsc-stress-tests mode. Self-reviewed: 8 concerns raised, 8 addressed (Notes).

Background

  • A parameter expression is a default value or a computed key in a pattern. The spec evaluates it in an environment that holds the parameters and arguments. The body's vars live in a second environment below it.
  • JSC builds the two scopes for every non-simple parameter list (default value, pattern or rest parameter).

Downsides

  • A behavior change for code that expects a closure in the parameters to see the body's store. V8 already gives the other result.
  • For a function named arguments in a block, the var exists from the start, as in V8. The spec text makes it when the block runs (Legacy hoisting for function arguments() {} still not correct? tc39/ecma262#991, Notes).
  • A function with parameter expressions that declares var arguments gets one more variable and one copy at entry. All other bytecode is unchanged (598 stress tests compared).
Notes
function m1(a = () => arguments.length) { var arguments = 7; return a(); }
function m2(a = () => arguments.length) { var arguments; return a(); }
function m3(a = () => arguments.length) { let arguments = 7; return a(); }
function m4(a = () => arguments.length) { var arguments = 7; return [a(), arguments]; }
print(JSON.stringify([m1(undefined, 1, 1, 1), m2(undefined, 1, 1, 1), m3(undefined, 1, 1, 1), m4(undefined, 1, 1, 1)]));
// node:  [4,4,4,[4,7]]
// jsc:   [null,4,4,[null,7]]   with this change: [4,4,4,[4,7]]

History. The skip and the test that expects it came with 176000@main (2016, bug 157786), which moved arguments to the parameter scope. Upstream main still has both. test262 has no test for this case: a differential run of test/language and test/annexB (45,053 runs, sloppy and strict) shows no change.

A function named arguments in a block. With this change its var exists from the start and starts as the arguments object, like a var arguments. The block stores the function to it, and the closures in the parameters keep the arguments object. V8 does the same, and the open issue tc39/ecma262#991 proposes it. The current text of Annex B.3.2.1 differs in one detail: it makes that var when the block runs. Only a store to arguments before the block, or a delete arguments after it, can tell the two apart. The new test pins V8's results for both. Before this change the block stored the function to the binding of the parameters, which no reading of the spec gives.

function f(a = () => arguments) { arguments = 5; { function arguments() {} } return [a(), arguments, delete arguments]; }
f();  // node and this change: [arguments object, function, false]   spec text: [5, function, true]   main: [function, function, false]

Two upstream tests expected the old behavior. stress/arrow-functions-as-default-parameter-values.js expected x() === arguments after var arguments = 25. stress/sloppy-mode-hoist-arguments-function-non-simple-parameter-list.js expected the closure in the parameters to see a function named arguments from a block. Each has one new expectation and a // Bun: comment. JSTests/BUN-TEST-DIFFERENCES.md has the new section A10. V8 fails the same two lines of the old tests.

The generated code. For function f(a = arguments, b = arguments.length) { var arguments = b + 3; ... } the var is a register, and the copy is two movs after the parameters. With a closure in the parameters both bindings are in environments: the parameter scope holds one arguments, the var scope holds the other, and the copy is a get_from_scope and a put_to_scope.

Same bytecode for other functions. I dumped the bytecode (--dumpGeneratedBytecodes=true) of 598 stress tests whose names mention parameters, arguments, closures, eval, generators or hoisting, with a jsc of main and with this change. It differs in the three tests above only (and in tests that generate random source or use workers, which also differ between two runs of one binary).

What I ran, with a Release jsc that has assertions on, against a jsc of main built the same way:

  • The new test and the two changed tests in all modes of run-jsc-stress-tests. The new test adds two runs of its own: the debugger's bytecode, and the type and control flow profilers. By hand also --validateBytecode=true --validateGraphAtEachPhase=true, --useJIT=false, --useLLInt=false, --useSourceProviderCache=false and --useBytecodeOptimizer=true.
  • All 5,879 files of JSTests/stress with the default options: the only differences are the three tests above.
  • LayoutTests/jsc-layout-tests.yaml, es6.yaml, ChakraCore.yaml, mozilla-tests.yaml, typeProfiler.yaml, controlFlowProfiler.yaml and modules.yaml: 14,714 runs pass.
  • About 120 small programs (every kind of function, every way to declare the var, Annex B functions, eval, with) compared with node. With this change the only differences left are the four defects below.
  • 50,000 generated programs compared with node: plain functions, methods, constructors, arrow functions, async functions without await and new Function, with up to four parameters from 16 kinds (closures that read or write arguments, eval, patterns, a rest parameter) and up to six body statements from 29 kinds. With this change all 50,000 agree with node. A jsc of main differs on about 5,200. 8,000 of them also ran 60 times each with low JIT thresholds and --useConcurrentJIT=false: the result is the same on every run.

Self-review. Eight concerns, all addressed. (1) Two upstream tests encode the old behavior: changed, with the reason in BUN-TEST-DIFFERENCES.md. (2) Is the removed fallback in hoistSloppyModeFunctionIfNecessary() still reachable? No. finalizeSloppyModeFunctionHoisting() is the only place that marks a function as hoisted, and it makes the name a var of varDeclarations() in the same step. The loop now creates every such var. The names of a generator wrapper's parameters are the one exception, and the hoist returns before the lookup for those. The RELEASE_ASSERT stays. (3) Does other bytecode change? No, see above. (4) The 200 ms rule: the new test takes 80 to 170 ms in its slowest mode (ftl-eager) on a build with assertions, 25 ms with the default options. (5) The first version of this text said that the function in a block follows the spec. It follows V8 and tc39/ecma262#991, see above. The comment in the code, BUN-TEST-DIFFERENCES.md and the test now say so. (6) The change also reaches a direct eval in the body that declares arguments again when the body has var arguments (main made a second binding that the body never read, also with a rest parameter only). The test pins it. (7) Shapes the first test did not pin: a parameter named arguments that a pattern or a rest parameter binds (a simple parameter of that name never reached the changed line), a function expression named arguments, a store combined with a function in a block, and the debugger and profiler modes. All added. (8) One stress test timed out once in a run under load with the debugger's bytecode. It has simple parameters only, and it passes in 1.5 s on both binaries, three times each.

Found next to it, not changed here. Each has a different cause.

  • A direct eval in the body that declares a var with a parameter's name, when the body does not declare it: function f(a = 1) { eval("var a = 2"); return a; } gives 1, V8 gives 2. The body resolves the name to the parameter scope when the code is generated. The same goes for eval("var arguments = 2") and for a function in a block inside the eval.
  • In a generator, an async generator, or an async function that has an await, var arguments; with no initializer reads undefined. The body is an inner function with its own var, and nothing gives it the arguments object.
  • In a generator with parameter expressions, a body var with a parameter's name shares the parameter's binding: function* g(a, b = () => a) { var a = 2; yield b(); } yields 2, V8 yields 1.
  • Not about parameter expressions: (function (a) { return typeof arguments[0]; function a() {} })(1) gives "number", V8 gives "function". The function declaration replaces the parameter, and the mapped arguments object does not see it.
  • A pattern that binds arguments, function (y = () => arguments, z = y(), {arguments}) {}: z is the arguments object. The spec and V8 throw a ReferenceError, because the parameter arguments is not initialized yet.

…ody is a binding of its own

    function f(a = () => arguments.length) { var arguments = 7; return a(); }
    f(undefined, 1, 1, 1); // 4. Before: undefined, because the closure read 7.

When a function has parameter expressions, the body's vars live in an
environment below the one that holds the parameters and `arguments`
(FunctionDeclarationInstantiation, step 28). A var that has the name of a
parameter binding starts with that binding's value, and the two are distinct
after that. "arguments" is one of those bindings.

For a non-simple parameter list the BytecodeGenerator puts "arguments" in the
parameter scope, and it skipped the body's `var arguments`, so the body's name
resolved to the parameter scope. A store in the body changed what the
parameter expressions and their closures read.

The loop over varDeclarations() now creates the var "arguments" like any other
var. initializeDefaultParameterValuesAndSetupFunctionScopeStack() already
copies the parameter scope's value into a var that has the name of a parameter
binding, and "arguments" is in that set.

A function named `arguments` in a block of the body had the same defect:
hoistSloppyModeFunctionIfNecessary() fell back to the parameter scope when the
var scope had no "arguments". The var scope always has it now, so the fallback
is gone. Annex B.3.2.1 stores the function to the VariableEnvironment.

Two upstream tests expected the closure in the parameters to see the body's
store. They expect the arguments object now (JSTests/BUN-TEST-DIFFERENCES.md,
A10). V8 gives the same results.
@github-actions

github-actions Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Preview build of f2bfc5b: autobuild-preview-pr-715-f2bfc5bc

@coderabbitai

coderabbitai Bot commented Sep 22, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

Navigate logical layers of code changes, visualize relationships, and explore their blast radius.

Warning

Review limit reached

  • Run on-demand review

This review includes 3 billable files and costs up to $0.75.

  • Ask an admin to make reviews automatic

Open in CodeRabbit

Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing.

Or wait 7 minutes for your next included review.

Check out review usage here.

View limit details

Limit details: You’ve used all 5 included reviews currently available.

Learn how review limits work.

Review configuration:

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: c343272b-dff5-4ba2-a82d-4c4bfeac1b14

📥 Commits

Reviewing files that changed from the base of the PR and between 7feef38 and f2bfc5b.

📒 Files selected for processing (3)
  • JSTests/BUN-TEST-DIFFERENCES.md
  • JSTests/stress/var-arguments-with-parameter-expressions.js
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Essentials

Run ID: 0e76171d-2948-4efc-be39-905f1c56239f

📥 Commits

Reviewing files that changed from the base of the PR and between 564ac2a and 7feef38.

📒 Files selected for processing (6)
  • CLAUDE.md
  • JSTests/BUN-TEST-DIFFERENCES.md
  • JSTests/stress/arrow-functions-as-default-parameter-values.js
  • JSTests/stress/sloppy-mode-hoist-arguments-function-non-simple-parameter-list.js
  • JSTests/stress/var-arguments-with-parameter-expressions.js
  • Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp

Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review.


Walkthrough

JavaScriptCore now creates separate body and parameter-scope arguments bindings for non-simple parameter lists. Stress tests cover the behavior across function forms and execution cases. Test-difference documentation records the two additional intentional differences.

Changes

Arguments binding semantics

Layer / File(s) Summary
Compiler binding and hoisting
Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
The compiler creates the body var arguments binding and initializes it from the parameter-scope value. Sloppy-mode hoisting resolves arguments through the var scope.
Regression test expectations
JSTests/stress/arrow-functions-as-default-parameter-values.js, JSTests/stress/sloppy-mode-hoist-arguments-function-non-simple-parameter-list.js
Existing tests now verify separate captured and body bindings, including sloppy-mode function hoisting.
Expanded coverage and documentation
JSTests/stress/var-arguments-with-parameter-expressions.js, JSTests/BUN-TEST-DIFFERENCES.md, CLAUDE.md
New tests cover function forms, declarations, closures, parameter shapes, and optimized loops. Documentation records 122 changed tests and category A10.

Priority: ➖ Normal

Merge Risk: ⚪ Minimal · up to 7feef

This change separates parameter and body arguments bindings with regression coverage and is ready to merge.

🚥 Pre-merge checks | ✅ 3 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Description check ⚠️ Warning The description gives a detailed problem statement, fix, testing evidence, and changed behavior. However, it does not include the required Bugzilla URL or the required Reviewed by NOBODY (OOPS!). li… Add the associated Bugzilla URL and Reviewed by NOBODY (OOPS!). line. Add a template-style list of changed paths and relevant functions, or provide the equivalent required information.
✅ Passed checks (3 passed)
Check name Status Explanation
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.
Title check ✅ Passed The title clearly identifies the main change: separate var arguments binding behavior in functions with parameter expressions.
Full details: Description check

Explanation

The description gives a detailed problem statement, fix, testing evidence, and changed behavior. However, it does not include the required Bugzilla URL or the required Reviewed by NOBODY (OOPS!). line, and it does not provide the template-style changed-file/function list.

Warning

Git: CodeRabbit could not clone the repository, so clone-backed analysis was skipped and this review may be incomplete. Verify repository clone access, such as SSH credentials, before requesting another full review. If clone access is intentionally unavailable, use path_filters to narrow the review scope.


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

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

Nothing blocking. The comments below are optional suggestions. There is no need to push a fix for them before merging.

Beyond the inline finding, I also checked whether removing the parameter-scope fallback in hoistSloppyModeFunctionIfNecessary() makes its RELEASE_ASSERT(!entry.isNull()) reachable: Parser.h finalizeSloppyModeFunctionHoisting() always adds a hoisted function's name to the function's declared variables with setIsVar() (or skips hoisting when the name is a parameter), and the var loop in the BytecodeGenerator constructor now creates every such var, so the assert is not newly reachable.

Extended reasoning...

The change touches the bytecode generator's function-scope setup (Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp) so that a body var arguments gets its own binding when a non-simple parameter list puts arguments in the parameter scope, and removes the matching fallback in sloppy-mode function hoisting; it also rewrites two upstream test expectations, adds a new stress test, and updates BUN-TEST-DIFFERENCES.md and the test count in CLAUDE.md. No security-sensitive surface is touched. It is a deliberate semantic divergence from upstream WebKit in a core engine path, and an inline finding reports a case where the new behavior disagrees with the spec, so a human should weigh it.

Comment thread Source/JavaScriptCore/bytecompiler/BytecodeGenerator.cpp
…at the function in a block follows

The stress test also covers a parameter named `arguments` that a pattern or a
rest parameter binds, a function expression named `arguments`, a direct eval
in the body that declares the name again, and a store before (and a `delete`
after) a function named `arguments` in a block. It also runs with the
debugger's bytecode and with the profilers.

For a function named `arguments` in a block, the var exists from the start and
starts as the arguments object. That is what V8 does and what the open issue
tc39/ecma262#991 proposes. The current text of Annex B.3.2.1 makes the var when
the block runs. Only a store before the block or a `delete` after it can tell
the two apart. The comment in the BytecodeGenerator and
JSTests/BUN-TEST-DIFFERENCES.md now say so.
@robobun

robobun commented Sep 22, 2026

Copy link
Copy Markdown
Collaborator Author

The bun side is oven-sh/bun#43772. It pins the preview build of the first commit here and adds a test that runs the same cases through Bun.

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

I re-reviewed after the follow-up commit and found no bugs; because this changes scoping semantics in the bytecode generator, diverges from upstream WebKit on two of its tests, and pins a deliberate V8-over-current-Annex-B choice for the block-function case, a JSC owner should still sign off on that policy.

What was reviewed:

  • The var-creation path: with arguments in the parameter scope, the body's var arguments now goes through the existing step-28 copy (parameterSet includes arguments at BytecodeGenerator.cpp:1278), so it starts as the arguments object and stays a separate binding.
  • Reachability of the remaining RELEASE_ASSERT in hoistSloppyModeFunctionIfNecessary after the parameter-scope fallback was removed: every hoisted name is an isVar entry the loop now creates, and generator/async wrapper parameter names return early before the lookup; generator bodies had a simple parameter list before and are unchanged.
  • The follow-up commit answers the earlier inline note by documenting the block-function-named-arguments behaviour in BUN-TEST-DIFFERENCES.md A10 and adding tests that pin it; the C++ diff is otherwise unchanged from the first push.
Extended reasoning...

The change removes the skip of var arguments in the BytecodeGenerator constructor's var loop and the parameter-scope fallback in hoistSloppyModeFunctionIfNecessary, so a sloppy function with parameter expressions gets two arguments bindings; it also rewrites one expectation in each of two upstream stress tests, adds a 195-line stress test, and documents the divergence in BUN-TEST-DIFFERENCES.md and CLAUDE.md. No security-sensitive surface is touched. The mechanism reuses the existing step-28 copy for vars that shadow parameters and the diff is small, but it is a language-semantics change that intentionally diverges from upstream WebKit and, for a function named arguments in a block, from the current Annex B.3.2.1 text in favour of V8, and /Source/JavaScriptCore and /JSTests are CODEOWNERS-covered, so a human decision on that policy is warranted rather than automated approval.

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