From f36973d42a0f42e3ff4390166ccf574894203ff7 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Fri, 23 Oct 2026 10:26:45 -0600 Subject: [PATCH 1/3] Fix lost register-allocated CVs on interrupt at the end of a loop trace When closing a loop trace that uses the initial IP, the interrupt check either jumps to a deoptimizing exit at the loop header or directly to jit_stub_interrupt_handler. The choice was made with zend_jit_trace_stack_needs_deoptimization(), which only checks STACK_REG() and the slot flags. A register-allocated variable is tracked during compilation by its IR ref only, and STACK_REG() is still ZREG_NONE, so the stub was chosen even when a CV lived only in a register. If EG(vm_interrupt) was raised during the iteration without a function call (e.g. by a signal handler or another thread), the VM resumed the loop with a stale CV value. Also check for refs that were not stored to memory, using the same criterion as the link-to-root path. VmInterruptComparable is backported to zend_test from 06bfe158a52 to raise the interrupt deterministically. --- ext/opcache/jit/zend_jit_trace.c | 17 ++++++- .../tests/jit/loop_vm_interrupt_001.phpt | 34 ++++++++++++++ ext/zend_test/object_handlers.c | 45 +++++++++++++++++++ ext/zend_test/object_handlers.stub.php | 5 +++ ext/zend_test/object_handlers_arginfo.h | 26 ++++++++++- 5 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 ext/opcache/tests/jit/loop_vm_interrupt_001.phpt diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c index e2bb21cbbf40..682191366fb7 100644 --- a/ext/opcache/jit/zend_jit_trace.c +++ b/ext/opcache/jit/zend_jit_trace.c @@ -3486,6 +3486,20 @@ static int zend_jit_trace_stack_needs_deoptimization(zend_jit_trace_stack *stack return 0; } +/* While a trace is being compiled, a register-allocated variable is tracked by + * its IR reference only (STACK_REG() is still ZREG_NONE). */ +static bool zend_jit_trace_stack_has_unstored_refs(zend_jit_trace_stack *stack, uint32_t stack_size) +{ + uint32_t i; + + for (i = 0; i < stack_size; i++) { + if (STACK_REF(stack, i) && !(STACK_FLAGS(stack, i) & (ZREG_LOAD|ZREG_STORE))) { + return 1; + } + } + return 0; +} + static int zend_jit_trace_exit_needs_deoptimization(uint32_t trace_num, uint32_t exit_num) { const zend_op *opline = zend_jit_traces[trace_num].exit_info[exit_num].opline; @@ -7170,7 +7184,8 @@ static const void *zend_jit_trace(zend_jit_trace_rec *trace_buffer, uint32_t par if (trace_buffer->stop != ZEND_JIT_TRACE_STOP_RECURSIVE_RET) { if (!(t->flags & ZEND_JIT_TRACE_USES_INITIAL_IP) || (ra - && zend_jit_trace_stack_needs_deoptimization(stack, op_array->last_var + op_array->T))) { + && (zend_jit_trace_stack_needs_deoptimization(stack, op_array->last_var + op_array->T) + || zend_jit_trace_stack_has_unstored_refs(stack, op_array->last_var + op_array->T)))) { /* Deoptimize to the first instruction of the loop */ uint32_t exit_point = zend_jit_trace_get_exit_point(trace_buffer[1].opline, ZEND_JIT_EXIT_TO_VM); diff --git a/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt b/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt new file mode 100644 index 000000000000..4fa31509e9a8 --- /dev/null +++ b/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt @@ -0,0 +1,34 @@ +--TEST-- +Tracing JIT: register-allocated loop variable must survive a VM interrupt raised in the loop body +--DESCRIPTION-- +VmInterruptComparable's compare handler raises EG(vm_interrupt) without a +function call, so the interrupt is only noticed by the check that closes the +loop trace. That check must deoptimize (write $s back to its CV slot) before +entering the VM. +--EXTENSIONS-- +opcache +zend_test +--INI-- +opcache.enable=1 +opcache.enable_cli=1 +opcache.jit=tracing +opcache.jit_buffer_size=64M +opcache.jit_hot_loop=1 +--FILE-- + +--EXPECT-- +float(300) diff --git a/ext/zend_test/object_handlers.c b/ext/zend_test/object_handlers.c index 15e362605f8e..f4c760a9381b 100644 --- a/ext/zend_test/object_handlers.c +++ b/ext/zend_test/object_handlers.c @@ -232,6 +232,46 @@ ZEND_METHOD(NumericCastableNoOperations, __construct) ZVAL_COPY(OBJ_PROP_NUM(Z_OBJ_P(ZEND_THIS), 0), n); } +/* Comparing two VmInterruptComparable objects raises EG(vm_interrupt) without a + * function call. Used by ext/opcache/tests/jit/loop_vm_interrupt_001.phpt. */ +static zend_class_entry *vm_interrupt_comparable_ce; +static zend_object_handlers vm_interrupt_comparable_object_handlers; + +static zend_object* vm_interrupt_comparable_object_create_ex(zend_class_entry* ce, zend_long l) { + zend_object *obj = zend_objects_new(ce); + object_properties_init(obj, ce); + obj->handlers = &vm_interrupt_comparable_object_handlers; + ZVAL_LONG(OBJ_PROP_NUM(obj, 0), l); + return obj; +} + +static zend_object *vm_interrupt_comparable_object_create(zend_class_entry *ce) +{ + return vm_interrupt_comparable_object_create_ex(ce, 0); +} + +static int vm_interrupt_comparable_compare(zval *op1, zval *op2) +{ + ZEND_COMPARE_OBJECTS_FALLBACK(op1, op2); + + zend_atomic_bool_store_ex(&EG(vm_interrupt), true); + + return ZEND_THREEWAY_COMPARE( + Z_LVAL_P(OBJ_PROP_NUM(Z_OBJ_P(op1), 0)), + Z_LVAL_P(OBJ_PROP_NUM(Z_OBJ_P(op2), 0))); +} + +ZEND_METHOD(VmInterruptComparable, __construct) +{ + zend_long l; + + ZEND_PARSE_PARAMETERS_START(1, 1) + Z_PARAM_LONG(l) + ZEND_PARSE_PARAMETERS_END(); + + ZVAL_LONG(OBJ_PROP_NUM(Z_OBJ_P(ZEND_THIS), 0), l); +} + static zend_class_entry *dimension_handlers_no_ArrayAccess_ce; static zend_object_handlers dimension_handlers_no_ArrayAccess_object_handlers; @@ -302,6 +342,11 @@ void zend_test_object_handlers_init(void) memcpy(&numeric_castable_no_operation_object_handlers, &std_object_handlers, sizeof(zend_object_handlers)); numeric_castable_no_operation_object_handlers.cast_object = numeric_castable_no_operation_cast_object; + vm_interrupt_comparable_ce = register_class_VmInterruptComparable(); + vm_interrupt_comparable_ce->create_object = vm_interrupt_comparable_object_create; + memcpy(&vm_interrupt_comparable_object_handlers, &std_object_handlers, sizeof(zend_object_handlers)); + vm_interrupt_comparable_object_handlers.compare = vm_interrupt_comparable_compare; + dimension_handlers_no_ArrayAccess_ce = register_class_DimensionHandlersNoArrayAccess(); dimension_handlers_no_ArrayAccess_ce->create_object = dimension_handlers_no_ArrayAccess_object_create; memcpy(&dimension_handlers_no_ArrayAccess_object_handlers, &std_object_handlers, sizeof(zend_object_handlers)); diff --git a/ext/zend_test/object_handlers.stub.php b/ext/zend_test/object_handlers.stub.php index a474908b1095..8c8e7f9bfe9b 100644 --- a/ext/zend_test/object_handlers.stub.php +++ b/ext/zend_test/object_handlers.stub.php @@ -23,6 +23,11 @@ final class NumericCastableNoOperations { public function __construct(int|float $val) {} } +final class VmInterruptComparable { + private int $val; + public function __construct(int $val) {} +} + class DimensionHandlersNoArrayAccess { public bool $read = false; public bool $write = false; diff --git a/ext/zend_test/object_handlers_arginfo.h b/ext/zend_test/object_handlers_arginfo.h index 370ad13894aa..b46028a6e011 100644 --- a/ext/zend_test/object_handlers_arginfo.h +++ b/ext/zend_test/object_handlers_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit the .stub.php file instead. - * Stub hash: 81be60f2c465ffe5c036739d072ab80d9c388907 */ + * Stub hash: 1a70ed60c5af38539b1222a979f97fddf7d1826e */ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_DoOperationNoCast___construct, 0, 0, 1) ZEND_ARG_TYPE_INFO(0, val, IS_LONG, 0) @@ -15,10 +15,13 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_class_NumericCastableNoOperations___construct, 0, ZEND_ARG_TYPE_MASK(0, val, MAY_BE_LONG|MAY_BE_DOUBLE, NULL) ZEND_END_ARG_INFO() +#define arginfo_class_VmInterruptComparable___construct arginfo_class_DoOperationNoCast___construct + static ZEND_METHOD(DoOperationNoCast, __construct); static ZEND_METHOD(LongCastableNoOperations, __construct); static ZEND_METHOD(FloatCastableNoOperations, __construct); static ZEND_METHOD(NumericCastableNoOperations, __construct); +static ZEND_METHOD(VmInterruptComparable, __construct); static const zend_function_entry class_DoOperationNoCast_methods[] = { ZEND_ME(DoOperationNoCast, __construct, arginfo_class_DoOperationNoCast___construct, ZEND_ACC_PUBLIC) @@ -40,6 +43,11 @@ static const zend_function_entry class_NumericCastableNoOperations_methods[] = { ZEND_FE_END }; +static const zend_function_entry class_VmInterruptComparable_methods[] = { + ZEND_ME(VmInterruptComparable, __construct, arginfo_class_VmInterruptComparable___construct, ZEND_ACC_PUBLIC) + ZEND_FE_END +}; + static zend_class_entry *register_class_DoOperationNoCast(void) { zend_class_entry ce, *class_entry; @@ -104,6 +112,22 @@ static zend_class_entry *register_class_NumericCastableNoOperations(void) return class_entry; } +static zend_class_entry *register_class_VmInterruptComparable(void) +{ + zend_class_entry ce, *class_entry; + + INIT_CLASS_ENTRY(ce, "VmInterruptComparable", class_VmInterruptComparable_methods); + class_entry = zend_register_internal_class_with_flags(&ce, NULL, ZEND_ACC_FINAL); + + zval property_val_default_value; + ZVAL_UNDEF(&property_val_default_value); + zend_string *property_val_name = zend_string_init("val", sizeof("val") - 1, 1); + zend_declare_typed_property(class_entry, property_val_name, &property_val_default_value, ZEND_ACC_PRIVATE, NULL, (zend_type) ZEND_TYPE_INIT_MASK(MAY_BE_LONG)); + zend_string_release(property_val_name); + + return class_entry; +} + static zend_class_entry *register_class_DimensionHandlersNoArrayAccess(void) { zend_class_entry ce, *class_entry; From 0441dbb68846fd7a114b3ba4e768a03f1e1813f7 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Tue, 27 Oct 2026 07:32:03 -0600 Subject: [PATCH 2/3] test: make it fail on x86_64 as well --- .../tests/jit/loop_vm_interrupt_001.phpt | 20 +++++++++++-------- 1 file changed, 12 insertions(+), 8 deletions(-) diff --git a/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt b/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt index 4fa31509e9a8..42196d595c4c 100644 --- a/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt +++ b/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt @@ -1,10 +1,14 @@ --TEST-- Tracing JIT: register-allocated loop variable must survive a VM interrupt raised in the loop body --DESCRIPTION-- -VmInterruptComparable's compare handler raises EG(vm_interrupt) without a -function call, so the interrupt is only noticed by the check that closes the -loop trace. That check must deoptimize (write $s back to its CV slot) before -entering the VM. +Comparing two VmInterruptComparable objects sets EG(vm_interrupt) without a +PHP function call, so the JIT only notices the interrupt at the end of the +loop. Before handing control to the VM there, it has to write $s back to +memory, or the loop continues with a stale value. + +This only shows up if $s is kept in a callee-saved register across the +comparison, which is why it's an int: x86_64 has no callee-saved floating +point registers, so a float would always be spilled to memory first. --EXTENSIONS-- opcache zend_test @@ -16,10 +20,10 @@ opcache.jit_buffer_size=64M opcache.jit_hot_loop=1 --FILE-- --EXPECT-- -float(300) +int(600) From e1ff5bdb2743f021497c73cb773a209632b43610 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Tue, 27 Oct 2026 07:32:21 -0600 Subject: [PATCH 3/3] docs: add fix to NEWS --- NEWS | 2 ++ 1 file changed, 2 insertions(+) diff --git a/NEWS b/NEWS index 5bf9f52d661e..2910e46616cf 100644 --- a/NEWS +++ b/NEWS @@ -99,6 +99,8 @@ PHP NEWS exits with an uninitialized opline). (waroad) . Fixed use-after-free in JIT ASSIGN_DIM_OP when concatenating into an empty array element. (jorgsowa) + . Fixed tracing JIT losing register-allocated CVs when an interrupt is + handled at the end of a loop trace. (Levi Morrison) - PDO: . Fixed invalid ATTR_ORACLE_NULLS values being accepted and truncated.