From f3b65e575fe049355f7deec4e82823302c8af1ce Mon Sep 17 00:00:00 2001 From: phpstan-bot <79867460+phpstan-bot@users.noreply.github.com> Date: Sat, 1 Aug 2026 11:06:40 +0000 Subject: [PATCH 1/3] Drop the memoized `TypeCombinator` results whenever the process-wide reflection provider, PHP version or feature toggles change MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `ValidateIgnoredErrorsExtension::loadConfiguration()` resolves the types named in `ignoreErrors` patterns under a throwaway `DummyReflectionProvider` and `PhpVersion`. Its `finally` restored the accessors and called `ObjectType::resetCaches()`, but not `TypeCombinator::clearCache()` — so with the turbo extension active (which is what installs the `TypeCombinatorCache` memo) every union/intersection computed against the dummy provider stayed memoized and was handed back to the real analysis. - Moved the invalidation into the mutators of the state that type operations read, so no call site can forget it: `ReflectionProviderStaticAccessor::registerInstance()`, `PhpVersionStaticAccessor::registerInstance()`, `BleedingEdgeToggle::setBleedingEdge()` and `ReportUnsafeArrayStringKeyCastingToggle::setLevel()` now all clear the memo. This is the invariant `ContainerFactory::postInitializeContainer()` already documents ("a memoized result is only valid for the state it was computed under"). - `BleedingEdgeToggle::withBleedingEdge()` now writes the toggle through `setBleedingEdge()` on both the set and the restore instead of assigning the static directly, so the temporary flip invalidates the memo too. - Kept an explicit `TypeCombinator::clearCache()` in `ValidateIgnoredErrorsExtension`'s `finally`: when the extension runs for the first container of the process there is no original accessor instance to restore, so nothing else would clear. - Probed the other sites that swap the same global state: `ContainerFactory::postInitializeContainer()` and `StubValidator` (which re-runs it) already cleared correctly; `BetterReflection::populate()` has no other caller. - Regression test `tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php` reproduces the leak (fails with the extension loaded before the fix, passes after; a no-op without it). --- .../BleedingEdgeToggle.php | 9 ++- ...eportUnsafeArrayStringKeyCastingToggle.php | 6 ++ .../ValidateIgnoredErrorsExtension.php | 8 +++ src/Reflection/PhpVersionStaticAccessor.php | 5 ++ .../ReflectionProviderStaticAccessor.php | 8 +++ .../Type/GlobalStateCacheInvalidationTest.php | 55 +++++++++++++++++++ 6 files changed, 89 insertions(+), 2 deletions(-) create mode 100644 tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php diff --git a/src/DependencyInjection/BleedingEdgeToggle.php b/src/DependencyInjection/BleedingEdgeToggle.php index 7868c34900e..64fa22418d7 100644 --- a/src/DependencyInjection/BleedingEdgeToggle.php +++ b/src/DependencyInjection/BleedingEdgeToggle.php @@ -4,6 +4,7 @@ use Generator; use PHPStan\ShouldNotHappenException; +use PHPStan\Type\TypeCombinator; final class BleedingEdgeToggle { @@ -18,6 +19,10 @@ public static function isBleedingEdge(): bool public static function setBleedingEdge(bool $bleedingEdge): void { self::$bleedingEdge = $bleedingEdge; + + // Type operations read this toggle, so a memoized result is only valid + // for the value it was computed under. + TypeCombinator::clearCache(); } /** @@ -34,7 +39,7 @@ public static function setBleedingEdge(bool $bleedingEdge): void public static function withBleedingEdge(bool $bleedingEdge, callable $callback) { $backup = self::$bleedingEdge; - self::$bleedingEdge = $bleedingEdge; + self::setBleedingEdge($bleedingEdge); try { $result = $callback(); @@ -44,7 +49,7 @@ public static function withBleedingEdge(bool $bleedingEdge, callable $callback) return $result; } finally { - self::$bleedingEdge = $backup; + self::setBleedingEdge($backup); } } diff --git a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php index e2f13563fec..b3adcd4ae9f 100644 --- a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php +++ b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php @@ -2,6 +2,8 @@ namespace PHPStan\DependencyInjection; +use PHPStan\Type\TypeCombinator; + /** * @phpstan-type Level = self::DETECT|self::PREVENT|null */ @@ -29,6 +31,10 @@ public static function getLevel(): ?string public static function setLevel(?string $level): void { self::$level = $level; + + // Type operations read this toggle, so a memoized result is only valid + // for the level it was computed under. + TypeCombinator::clearCache(); } } diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 63c98a70d34..3a6177a3bb6 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -35,6 +35,7 @@ use PHPStan\Type\OperatorTypeSpecifyingExtensionRegistry; use PHPStan\Type\Type; use PHPStan\Type\TypeAliasResolver; +use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnaryOperatorTypeSpecifyingExtensionRegistry; use function array_keys; use function array_map; @@ -211,6 +212,13 @@ public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type PhpVersionStaticAccessor::registerInstance($originalPhpVersion); } ObjectType::resetCaches(); + // The validation above resolved the types named in the ignoreErrors + // patterns while the process-wide accessors pointed at the throwaway + // instances registered before the try. Both restores drop the memoized + // type operations on their own, but there is nothing to restore when + // this is the first container of the process - the entries computed + // against the DummyReflectionProvider would then outlive it. + TypeCombinator::clearCache(); } } diff --git a/src/Reflection/PhpVersionStaticAccessor.php b/src/Reflection/PhpVersionStaticAccessor.php index 294d71427a9..5623bdc791a 100644 --- a/src/Reflection/PhpVersionStaticAccessor.php +++ b/src/Reflection/PhpVersionStaticAccessor.php @@ -3,6 +3,7 @@ namespace PHPStan\Reflection; use PHPStan\Php\PhpVersion; +use PHPStan\Type\TypeCombinator; final class PhpVersionStaticAccessor { @@ -16,6 +17,10 @@ private function __construct() public static function registerInstance(PhpVersion $phpVersion): void { self::$instance = $phpVersion; + + // Type operations read this accessor, so a memoized result is only valid + // for the PHP version it was computed under. + TypeCombinator::clearCache(); } public static function getInstance(): PhpVersion diff --git a/src/Reflection/ReflectionProviderStaticAccessor.php b/src/Reflection/ReflectionProviderStaticAccessor.php index 33ef7d0cf3a..836711c5cb0 100644 --- a/src/Reflection/ReflectionProviderStaticAccessor.php +++ b/src/Reflection/ReflectionProviderStaticAccessor.php @@ -2,6 +2,8 @@ namespace PHPStan\Reflection; +use PHPStan\Type\TypeCombinator; + final class ReflectionProviderStaticAccessor { @@ -14,6 +16,12 @@ private function __construct() public static function registerInstance(ReflectionProvider $reflectionProvider): void { self::$instance = $reflectionProvider; + + // Type operations read this accessor, so a memoized result is only valid + // for the provider it was computed under. Dropping the memo here means no + // caller can swap the provider - not even temporarily - and leak types + // resolved against the old one into the next. + TypeCombinator::clearCache(); } public static function getInstance(): ReflectionProvider diff --git a/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php b/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php new file mode 100644 index 00000000000..f75bb2a9ad7 --- /dev/null +++ b/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php @@ -0,0 +1,55 @@ +exceptionUnion(); + $this->assertInstanceOf( + UnionType::class, + $underDummyProvider, + 'the dummy provider knows no class hierarchy, so the union cannot collapse', + ); + } finally { + ReflectionProviderStaticAccessor::registerInstance($originalReflectionProvider); + ObjectType::resetCaches(); + } + + $this->assertSame( + Exception::class, + $this->exceptionUnion()->describe(VerbosityLevel::precise()), + ); + } + + private function exceptionUnion(): Type + { + return TypeCombinator::union(new ObjectType(Exception::class), new ObjectType(RuntimeException::class)); + } + +} From a94ca66d0e00ba5ab136785b5264d1d433dfad22 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 1 Aug 2026 11:29:16 +0000 Subject: [PATCH 2/3] Revert "Drop the memoized `TypeCombinator` results whenever the process-wide reflection provider, PHP version or feature toggles change" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f3b65e575fe049355f7deec4e82823302c8af1ce. The premise does not hold. `ValidateIgnoredErrorsExtension::loadConfiguration()` runs while the container is being compiled, and `ContainerFactory::create()` calls `postInitializeContainer()` on the very next line — which already drops the memo as its last step. Everything the extension memoizes under the throwaway `DummyReflectionProvider` is therefore gone before the container is handed to anything that analyses code. Verified with a locally built `phpstan_turbo.so` (1ecf6e0) loaded through `php.ini`, so the workers inherit it (`phpstan diagnose`: "Turbo extension: enabled", "workers inherit it"): the issue's reproducer, both as a single file and fanned out to 60 files so the run goes parallel, reports no `method.notFound`/`staticMethod.notFound` with the patch reverted. `ReflectionProviderStaticAccessor` and `PhpVersionStaticAccessor` are also the wrong place for the invalidation: `postInitializeContainer()` registers both *before* it sets the feature toggles, so a clear there runs too early to be the guard while looking like one. --- .../BleedingEdgeToggle.php | 9 +-- ...eportUnsafeArrayStringKeyCastingToggle.php | 6 -- .../ValidateIgnoredErrorsExtension.php | 8 --- src/Reflection/PhpVersionStaticAccessor.php | 5 -- .../ReflectionProviderStaticAccessor.php | 8 --- .../Type/GlobalStateCacheInvalidationTest.php | 55 ------------------- 6 files changed, 2 insertions(+), 89 deletions(-) delete mode 100644 tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php diff --git a/src/DependencyInjection/BleedingEdgeToggle.php b/src/DependencyInjection/BleedingEdgeToggle.php index 64fa22418d7..7868c34900e 100644 --- a/src/DependencyInjection/BleedingEdgeToggle.php +++ b/src/DependencyInjection/BleedingEdgeToggle.php @@ -4,7 +4,6 @@ use Generator; use PHPStan\ShouldNotHappenException; -use PHPStan\Type\TypeCombinator; final class BleedingEdgeToggle { @@ -19,10 +18,6 @@ public static function isBleedingEdge(): bool public static function setBleedingEdge(bool $bleedingEdge): void { self::$bleedingEdge = $bleedingEdge; - - // Type operations read this toggle, so a memoized result is only valid - // for the value it was computed under. - TypeCombinator::clearCache(); } /** @@ -39,7 +34,7 @@ public static function setBleedingEdge(bool $bleedingEdge): void public static function withBleedingEdge(bool $bleedingEdge, callable $callback) { $backup = self::$bleedingEdge; - self::setBleedingEdge($bleedingEdge); + self::$bleedingEdge = $bleedingEdge; try { $result = $callback(); @@ -49,7 +44,7 @@ public static function withBleedingEdge(bool $bleedingEdge, callable $callback) return $result; } finally { - self::setBleedingEdge($backup); + self::$bleedingEdge = $backup; } } diff --git a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php index b3adcd4ae9f..e2f13563fec 100644 --- a/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php +++ b/src/DependencyInjection/ReportUnsafeArrayStringKeyCastingToggle.php @@ -2,8 +2,6 @@ namespace PHPStan\DependencyInjection; -use PHPStan\Type\TypeCombinator; - /** * @phpstan-type Level = self::DETECT|self::PREVENT|null */ @@ -31,10 +29,6 @@ public static function getLevel(): ?string public static function setLevel(?string $level): void { self::$level = $level; - - // Type operations read this toggle, so a memoized result is only valid - // for the level it was computed under. - TypeCombinator::clearCache(); } } diff --git a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php index 3a6177a3bb6..63c98a70d34 100644 --- a/src/DependencyInjection/ValidateIgnoredErrorsExtension.php +++ b/src/DependencyInjection/ValidateIgnoredErrorsExtension.php @@ -35,7 +35,6 @@ use PHPStan\Type\OperatorTypeSpecifyingExtensionRegistry; use PHPStan\Type\Type; use PHPStan\Type\TypeAliasResolver; -use PHPStan\Type\TypeCombinator; use PHPStan\Type\UnaryOperatorTypeSpecifyingExtensionRegistry; use function array_keys; use function array_map; @@ -212,13 +211,6 @@ public function resolveTypeAlias(string $aliasName, NameScope $nameScope): ?Type PhpVersionStaticAccessor::registerInstance($originalPhpVersion); } ObjectType::resetCaches(); - // The validation above resolved the types named in the ignoreErrors - // patterns while the process-wide accessors pointed at the throwaway - // instances registered before the try. Both restores drop the memoized - // type operations on their own, but there is nothing to restore when - // this is the first container of the process - the entries computed - // against the DummyReflectionProvider would then outlive it. - TypeCombinator::clearCache(); } } diff --git a/src/Reflection/PhpVersionStaticAccessor.php b/src/Reflection/PhpVersionStaticAccessor.php index 5623bdc791a..294d71427a9 100644 --- a/src/Reflection/PhpVersionStaticAccessor.php +++ b/src/Reflection/PhpVersionStaticAccessor.php @@ -3,7 +3,6 @@ namespace PHPStan\Reflection; use PHPStan\Php\PhpVersion; -use PHPStan\Type\TypeCombinator; final class PhpVersionStaticAccessor { @@ -17,10 +16,6 @@ private function __construct() public static function registerInstance(PhpVersion $phpVersion): void { self::$instance = $phpVersion; - - // Type operations read this accessor, so a memoized result is only valid - // for the PHP version it was computed under. - TypeCombinator::clearCache(); } public static function getInstance(): PhpVersion diff --git a/src/Reflection/ReflectionProviderStaticAccessor.php b/src/Reflection/ReflectionProviderStaticAccessor.php index 836711c5cb0..33ef7d0cf3a 100644 --- a/src/Reflection/ReflectionProviderStaticAccessor.php +++ b/src/Reflection/ReflectionProviderStaticAccessor.php @@ -2,8 +2,6 @@ namespace PHPStan\Reflection; -use PHPStan\Type\TypeCombinator; - final class ReflectionProviderStaticAccessor { @@ -16,12 +14,6 @@ private function __construct() public static function registerInstance(ReflectionProvider $reflectionProvider): void { self::$instance = $reflectionProvider; - - // Type operations read this accessor, so a memoized result is only valid - // for the provider it was computed under. Dropping the memo here means no - // caller can swap the provider - not even temporarily - and leak types - // resolved against the old one into the next. - TypeCombinator::clearCache(); } public static function getInstance(): ReflectionProvider diff --git a/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php b/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php deleted file mode 100644 index f75bb2a9ad7..00000000000 --- a/tests/PHPStan/Type/GlobalStateCacheInvalidationTest.php +++ /dev/null @@ -1,55 +0,0 @@ -exceptionUnion(); - $this->assertInstanceOf( - UnionType::class, - $underDummyProvider, - 'the dummy provider knows no class hierarchy, so the union cannot collapse', - ); - } finally { - ReflectionProviderStaticAccessor::registerInstance($originalReflectionProvider); - ObjectType::resetCaches(); - } - - $this->assertSame( - Exception::class, - $this->exceptionUnion()->describe(VerbosityLevel::precise()), - ); - } - - private function exceptionUnion(): Type - { - return TypeCombinator::union(new ObjectType(Exception::class), new ObjectType(RuntimeException::class)); - } - -} From 1870b3715e165cbba21443635f7a02d6f3ed6e65 Mon Sep 17 00:00:00 2001 From: phpstan-bot Date: Sat, 1 Aug 2026 11:29:31 +0000 Subject: [PATCH 3/3] Record why postInitializeContainer() is the only place the type-operation memo needs dropping MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Compiling the container runs ValidateIgnoredErrorsExtension, which resolves the types named in the ignoreErrors patterns under a throwaway DummyReflectionProvider and PhpVersion. That looks like a second site that has to invalidate the memo, but postInitializeContainer() is the next thing create() does and already drops it — note that, so the next reader does not add a redundant clear to the accessors or the toggles, where it would run before the toggles are set anyway. --- src/DependencyInjection/ContainerFactory.php | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/DependencyInjection/ContainerFactory.php b/src/DependencyInjection/ContainerFactory.php index b2d2e6dc4cd..d8c86cae369 100644 --- a/src/DependencyInjection/ContainerFactory.php +++ b/src/DependencyInjection/ContainerFactory.php @@ -211,6 +211,17 @@ public static function postInitializeContainer(Container $container): void // under. Clearing must be the LAST step: building the typeSpecifier service runs // extension constructors that can already perform type operations, and entries // memoized before the toggles are set would encode the previous container's state. + // + // This is also the only invalidation the process needs, because it is the last step + // of create(): compiling the container runs ValidateIgnoredErrorsExtension, which + // swaps in a DummyReflectionProvider and a runtime PhpVersion to resolve the types + // named in the ignoreErrors patterns. Whatever that memoizes against the throwaway + // state (a DummyReflectionProvider knows no class hierarchy, so e.g. + // union(Exception, RuntimeException) does not collapse under it) is dropped here, + // before the container reaches anything that analyses code. Keep it that way — do + // not clear the memo from the mutators of that state instead: registerInstance() on + // either accessor runs above, before the toggles are set, so a clear there would run + // too early to be the guard while looking like one. TypeCombinator::clearCache(); }