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..660378e7275d 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 655c84e3f496..5bb846b518f8 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 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..12f306f8c5f1 --- /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-- + ['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 000000000000..a2d4654791be --- /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-- + 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)