[php-src] Issue #21243: JIT tracing: infinite loop on FETCH_OBJ_R with IS_UNDEF property in polymorphic context
[email protected] (esistgut)
| Newsgroups | php.bugs |
|---|---|
| Message-ID | <[email protected]> |
Issue: https://github.com/php/php-src/issues/21243
Author: esistgut
## Description
When using `opcache.jit=tracing`, a class with:
1. An **untyped** declared property with a default value (`public $incrementing = true`)
2. A `__get()` magic method
3. **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
<?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:
1. The JIT compiles `FETCH_OBJ_R` for `$this->incrementing` with a fast path for `IS_TRUE` (type 3)
2. When the property type is `IS_UNDEF` (type 0, after `unset()`), the JIT code jumps to a fallback handler
3. 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
4. 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
## Operating system
Linux x86_64