Skip to content
Draft
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
4 changes: 4 additions & 0 deletions NEWS
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
12 changes: 12 additions & 0 deletions ext/opcache/jit/zend_jit_ir.c
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
12 changes: 10 additions & 2 deletions ext/opcache/jit/zend_jit_trace.c
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
72 changes: 72 additions & 0 deletions ext/opcache/tests/jit/gh23628_001.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php
final class Table {
protected array $map = ['start' => ['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)
40 changes: 40 additions & 0 deletions ext/opcache/tests/jit/gh23628_002.phpt
Original file line number Diff line number Diff line change
@@ -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--
<?php
final class Table {
protected array $map = ['start' => ['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)
Loading