From 5764c1470d0603c47257ec6dd8bdbe37d3cf4083 Mon Sep 17 00:00:00 2001 From: Edmond Date: Thu, 13 Aug 2026 07:07:33 +0000 Subject: [PATCH] JIT: Do not read an undefined property slot as NULL in FETCH_OBJ_IS isset($obj->prop[$key]) answered false for a key the array holds, when prop was a declared property removed by unset() and served by __isset()/__get(), and it answered from the IS_UNDEF slots of a lazy object instead of running its initializer. A trace reads the slot of a known property directly and left IS_UNDEF to the result type guard, which admits it whenever the recorded type is NULL. That holds only while nothing else answers for the slot, so FETCH_OBJ_IS now checks the slot before the guard: a lazy object and a property the accessors answer for leave the trace, while a typed property that was never assigned still reads as NULL inline. The side exit taken on any other recorded type stored NULL in the result and resumed after the fetch. FETCH_OBJ_IS now re-runs in the VM, as FETCH_OBJ_R already does. --- NEWS | 5 ++ ext/opcache/jit/zend_jit_ir.c | 46 +++++++++++-- ext/opcache/jit/zend_jit_trace.c | 6 +- .../tests/jit/fetch_obj_is_lazy_object.phpt | 49 ++++++++++++++ .../tests/jit/fetch_obj_is_unset_prop.phpt | 64 +++++++++++++++++++ .../fetch_obj_is_unset_prop_null_guard.phpt | 58 +++++++++++++++++ 6 files changed, 220 insertions(+), 8 deletions(-) create mode 100644 ext/opcache/tests/jit/fetch_obj_is_lazy_object.phpt create mode 100644 ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt create mode 100644 ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt 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)