From 6270c83fc2ade4f8ffa2bc5c8a2d572ec59cbf51 Mon Sep 17 00:00:00 2001 From: lisachenko Date: Wed, 9 Sep 2026 15:22:43 +0000 Subject: [PATCH] Fix GH-23628: Tracing JIT reads undefined property slots of lazy proxies 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. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01ECekMqERF8jnqBxo1cXe3V --- NEWS | 4 ++ ext/opcache/jit/zend_jit_ir.c | 12 +++++ ext/opcache/jit/zend_jit_trace.c | 12 ++++- ext/opcache/tests/jit/gh23628_001.phpt | 72 ++++++++++++++++++++++++++ ext/opcache/tests/jit/gh23628_002.phpt | 40 ++++++++++++++ 5 files changed, 138 insertions(+), 2 deletions(-) create mode 100644 ext/opcache/tests/jit/gh23628_001.phpt create mode 100644 ext/opcache/tests/jit/gh23628_002.phpt 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)