[JSC] DFG folds a module variable to a constant when an import cycle links a function that writes it before the module's own code - #624
Conversation
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (4)
Included review availability: Your plan provides up to 5 included reviews per hour; 1 remains after this review. WalkthroughThe change updates lexical variable watchpoint handling in JavaScriptCore and adds cyclic module tests. The tests cover linked function calls before module evaluation, TDZ behavior, hoisted bindings, direct and namespace imports, and repeated counter updates. ChangesModule cycle binding synchronization
Priority: ⬇️ Low Merge Risk: ⚪ Minimal · up to The module-cycle watchpoint fix and its targeted regression coverage do not leave a concrete merge-blocking risk. 🚥 Pre-merge checks | ✅ 4✅ Passed checks (4 passed)
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 Comment |
…links a function that writes it before the module's own code DFG folds a closure variable to a constant while its SymbolTableEntry watchpoint set is IsWatched (Graph::tryGetConstantClosureVar). That is sound only if every op_put_to_scope that writes the variable either notifies the set or invalidated it when its CodeBlock linked. A ClosureVar put does the second: CodeBlock::finishCreation invalidates op.watchpointSet, and the put itself never notifies. The set is created lazily, by SymbolTableEntry::prepareToWatch(), when the code that declares the variable links (its ResolvedClosureVar put). For a function or program scope that code always links first. A module is different: its function declarations are instantiated when the module links, so in an import cycle another module can call one before the module's own code is linked. At that point the entry has no set, so linking the function's put invalidates nothing. The module's code links later, creates the set, its initializing store moves it to IsWatched, and nothing invalidates it again. Once the function tiers up to DFG, reads of the variable fold to a stale value and the writes are lost. abstractAccess() now calls prepareToWatch() for a ClosureVar Put, so the set exists when CodeBlock::finishCreation invalidates it. * JSTests/modules/import-cycle-function-linked-before-module-code.js: Added. * JSTests/modules/import-cycle-function-linked-before-module-code/1.js: Added. * JSTests/modules/import-cycle-function-linked-before-module-code/2.js: Added. * Source/JavaScriptCore/runtime/JSScope.cpp: (JSC::abstractAccess): Co-authored-by: Dylan Conway <dylan.conway567@gmail.com>
628f692 to
5a7362b
Compare
JSTests/README.md asks new tests to size their loop with testLoopCount. The test still fails in all 11 module configurations on an unpatched shell, and each configuration runs in at most 122ms on a patched one.
There was a problem hiding this comment.
Thanks for switching the driver to testLoopCount — that addresses the only note from the earlier pass. I re-reviewed and didn't find any bugs; because this is a JIT-correctness change to the ClosureVar watchpoint lifecycle in runtime/JSScope.cpp (paths owned by jsc-reviewers in CODEOWNERS), a human JSC reviewer should still sign off.
What was reviewed
abstractAccess()change:prepareToWatch()runs under the existingsymbolTable->m_locklocker and only on thePutpath, feedingentry.watchpointSet()into theResolveOpsoCodeBlock::finishCreationcan invalidate it — matches theResolvedClosureVarpattern it mirrors.SymbolTableEntry::prepareToWatch()is a no-op when the entry is already fat or unwatchable, so repeated/late calls are safe.- New module regression test: import cycle triggers the pre-link call, asserts via
shouldBe/shouldThrow, iteratestestLoopCounttimes, and covers direct binding, namespace, and reader-function paths for bothletandvar.
Extended reasoning...
Overview
The PR adds a two-line fix in Source/JavaScriptCore/runtime/JSScope.cpp: when abstractAccess() resolves a Put to a ClosureVar in a JSLexicalEnvironment, it now calls entry.prepareToWatch() before constructing the ResolveOp. This ensures the SymbolTableEntry's watchpoint set exists at link time so CodeBlock::finishCreation can invalidate it, closing a hole where an import cycle links a module's function (and its op_put_to_scope) before the module code that would otherwise create the set. Three new files under JSTests/modules/ add a regression test exercising the cycle across all reader shapes (closure read, imported binding, namespace property) for both let and var exports.
Security risks
None identified. The change does not touch auth, crypto, permissions, or untrusted input parsing. prepareToWatch() only inflates a symbol-table entry to allocate a WatchpointSet; it is invoked under the existing ConcurrentJSLocker on symbolTable->m_lock, matching how other callers use it. The worst-case downside noted by the author is a small per-entry allocation for function-scope vars that only inner functions assign — a memory cost, not a safety issue.
Level of scrutiny
High. Although the code delta is tiny, it sits on the boundary between scope resolution, CodeBlock linking, and DFG constant-folding invariants — an area where subtle ordering bugs manifest as silent miscompilations rather than crashes. Both changed paths (Source/JavaScriptCore/** and JSTests/**) are covered by .github/CODEOWNERS (@ WebKit/jsc-reviewers), which per the approval guidelines means this should not be auto-approved.
Other factors
Since the prior review, the author pushed a commit replacing the hardcoded 1e5 loop with testLoopCount, addressing the only inline note. The bug-hunting pass exited on a dry streak with no findings. The PR description includes stress-test parity results and an unpatched-vs-patched failure/pass demonstration for the new test, and the fix mirrors the established prepareToWatch()-before-watchpointSet() pattern used elsewhere (e.g., the ResolvedClosureVar handling in CodeBlock::finishCreation). Given all feedback is addressed and no new issues surfaced, the remaining reason to defer is CODEOWNERS coverage and the inherent subtlety of watchpoint-lifecycle changes.
Preview Builds
|
Problem
SymbolTableEntrywatchpoint set isIsWatched(Graph::tryGetConstantClosureVar). AClosureVarop_put_to_scopenever notifies that set. Linking the put invalidates it instead (CodeBlock::finishCreation:if (op.watchpointSet) op.watchpointSet->invalidate(...)).IsWatched, and it stays that way.Fix
abstractAccess()(runtime/JSScope.cpp) callsentry.prepareToWatch()for aClosureVarPut, so the set exists whenCodeBlock::finishCreationinvalidates it.JSTests/modules/import-cycle-function-linked-before-module-code.js(new,letandvar). An unpatched shell fails all 11 module configurations, a patched shell passes all 11.Background
Clear,IsWatchedon the first write,IsInvalidatedon the second. DFG treats anIsWatchedvariable as a constant.CodeBlocklinks on its first call. Linking resolves each scope access against the real scope chain and caches the result in the instruction's metadata.letbindings are still in their TDZ.Notes
Reported with the patch by @dylan-conway. Reproduces on Bun 1.3.13 and on the plain
jscshell built from this tree. The Bun side is oven-sh/bun#42321: it pins this PR's preview build and adds a regression test to Bun's own suite.abstractAccess()and theop_put_to_scopelink code are byte-identical in upstream WebKitmain, so Safari should have the same bug and the patch applies there unchanged.Repro (
jsc -m main.mjsprintsBAD ...unpatched andOKpatched,--useDFGJIT=falseis alwaysOK):putClosureVarstore without a notify, and the DFG parser reads the watchpoint set from the put's metadata only forResolvedClosureVar,GlobalVarandGlobalLexicalVar. AClosureVarput relies on the link-time invalidation alone.ModuleVarget links as aClosureVarget on the exporter's environment), and a namespace object property (DFGByteCodeParser.cpp, thegetById.moduleEnvironment()path). The test checks all three.symbolTablePutlooks the set up on every call. Globalvarand global lexical entries callprepareToWatch()when they are declared. The bytecode optimizer leavesput_to_scopedynamic for this reason (BytecodeOptimizer.cpp: "linking it also invalidates the variable's watchpoint").Getis unchanged.abstractResolve(..., Put, ...)has one caller, theop_put_to_scopecase inCodeBlock::finishCreation.varthat its declaring function never assigns, and an inner function does, now gets a 16-byteFatEntrythat is invalidated at once. It was not foldable before either.prepareToWatch()runs undersymbolTable->m_lock, as theResolvedClosureVarcase inCodeBlock::finishCreationdoes.jscfrom this tree, with and without the patch, throughTools/Scripts/run-jsc-stress-tests.JSTests/modules.yaml(111 files, 11 configurations): the only difference is the new test, 11 failures to 11 passes. 8 files fail on both shells (they need$vm, or they expect upstream behaviour).JSTests/stress, the 287 files that matchclosure|scope|watchpoint|symbol-table|tdz|captured|put-to|lexical|let-|const-|var-inject|activation(4433 runs): identical results, 1 file fails on both.testLoopCount. With--report-execution-timethe slowest of the 11 configurations takes 122 ms on the patched Release shell. On the unpatched shell the default configuration fails 30 of 30 runs.JSTests/is in the sparse-checkout exclude list of.github/workflows/build-reusable.yml, so this repo's CI builds the shell and does not run the new test.