From 77b6cfebe04d30b697c5b421eb7ad19970e58207 Mon Sep 17 00:00:00 2001 From: Edmond Date: Thu, 10 Sep 2026 10:06:52 +0300 Subject: [PATCH] Let a JIT'd isset() reach __isset() on an unset property The tracing JIT knows the offset of a declared property and reads its slot directly, deferring the IS_UNDEF check to the result type guard that follows. The guard admits IS_UNDEF only for ZEND_FETCH_OBJ_IS with a NULL result, and its deoptimization resumes at the next opline with the slot copied into the result rather than re-running the fetch. A property removed by unset() and served by __isset()/__get() traces to the type those return, so IS_UNDEF fails the guard and isset($obj->prop[$key]) answers false for a key the array holds, with the magic handler never called. Check IS_UNDEF before the fetch where the guard cannot stand in for it. FETCH_OBJ_R defers the same check and its deoptimization has the same shape, but no script made it answer wrongly, so its condition is left alone. Reported as true-async/php-async#223, where Laravel's Blade emitted an @once block twice under concurrent renders. Reproduces on upstream master, which carries the same condition. #223 --- ext/opcache/jit/zend_jit_ir.c | 17 ++++- .../tests/jit/fetch_obj_is_unset_prop.phpt | 64 +++++++++++++++++++ 2 files changed, 79 insertions(+), 2 deletions(-) create mode 100644 ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c index a9d8d2c255a5..82e9b4597078 100644 --- a/ext/opcache/jit/zend_jit_ir.c +++ b/ext/opcache/jit/zend_jit_ir.c @@ -14554,8 +14554,21 @@ static int zend_jit_fetch_obj(zend_jit_ctx *jit, prop_ref = ir_ADD_OFFSET(obj_ref, prop_info->offset); prop_addr = ZEND_ADDR_REF_ZVAL(prop_ref); if (JIT_G(trigger) == ZEND_JIT_ON_HOT_TRACE) { - if (opline->opcode == ZEND_FETCH_OBJ_W || !(res_info & MAY_BE_GUARD) || !JIT_G(current_frame)) { - /* perform IS_UNDEF check only after result type guard (during deoptimization) */ + /* Where this check is skipped, IS_UNDEF is caught only by the result type + * guard that follows (during deoptimization). That guard stands in for it + * only where it admits IS_UNDEF, which zend_jit_guard_fetch_result_type() + * does for ZEND_FETCH_OBJ_IS with a NULL result. An unset() declared + * property served by __isset()/__get() traces to the type those return, so + * IS_UNDEF fails the guard, and the deoptimization resumes at the next + * opline with the empty slot copied into the result: isset() answers false + * and the magic handler never runs. */ + bool undef_needs_vm = opline->opcode == ZEND_FETCH_OBJ_IS + && concrete_type(res_info) != IS_NULL; + + if (opline->opcode == ZEND_FETCH_OBJ_W + || !(res_info & MAY_BE_GUARD) + || !JIT_G(current_frame) + || undef_needs_vm) { 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); 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)