[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
lmpx.com only provides a reader for public news (NNTP) servers. It is not affiliated with the servers or forums shown here and is not responsible for the content of articles, which is written by their respective authors.