Description
When using opcache.jit=tracing, a class with:
- An untyped declared property with a default value (
public $incrementing = true)
- A
__get() magic method
- Three or more subclasses (polymorphic call context)
If unset() is called on that property on some instances (making the property slot IS_UNDEF), and the getter is then called in a hot loop mixing normal and unset instances, the JIT-compiled FETCH_OBJ_R handler enters an infinite loop burning 100% CPU instead of falling back to the interpreter to call __get().
Reproduction script
<?php
class Base
{
public $incrementing = true;
public function __get($name) { return null; }
public function getIncrementing() { return $this->incrementing; }
public function getCasts(): array
{
if ($this->getIncrementing()) {
return ['id' => 'int'];
}
return [];
}
public function hasCast(string $key): bool
{
return array_key_exists($key, $this->getCasts());
}
}
class ChildA extends Base {}
class ChildB extends Base {}
class ChildC extends Base { public $incrementing = false; }
$classes = [ChildA::class, ChildB::class, ChildC::class];
// Warmup: train the JIT on the hot path ($incrementing = true/false)
for ($round = 0; $round < 5; $round++) {
for ($i = 0; $i < 1000; $i++) {
$m = new $classes[$i % 3]();
$m->getIncrementing();
$m->getCasts();
$m->hasCast('id');
}
}
// Trigger: mix normal and unset($incrementing) objects in a hot loop.
// The JIT-compiled trace for FETCH_OBJ_R on $this->incrementing
// enters an infinite loop when it encounters IS_UNDEF.
for ($i = 0; $i < 2000; $i++) {
$m = new $classes[$i % 3]();
if ($i % 7 === 0) {
unset($m->incrementing);
}
@$m->getIncrementing();
@$m->getCasts();
@$m->hasCast('id');
}
echo "OK — bug not triggered\n";
Run with:
timeout 10 php -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing repro.php
Expected: prints "OK" and exits
Actual: hangs forever burning 100% CPU (killed by timeout)
Affected versions
| Version |
ZTS/NTS |
Result |
| PHP 8.4.15 |
ZTS |
HANG |
| PHP 8.4.15 |
NTS |
HANG |
| PHP 8.4.18 |
NTS |
HANG |
| PHP 8.5.3 |
NTS |
HANG |
Not affected with opcache.jit=disable or opcache.jit=function.
Analysis from production incident
This bug was discovered during a production outage where a FrankenPHP server (PHP 8.4.15 ZTS, opcache.jit=tracing) became completely unresponsive. All 8 PHP worker threads were stuck in an infinite loop inside JIT-compiled code.
Using gdb to disassemble the JIT-compiled code at the stuck address, the root cause was identified:
- The JIT compiles
FETCH_OBJ_R for $this->incrementing with a fast path for IS_TRUE (type 3)
- When the property type is
IS_UNDEF (type 0, after unset()), the JIT code jumps to a fallback handler
- The fallback handler at the
IS_UNDEF check (cmpb $0x0, 0x8(%rax) / je ...) dispatches back to the same opcode handler entry point instead of the interpreter
- This creates an infinite loop with no way to break out (no VM interrupt check in the loop path)
Since max_execution_time=0 in CLI/FrankenPHP mode, the loop runs forever.
Key conditions for triggering
opcache.jit=tracing (not function)
- Polymorphic calls (3+ subclasses calling the same method)
- Warmup phase trains JIT on the "property is initialized" path
- Then
unset() on the property creates IS_UNDEF
- The
@ error suppression or __get() magic method must be present
Note
This bug was diagnosed and this report was compiled by Claude Code (Claude Opus 4.6), Anthropic's AI coding agent, during a production incident investigation.
PHP Version
PHP 8.5.3 (cli) (built: Feb 12 2026 16:29:14) (NTS)
Copyright (c) The PHP Group
Zend Engine v4.5.3, Copyright (c) Zend Technologies
with Zend OPcache v8.5.3, Copyright (c), by Zend Technologies
and multiple other versions via Docker
Operating System
Linux x86_64
Description
When using
opcache.jit=tracing, a class with:public $incrementing = true)__get()magic methodIf
unset()is called on that property on some instances (making the property slotIS_UNDEF), and the getter is then called in a hot loop mixing normal and unset instances, the JIT-compiledFETCH_OBJ_Rhandler enters an infinite loop burning 100% CPU instead of falling back to the interpreter to call__get().Reproduction script
Run with:
Expected: prints "OK" and exits
Actual: hangs forever burning 100% CPU (killed by
timeout)Affected versions
Not affected with
opcache.jit=disableoropcache.jit=function.Analysis from production incident
This bug was discovered during a production outage where a FrankenPHP server (PHP 8.4.15 ZTS,
opcache.jit=tracing) became completely unresponsive. All 8 PHP worker threads were stuck in an infinite loop inside JIT-compiled code.Using
gdbto disassemble the JIT-compiled code at the stuck address, the root cause was identified:FETCH_OBJ_Rfor$this->incrementingwith a fast path forIS_TRUE(type 3)IS_UNDEF(type 0, afterunset()), the JIT code jumps to a fallback handlerIS_UNDEFcheck (cmpb $0x0, 0x8(%rax)/je ...) dispatches back to the same opcode handler entry point instead of the interpreterSince
max_execution_time=0in CLI/FrankenPHP mode, the loop runs forever.Key conditions for triggering
opcache.jit=tracing(notfunction)unset()on the property createsIS_UNDEF@error suppression or__get()magic method must be presentNote
This bug was diagnosed and this report was compiled by Claude Code (Claude Opus 4.6), Anthropic's AI coding agent, during a production incident investigation.
PHP Version
Operating System
Linux x86_64