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
When
$thisis an initialized lazy proxy created byReflectionClass::newLazyProxy(), theproxy 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 beforwarded 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_ISon$this) using the known-property fast path fromzend_jit_fetch_obj(). That fast path reads the property directly atprop_info->offsetand only guardsZ_TYPE != IS_UNDEF; it does not account forthe object being a lazy proxy, so it never forwards to
read_property. It reads theproxy's own dead slot (
IS_UNDEF), takes the deopt exit, and inzend_jit_trace_exit()theZREG_ZVAL_COPYhandler converts theIS_UNDEFvalue of aFETCH_OBJ_ISintoNULL(treating it as an undefined property). The result is thatisset($this->prop['k'])evaluates to false and property reads see a missing value,even though the backing instance holds the data.
opcache.jit=functionandopcache.jit=0are correct; only tracing JIT (1254/1255/tracing) is affected.Lazy ghosts (
newLazyGhost) and eager objects are unaffected, because a ghostinitializes in place so its own slots become real values.
Reproducer (self-contained)
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_loopto a small value makes it deterministic on theaffected file. The behaviour is 100% reproducible in a real workload (see below).
Confirmed on
Root cause (code)
ext/opcache/jit/zend_jit_ir.c,zend_jit_fetch_obj(): theprop_info != NULL(known property) branch computes
prop_addr = obj + prop_info->offsetand guards onlyon
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 usableprop_infowhenever the class is not immutable but is declared in the same file as theaccessing op_array (
ce->info.user.filename == op_array->filename) — which is the commoncase for a class and its own method — enabling that fast path.
ext/opcache/jit/zend_jit_trace.c,zend_jit_trace_exit(): for aFETCH_OBJ_ISdeopt whose value is
IS_UNDEF, theZREG_ZVAL_COPYbranch sets the result toNULL(undefined-property semantics), which is wrong when the slot isIS_UNDEF | IS_PROP_LAZYon a lazy proxy.The interpreter and function JIT reach
zend_std_read_property(), whoseuninit_errorpath checks
zend_lazy_object_must_init()/IS_PROP_LAZYand forwards to the backinginstance. The tracing JIT fast path skips this.
Real-world reproducer (100% deterministic)
goaop/framework wraps its
PointcutParser(subclass ofDissect\Parser\LALR1\Parser,which reads
$this->parseTable[...][...][...]viaisset()in a hot loop) in aReflectionClass::newLazyProxy()via its service container. Under the container image'sdefault CLI JIT settings this makes every pointcut fail to parse:
See goaop/framework#661 for the full context.
PHP Version
Operating System
No response