Skip to content

[JSC] The inline cache of a DFG/FTL global variable read throws when the property is gone - #908

Open
robobun wants to merge 2 commits into
mainfrom
robobun/0767d4fc/dfg-global-read-not-found
Open

robobun wants to merge 2 commits into
mainfrom
robobun/0767d4fc/dfg-global-read-not-found

Conversation

@robobun

@robobun robobun commented Oct 26, 2026 •

Copy link
Copy Markdown
Collaborator

Problem

  • DFG and FTL code reads undefined for a global name after its property is deleted. LLInt, Baseline and AOT code throw ReferenceError: x is not defined.
  • The DFG parser emits a plain GetByIdFlush when it cannot prove where the property is (dfg/DFGByteCodeParser.cpp:10835). Its inline cache caches the miss.
  • No user reported it. A deleted value regressed with upstream 318918@main (Bun 1.4.0). [JSC] Add live module exports for module mocks, plus fifteen smaller changes for bun test #781 lists it as a known difference.

Fix

  • New access type GetByIdThrowIfNotFound, picked from the bytecode's ResolveMode: its slow path throws the ReferenceError and caches no miss. A hit runs the code of GetById.
  • A cache that gives up drops its cases (second commit), so a site that does not cache costs what it costs with GetById.
  • Verified: JSTests/stress/global-property-read-not-found.js fails in 17 of 18 modes on main and passes here, also with assertions. Bun's new test passes with this change on Bun's pin.
  • Self-reviewed: 30 concerns raised, 26 addressed. Not done: a test that pins the cache (needs a new $vm function), an upstream bug report, a named owner.

Background

  • ThrowIfNotFound is the mode of every read of a global name except typeof x.
  • An inline cache keeps, per site, where a property was for each Structure. Its access type selects the slow path operations.
  • Considered GetDynamicVar (11 lines), as [JSC] Ahead-of-time compilation #757 has for the write. With no inline cache a getter global, such as Bun's Buffer, costs 458 instructions per read, not 62.5.

Downsides

Notes

Repro in the jsc shell. jsc --useConcurrentJIT=0 repro.js prints 399 on main (f994ea3) and on 680ef47, the commit Bun pins. It prints -1 here and with --useDFGJIT=0.

function read() {
    try { return document; } catch (e) { return "threw"; }
}
globalThis.document = {};
read();
delete globalThis.document;
let firstWrong = -1;
for (let i = 0; i < 3000000; i++) {
    if (read() !== "threw") { firstWrong = i; break; }
}
print("first wrong answer at call: " + firstWrong);

How the parser gets to the fallback. From graph dumps (--dumpGraphAfterParsing=1, --verboseOSR=1):

  • The lower tiers cache the structure and offset of the property in the metadata of op_get_from_scope. After the delete their structure check fails, and their slow path throws.
  • The first DFG compile emits CheckStructure of that stale structure. It exits with BadCache. The next compile sees the exit site, and GetByStatus::computeFor answers LikelyTakesSlowPath, so the parser emits GetByIdFlush.
  • When there was never a structure to cache, the first compile emits GetByIdFlush at once, and the code that is already there answers wrong at the first call after the delete. That is the case for an accessor, a property with a C++ getter, a property that the global object inherits, and a global object that is an uncacheable dictionary (129 deletes in a row make it one).

Where it comes from. No user reported this. It was found in a test of reads of browser globals in Bun.

  • The second bullet above is upstream 318918@main (webkit.org/b/320802, "Avoid repeated OSR exits in op_get_from_scope and op_put_to_scope"). It added the hasBadCacheExitSite test to both parser arms, "so we'll emit a generic IC instead of speculating and failing repeatedly". Before it, the DFG kept its CheckStructure, and a deleted value threw.
  • It reached Bun with Upgrade WebKit to 47f7250137c6 bun#39371 and shipped in Bun 1.4.0. Bun 1.3.14 answers right for the deleted value. Bun 1.4.0 to 1.4.3 do not.
  • The third bullet is older. Bun 1.3.14 has it too.
  • Upstream main has the same GetByIdFlush, and no test for a name that is gone. No upstream bug is filed for it.
  • [JSC] Add live module exports for module mocks, plus fifteen smaller changes for bun test #781 has it as a known difference from V8: "Once the DFG has compiled a global that was found, it doesn't ask [[HasProperty]] again. That's also true without a Proxy, for example Object.prototype.x read in a hot loop and then deleted." This PR removes the part without a Proxy.
  • [JSC] Ahead-of-time compilation #757 requires the ReferenceError from code compiled ahead of time (global-bindings/programs/delete-then-read-throws in aot/tests/AOTGlobalBindingProgramTests.cpp), and fixed the write arm of this parser fallback.

What is wrong where. The same scripts on three builds, with --useConcurrentJIT=0:

680ef47 (Bun 1.4.3) main this PR
read of a deleted value wrong at call 399 wrong at call 399 right
call of a deleted function TypeError: f is not a function at call 396 the same right
read of a deleted getter, after warm-up wrong at call 0 wrong at call 0 right
read of a deleted C++ custom getter, after warm-up wrong at call 0 wrong at call 0 right
read of a deleted getter, then a script var x = 9; undefined undefined 9
strict write to a deleted value wrong at call 445, creates the global right (#757) right, not changed
typeof of a deleted name, sloppy write right right right

The write is not in this PR. An access type for the strict mode write was built too (75 instructions per write through a global setter, where PutDynamicVar costs 843). It is left out. A cached setter stays in use after a script declares var x; with that name, which PutDynamicVar gets right (see #738 below), and at a site that does not cache it costs 41 to 58 instructions more than PutDynamicVar.

Why no miss can be cached. Every way a GetByIdThrowIfNotFound access can end without a property:

  • The two operations (Optimize, GaveUp) throw when getPropertySlot finds nothing, before repatchGetBy.
  • tryCacheGetBy builds AccessCase::Miss only for an unset slot. It returns first for this kind.
  • tryFoldToMegamorphic lists the access types that get a megamorphic cache. The new one is not in it.
  • The DFG folds a GetByIdFlush only when GetByStatus::computeFor(globalObject, structures, ...) is Simple, and that needs the property on the structure.
  • The fixup phase gives the base a cell edge and converts the node to nothing else. So the FTL never takes its arm for a base that is not a cell, and the access type needs no Generic operation.

Tests. run-jsc-stress-tests on the two new files, 18 modes each (the 17 default ones and --forceICFailure=true, which sends every access to the GaveUp operation):

build global-property-read-not-found.js global-property-strict-write-not-found.js
680ef47 fails in 17 fails in 17
main fails in 17 fails in 13 to 15, only where it reads
this PR passes in 18 passes in 18
this PR, ENABLE_ASSERTS=ON passes in 18 passes in 18
  • The mode that passes everywhere is lockdown, which has no JIT.

  • The write test is the first one in JSTests for the statement of [JSC] Ahead-of-time compilation #757. It has a strict write through an inherited setter that a later script shadows with var. A build with an inline cache for the write fails there.

  • The C++ custom getters are on the prototype chain of a second global object ($vm.createGlobalObject). The shell has no custom getter directly on a global object. Bun has, and that case ran there (see Bun below).

  • The build has -Wall, and no -Wswitch diagnostic names a changed file. No tested CI lane has assertions on, so the assertions build above was made by hand.

  • A build with GetDynamicVar passes both tests too. Nothing in CI compares speed. The two files in JSTests/microbenchmarks are an input for run-jsc-benchmarks, no more.

  • The related tests (804 files of JSTests/stress whose names have global, scope, get-by-id, getter, length and the like, 12447 runs) pass on the assertions build.

  • All of JSTests/stress with --memory-limited on the Release build, 91132 runs: 20 fail, in bigint-inc-dec-in-place (17), big-int-out-of-memory-tests, bigint-terminate-multiply and ftl-osr-exit-stack-overflow-phantom-array. Each ends with Crashing because current footprint: ... exceeds limit: 629145600, the limit that the option sets, and each fails the same way on main (the last one in 5 of 6 runs on both).

Instructions per read (perf_event_open, user space, x64 Linux, ASLR off, (count(4M) - count(2M)) / 2M, Release -O3 -march=nehalem, same toolchain for all builds). FTL / DFG only:

read through the fallback main this PR with GetDynamicVar
a getter global 62.50 / 86.00 62.50 / 86.00 458 / 477
an inherited name 18.50 / 39.00 18.50 / 39.00 490 / 494
a C++ custom getter global 67.00 / 64.00 67.00 / 64.00 566 / 567
a value, on a global object after its first burst of deletes 14.50 / 29.00 14.50 / 29.00 220 / 224
Math, Array, Object on such a global object 67.00 / 105.00 67.00 / 105.00 733 / 748
typeof of a getter global 70.00 / 92.00 70.00 / 92.00 the same
control: a plain value 6.50 / 11.00 6.50 / 11.00 the same
  • The lower tiers cache only an own value. A getter global and an inherited name cost 685 and 715 instructions per read in Baseline, and 881 and 812 in LLInt, on main and here. So the column on the right is about optimized code only.
  • In Bun, Buffer is such a global: a function that reads Buffer gets GetByIdFlush(... cachable-id {uid:(Buffer)} ...) from the DFG of Bun 1.4.3.

A site that does not cache. Every read is a call of the GaveUp operation. FTL / DFG only:

read 680ef47 main this PR with GetDynamicVar
a getter global, --forceICFailure=true 502.50 / 504.00 518.50 / 520.00 516.50 / 518.00 458 / 477
an inherited name, --forceICFailure=true 531.50 / 518.00 533.50 / 520.00 532.50 / 519.00 490 / 494
a value, on a global object whose one flatten is spent 265.50 / 252.00 269.50 / 256.00 268.50 / 255.00 220 / 224
a getter global, at a site that saw more than 8 structures 531 / 565 529 / 563 479 / 523
an inherited name, at such a site 584 / 602 583 / 601 551 / 579
  • A global object is an uncacheable dictionary after 129 deletes in a row. An inline cache flattens it once. After the next such burst nothing is cached for it, on main and here. GlobalRegistrator.unregister() of happy-dom 17.0.3 deletes 344 globals: in Bun 1.4.3 a read of a global that it did not touch costs 1 to 2 ns before the second register() and unregister(), and 11 to 16 ns after. That is an older limit of the inline caches, and not changed here.
  • A site fills its cache with 8 structures of the global object. On main the eighth case folds into a megamorphic cache, which does not take a global object and resets the site. This access type has no megamorphic case, so repatchGetBy resets the site when its cache gives up (the second commit). Without that the 8 handlers stay in front of the call, which costs 38 instructions more per read in the DFG and 7 in the FTL (603 and 538 in the last rows). The numbers of the last two rows include the call of the function that reads.

Slow path calls (gdb breakpoint counts, N = 20000 and 40000 reads):

  • A getter global that is there: 2 and 2 calls of operationGetByIdThrowIfNotFoundOptimize, so none per read. main: 2 and 2 of operationGetByIdOptimize. A C++ custom getter: 3 and 3.
  • A missing name: 19220 and 39220 calls of it, one per read in optimized code. 0 calls of the GaveUp operation and of the four plain GetById operations.

A name that stays missing. 200000 reads after the delete: 0 wrong (199220 wrong on main). 3526 instructions per read with the throw and the catch, 4049 with the DFG off. The function is compiled 4 times by the DFG at 200000 and at 400000 reads, so there is no recompile loop. BadCache exits in a script that defines and deletes the name 40 times: 216, and 216 on main.

A global object that keeps changing shape.

  • 40 deletes of other properties with 1000 reads of a getter global after each: 33998 GaveUp calls, 33837 on main.
  • 40 cycles of define, 1000 reads, delete, 1000 reads: 55998 GaveUp calls, 76997 on main. A miss takes no place in the cache now.
  • Handler compilations in that script: 9, and 6 on main. In the getter benchmark: 1 and 1.

Size. jsc text: 49228940 to 49231356 bytes, +2416. The two new operations are 1819 bytes of it. sizeof(SharedJITStubSet): 264 to 272, one slow path handler slot per access type and VM. DFG::GetByIdData stays 16 bytes, HandlerPropertyInlineCache 80, RepatchingPropertyInlineCache 144. The startup of the shell calls neither new operation. Its instruction count is 5017185 on main and 1955 more here, but builds of this change differ by 4400 among themselves (a hash table keyed by the addresses of functions), so that number says nothing.

Other open PRs.

Upstream. The source files have no fork guard. 14 of the 15 apply to WebKit/WebKit main (801209c) as they are. In the parser one hunk of 8 does not: a GetByIdData in handleIteratorNext that only the fork has. The tests expect this fork's message, x is not defined. Upstream's is Can't find variable: x.

Bun. Bun 1.4.3 pins 680ef47. With console.log for print, the script above prints 399 under BUN_JSC_useConcurrentJIT=0, a few thousand without it, and -1 under BUN_JSC_useDFGJIT=0. Node v26.3.0 prints -1.

Bun cannot link a build of this branch before its own upgrade past #757. So the branch robobun/0767d4fc/read-not-found-on-680ef47cec has the two commits of this PR and the write statement of #757 on top of 680ef47. Its lanes are autobuild-aef2ee0608c6c30e03ce8eee4122ce6c10b37ee4, from a run of the workflow by hand. That branch is for this test only. The table is from two Release builds of Bun main (179664ea89, --lto=off, Linux x64): one with 680ef47, one with --webkit-version=aef2ee0608c6c30e03ce8eee4122ce6c10b37ee4.

Bun on 680ef47 Bun with this change
the script above, BUN_JSC_useConcurrentJIT=0 399 -1
the new test for Bun's suite (reads and strict writes of deleted globals, in subprocesses) 2 fail 2 pass
happy-dom 17.0.3, 300 cycles of register(), 100 uses of document, unregister(): cycles where a read of document is not a ReferenceError after it 297 0
test/js/bun/jsc, test/js/node/vm and three more files (34 files) 805 pass, 2 fail 807 pass, 0 fail
726 test files (test/js/web, test/js/bun/util, test/regression/issue, four directories of test/js/node), one process for each file 20914 pass, 17 fail 20913 pass, 18 fail
instructions per iteration, FTL / DFG only: Buffer.isBuffer(b) 68.00 / 67.00 68.01 / 67.00
a read of self 80.76 / 93.01 80.76 / 93.01
a read of a getter global 62.50 / 86.01 62.51 / 85.99
a read of an inherited name 18.51 / 38.99 18.50 / 39.01
Buffer.from(arrayBuffer).length 690.14 / 755.74 690.99 / 755.64
after three cycles of happy-dom: two reads of globals and Buffer.isBuffer(b) 835.92 / 799.43 831.13 / 793.42
text of bun 102140490 102143306 (+2816)
  • Node v26.3.0 and Bun with BUN_JSC_useDFGJIT=0 give 0 in the happy-dom row too.
  • The DFG graphs of these scripts have the same nodes on both builds. Each GetByIdFlush of a global has ThrowIfNotFound on the second one.
  • A module's strict write through a setter global costs 79.82 / 104.00 instructions on 680ef47 and 820.00 / 839.99 with the statement of [JSC] Ahead-of-time compilation #757. That is not from this PR. It is what an inline cache for the write would take back.
  • A debug build of Bun with ASan, on the -debug-asan lane of that autobuild (JavaScriptCore with assertions), passes the new test and the happy-dom loop. On it, a read of each of 15 of Bun's own globals after its delete (self, onerror, Buffer, process, fetch, URL, setTimeout and more) is a ReferenceError at every call, also under BUN_JSC_validateExceptionChecks=1. On 680ef47 these read undefined from call 0 or call 601. self has a getter in C++ directly on the global object, which the jsc shell cannot test.
  • In the row of 726 files, 17 tests fail on both builds. The one more is a pipe test in streams-leak.test.ts that takes about a minute on this machine, which was the timeout of the run. Run six more times, it timed out 2 times on the first build and 5 times on the second. Its loop executes no more instructions on the second build (242 and 251 million for 1000 rounds, against 259 and 274), so that is its timing.
  • The test for Bun's suite is on the branch robobun/0767d4fc/global-variable-not-defined-test of oven-sh/bun (test/js/bun/jsc/global-variable-not-defined-after-tier-up.test.ts). It goes in with the WebKit upgrade that has this change. Only Linux x64 was run. No run of Bun's CI was made with this build.

…s gone

A name that is a property of the global object is read with op_get_from_scope
and the resolve type GlobalProperty. When no property has the name, the read
is a ReferenceError if its mode is ThrowIfNotFound. That is the mode of every
read except `typeof name`. LLInt, Baseline and code compiled ahead of time
throw it from their slow paths.

The DFG bytecode parser loads the property directly when it can prove where
the property is. When it cannot, it emitted a GetByIdFlush of the global
object. That is a plain get by id: it gives undefined when it finds no
property, and its inline cache caches that answer. So once a function was
compiled by the DFG, a read of a global whose property was deleted gave
undefined, and a call of it gave "x is not a function".

The parser takes this path when the lower tiers have no structure to check:
an accessor, a property with a getter in C++, a property that the global
object inherits, a global object that is an uncacheable dictionary. Since
318918@main (webkit.org/b/320802) it also takes it when the structure that
the lower tiers cached no longer matches and the site exited once. That is
the case of a plain value that was deleted.

Add the inline cache access type GetByIdThrowIfNotFound. Its two slow path
operations throw the ReferenceError when they find no property, and never get
as far as caching it. tryCacheGetBy builds no miss case for it, and the access
type is not in the list that allows a megamorphic cache, so every way the
access can end without a property is one of these operations. A hit is what
it is for GetById: the same cases, the same handlers, the same machine code at
the site.

GetByIdData carries the ResolveMode of the access, and has to be given one.
The parser passes the mode of the bytecode for the read of a global, and the
DFG and FTL back ends pick the access type from it. The base of such a read is
a scope, so the fixup phase gives it a cell edge, and there is no operation
for a base that is not a cell.

The write is not changed. A strict mode write to such a name is a
PutDynamicVar since #757, and global-property-strict-write-not-found.js is
the test for it. Without this change that test fails only where it reads.

Upstream main has the same GetByIdFlush.

* JSTests/microbenchmarks/global-accessor-property-read.js: Added.
* JSTests/microbenchmarks/global-inherited-property-read.js: Added.
* JSTests/stress/global-property-read-not-found.js: Added.
* JSTests/stress/global-property-strict-write-not-found.js: Added.
* Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp:
* Source/JavaScriptCore/bytecode/PropertyInlineCache.cpp:
* Source/JavaScriptCore/bytecode/PropertyInlineCache.h:
* Source/JavaScriptCore/bytecode/Repatch.cpp:
* Source/JavaScriptCore/bytecode/Repatch.h:
* Source/JavaScriptCore/dfg/DFGByteCodeParser.cpp:
* Source/JavaScriptCore/dfg/DFGFixupPhase.cpp:
* Source/JavaScriptCore/dfg/DFGGraph.cpp:
* Source/JavaScriptCore/dfg/DFGNode.cpp:
* Source/JavaScriptCore/dfg/DFGNode.h:
* Source/JavaScriptCore/dfg/DFGSpeculativeJIT64.cpp:
* Source/JavaScriptCore/ftl/FTLLowerDFGToB3.cpp:
* Source/JavaScriptCore/jit/ICStats.h:
* Source/JavaScriptCore/jit/JITOperations.cpp:
* Source/JavaScriptCore/jit/JITOperations.h:
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

Status. The PR is open and waits for review: #908

How it was reproduced. With the script from the description, in the jsc shell:

jsc --useConcurrentJIT=0 repro.js
  • main (f994ea3) and 680ef47 (the commit Bun pins): first wrong answer at call: 399
  • This PR, and main with --useDFGJIT=0: first wrong answer at call: -1

In Bun 1.4.3 the same script (with console.log for print) prints 399 under BUN_JSC_useConcurrentJIT=0 and -1 under BUN_JSC_useDFGJIT=0.

@coderabbitai

coderabbitai Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Review in Change Stack →

No actionable comments were generated in the recent review.

Recent review info
Run configuration
  • Source mode: Auto
  • Repository: Unavailable
  • Branch: Unavailable
  • File: No configuration file from this repository applied
  • Commit: Unavailable
  • Layers: Organization UI
  • Review profile: ASSERTIVE
  • Plan: Advanced
  • Run ID: d4382d3a-46c3-4fc9-a6d3-c0a11e542f0c
Commits

Reviewing files that changed from the base of the PR and between f0f4ade and edd7475.

Files selected for processing: 1
  • Source/JavaScriptCore/bytecode/Repatch.cpp

Included review availability: This review used your included allowance. Your plan provides up to 2 included reviews per hour; 1 remain after this review.

Walkthrough

The change carries ThrowIfNotFound resolution through DFG nodes and JIT inline-cache operations. It adds stress tests for missing global reads and strict writes, plus microbenchmarks for accessor and inherited-property reads.

Changes

Global Property Resolution

Layer / File(s) Summary
Retain resolution mode in DFG
Source/JavaScriptCore/dfg/DFGNode.h, DFGByteCodeParser.cpp, DFGFixupPhase.cpp, DFGSpeculativeJIT64.cpp, FTLLowerDFGToB3.cpp, DFGNode.cpp, DFGGraph.cpp
DFG get-by-identifier data now stores a resolve mode. Global scope reads pass that mode through parsing and JIT lowering. Other read paths specify non-throwing modes.
Throwing inline-cache path
Source/JavaScriptCore/bytecode/PropertyInlineCache.h, Repatch.h, Repatch.cpp, PropertyInlineCache.cpp, InlineCacheCompiler.cpp, Source/JavaScriptCore/jit/JITOperations.h, JITOperations.cpp, ICStats.h
The inline-cache path adds a get-by-identifier access type for missing-property errors. JIT operations handle lookup and missing properties, and repatching handles eligible caches.
Global read tests and benchmarks
JSTests/stress/global-property-read-not-found.js, JSTests/microbenchmarks/global-accessor-property-read.js, global-inherited-property-read.js
Stress tests cover reads across property deletion and restoration, accessors, inherited properties, bindings, and realms. Benchmarks check repeated reads from accessor and inherited properties.
Strict global write tests
JSTests/stress/global-property-strict-write-not-found.js
The stress test checks strict writes to missing, present, accessor, and inherited properties. It also includes property changes, garbage collection, and a sloppy-mode control case.

Priority: Normal

Merge Risk: Minimal · up to edd74

No concrete merge-blocking risk is established in the reviewed change.

Pre-merge checks: Passed 3 · Failed 1

Failed Failed checks: 1 warning

Check name Status Explanation Resolution
Description check Warning The description follows the WebKit-style intent of the template: it names the bug (DFG/FTL read undefined for a deleted global where LLInt, Baseline and AOT throw ReferenceError), explains the fix… Add the WebKit Bugzilla bug link in the form https://bugs.webkit.org/show_bug.cgi?id=##### and use a bug title as the first line. Add a Reviewed by line (the template expects NOBODY (OOPS!) as a placeholder until review). Replace the free…
Passed Passed checks: 3 passed
Check name Status Explanation
Title check Passed The title clearly and concisely describes the main change: making DFG/FTL global variable reads throw when the property is missing.
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.
Full details: Description check

Explanation

The description follows the WebKit-style intent of the template: it names the bug (DFG/FTL read undefined for a deleted global where LLInt, Baseline and AOT throw ReferenceError), explains the fix (a new GetByIdThrowIfNotFound access type selected from ResolveMode, with a second commit that resets the cache when it gives up), and gives thorough verification. It is not a fit for the template's required structure, though. It has no bug title, no Bugzilla link (it says upstream bug is not filed, and gives a PR-style reference instead), no Reviewed by line, and no changelog-style file list with function names. The instructions say a description that is mostly complete should pass, and this one has the substance. The gaps in format are real but non-critical to the content. The description also lacks a clear statement of the bug tracker link that the template requires for every change, which is a required item in the template.

Resolution

Add the WebKit Bugzilla bug link in the form https://bugs.webkit.org/show_bug.cgi?id=##### and use a bug title as the first line. Add a Reviewed by line (the template expects NOBODY (OOPS!) as a placeholder until review). Replace the free-form notes with a short explanation of the fix in the template's style. Add a file list at the end (for example * Source/JavaScriptCore/bytecode/PropertyInlineCache.h: with the changed functions) so the change is traceable. Keep the detailed benchmark and test tables, but move them below the template sections so the core fields come first.

  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

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 findings, two further concerns were examined and ruled out: an FTL/DFG GetById with ThrowIfNotFound reaching a non-cell base (and thus the missing Generic operation) — FixupPhase pins the edge to CellUse and both backends assert on it; and the missing DFGSpeculativeJIT32_64 arm — that backend does not exist in this checkout, so the 64-bit and FTL updates cover every compiled tier.

Extended reasoning...

The change adds a new inline-cache access type for DFG/FTL global reads across the IC compiler, Repatch, JIT operations, and the DFG parser/fixup/backends, plus four JS tests; it touches no auth, crypto, or data-exposure surface. Two confirmed findings (a stale inherited-property cache after a later var declaration, and the lack of megamorphic folding for the new access type) are posted inline, and the change is a non-trivial JIT correctness fix, so human review is warranted.

Additional findings (outside the current diff — GitHub can't attach inline comments there):

  • 🟡 Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp — Hot DFG/FTL reads of a global name whose global object changed structure 8 times now walk 8 dead handlers and then call C++ on every read. tryFoldToMegamorphic at InlineCacheCompiler.cpp:4697 only folds AccessType::GetById and GetByIdWithThis; a GetByIdThrowIfNotFound cache falls to the default, returns nullptr and goes to GiveUpOnCache with its full handler list kept. Fix: when the list reaches maxAccessVariantListSize for GetByIdThrowIfNotFound, drop the dead handlers (or add a throw-aware megamorphic case) so the slow path call is reached directly, matching what base GetById does for the same site.

    Why this was flagged

    A ThrowIfNotFound GetByIdFlush is emitted at DFGByteCodeParser.cpp:10837 for every unproven global name read (accessor, inherited, dictionary global). Each new structure of the global object seen by that site adds a Load/Getter case; after Options::maxAccessVariantListSize() cases, InlineCacheCompiler.cpp:4692-4735 returns nullptr for the new access type, so the cache gives up and repatches to operationGetByIdThrowIfNotFoundGaveUp (JITOperations.cpp) while all 8 handlers stay chained before the call. Base GetById folds to one LoadMegamorphic case at InlineCacheCompiler.cpp:4734 and, because JSGlobalObject has OverridesGetOwnPropertySlot (JSGlobalObject.h:778), getByIdMegamorphic at JITOperations.cpp:379 repatches to a slow path call behind one handler. The author sizes the delta at +38 instructions per read (DFG) and +7 (FTL) on every read for the lifetime of the compiled code; global-object churn (polyfills, test harnesses, delete/re-add of globalThis properties) reaches it in production. Remedy: replace the handler list with a direct slow path call for this access type once it gives up.

    Verification: Trigger: a hot DFG/FTL read of an unproven global name after 8 global object structure changes. tryFoldToMegamorphic (Source/JavaScriptCore/bytecode/InlineCacheCompiler.cpp:4692-4698) only folds AccessType::GetById/GetByIdWithThis; GetByIdThrowIfNotFound returns nullptr, so every read lands in the GaveUp operation. On the base branch the same site folds to LoadMegamorphic.

@github-actions

github-actions Bot commented Oct 26, 2026 •

Copy link
Copy Markdown

Preview build of edd7475: autobuild-preview-pr-908-edd7475c

The base of such an access is one global object. A global object is a
dictionary from its start, so no transition gives it a structure that it had
before. When the cache gives up, its cases are for structures that are gone.
The one exception is the last case of a cache that gives up because it is
full: that case is for the structure of the moment.

The cases stayed in front of the slow path call. A site that saw more than 8
structures of the global object then cost 38 instructions per read more in
the DFG, and 7 in the FTL, than the same site with GetById, whose
megamorphic case does not take a global object and resets the site.

repatchGetBy now resets the cache when it gives up for this kind, with
repatchGetBySlowPathCall, as the megamorphic operations do. Such a site now
costs what it costs with GetById: 563 instructions per read of a getter
global in the DFG (565 with GetById), 529 in the FTL (531).

* Source/JavaScriptCore/bytecode/Repatch.cpp:
(JSC::repatchGetBy):
@robobun

robobun commented Oct 26, 2026

Copy link
Copy Markdown
Collaborator Author

About the 8 dead handlers of the review above: the second commit (edd7475) takes the suggestion. repatchGetBy resets the cache when it gives up for this kind, with repatchGetBySlowPathCall, which is what the megamorphic operations do for GetById.

A site that saw more than 8 structures of the global object, instructions per read of a getter global (FTL / DFG only):

main (GetById) first commit with the second commit
a getter global 531 / 565 538 / 603 529 / 563
an inherited name 584 / 602 592 / 641 583 / 601

The reset also drops the last case of a cache that gives up because it is full, as it does on main. Both new tests pass in 36 of 36 runs with it, on the Release build and on the assertions build.

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

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