Commit 35655073145 for php
commit 35655073145bb7130c3908fcb25c2551e73bdf1d
Author: Alexander Lisachenko <640114+lisachenko@users.noreply.github.com>
Date: Thu Sep 24 19:58:46 2026 +0300
Fix GH-23628: Tracing JIT reads undefined property slots of lazy proxies and unset properties (#23640)
A lazy proxy keeps its own property slots IS_UNDEF|IS_PROP_LAZY even after
it has been initialized, and the object handlers forward every property
access to the real instance. The tracing JIT was not aware of this in two
places:
1. When the recorded trace contained a FETCH_OBJ_R/IS/W on a known property
whose slot was IS_UNDEF, the known-offset fast path was still compiled.
For a lazy proxy this path never succeeds, and it deoptimized on every
execution. Use the generic code path (that falls back to the object
handlers for undefined slots) when the slot was IS_UNDEF at recording
time. This also covers uninitialized and unset properties.
2. During deoptimization of a failed result type guard after FETCH_OBJ_IS,
an IS_UNDEF slot was turned into NULL, assuming an undefined property.
For a slot flagged IS_PROP_LAZY the fetch has to be forwarded to the
real instance instead, so re-execute the opline in the VM, the same way
it is already done for FETCH_OBJ_R.
diff --git a/NEWS b/NEWS
index 88c009de5c2..f0e89aa088b 100644
--- a/NEWS
+++ b/NEWS
@@ -95,6 +95,10 @@ PHP NEWS
. Fixed inflate_init() dropping the preset dictionary for raw streams with
a non-default window. (Ilia Alshanetsky)
+- Opcache:
+ . Fixed bug GH-23628 (Tracing JIT reads undefined property slots of lazy
+ proxy objects instead of forwarding to the real instance). (lisachenko)
+
24 Sep 2026, PHP 8.5.11
diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c
index 5e4af150861..660378e7275 100644
--- a/ext/opcache/jit/zend_jit_ir.c
+++ b/ext/opcache/jit/zend_jit_ir.c
@@ -14247,6 +14247,11 @@ static int zend_jit_fetch_obj(zend_jit_ctx *jit,
ZEND_ASSERT(Z_TYPE_P(member) == IS_STRING && Z_STRVAL_P(member)[0] != '\0');
prop_info = zend_get_known_property_info(op_array, ce, Z_STR_P(member), on_this, op_array->filename);
+ if (JIT_G(trigger) == ZEND_JIT_ON_HOT_TRACE && prop_type == IS_UNDEF) {
+ prop_info = NULL;
+ trace_ce = NULL;
+ }
+
if (on_this) {
zend_jit_addr this_addr = ZEND_ADDR_MEM_ZVAL(ZREG_FP, offsetof(zend_execute_data, This));
obj_ref = jit_Z_PTR(jit, this_addr);
diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c
index 655c84e3f49..5bb846b518f 100644
--- a/ext/opcache/jit/zend_jit_trace.c
+++ b/ext/opcache/jit/zend_jit_trace.c
@@ -8713,10 +8713,13 @@ int ZEND_FASTCALL zend_jit_trace_exit(uint32_t exit_num, zend_jit_registers_buf
const zend_op *op = t->exit_info[exit_num].opline;
ZEND_ASSERT(op);
op--;
- if (op->opcode == ZEND_FETCH_DIM_IS || op->opcode == ZEND_FETCH_OBJ_IS) {
+ if (op->opcode == ZEND_FETCH_DIM_IS) {
+ ZVAL_NULL(EX_VAR_NUM(i));
+ } else if (op->opcode == ZEND_FETCH_OBJ_IS
+ && (Z_PROP_FLAG_P(val) & (IS_PROP_LAZY|IS_PROP_UNINIT)) == IS_PROP_UNINIT) {
ZVAL_NULL(EX_VAR_NUM(i));
} else {
- ZEND_ASSERT(op->opcode == ZEND_FETCH_DIM_R || op->opcode == ZEND_FETCH_LIST_R || op->opcode == ZEND_FETCH_OBJ_R || op->opcode == ZEND_FETCH_DIM_FUNC_ARG || op->opcode == ZEND_FETCH_OBJ_FUNC_ARG);
+ ZEND_ASSERT(op->opcode == ZEND_FETCH_DIM_R || op->opcode == ZEND_FETCH_LIST_R || op->opcode == ZEND_FETCH_OBJ_R || op->opcode == ZEND_FETCH_OBJ_IS || op->opcode == ZEND_FETCH_DIM_FUNC_ARG || op->opcode == ZEND_FETCH_OBJ_FUNC_ARG);
repeat_last_opline = 1;
}
} else {
diff --git a/ext/opcache/tests/jit/gh23628_001.phpt b/ext/opcache/tests/jit/gh23628_001.phpt
new file mode 100644
index 00000000000..430cfbc8dc9
--- /dev/null
+++ b/ext/opcache/tests/jit/gh23628_001.phpt
@@ -0,0 +1,72 @@
+--TEST--
+GH-23628 001: Tracing JIT reads undefined property slots of a lazy proxy
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.file_update_protection=0
+opcache.jit=tracing
+opcache.jit_buffer_size=32M
+opcache.jit_hot_loop=16
+--EXTENSIONS--
+opcache
+--FILE--
+<?php
+final class Table {
+ protected array $map = ['start' => ['next' => 1]];
+ public int $count = 0;
+ 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;
+ }
+ public function coalesce(int $n): int {
+ $sum = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $sum += $this->map['start']['next'] ?? 100;
+ }
+ return $sum;
+ }
+ public function read(int $n): int {
+ $sum = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $sum += $this->map['start']['next'];
+ }
+ return $sum;
+ }
+ public function write(int $n): int {
+ for ($i = 0; $i < $n; $i++) {
+ $this->map['start']['next'] = $i;
+ $this->count++;
+ }
+ return $this->map['start']['next'];
+ }
+}
+
+$reflector = new ReflectionClass(Table::class);
+
+$proxy = $reflector->newLazyProxy(fn () => new Table());
+var_dump($proxy->parse(100));
+$proxy = $reflector->newLazyProxy(fn () => new Table());
+var_dump($proxy->coalesce(100));
+$proxy = $reflector->newLazyProxy(fn () => new Table());
+var_dump($proxy->read(100));
+$proxy = $reflector->newLazyProxy(fn () => new Table());
+var_dump($proxy->write(100));
+var_dump($proxy->count);
+
+$ghost = $reflector->newLazyGhost(function (Table $table) {});
+var_dump($ghost->parse(100));
+?>
+--EXPECT--
+int(100)
+int(100)
+int(100)
+int(99)
+int(100)
+int(100)
diff --git a/ext/opcache/tests/jit/gh23628_002.phpt b/ext/opcache/tests/jit/gh23628_002.phpt
new file mode 100644
index 00000000000..12f306f8c5f
--- /dev/null
+++ b/ext/opcache/tests/jit/gh23628_002.phpt
@@ -0,0 +1,46 @@
+--TEST--
+GH-23628 002: Tracing JIT deoptimization on an undefined property slot of a lazy proxy
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.file_update_protection=0
+opcache.jit=tracing
+opcache.jit_buffer_size=32M
+opcache.jit_hot_loop=16
+--EXTENSIONS--
+opcache
+--FILE--
+<?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;
+ }
+}
+
+// The trace is recorded and compiled for a regular object, so that the
+// property is read directly from the property slot...
+var_dump((new Table())->parse(100));
+
+// ... and later executed for a lazy proxy, whose property slot is undefined
+// and has to be forwarded to the real instance during deoptimization.
+$proxy = (new ReflectionClass(Table::class))->newLazyProxy(fn () => new Table());
+var_dump($proxy->parse(100));
+
+// ... and for an uninitialized lazy ghost, which is initialized on the first
+// property access.
+$ghost = (new ReflectionClass(Table::class))->newLazyGhost(function (Table $table) {});
+var_dump($ghost->parse(100));
+?>
+--EXPECT--
+int(100)
+int(100)
+int(100)
diff --git a/ext/opcache/tests/jit/gh23628_003.phpt b/ext/opcache/tests/jit/gh23628_003.phpt
new file mode 100644
index 00000000000..a2d4654791b
--- /dev/null
+++ b/ext/opcache/tests/jit/gh23628_003.phpt
@@ -0,0 +1,38 @@
+--TEST--
+GH-23628 003: Tracing JIT deoptimization on an unset() property served by __isset()/__get()
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.file_update_protection=0
+opcache.jit=tracing
+opcache.jit_buffer_size=32M
+opcache.jit_hot_loop=16
+--EXTENSIONS--
+opcache
+--FILE--
+<?php
+class A {
+ public $p = ['x' => 1];
+ public function __isset($n) { return true; }
+ public function __get($n) { return ['x' => 42]; }
+ function f($n) {
+ $s = 0;
+ for ($i = 0; $i < $n; $i++) {
+ $s += $this->p['x'] ?? 1000;
+ }
+ return $s;
+ }
+}
+
+// The trace is recorded and compiled while the property is initialized...
+var_dump((new A)->f(100));
+
+// ... and then executed after the property was unset(), so the fetch has to
+// go through __isset()/__get() instead of yielding NULL.
+$a = new A;
+unset($a->p);
+var_dump($a->f(100));
+?>
+--EXPECT--
+int(100)
+int(4200)