Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 40 additions & 6 deletions ext/opcache/jit/zend_jit_ir.c
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
6 changes: 4 additions & 2 deletions ext/opcache/jit/zend_jit_trace.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
49 changes: 49 additions & 0 deletions ext/opcache/tests/jit/fetch_obj_is_lazy_object.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php

class Marks {
public $list = null;
}

function probe($holder): bool {
return isset($holder->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)
64 changes: 64 additions & 0 deletions ext/opcache/tests/jit/fetch_obj_is_unset_prop.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php

class Store {
public array $marks = [];
}

class Holder {
public static Store $store;
public array $marks = [];

public function __construct() {
unset($this->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)
58 changes: 58 additions & 0 deletions ext/opcache/tests/jit/fetch_obj_is_unset_prop_null_guard.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php

class Slot {
public $marks;
}

class Holder {
public static Slot $slot;
public array $marks = [];

public function __construct() {
unset($this->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)
Loading