Skip to content

[PHP8.5] [JIT] Tracing JIT wrong optimization for LazyProxy instances #23628

Description

@lisachenko

When $this is an initialized lazy proxy created by ReflectionClass::newLazyProxy(), the
proxy object's own declared-property slots are permanently IS_UNDEF | IS_PROP_LAZY
(set in zend_lazy_object_init_proxy()), and every property access is meant to be
forwarded to the real backing instance through read_property.

The tracing JIT compiles a hot loop that reads such a property in isset()
context (FETCH_OBJ_IS on $this) using the known-property fast path from
zend_jit_fetch_obj(). That fast path reads the property directly at
prop_info->offset and only guards Z_TYPE != IS_UNDEF; it does not account for
the object being a lazy proxy, so it never forwards to read_property. It reads the
proxy's own dead slot (IS_UNDEF), takes the deopt exit, and in
zend_jit_trace_exit() the ZREG_ZVAL_COPY handler converts the IS_UNDEF value of a
FETCH_OBJ_IS into NULL (treating it as an undefined property). The result is that
isset($this->prop['k']) evaluates to false and property reads see a missing value,
even though the backing instance holds the data.

opcache.jit=function and opcache.jit=0 are correct; only tracing JIT (1254/1255/tracing) is affected.
Lazy ghosts (newLazyGhost) and eager objects are unaffected, because a ghost
initializes in place so its own slots become real values.

Reproducer (self-contained)

<?php
final class Table {
    protected array $map = ['start' => ['next' => 1]];
    public function parse(int $n): int {
        $ok = 0;
        for ($i = 0; $i < $n; $i++) {
            if (isset($this->map['start']['next'])) {
                $ok++;
            } else {
                throw new RuntimeException('isset false at ' . $i);
            }
        }
        return $ok;
    }
}
$proxy = (new ReflectionClass(Table::class))->newLazyProxy(fn() => new Table());
echo $proxy->parse(1000), "\n";
$ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M script.php
PHP Fatal error:  Uncaught RuntimeException: isset false at 61   # trace compiles at jit_hot_loop=61

$ php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M -d opcache.jit_hot_loop=1 script.php
PHP Fatal error:  Uncaught RuntimeException: isset false at 1    # deterministic: fails on the first traced iteration

$ php -d opcache.jit=function script.php   # 1000  (OK)
$ php -d opcache.jit=0        script.php   # 1000  (OK)

Note: as a tiny standalone script the failure is JIT trace-layout sensitive (byte-identical
files can reproduce or not depending only on their path), because it depends on which trace
compiles first. Forcing opcache.jit_hot_loop to a small value makes it deterministic on the
affected file. The behaviour is 100% reproducible in a real workload (see below).

Confirmed on

  • PHP 8.5.10 (release) and PHP-8.5 branch head (8.5.12-dev), NTS, x86_64, Zend OPcache, tracing JIT.

Root cause (code)

  • ext/opcache/jit/zend_jit_ir.c, zend_jit_fetch_obj(): the prop_info != NULL
    (known property) branch computes prop_addr = obj + prop_info->offset and guards only
    on IS_UNDEF; there is no lazy-object check, so a lazy proxy's slot is read directly.
  • ext/opcache/jit/zend_jit.c, zend_get_known_property_info(): returns a usable
    prop_info whenever the class is not immutable but is declared in the same file as the
    accessing op_array (ce->info.user.filename == op_array->filename) — which is the common
    case for a class and its own method — enabling that fast path.
  • ext/opcache/jit/zend_jit_trace.c, zend_jit_trace_exit(): for a FETCH_OBJ_IS
    deopt whose value is IS_UNDEF, the ZREG_ZVAL_COPY branch sets the result to
    NULL (undefined-property semantics), which is wrong when the slot is
    IS_UNDEF | IS_PROP_LAZY on a lazy proxy.

The interpreter and function JIT reach zend_std_read_property(), whose uninit_error
path checks zend_lazy_object_must_init() / IS_PROP_LAZY and forwards to the backing
instance. The tracing JIT fast path skips this.

Real-world reproducer (100% deterministic)

goaop/framework wraps its PointcutParser (subclass of Dissect\Parser\LALR1\Parser,
which reads $this->parseTable[...][...][...] via isset() in a hot loop) in a
ReflectionClass::newLazyProxy() via its service container. Under the container image's
default CLI JIT settings this makes every pointcut fail to parse:

git clone https://github.com/goaop/framework.git && cd framework && composer install
rm -rf tests/Fixtures/project/var/cache/aspect
php -d opcache.enable_cli=1 -d opcache.jit=tracing -d opcache.jit_buffer_size=64M \
    tests/Fixtures/project/bin/console --no-ansi cache:warmup:aop tests/Fixtures/project/web/index.php
# -> "Unexpected token ... Expected one of: ..." where the rejected token IS in the expected list

See goaop/framework#661 for the full context.

PHP Version

PHP 8.5.10 (cli) (built: Aug 28 2026 06:46:18) (NTS)
Copyright (c) The PHP Group
Built by Ubuntu
Zend Engine v4.5.10, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.10, Copyright (c), by Zend Technologies

Operating System

No response

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions