Commit 89f1fdab672 for php

commit 89f1fdab6725598606ae34cce474361906d3f26c
Author: Levi Morrison <levi.morrison@datadoghq.com>
Date:   Tue Oct 27 09:18:20 2026 -0600

    JIT: fix lost register-allocated CVs on interrupt at the end of a loop trace (#24610)

    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.

diff --git a/NEWS b/NEWS
index ed773193df4..69c0873aed6 100644
--- a/NEWS
+++ b/NEWS
@@ -141,6 +141,8 @@ PHP                                                                        NEWS
   . Fixed bug GH-24438 (Preserve indirect types in JIT side traces). (aasul)
   . Fixed bug GH-24577 (CE-cache flag on interned class name visible before
     map_ptr offset). (prateekbhujel)
+  . 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.
diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c
index 413c28d1127..b424934e492 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;
@@ -7160,7 +7174,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 00000000000..42196d595c4
--- /dev/null
+++ b/ext/opcache/tests/jit/loop_vm_interrupt_001.phpt
@@ -0,0 +1,38 @@
+--TEST--
+Tracing JIT: register-allocated loop variable must survive a VM interrupt raised in the loop body
+--DESCRIPTION--
+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
+--INI--
+opcache.enable=1
+opcache.enable_cli=1
+opcache.jit=tracing
+opcache.jit_buffer_size=64M
+opcache.jit_hot_loop=1
+--FILE--
+<?php
+function sum(array $objs, VmInterruptComparable $t): int {
+    $s = 0;
+    foreach ($objs as $o) {
+        $s += 3;
+        // Only VmInterruptComparable's compare handler raises the interrupt.
+        $r = $o == $t;
+    }
+    return $s;
+}
+
+$objs = array_fill(0, 200, new stdClass);
+$objs[150] = new VmInterruptComparable(0);
+var_dump(sum($objs, new VmInterruptComparable(0)));
+?>
+--EXPECT--
+int(600)
diff --git a/ext/zend_test/object_handlers.c b/ext/zend_test/object_handlers.c
index 15e362605f8..f4c760a9381 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 a474908b109..8c8e7f9bfe9 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 370ad13894a..b46028a6e01 100644
Binary files a/ext/zend_test/object_handlers_arginfo.h and b/ext/zend_test/object_handlers_arginfo.h differ