Repository navigation
Detect trait and parent class reference cycles instead of walking them forever - #6465
Merged
ondrejmirtes merged 1 commit intoSep 17, 2026
Merged
Conversation
…m forever - `ClassReflection::collectTraits()` compared already collected traits with `in_array($subTrait, $traits, true)`. BetterReflection hands out a fresh reflection object on every `getTraits()` call, so a trait reachable from itself was never recognised as already seen and the worklist grew forever. Collect the traits keyed by name instead. - `TraitUseHandler` derived the "already inside this trait" set from `Scope::getParentScope()`, which only ever sees a trait that is an enclosing scope (the anonymous-class case of bug 7214) because `enterTrait()` does not set a parent scope. Keep a stack of the traits whose statements are currently being processed and skip a `use` of any of them. - `ClassReflection::getAncestors()` recursed through `$trait->getAncestors()`, which never terminates for two traits using each other. It now descends through a private helper that uses the collected ancestors as the visited set, keeping the previous ordering. - BetterReflection only rejects a class extending itself directly, so a cycle spanning several classes (`A extends B`, `B extends A`) let every walk over the class hierarchy run forever. `ClassReflection::getParentClass()` now looks for such a cycle and throws `CircularReference`, the same error PHPStan already reported for `class A extends A`. - `ClassReflection::getTraits(true)` and `getClassHierarchyDistances()` walked the parents through the native reflection, bypassing that check - they go through `getParentClass()` now. Probed and found already correct: `@mixin` cycles, interface `extends` cycles, trait cycles with `insteadof`/`as` adaptations, enums using a cyclic trait, trait cycles spanning several files, and anonymous classes using the trait they are declared in.
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.
Summary
A trait using itself (
trait T { use T; }) or a group of traits using each other made PHPStan loop until it hit the memory limit. The same happened for a class hierarchy cycle spanning more than one class (class A extends B {},class B extends A {}).All of these are fatal errors at runtime in PHP, so the analysis now ends with the
Reflection error: Circular reference to class "..."error PHPStan already reported forclass A extends A {}instead of hanging.Changes
src/Reflection/ClassReflection.phpcollectTraits()collects traits keyed by name instead of comparing reflection objects by identity.getAncestors()delegates to a new privatecollectAncestors()that descends with the collected ancestors as its visited set, instead of recursing into$ancestor->getAncestors(). The resulting order is unchanged.getParentClass()walks the parent chain looking for a cycle and throwsCircularReferencewhen it finds one.getTraits(true)andgetClassHierarchyDistances()walk the parents viagetParentClass()instead of the native reflection, so they are covered by that check.src/Analyser/StmtHandler/TraitUseHandler.phpkeeps a stack of the traits whose statements are currently being processed and skips auseof any of them, replacing the previous parent-scope walk.build/phpstan.neonexcludes the new test case from self-analysis, next to the other test files whose fixtures cannot be reflected (UnionTypesTest.php,MixedTypeTest.php).Analogous constructs probed that already behaved correctly and needed no change:
@mixincycles,interface A extends B/interface B extends A, trait cycles combined withinsteadofandasadaptations, enums using a cyclic trait, trait cycles spanning several files, and an anonymous class using the trait it is declared in (bug 7214).Root cause
The pattern is cycle detection missing on every walk over the class graph. Four separate walks assumed the graph is a DAG:
ClassReflection::collectTraits()had a visited check, but comparedReflectionClassobjects within_array(..., true). BetterReflection creates a fresh adapter object pergetTraits()call, so the check never matched.ClassReflection::getAncestors()recursed into$interface->getAncestors()/$trait->getAncestors(). Each of those starts with an empty result, so two traits using each other call back and forth forever.TraitUseHandler::processTraitUse()derived its skip set fromScope::getParentScope().MutatingScope::enterTrait()leaves the parent scopenull, so the set was always empty once the analyser had descended into a trait.ReflectionClass::getParentClass()only rejects$this->name === $parentClassName, i.e. a class extending itself. A two-class cycle passes that check, sogetTraits(true),getClassHierarchyDistances(),getParentClassesNames()andDependencyResolver::buildClassDependencies()all recursed forever. Putting the check intoClassReflection::getParentClass()fixes all of them at once, because every one of those walks goes through it.Test
tests/PHPStan/Analyser/AnalyserIntegrationTest(end to end, each hangs until killed without the fix):testBug8082— the reproducer from the issue: a trait using itself, used by a class.testBug8082TraitCycle—TraitAusesTraitB,TraitBusesTraitA, a class usesTraitA.testCircularParentClass—Foo extends Bar,Bar extends Foo.tests/PHPStan/Reflection/CircularReferenceClassReflectionTestcovers the reflection APIs directly, since that is where the walks live —getTraits(true),getAncestors()andgetClassHierarchyDistances()for a self-using trait, a trait cycle and a parent class cycle. Every one of them exhausts the memory limit without the fix.Fixes phpstan/phpstan#8082