Skip to content

Fix GH-21267: JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property#21368

Merged
dstogov merged 1 commit intophp:PHP-8.4from
iliaal:fix/gh-21267-jit-fetch-obj-undef-loop
Mar 16, 2026
Merged

Fix GH-21267: JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property#21368
dstogov merged 1 commit intophp:PHP-8.4from
iliaal:fix/gh-21267-jit-fetch-obj-undef-loop

Conversation

@iliaal
Copy link
Contributor

@iliaal iliaal commented Mar 7, 2026

Fixes #21267

When the JIT defers the IS_UNDEF check for FETCH_OBJ_R to the result type guard (the MAY_BE_GUARD + current_frame path), the deoptimization escape dispatches to opline->handler via the trace_escape stub. If opline->handler has been overwritten with JIT code (e.g. a function entry trace for a zero-param method where FETCH_OBJ_R is the first opline), this creates an infinite loop since no exit counters are bumped and no blacklisting occurs.

The fix changes zend_jit_escape_if_undef() to dispatch directly to orig_handler (the original VM handler stored in the trace extension) instead of going through trace_escape. This preserves the optimization of deferring the IS_UNDEF check to the result type guard while correctly handling the rare IS_UNDEF case during deoptimization.

Additionally, sets current_op_array in zend_jit_trace_exit_to_vm() so that the blacklisted exit deoptimizer can also resolve orig_handler, covering the case where side trace compilation is exhausted (jit_max_side_traces reached).

Test cases cover both the side trace path and the blacklisted exit path.

@dstogov
Copy link
Member

dstogov commented Mar 16, 2026

I see the problem, but the fix looks too pessimistic. It adds a guard check that is going to be almost always useless.
I should be possible to fix the problem in a better way. I'm continue working on this...

For now I simplified the test case . The first one fails on PHP-8.4 and above the second also fails on PHP-8.3

--TEST--
GH-21267 (JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property in polymorphic context)
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=64M
opcache.jit_hot_loop=0
opcache.jit_hot_func=2
opcache.jit_hot_return=0
opcache.jit_hot_side_exit=1
--FILE--
class C {
    public $x = true;
    public function __get($name) { return null; }
    public function getX() { return $this->x; }
}

$o1 = new C;
$o2 = new C;
$o2->x = false;
$o3 = new C;
unset($o3->x);
$a = [$o1, $o2, $o3];

for ($i = 0; $i < 8; $i++) {
    $m = $a[$i % 3];
    $m->getX();
    $m->getX();
}
?>
OK
--EXPECT--
OK
--TEST--
GH-21267 (JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property in polymorphic context)
--INI--
opcache.enable=1
opcache.enable_cli=1
opcache.jit=tracing
opcache.jit_buffer_size=64M
opcache.jit_hot_loop=0
opcache.jit_hot_func=2
opcache.jit_hot_return=0
opcache.jit_hot_side_exit=1
--FILE--
class C {
    public $x = true;
    public function __get($name) { return null; }
    public function getX() { return $this->x; }
}

$o1 = new C;
unset($o1->x);
$o2 = new C;
$a = [$o1, $o2, $o2];

for ($i = 0; $i < 8; $i++) {
    $m = $a[$i % 3];
    $m->getX();
    $m->getX();
}
?>
OK
--EXPECT--
OK

@dstogov
Copy link
Member

dstogov commented Mar 16, 2026

Following is a better fix:

diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c
index 19b3ce12573..899188d5423 100644
--- a/ext/opcache/jit/zend_jit_ir.c
+++ b/ext/opcache/jit/zend_jit_ir.c
@@ -7972,7 +7972,19 @@ static int zend_jit_escape_if_undef(zend_jit_ctx *jit, int var, uint32_t flags,
 	}
 
 	jit_LOAD_IP_ADDR(jit, opline - 1);
-	ir_IJMP(jit_STUB_ADDR(jit, jit_stub_trace_escape));
+
+	/* We can't use trace_escape() because opcode handler may be overriden by JIT */
+	zend_jit_op_array_trace_extension *jit_extension =
+		(zend_jit_op_array_trace_extension*)ZEND_FUNC_INFO(jit->current_op_array);
+	size_t offset = jit_extension->offset;
+	zend_vm_opcode_handler_func_t orig_handler =
+		ZEND_OP_TRACE_INFO((opline - 1), offset)->orig_handler;
+	ir_ref ref = ir_CONST_ADDR(orig_handler);
+	if (GCC_GLOBAL_REGS || ZEND_VM_KIND == ZEND_VM_KIND_TAILCALL) {
+		zend_jit_tailcall_handler(jit, ref);
+	} else {
+		zend_jit_vm_enter(jit, ref);
+	}
 
 	ir_IF_TRUE(if_def);
 

This should be applied to PHP-8.4 and above.
@iliaal can you please update the PR.

iliaal added a commit to iliaal/php-src that referenced this pull request Mar 16, 2026
When the JIT defers the IS_UNDEF check for FETCH_OBJ_R to the result
type guard, the deoptimization escape path dispatches to opline->handler
via the trace_escape stub. If opline->handler has been overwritten with
JIT code (e.g. a function entry trace), this creates an infinite loop.

Fix by dispatching to the original VM handler (orig_handler from the
trace extension) instead of going through the trace_escape stub. This
avoids the extra IS_UNDEF guard on every property read while correctly
handling the rare IS_UNDEF case during deoptimization.

Also set current_op_array in zend_jit_trace_exit_to_vm so that the
blacklisted exit deoptimizer can resolve orig_handler, covering the
case where side trace compilation is exhausted.

Closes phpGH-21368.
@iliaal iliaal force-pushed the fix/gh-21267-jit-fetch-obj-undef-loop branch from 28e99da to 1d9aba6 Compare March 16, 2026 13:22
@iliaal iliaal changed the base branch from master to PHP-8.4 March 16, 2026 13:22
@iliaal
Copy link
Contributor Author

iliaal commented Mar 16, 2026

@dstogov Thanks for the review and the better fix. Updated the PR based on your approach — zend_jit_escape_if_undef() now dispatches to orig_handler directly instead of going through trace_escape. Simplified test case adopted, branch rebased to PHP-8.4.

One addition: I also set ctx.current_op_array in zend_jit_trace_exit_to_vm() before calling zend_jit_trace_deoptimization(). Without this, the blacklisted exit path (when jit_max_side_traces is exhausted) would hit a NULL current_op_array in zend_jit_escape_if_undef() since zend_jit_deoptimizer_start() doesn't set it. Added a second test with opcache.jit_max_side_traces=0 to cover this path — it reproduces the same infinite loop on unpatched code.

Comment on lines +8097 to +8107
ir_ref ref = ir_CONST_ADDR(ZEND_OP_TRACE_INFO((opline - 1), offset)->orig_handler);
if (GCC_GLOBAL_REGS) {
ir_TAILCALL(IR_VOID, ref);
} else {
#if defined(IR_TARGET_X86)
ref = ir_CAST_FC_FUNC(ref);
#endif
ref = ir_CALL_1(IR_I32, ref, jit_FP(jit));
ir_GUARD(ir_GE(ref, ir_CONST_I32(0)), jit_STUB_ADDR(jit, jit_stub_trace_halt));
ir_RETURN(ir_CONST_I32(1)); // ZEND_VM_ENTER
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I see, my patch didn't handle all VM kinds right.
Anyway, I'm not completely sure if the "else" part of your patch is going to work for all VM kinds.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The else branch matches the dispatch pattern used by zend_jit_trace_exit_stub (line 2527) and zend_jit_trace_return (line 17148) on PHP-8.4: ir_CALL_1(IR_I32, ...) + ir_GUARD + ir_RETURN(ZEND_VM_ENTER). On 8.4, JIT only runs with HYBRID VM kind — GCC_GLOBAL_REGS vs CALL convention covers both paths.

For a master forward-port this would need zend_jit_tailcall_handler/zend_jit_vm_enter to also handle ZEND_VM_KIND_TAILCALL.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we can use TAILCALL for the else part.

Comment on lines +7371 to +7375
/* Set current_op_array so that zend_jit_escape_if_undef() can
* resolve orig_handler to avoid dispatching to JIT-overwritten
* opline->handler (which would cause an infinite loop). */
ctx.current_op_array = zend_jit_traces[zend_jit_traces[trace_num].root].op_array;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

if ctx.current_op_array may be uninitialized, better pass op_array into zend_jit_escape_if_undef() as a parameter.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done — zend_jit_escape_if_undef now takes op_array as an explicit parameter. The call site passes jit->current_op_array, which is set by zend_jit_trace_start in the side-trace path and by the new initialization in zend_jit_trace_exit_to_vm for the blacklist path.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant not to set ctx.current_op_array, but pass the value directly into zend_jit_escape_if_undef() completely removing this chunk.

iliaal added a commit to iliaal/php-src that referenced this pull request Mar 16, 2026
When the JIT defers the IS_UNDEF check for FETCH_OBJ_R to the result
type guard, the deoptimization escape path dispatches to opline->handler
via the trace_escape stub. If opline->handler has been overwritten with
JIT code (e.g. a function entry trace), this creates an infinite loop.

Fix by dispatching to the original VM handler (orig_handler from the
trace extension) instead of going through the trace_escape stub. This
avoids the extra IS_UNDEF guard on every property read while correctly
handling the rare IS_UNDEF case during deoptimization.

Also set current_op_array in zend_jit_trace_exit_to_vm so that the
blacklisted exit deoptimizer can resolve orig_handler, covering the
case where side trace compilation is exhausted.

Closes phpGH-21368.
@iliaal iliaal force-pushed the fix/gh-21267-jit-fetch-obj-undef-loop branch from 1d9aba6 to c95f761 Compare March 16, 2026 14:04
Copy link
Member

@dstogov dstogov left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I reduced the patch to

diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c
index 3ddaa027088..f5e821c2c66 100644
--- a/ext/opcache/jit/zend_jit_ir.c
+++ b/ext/opcache/jit/zend_jit_ir.c
@@ -8066,7 +8066,7 @@ static int zend_jit_defined(zend_jit_ctx *jit, const zend_op *opline, uint8_t sm
 	return 1;
 }
 
-static int zend_jit_escape_if_undef(zend_jit_ctx *jit, int var, uint32_t flags, const zend_op *opline, int8_t reg)
+static int zend_jit_escape_if_undef(zend_jit_ctx *jit, int var, uint32_t flags, const zend_op *opline, const zend_op_array *op_array, int8_t reg)
 {
 	zend_jit_addr reg_addr = ZEND_ADDR_REF_ZVAL(zend_jit_deopt_rload(jit, IR_ADDR, reg));
 	ir_ref if_def = ir_IF(jit_Z_TYPE(jit, reg_addr));
@@ -8089,7 +8089,21 @@ static int zend_jit_escape_if_undef(zend_jit_ctx *jit, int var, uint32_t flags,
 	}
 
 	jit_LOAD_IP_ADDR(jit, opline - 1);
-	ir_IJMP(jit_STUB_ADDR(jit, jit_stub_trace_escape));
+
+	/* We can't use trace_escape() because opcode handler may be overridden by JIT */
+	zend_jit_op_array_trace_extension *jit_extension =
+		(zend_jit_op_array_trace_extension*)ZEND_FUNC_INFO(op_array);
+	size_t offset = jit_extension->offset;
+	if (GCC_GLOBAL_REGS) {
+		ir_ref ref = ir_CONST_ADDR(ZEND_OP_TRACE_INFO((opline - 1), offset)->orig_handler);
+		ir_TAILCALL(IR_VOID, ref);
+	} else {
+		ir_ref ref = ir_CONST_ADDR(ZEND_OP_TRACE_INFO((opline - 1), offset)->call_handler);
+#if defined(IR_TARGET_X86)
+		ref = ir_CAST_FC_FUNC(ref);
+#endif
+		ir_TAILCALL_1(IR_I32, ref, jit_FP(jit));
+	}
 
 	ir_IF_TRUE(if_def);
 
diff --git a/ext/opcache/jit/zend_jit_trace.c b/ext/opcache/jit/zend_jit_trace.c
index 03b59eea615..b5d980ca5af 100644
--- a/ext/opcache/jit/zend_jit_trace.c
+++ b/ext/opcache/jit/zend_jit_trace.c
@@ -3603,7 +3603,7 @@ static int zend_jit_trace_deoptimization(
 
 		ZEND_ASSERT(STACK_FLAGS(parent_stack, check2) == ZREG_ZVAL_COPY);
 		ZEND_ASSERT(reg != ZREG_NONE);
-		if (!zend_jit_escape_if_undef(jit, check2, flags, opline, reg)) {
+		if (!zend_jit_escape_if_undef(jit, check2, flags, opline, exit_info->op_array, reg)) {
 			return 0;
 		}
 		if (!zend_jit_restore_zval(jit, EX_NUM_TO_VAR(check2), reg)) {

Do you see any problems?
Merge to master is going to be not trivial because of tail-call VM...

Comment on lines +7371 to +7375
/* Set current_op_array so that zend_jit_escape_if_undef() can
* resolve orig_handler to avoid dispatching to JIT-overwritten
* opline->handler (which would cause an infinite loop). */
ctx.current_op_array = zend_jit_traces[zend_jit_traces[trace_num].root].op_array;

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I meant not to set ctx.current_op_array, but pass the value directly into zend_jit_escape_if_undef() completely removing this chunk.

Comment on lines +8097 to +8107
ir_ref ref = ir_CONST_ADDR(ZEND_OP_TRACE_INFO((opline - 1), offset)->orig_handler);
if (GCC_GLOBAL_REGS) {
ir_TAILCALL(IR_VOID, ref);
} else {
#if defined(IR_TARGET_X86)
ref = ir_CAST_FC_FUNC(ref);
#endif
ref = ir_CALL_1(IR_I32, ref, jit_FP(jit));
ir_GUARD(ir_GE(ref, ir_CONST_I32(0)), jit_STUB_ADDR(jit, jit_stub_trace_halt));
ir_RETURN(ir_CONST_I32(1)); // ZEND_VM_ENTER
}
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It seems we can use TAILCALL for the else part.

@dstogov
Copy link
Member

dstogov commented Mar 16, 2026

  • ir_ref ref = ir_CONST_ADDR(ZEND_OP_TRACE_INFO((opline - 1), offset)->call_handler);
    

It seems we may always use orig_handler.

When the JIT defers the IS_UNDEF check for FETCH_OBJ_R to the result
type guard, the deoptimization escape path dispatches to opline->handler
via the trace_escape stub. If opline->handler has been overwritten with
JIT code (e.g. a function entry trace), this creates an infinite loop.

Fix by dispatching to the original VM handler (orig_handler from the
trace extension) instead of going through the trace_escape stub. This
avoids the extra IS_UNDEF guard on every property read while correctly
handling the rare IS_UNDEF case during deoptimization.

Also set current_op_array in zend_jit_trace_exit_to_vm so that the
blacklisted exit deoptimizer can resolve orig_handler, covering the
case where side trace compilation is exhausted.

Closes phpGH-21368.
@iliaal
Copy link
Contributor Author

iliaal commented Mar 16, 2026

Updated — adopted all suggestions:

  1. zend_jit_escape_if_undef() takes op_array as an explicit parameter. The ctx.current_op_array assignment in zend_jit_trace_exit_to_vm() is removed; the call site passes exit_info->op_array directly.

  2. The non-GCC path now uses ir_TAILCALL_1 instead of ir_CALL_1 + ir_GUARD + ir_RETURN.

  3. orig_handler is used in both branches (per the follow-up comment — in the non-GCC path it's already the CALL-convention handler, so orig_handler == call_handler). Since both branches use the same handler, ref is hoisted before the if.

@dstogov
Copy link
Member

dstogov commented Mar 16, 2026

It's something wrong with github.
The commit 4cebe12 looks right, but PR still shows the old patch.

@iliaal iliaal force-pushed the fix/gh-21267-jit-fetch-obj-undef-loop branch from c95f761 to 4cebe12 Compare March 16, 2026 17:38
@iliaal
Copy link
Contributor Author

iliaal commented Mar 16, 2026

It's something wrong with github. The commit 4cebe12 looks right, but PR still shows the old patch.

There was wrong branch set for diff, should be ok now.

@dstogov
Copy link
Member

dstogov commented Mar 16, 2026

I don't see problems.
I'll merge this when tests passed.

@iliaal
Copy link
Contributor Author

iliaal commented Mar 16, 2026

Cool, thanks your help :)

@dstogov dstogov merged commit e1a3a4c into php:PHP-8.4 Mar 16, 2026
19 checks passed
dstogov added a commit that referenced this pull request Mar 16, 2026
* PHP-8.4:
  Fix GH-21267: JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property (#21368)
dstogov added a commit that referenced this pull request Mar 16, 2026
* PHP-8.5:
  Fix support for TAILCALL VM
  Fix GH-21267: JIT infinite loop on FETCH_OBJ_R with IS_UNDEF property (#21368)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

JIT tracing: infinite loop on FETCH_OBJ_R with IS_UNDEF property in polymorphic context

2 participants