Skip to content

Re-apply global state in ContainerFactory::postInitializeContainer() even for an already-initialized container#5918

Closed
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-6u2w605
Closed

Re-apply global state in ContainerFactory::postInitializeContainer() even for an already-initialized container#5918
phpstan-bot wants to merge 1 commit into
phpstan:2.2.xfrom
phpstan-bot:create-pull-request/patch-6u2w605

Conversation

@phpstan-bot

Copy link
Copy Markdown
Collaborator

Summary

GenericObjectTypeTest and IntersectionTypeTest failed intermittently on CI
(ReflectionClass<object>/ReflectionClass<stdClass> variance flipping to No,
and non-empty-array&hasOffsetValue(0, int) no longer accepted by array{int, int}).
The root cause was leaking global state between tests that share the same cached
DI container, caused by an over-eager early-return guard in
ContainerFactory::postInitializeContainer().

Changes

  • src/DependencyInjection/ContainerFactory.php: the spl_object_id() guard now
    only wraps the expensive BetterReflection::populate() call. The remaining global
    state is re-applied on every invocation, even when returning to a container
    that was already the last initialized one:
    • ReflectionProviderStaticAccessor::registerInstance(...)
    • PhpVersionStaticAccessor::registerInstance(...)
    • ObjectType::resetCaches()
    • $container->getService('typeSpecifier')
    • BleedingEdgeToggle::setBleedingEdge(...)
    • ReportUnsafeArrayStringKeyCastingToggle::setLevel(...)
  • tests/PHPStan/DependencyInjection/ContainerFactoryPostInitializeTest.php: new
    regression test.

Root cause

postInitializeContainer() is what binds a container's services to PHPStan's
process-global statics (the static reflection-provider / php-version accessors, the
ObjectType caches, and the BleedingEdgeToggle / ReportUnsafeArrayStringKeyCasting
feature toggles). It is invoked before every test via the PHPUnit
InitContainerBeforeTestSubscriber/ContainerInitializer.

It guarded its whole body with if (spl_object_id($container) === $lastInitializedContainerId) return;.
That guard is correct for the one-time BetterReflection::populate() work, but it
also skipped re-applying the mutable global state. When two test classes share the
same cached container (e.g. two classes with the default config), the second test's
getContainer() call hit the early return, so any global state mutated in the
meantime was not restored:

  • BleedingEdgeToggle controls whether ConstantArrayType becomes a sealed array
    shape — a leaked true makes array{int, int} sealed, so
    non-empty-array&hasOffsetValue(0, int) is no longer accepted (MaybeNo),
    which is the IntersectionTypeTest failure.
  • The static ReflectionProvider / PhpVersion determine which ReflectionClass
    stub is loaded (@template-covariant T for < 8.4 vs invariant @template T for
    the lazy-objects stub on ≥ 8.4). A leaked accessor flips the inferred variance, so
    ReflectionClass<object>ReflectionClass<stdClass> isSuperTypeOf results
    change, which is the GenericObjectTypeTest failure.

The fix moves only the expensive, genuinely once-per-container work behind the guard
and unconditionally re-applies the cheap global-state registration, so returning to
an already-initialized container always restores a consistent global state. All
global setters used during container post-initialization were swept together, so the
whole family is covered rather than just one toggle.

Test

ContainerFactoryPostInitializeTest::testReappliesGlobalStateForAlreadyInitializedContainer
makes a container the last-initialized one, simulates another test leaking global
state (a foreign ReflectionProvider, a bogus PhpVersion, and a flipped
BleedingEdgeToggle) without changing the last-initialized container id, then calls
postInitializeContainer() again and asserts the bleeding-edge toggle, the static
PhpVersion, and the static ReflectionProvider are all restored to the container's
values. The test fails on the previous code (the leaked state survives the guard) and
passes with the fix. The full suite, make phpstan, and make cs-fix are green.

Fixes phpstan/phpstan#14861

…` even for an already-initialized container

- Restrict the `spl_object_id()` early-return guard to only skip the expensive
  `BetterReflection::populate()` call, which wires up one-time global state in the
  BetterReflection facade per container.
- Always re-apply the remaining mutable global state on every call:
  `ReflectionProviderStaticAccessor`, `PhpVersionStaticAccessor`,
  `ObjectType::resetCaches()`, the `typeSpecifier` service, `BleedingEdgeToggle`
  and `ReportUnsafeArrayStringKeyCastingToggle`.
- This fixes flaky tests (GenericObjectTypeTest, IntersectionTypeTest): two test
  classes sharing the same cached container would short-circuit the guard, so global
  state mutated by one test (e.g. the bleeding-edge toggle controlling sealed array
  shapes, or a static ReflectionProvider/PhpVersion selecting the ReflectionClass
  variance stub) leaked into the next.
- Add ContainerFactoryPostInitializeTest covering that returning to an
  already-initialized container restores the bleeding-edge toggle and the static
  PhpVersion / ReflectionProvider accessors.
@staabm staabm force-pushed the create-pull-request/patch-6u2w605 branch from ed24914 to 14fc334 Compare June 23, 2026 14:04
@staabm staabm closed this Jun 23, 2026
@staabm staabm deleted the create-pull-request/patch-6u2w605 branch June 23, 2026 14:17
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.

CI: Intermittent in GenericObjectTypeTest, IntersectionTypeTest

2 participants