Repository navigation
[JSC] JSGlobalObject::addSymbolTableEntry makes the global object a dictionary before it adds a name - #738
Open
robobun wants to merge 3 commits into
Open
[JSC] JSGlobalObject::addSymbolTableEntry makes the global object a dictionary before it adds a name#738robobun wants to merge 3 commits into
robobun wants to merge 3 commits into
Conversation
…ictionary before it adds a name
A program of the form `var x = <JSON>;` runs on the JSONP fast path of
Interpreter::executeProgram. That path calls createGlobalVarBinding, which
adds the name to the symbol table of the global object. The Structure of the
global object stays the same, so inline caches and optimized code that hold
"x is not an own property" against that Structure keep their answer:
function get() { return globalThis.x; }
noInline(get);
for (let i = 0; i < 100000; ++i) get();
loadString("var x = 33;");
get(); // undefined, expected 33
A program that runs as bytecode does not have the bug, because
ProgramExecutable::initializeGlobalProperties makes the global object a
dictionary before it creates a binding.
addSymbolTableEntry now converts a global object whose Structure is not a
dictionary before it adds the name. Caches hold the absence of a property
only against a Structure that is not a dictionary, and the conversion gives
the object another Structure and fires the transition watchpoint of the old
one. A declaration of a name that exists creates no binding and converts
nothing.
addStaticGlobals, the other function that adds names to that symbol table,
asserts the same condition. Its callers run while the global object is
created, after init() has made it a dictionary.
* JSTests/stress/jsonp-var-declaration-invalidates-global-object-property-caches.js: Added.
* Source/JavaScriptCore/runtime/JSGlobalObject.cpp:
(JSC::JSGlobalObject::addSymbolTableEntry):
(JSC::JSGlobalObject::addStaticGlobals):
Collaborator
Author
|
Status
|
|
Preview build of 67fe859: |
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 27, 2026
Collaborator
|
@robobun wake up!! |
…e for the LLInt - The reader of the case "cache created by an earlier statement" is cold before the program runs. The second declaration of the program has to convert the global object. - Two cases use a global object with no JSGlobalProxy. The LLInt cache of a load from the prototype chain is stale there without the fix, also with --useJIT=0. - The last block checks that a new name on a dictionary keeps the Structure, and that a new name converts again after the global object was flattened again. - A function declaration as a program.
…mode $vm.evaluateWithScopeExtension() has no bytecode cache, and the second run of the bytecode-cache mode asserts that each program comes from the cache. So the cases that use it give it only programs of the JSONP form, which have no bytecode.
Collaborator
Author
|
I am here. The state of this PR:
I mark this PR ready for review when CI is green on |
robobun
added a commit
to oven-sh/bun
that referenced
this pull request
Sep 29, 2026
robobun
marked this pull request as ready for review
September 29, 2026 00:26
|
Warning Review limit reached
This review includes 2 billable files and costs up to $0.50.
Reviews can continue after your included limit without a manual trigger. An admin must approve usage-based billing. Or wait 20 minutes for your next included review. View limit detailsLimit details: You’ve used all 5 included reviews currently available. Review configuration: ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Essentials Run ID: 📒 Files selected for processing (2)
Comment |
This was referenced Oct 20, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
globalThis.xread keeps its old answer after the scriptvar x = 33;declaresx:stale miss: undefined. No user reported it.interpreter/Interpreter.cpp:1119) adds the name to the symbol table with no new Structure (runtime/JSGlobalObject.cpp:2556).runtime/ProgramExecutable.cpp:219makes the global object a dictionary first.Fix
JSGlobalObject::addSymbolTableEntryconverts a global object that is not a dictionary before it adds the name. That retires the old Structure: its ID changes, and its transition watchpoint fires.addStaticGlobalsgets anASSERT. Its callers run while the global object is created.JSTests/stress/jsonp-var-declaration-invalidates-global-object-property-caches.jsfails on main and passes here in 10 of 10 JIT configurations. Also 15 existing tests.Background
varandfunctionbindings of classic scripts, outside the Structure (hidden class).Downsides
globalThisreads uncached: about 500 instructions each, not 3. Every bytecode script does this today.jsctext: 32 bytes less. A new global binding costs 5 more instructions (321 to 326).Notes
Repro (
jsc)It throws with the JIT on (default,
--useDFGJIT=0,--useFTLJIT=0,--useLLInt=0,--useConcurrentJIT=0) and passes with--useJIT=0. About 40 warm-up calls are enough when the concurrent JIT is off.The interpreter has the same problem for a global object with no JSGlobalProxy in front of it (
$vm.createGlobalObject()in the shell). After 20 calls oftypeof object.toStringand the scriptvar toString = 33;the LLInt cache still answersfunction, also with--useJIT=0.Repro (Bun 1.4.3, node prints
33 33, Bun printsundefined 33)What it takes to see the bug
All of these at once:
var name = <JSON>;,name = <JSON>;ora.b = <JSON>;. The same declaration with one more statement (var x = 33; void 0;) runs as bytecode and is correct. Afunctiondeclaration is never JSONP.globalThisorthis(or as an identifier, for the DFG).require()of a CommonJS file in Bun, or a new global property gives it another Structure and hides the bug.What is stale on main
Every cache that looks through the JSGlobalProxy to the Structure of the global object (
tryCacheGetBy,bytecode/Repatch.cpp:568, andtryCachePutBy,:1072), and the DFG and FTL code built from those caches:globalThis.x,this.x,globalThis[key],super.x,Reflect.get, destructuring, optional chainglobalThis.toStringaftervar toString = 33;)delete namein BaselineNot affected:
in,hasOwnProperty,Object.hasOwn,Reflect.has,Object.keys,for-in, the megamorphic path, and an object whose prototype is the global object. They stop at the proxy or atOverridesGetOwnPropertySlot. A node:vm context is not affected, its global object prohibits caching.Entry points in Bun
oven-sh/bun#44097 pins the preview build of this branch and adds the node:vm and napi tests.
vm.runInThisContext,new vm.Script().runInThisContext(), the same two in a Worker, andnapi_run_scriptwere run and are stale on 1.4.3. The inspector'sRuntime.evaluate, the raw fallback ofbun repland the Bake loaders reachInterpreter::executeProgramtoo. They were not run for this PR.Why the fix is in
addSymbolTableEntrycreateGlobalVarBinding<Global>andcreateGlobalFunctionBinding<Global>, both after their check for an existing name.GetOwnPropertySlotIsImpureForPropertyAbsenceonJSGlobalObject(it covers misses only and turns the miss cache off for every program), a refusal in the caches (the same cost), a watchpoint for each name (every reader has to subscribe, and the write still happens under the live Structure).var x = <1.3 MB of small objects>;takes 26 ms on the fast path and 1,170 ms as bytecode, and 738 KB of nested arrays take 24 ms and 92 ms (jsc, release build of this PR, best of 7).evalflattens an uncacheable dictionary (interpreter/Interpreter.cpp:1548). The next new name then converts again, because the guard runs at each insert.ASSERTinaddStaticGlobalsJSGlobalObject::init()converts the global object first (runtime/JSGlobalObject.cpp:1102). Checked:jscwith assertions and this diff: the main global object,$262.createRealm(),createGlobalObject(),$vm.createGlobalObject(), a$262.agentthread and a ShadowRealm, all made while caches of the main realm were warm. No assertion.addStaticGlobalscalls arrive with a dictionary (process start, ESM, CommonJS, a Worker, node:vm contexts, a ShadowRealm).An embedder that calls
addStaticGlobalsafter code ran needs the conversion there too.Measurements, release
jsc(bun-webkit-linux-amd64-lto), mainf20ce77445against this PRTools: gdb breakpoint counts, gdb single-step counts,
nm -S,objdump -d,size. All with--useConcurrentJIT=0. The column for this PR is thebun-webkit-linux-amd64-ltobuild that CI made of6f120a3bf2. Each step count was the same in 3 of 3 runs, but for the last row of the table and the reads after a conversion below.jsctext (size)addSymbolTableEntry(nm -S,objdump -d)addStaticGlobals(nm -S,objdump -d)addSymbolTableEntry, call 100 of 20,000 bytecode declarations)executeProgram, call 100)x = -0convertToDictionarycalls: empty script / 20,000 bytecode declarations / 20,000 JSONP assignments / 20,000 JSONP declarations of an existing name / 2,000 new JSONP variables after a warm missOne read through
globalThis, in instructions above an empty function call:On main a JSONP script leaves all three reads on their caches (+29, +32, +33), and that is the bug for the name it declared.
The state after the script is a dictionary that was flattened before. The miss and prototype caches give up on it. With this PR a JSONP script that declares a new name leaves the same state that every bytecode script leaves on main.
Tests
--useConcurrentJIT=023 of the 51 fail: 18 of the JSONP form, the 2 with no proxy, and 3 of the last block. With--useJIT=03 fail: the LLInt case and 2 of the last block. With compiler threads the number changes from run to run.--useConcurrentJIT=0,--useDFGJIT=0,--useFTLJIT=0,--useLLInt=0,--useJIT=0,--forceEagerCompilation=1,--useAccessInlining=0, low tier-up thresholds,--validateExceptionChecks=1. Main fails in all 10. The release build of CI and a local build with assertions pass all 10.run-jsc-stress-tests, 17 modes (default,bytecode-cache,mini-mode,lockdown,no-llint, and the eager, no-cjit and FTL modes). The release build and the build with assertions pass 17 of 17. The build of main fails 17 of 17.$vm.evaluateWithScopeExtension()give it only programs of the JSONP form. It has no bytecode cache, and the second run of thebytecode-cachemode asserts that each program comes from the cache.jneof the guard made anopunder gdb) fails the check "new name, the dictionary of init(): same Structure". A build that never converts fails the first cache check.linux-amd64-ltobuild of this branch: passed.stress/can-declare-global-var-invoked-from-jsonp-fast-path.js, fivestress/jsonp-*.js,stress/get-from-scope-global-variable-watchpoint-lookup.js,stress/create-global-function-binding-updates-descriptor-global.js, twostress/cached-custom-*-shadowed-on-flattened-dictionary.js,stress/get-by-id-constant-fold-dictionary-head-shadow-property.js,microbenchmarks/JSONP-negative-0.js, threemicrobenchmarks/global-var-*.js. Tier configurations: default,--useJIT=0,--useDFGJIT=0,--useFTLJIT=0,--useConcurrentJIT=0.Self-review
The four concerns, all closed:
--useConcurrentJIT=0, and it changes with compiler threads.The self-review did not return its final list. These four come from its working files, and I checked each one before I acted on it. Other concerns of that review can be open.
One review comment on this PR asks for a plain transition in place of the dictionary, to keep the caches. It is not adopted, and the thread has the reasons.
Related
#735 and oven-sh/bun#44024 each have one test block that runs only while the reader is in the interpreter. The reason is this bug.
Not in this PR
The JSONP declaration branch (
interpreter/Interpreter.cpp:1116-1126) differs from the bytecode path in more ways. They need no warm cache and this PR does not change them:let a = 1;and then the scriptvar a = 2;: no SyntaxError, andglobalThis.ais 2x = 1; var x = 2;leavesxconfigurableAlso left as it is:
ProgramExecutable.cpp:219converts for every bytecode script, also for one that declares nothing.Upstream WebKit has the same code in all of these places (checked at
be499e24a6).perf,valgrind,straceandbloatyare not installed on the machine that made these numbers.