diff --git a/NEWS b/NEWS index 63cbbca22b54..648259a7f9f1 100644 --- a/NEWS +++ b/NEWS @@ -7,6 +7,10 @@ PHP NEWS registrations are freed while still reachable from the cycle collector. (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 5e4af150861e..d18fbf8281c8 100644 --- a/ext/opcache/jit/zend_jit_ir.c +++ b/ext/opcache/jit/zend_jit_ir.c @@ -14247,6 +14247,18 @@ 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) { + /* The property slot was IS_UNDEF when the trace was recorded. This + * happens for lazy objects (a lazy proxy keeps its own slots undefined + * and forwards the accesses to the real instance), as well as for + * uninitialized or unset properties. The fast path with a known + * property offset would deoptimize on every execution, so use the + * generic code path that falls back to the object handlers for + * undefined slots instead. */ + 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 655c84e3f496..40dc531ab0a2 100644 --- a/ext/opcache/jit/zend_jit_trace.c +++ b/ext/opcache/jit/zend_jit_trace.c @@ -8713,10 +8713,18 @@ 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)) { 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); + /* Undefined array index or property that has to emit a warning, + * or a property slot of a lazy object that has to be forwarded + * to the real instance by the read_property handler (a lazy + * proxy keeps its own slots IS_UNDEF|IS_PROP_LAZY even after + * initialization): re-execute the opline in the VM. */ + 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 000000000000..430cfbc8dc97 --- /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-- + ['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 000000000000..8f1699fda8ed --- /dev/null +++ b/ext/opcache/tests/jit/gh23628_002.phpt @@ -0,0 +1,40 @@ +--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-- + ['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)); +?> +--EXPECT-- +int(100) +int(100)