JSC: shorthand property in arrow should not force enclosing function to capture arguments - #379
Conversation
…to capture arguments parseProperty called setInnerArrowFunctionUsesEval() unconditionally for every shorthand property when currentScope() was an arrow function (introduced in r197296 / bug 153981). That marked the arrow scope as using eval, so the enclosing ordinary function computed m_needsArguments = true in BytecodeGenerator and stored its arguments object in the JSLexicalEnvironment. Any closure returned from such a function then retained every call argument for the closure lifetime. TypeScript createProgram(createProgramOptions) contains an arrow of this shape, and the options object it receives holds oldProgram. Because the ~100 closures it returns share a captured arguments, every Program keeps its predecessor alive, so tsserver grows JS heap by several MB per file save and never reclaims it (oven-sh/bun#9769, oven-sh/bun#15857). The unconditional call is redundant: useVariable() already sets m_usesEval when the identifier is eval, and the scope-pop handler setInnerArrowFunctionUsesEvalAndUseArgumentsIfNeeded() propagates that flag (and the arguments flag for { arguments }). The canonical identifier path in parsePrimaryExpression does not perform this extra step either. Remove it.
|
Caution Review failedThe pull request is closed. ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Run ID: 📒 Files selected for processing (1)
Disabled knowledge base sources:
WalkthroughChangesParser eval tracking
Comment |
Preview Builds
|
There was a problem hiding this comment.
Beyond the inline note: I checked whether removing this call could regress { eval } / { arguments } shorthand semantics inside arrows — useVariable() (Parser.h:650-654) already sets m_usesEval for the eval identifier, and the arrow-scope-pop hook (Parser.h:1363-1364 → setInnerArrowFunctionUsesEvalAndUseArgumentsIfNeeded) propagates both the eval and arguments flags, so the capture behavior for those cases is preserved. parsePrimaryExpression's bare-identifier path has no equivalent call either, confirming this was asymmetric. The 2-line removal itself looks semantically sound.
Extended reasoning...
This run's inline finding is a nit about the stress test being deleted (in response to my previous review) rather than fixed, leaving the parser change without regression coverage. Separately from that, I traced the correctness question the removed lines raise: does { eval } or { arguments } as a shorthand property inside an arrow still cause the enclosing ordinary function to capture what it must? useVariable(ident, isEval) unconditionally does m_usesEval |= isEval and adds the identifier to the used-variables set; when the arrow scope is popped, setInnerArrowFunctionUsesEvalAndUseArgumentsIfNeeded() reads m_usesEval (and the arguments used-variable) and sets the corresponding inner-arrow-function feature bits, which mergeInnerArrowFunctionFeatures then bubbles to the parent. So the removed unconditional setInnerArrowFunctionUsesEval() was redundant for the eval case and simply wrong for every other identifier. Not approving because this is a JSC parser-semantics change (not mechanical) that diverges from upstream and now ships without a test — a human should sign off.
| JSTextPosition start = tokenStartPosition(); | ||
| JSTokenLocation location(tokenLocation()); | ||
| currentScope()->useVariable(ident, m_vm.propertyNames->eval == *ident); |
There was a problem hiding this comment.
🟡 The stress test was deleted (ba2b56b) rather than fixed with the suggested one-line releaseWeakRefs(); insertion, so this parser-semantics change now lands with no regression coverage — and the PR description's "covered by the added stress test" line is stale. Consider restoring JSTests/stress/shorthand-property-in-arrow-does-not-capture-arguments.js with releaseWeakRefs(); added before the gc() calls instead of dropping it.
Extended reasoning...
What happened
The previous review round flagged that the new stress test would always throw closure retained arguments: 64/64 because all 64 new WeakRef(opts) constructions and both gc() calls happen in the same synchronous turn, so [[KeptAlive]] keeps every target alive through GC. The suggested fix was a one-line insertion of releaseWeakRefs(); (or drainMicrotasks();) before the gc() calls to cross a turn boundary.
Instead of applying that fix, commit ba2b56b deleted the test file entirely. The PR now touches only Source/JavaScriptCore/parser/Parser.cpp (changed-files count = 1), and the PR description still claims:
arguments/evalsemantics inside arrows are unchanged (covered by the added stress test)
which is no longer true — there is no added stress test.
Why it matters
The Parser.cpp change itself looks correct: useVariable(ident, m_vm.propertyNames->eval == *ident) already sets m_usesEval when the shorthand identifier is eval, and setInnerArrowFunctionUsesEvalAndUseArgumentsIfNeeded() on scope pop propagates both the eval flag and the arguments flag for { arguments }. So removing the unconditional setInnerArrowFunctionUsesEval() should not regress semantics.
But that is exactly what the deleted test was written to lock in. Without it:
- There is no regression test guarding against a future refactor reintroducing the over-capture (the original bug —
make(opts)'s closures retainingarguments[0]via the lexical environment — would silently return). - There is no test verifying that
{ eval }/{ arguments }shorthand inside an arrow still correctly cause the enclosing ordinary function to capture what it needs. - The PR description is now factually wrong about what the PR contains.
Step-by-step: how the restored test would work
With the one-line fix applied, the test does distinguish fixed vs. unfixed behavior:
- Loop 64×: build
opts = { previous: cur, payload: new Uint8Array(1<<20) }, pushnew WeakRef(opts), callcur = make(opts). - Call
releaseWeakRefs();→VM::finalizeSynchronousJSExecution()bumpsm_currentWeakRefVersion, soJSWeakObjectRef::visitChildrenImplno longer strongly visits the targets. gc(); gc();- Without this PR's parser change:
make's bytecode containscreate_direct_arguments+put_to_scopeinto the var-scope lexical environment; every returned{ worker, inner }closure retainsarguments[0] === opts, which retainsopts.previous, forming a chain — all 64optssurvive →alive == 64→ test throws. - With this PR's parser change:
makeno longer capturesarguments; eachoptsis only reachable via its successor'sprevious, and only the finalcuris a root. Prioroptsobjects become unreachable once overwritten →alivedrops to ≤ 4 → test passes.
That is precisely the coverage this fix wants.
Suggested fix
Restore JSTests/stress/shorthand-property-in-arrow-does-not-capture-arguments.js from df3f3fe and insert one line:
releaseWeakRefs();
gc();
gc();(or equivalently drainMicrotasks();, or move the GC + assertion into setTimeout(..., 0) as JSTests/stress/v8-finalizationregistry-and-weakref.js does). Also drop or update the "covered by the added stress test" sentence in the PR description if the test stays deleted.
parsePropertycallssetInnerArrowFunctionUsesEval()unconditionally for every shorthand property whencurrentScope()is an arrow function (introduced in r197296 / https://bugs.webkit.org/show_bug.cgi?id=153981). That marks the arrow's scope as "uses eval", so the enclosing ordinary function computesm_needsArguments = trueinBytecodeGeneratorand stores itsargumentsobject in theJSLexicalEnvironment. Any closure returned from such a function then retains every call argument for the closure's lifetime.Real-world impact
TypeScript's
createProgram(createProgramOptions)contains an arrow of this shape:createProgramreturns ~100 closures over its locals, andarguments[0]is the options object holdingoldProgram. Becauseargumentsis captured in those closures' shared lexical environment, everyProgramkeeps its predecessor alive. Under a TypeScript language server each file save creates a newProgram, so retained JS heap grows by several MB per save with no plateau; a heap snapshot shows a linked list ofProgramobjects reachable viaJSLexicalEnvironment→arguments→[0]→.oldProgram→ previousProgram(oven-sh/bun#9769, oven-sh/bun#15857).Repro
With
dumpGeneratedBytecodes=1,makeemitscreate_direct_arguments(orcreate_cloned_argumentsin strict mode) followed by aput_to_scopeof that value into the var-scope lexical environment. Change the arrow to(r) => ror afunctionexpression and both opcodes disappear.Fix
Remove the two lines.
useVariable()already setsm_usesEvalwhen the identifier iseval, and the scope-pop hooksetInnerArrowFunctionUsesEvalAndUseArgumentsIfNeeded()propagates that flag (and theargumentsflag for{ arguments }) correctly. The canonical identifier path inparsePrimaryExpressiondoes not perform this extra step either. This is a pure de-optimization removal;arguments/evalsemantics inside arrows are unchanged (covered by the added stress test).