diff --git a/NEWS b/NEWS index 0d9e42792078..45d1246f3c4e 100644 --- a/NEWS +++ b/NEWS @@ -6,6 +6,11 @@ PHP NEWS . Fixed bug GH-15375 (Nested "yield from" skips items after a valid() or next() call on the inner generator). (iliaal) +- Opcache: + . Fixed tracing JIT reading an undefined property slot as NULL in + isset($obj->prop[$key]): a lazy object stayed uninitialized, and a property + removed by unset() never reached __isset()/__get(). (EdmondDantes) + 27 Aug 2026, PHP 8.4.25 - Core: diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c index 4c20c115b848..5830839f6cbb 100644 --- a/ext/opcache/jit/zend_jit_ir.c +++ b/ext/opcache/jit/zend_jit_ir.c @@ -8130,7 +8130,8 @@ static zend_jit_addr zend_jit_guard_fetch_result_type(zend_jit_ctx *jit, uint8_t type, bool deref, uint32_t flags, - bool op1_avoid_refcounting) + bool op1_avoid_refcounting, + bool undef_is_null) { zend_jit_trace_stack *stack = JIT_G(current_frame)->stack; int32_t exit_point; @@ -8158,7 +8159,7 @@ static zend_jit_addr zend_jit_guard_fetch_result_type(zend_jit_ctx *jit, if (deref) { ir_ref if_type; - if (type == IS_NULL && (opline->opcode == ZEND_FETCH_DIM_IS || opline->opcode == ZEND_FETCH_OBJ_IS)) { + if (type == IS_NULL && undef_is_null) { if_type = ir_IF(ir_ULE(jit_Z_TYPE(jit, val_addr), ir_CONST_U8(type))); } else { if_type = jit_if_Z_TYPE(jit, val_addr, type); @@ -8187,7 +8188,7 @@ static zend_jit_addr zend_jit_guard_fetch_result_type(zend_jit_ctx *jit, return 0; } - if (!deref && type == IS_NULL && (opline->opcode == ZEND_FETCH_DIM_IS || opline->opcode == ZEND_FETCH_OBJ_IS)) { + if (!deref && type == IS_NULL && undef_is_null) { ir_GUARD(ir_ULE(jit_Z_TYPE(jit, val_addr), ir_CONST_U8(type)), ir_CONST_ADDR(res_exit_addr)); } else { jit_guard_Z_TYPE(jit, val_addr, type, res_exit_addr); @@ -8257,7 +8258,7 @@ static int zend_jit_fetch_constant(zend_jit_ctx *jit, uint8_t type = concrete_type(res_info); zend_jit_addr const_addr = ZEND_ADDR_REF_ZVAL(ref); - const_addr = zend_jit_guard_fetch_result_type(jit, opline, const_addr, type, 0, 0, 0); + const_addr = zend_jit_guard_fetch_result_type(jit, opline, const_addr, type, 0, 0, 0, 0); if (!const_addr) { return 0; } @@ -12599,7 +12600,8 @@ static int zend_jit_fetch_dim_read(zend_jit_ctx *jit, } val_addr = zend_jit_guard_fetch_result_type(jit, opline, val_addr, type, - (op1_info & MAY_BE_ARRAY_OF_REF) != 0, flags, op1_avoid_refcounting); + (op1_info & MAY_BE_ARRAY_OF_REF) != 0, flags, op1_avoid_refcounting, + opline->opcode == ZEND_FETCH_DIM_IS); if (!val_addr) { return 0; } @@ -14401,6 +14403,36 @@ static int zend_jit_fetch_obj(zend_jit_ctx *jit, } prop_type_ref = jit_Z_TYPE_INFO(jit, prop_addr); ir_GUARD(prop_type_ref, ir_CONST_ADDR(exit_addr)); + } else if (opline->opcode == ZEND_FETCH_OBJ_IS) { + /* The result type guard reads IS_UNDEF as NULL, which holds only while + * nothing else answers for the slot: a lazy object initializes on read, + * and unset() leaves a property to __isset()/__get() */ + int32_t exit_point = zend_jit_trace_get_exit_point(opline, ZEND_JIT_EXIT_TO_VM); + const void *exit_addr = zend_jit_trace_get_exit_addr(exit_point); + ir_ref if_def, undef_path; + + if (!exit_addr) { + return 0; + } + if_def = ir_IF(jit_Z_TYPE_INFO(jit, prop_addr)); + ir_IF_FALSE_cold(if_def); + ir_GUARD_NOT( + ir_AND_U32( + ir_LOAD_U32(ir_ADD_OFFSET(obj_ref, offsetof(zend_object, extra_flags))), + ir_CONST_U32(IS_OBJ_LAZY_UNINITIALIZED|IS_OBJ_LAZY_PROXY)), + ir_CONST_ADDR(exit_addr)); + if (!ce || ce_is_instanceof || ce->__isset || ce->__get) { + /* a typed property that was never assigned is the one IS_UNDEF slot + * the accessors do not answer for */ + ir_GUARD( + ir_AND_U32( + ir_LOAD_U32(ir_ADD_OFFSET(prop_ref, offsetof(zval, u2.extra))), + ir_CONST_U32(IS_PROP_UNINIT)), + ir_CONST_ADDR(exit_addr)); + } + undef_path = ir_END(); + ir_IF_TRUE(if_def); + ir_MERGE_WITH(undef_path); } } else { prop_type_ref = jit_Z_TYPE_INFO(jit, prop_addr); @@ -14583,7 +14615,9 @@ static int zend_jit_fetch_obj(zend_jit_ctx *jit, } val_addr = zend_jit_guard_fetch_result_type(jit, opline, val_addr, type, - 1, flags, op1_avoid_refcounting); + 1, flags, op1_avoid_refcounting, + /* an IS_UNDEF slot reaches the guard only once the check above cleared it */ + opline->opcode == ZEND_FETCH_OBJ_IS && prop_info); if (!val_addr) { return 0; } diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c index 225257ecd6a4..52ade7960b72 100644 --- a/ext/opcache/jit/zend_jit_trace.c +++ b/ext/opcache/jit/zend_jit_trace.c @@ -8634,10 +8634,12 @@ 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 { - 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); + /* FETCH_OBJ_IS cannot answer NULL: the slot of an unset() property + * is IS_UNDEF too, and __isset()/__get() decide its result */ + 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/fetch_obj_is_lazy_object.phpt b/ext/opcache/tests/jit/fetch_obj_is_lazy_object.phpt new file mode 100644 index 000000000000..8b3aa6818e3b --- /dev/null +++ b/ext/opcache/tests/jit/fetch_obj_is_lazy_object.phpt @@ -0,0 +1,49 @@ +--TEST-- +FETCH_OBJ_IS on a lazy object must initialize it instead of reading the IS_UNDEF slot +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.file_update_protection=0 +opcache.jit=tracing +opcache.jit_buffer_size=16M +--FILE-- +list['hot']); +} + +$plain = new Marks(); +$reflection = new ReflectionClass(Marks::class); +$inits = 0; + +/* The trace is recorded on plain instances, whose property holds NULL, so it + guards the result of FETCH_OBJ_IS against NULL. Every slot of a lazy ghost is + IS_UNDEF as well, and reading one has to run the initializer. */ +$holders = array_fill(0, 64, $plain); + +for ($n = 64; $n < 96; $n++) { + $holders[$n] = $reflection->newLazyGhost(function ($holder) use (&$inits) { + $inits++; + $holder->list = ['hot' => true]; + }); +} + +$hits = 0; +for ($n = 0; $n < 96; $n++) { + $hits += probe($holders[$n]) ? 1 : 0; +} + +var_dump($hits, $inits, $reflection->isUninitializedLazyObject($holders[95])); + +?> +--EXPECT-- +int(32) +int(32) +bool(false) diff --git a/ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt b/ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt new file mode 100644 index 000000000000..4d057405a413 --- /dev/null +++ b/ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt @@ -0,0 +1,64 @@ +--TEST-- +FETCH_OBJ_IS on a declared property removed by unset() must reach __isset()/__get() +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.file_update_protection=0 +opcache.jit=tracing +opcache.jit_buffer_size=16M +opcache.jit_hot_func=2 +--FILE-- +marks); + } + + public function &__get(string $name) { + return self::$store->$name; + } + + public function __isset(string $name): bool { + return isset(self::$store->$name); + } + + public function mark(string $key): void { + $this->marks[$key] = true; + } + + public function has(string $key): bool { + return isset($this->marks[$key]); + } +} + +Holder::$store = new Store(); +$holder = new Holder(); + +for ($n = 0; $n < 10; $n++) { + $key = "k{$n}"; + $holder->mark($key); + var_dump($holder->has($key)); +} + +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt b/ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt new file mode 100644 index 000000000000..a09b1d7a4d90 --- /dev/null +++ b/ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt @@ -0,0 +1,58 @@ +--TEST-- +A NULL result guard on FETCH_OBJ_IS must not admit the IS_UNDEF slot of an unset() property +--EXTENSIONS-- +opcache +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.file_update_protection=0 +opcache.jit=tracing +opcache.jit_buffer_size=16M +--FILE-- +marks); + } + + public function &__get(string $name) { + return self::$slot->$name; + } + + public function __isset(string $name): bool { + return isset(self::$slot->$name); + } + + public function has(string $key): bool { + return isset($this->marks[$key]); + } +} + +Holder::$slot = new Slot(); +$holder = new Holder(); + +/* The trace is recorded while __isset() answers false, so it guards the result + of FETCH_OBJ_IS against NULL. The slot of the unset() property is IS_UNDEF, + and admitting it as NULL would answer the second half of the loop from the + trace without ever calling __isset()/__get(). */ +$hits = 0; +for ($n = 0; $n < 96; $n++) { + if ($n === 64) { + Holder::$slot->marks = ['hot' => true]; + } + $hits += $holder->has('hot') ? 1 : 0; +} + +var_dump($hits); + +?> +--EXPECT-- +int(32)