Problem
ReflectionClass keeps two process-lifetime caches keyed by the numeric address of a zend_class_entry:
ReflectionClass::getObjectHandlers() / allocateClassObjectHandlers() (src/Reflection/ReflectionClass.php:142-153, :2908-2916) — mints a persistent (malloc) zend_object_handlers block per class entry that is never freed, cached by Core::addressOf($classEntry).
$propertyTableCapacity (src/Reflection/ReflectionClass.php:1738) — same address keying.
The docblock argues address-keying "keeps the cache bounded", but nothing ever removes entries. Since ClassSpecializer::evict() now exists and destroys class entries, the allocator can hand the same address to a newly-created class entry — which then silently inherits the evicted class's handler block and capacity entry. With opcache class-entry churn in long-running workers this is a real (if hard-to-hit) misbehaviour, and the handler blocks are an unbounded persistent-memory leak besides.
Proposed fix
Add an internal ReflectionClass::forgetClassEntry(int $address) (or similar) that:
- unsets the
getObjectHandlers() cache entry for the address,
- frees the malloc'd handler block iff z-engine allocated it (it is already registered via
Core::trackedNew()-style tracking, so Core::untrackAndFree() semantics apply),
- unsets the
$propertyTableCapacity entry,
and have ClassSpecializer::evict() call it as part of eviction. Needs a test in the internal group covering evict → re-create at (potentially) the same address → handlers are freshly allocated.
Notes
- Design-sensitive: freeing the handler block is only legal if no live object still points at it — eviction semantics must guarantee that first (documented in
ClassSpecializer::evict()'s contract).
- Found during the 2026-08 modernization review.
Problem
ReflectionClasskeeps two process-lifetime caches keyed by the numeric address of azend_class_entry:ReflectionClass::getObjectHandlers()/allocateClassObjectHandlers()(src/Reflection/ReflectionClass.php:142-153,:2908-2916) — mints a persistent (malloc)zend_object_handlersblock per class entry that is never freed, cached byCore::addressOf($classEntry).$propertyTableCapacity(src/Reflection/ReflectionClass.php:1738) — same address keying.The docblock argues address-keying "keeps the cache bounded", but nothing ever removes entries. Since
ClassSpecializer::evict()now exists and destroys class entries, the allocator can hand the same address to a newly-created class entry — which then silently inherits the evicted class's handler block and capacity entry. With opcache class-entry churn in long-running workers this is a real (if hard-to-hit) misbehaviour, and the handler blocks are an unbounded persistent-memory leak besides.Proposed fix
Add an internal
ReflectionClass::forgetClassEntry(int $address)(or similar) that:getObjectHandlers()cache entry for the address,Core::trackedNew()-style tracking, soCore::untrackAndFree()semantics apply),$propertyTableCapacityentry,and have
ClassSpecializer::evict()call it as part of eviction. Needs a test in theinternalgroup covering evict → re-create at (potentially) the same address → handlers are freshly allocated.Notes
ClassSpecializer::evict()'s contract).