Skip to content

Cache a class's ancestors without the class itself - #6593

Merged
ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:class-reflection-ancestors-no-self-cycle
Sep 25, 2026
Merged

ondrejmirtes merged 1 commit into
phpstan:2.2.xfrom
SanderMuller:class-reflection-ancestors-no-self-cycle

Conversation

@SanderMuller

Copy link
Copy Markdown
Contributor

ClassReflection::getAncestors() cached an array that contained $this, so every ClassReflection that had answered it referenced itself. PHPStan runs with gc_disable(), so such a cycle is never freed. That matters for a ClassReflection that the reflection provider does not keep, such as a withTypes() copy. It stayed in memory until the process ended, together with its template type map and variance map.

The cache now leaves the class out, and getAncestors() adds it back first, so the returned array is the same.

Effect

Single process, from source. The peak is PHPStan's own, with BytesHelper patched locally to print MB. A gc_collect_cycles() at the end of the run separates live data from uncollected cycles:

peak before peak after cycles at the end, before → after live
tempest-framework, 2.7k files 698.0 MB 630.0 MB 297,838 → 39,012 568.1 → 568.2 MB
a 3.4k-file Laravel app (Larastan, bleedingEdge) 1409.0 MB 1361.0 MB 826,416 → 657,345 1065.7 → 1065.7 MB

Both rounds of each arm gave the same numbers, and the JSON output is identical before and after on both projects. With 4 workers, tempest's largest worker peaks at 350/340 MB instead of 374/370 MB. CPU is unchanged: on tempest the medians of 3 interleaved rounds are 67.75 s before and 67.73 s after.

On 2.3.x the effect is larger. Bidirectional type narrowing creates more of these copies, and on the Laravel app this cycle is about 77 MB of the uncollected garbage there.

Test

ClassReflectionTest::testAncestorsDoNotKeepClassReflectionAlive() takes a withTypes() copy, calls getAncestors(), drops it with the GC disabled and checks a WeakReference to it. It fails without the change.

On 2.3.x, ClassReflection is shadowed by the turbo extension. Its getAncestors() in turbo-ext/src/ClassReflection.cpp stores the same array, so it needs the same change after this is merged up.

🤖 Generated with Claude Code

getAncestors() cached an array that contained $this, so every
ClassReflection that had answered it referenced itself. PHPStan runs
with gc_disable(), so a ClassReflection the reflection provider does not
keep, like a withTypes() copy, then stayed in memory until the process
ended, together with its template type map.

The cache now leaves the class out, and getAncestors() adds it back
first, so the returned array is the same.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@ondrejmirtes
ondrejmirtes merged commit 4afc290 into phpstan:2.2.x Sep 25, 2026
863 of 876 checks passed
@ondrejmirtes

Copy link
Copy Markdown
Member

Thank you very much!

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.

2 participants