-
Notifications
You must be signed in to change notification settings - Fork 8k
Description
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_Rfor$this->incrementingwith a fast path forIS_TRUE(type 3) - When the property type is
IS_UNDEF(type 0, afterunset()), the JIT code jumps to a fallback handler - The fallback handler at the
IS_UNDEFcheck (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(notfunction)- Polymorphic calls (3+ subclasses calling the same method)
- Warmup phase trains JIT on the "property is initialized" path
- Then
unset()on the property createsIS_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