Skip to content

Tracing JIT loses the result of a dynamic method call traced with Closure::__invoke (8.5 regression) #24286

Open

Description

@liquidice

Description

With the tracing JIT, the result of a dynamic method call $o->{$m}() is lost when the call site was first traced while calling Closure::__invoke. The caller then reads whatever is left in the result slot.

The following code:

<?php
class A { function m() { return false; } }
function call($o, $m) { return $o->{$m}(); }
function every($items, $m) {
    foreach ($items as $i) { if (!call($i, $m)) return false; }
    return true;
}
$c = function () { return false; };
for ($i = 0; $i < 2000; $i++) every([$c], '__invoke');
var_dump(call(new A, 'm'));
php -n -d opcache.enable_cli=1 -d opcache.jit_buffer_size=64M -d opcache.jit=tracing test.php

Resulted in this output:

UNKNOWN:0

But I expected this output instead:

bool(false)

Valgrind on PHP-8.5 @ bbfdd69 (-g -O2, USE_ZEND_ALLOC=0 valgrind --smc-check=all --track-origins=yes). Leaving out the ir_sparse_set_in reports from the JIT compiler, every remaining error is about the same never-written VM stack memory: it is used in JIT code, printed by var_dump(), then destroyed:

Conditional jump or move depends on uninitialised value(s)
   at 0x11800ADF: ???
   by 0x8C4E9F: zend_execute (zend_vm_execute.h:122008)
   by 0x9270D5: zend_execute_script (zend.c:1985)
 Uninitialised value was created by a heap allocation
   by 0x88CAE8: zend_vm_stack_new_page (zend_execute.h:327)
   by 0x88CAE8: zend_vm_stack_init (zend_execute.c:195)

Conditional jump or move depends on uninitialised value(s)
   at 0x7668EE: php_var_dump (var.c:130)
   by 0x7672E0: zif_var_dump (var.c:257)
   by 0x8C0273: ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER (zend_vm_execute.h:1360)
 Uninitialised value was created by a heap allocation
   by 0x88CAE8: zend_vm_stack_new_page (zend_execute.h:327)
   by 0x88CAE8: zend_vm_stack_init (zend_execute.c:195)

Conditional jump or move depends on uninitialised value(s)
   at 0x8C02AE: zval_ptr_dtor_nogc (zend_variables.h:35)
   by 0x8C02AE: zend_vm_stack_free_args (zend_execute.h:405)
   by 0x8C02AE: ZEND_DO_ICALL_SPEC_RETVAL_UNUSED_HANDLER (zend_vm_execute.h:1379)
 Uninitialised value was created by a heap allocation
   by 0x88CAE8: zend_vm_stack_new_page (zend_execute.h:327)
   by 0x88CAE8: zend_vm_stack_init (zend_execute.c:195)

Real-world impact: Laravel's HigherOrderCollectionProxy closure has this shape. Telescope calls it with ->every->__invoke($entry) for its filters, and then with ->partition->isException() when storing entries. On a fresh laravel/laravel 13.35 + laravel/telescope 5.25 install (official php:8.5.11-cli image, default JIT settings apart from opcache.jit=tracing), an artisan command that logs, uses the cache and runs queries in a loop crashes with zend_mm_heap corrupted or SIGSEGV with APP_ENV=production. It crashed in 28 of 32 runs from 250 iterations up. It does not crash with function JIT, with the JIT off, or on 8.4.26.

PHP Version

PHP 8.5.11 (cli) (built: Oct  6 2026 01:22:31) (NTS)
Copyright (c) The PHP Group
Built by https://github.com/docker-library/php
Zend Engine v4.5.11, Copyright (c) Zend Technologies
    with Zend OPcache v8.5.11, Copyright (c), by Zend Technologies

Operating System

Debian 13 (official Docker image), x86_64

Activity

  1. ndossche commented on Oct 13, 2026

    @ndossche
    Member

    Commit 097edc8 stopped aborting traces at internal trampolines like Closure::__invoke, so these calls are now recorded as unknown internal calls without a callee-type guard.

  2. ndossche commented on Oct 13, 2026

    @ndossche
    Member

    Please try if this patch works for your real-world workload:

    diff --git a/ext/opcache/jit/zend_jit_ir.c b/ext/opcache/jit/zend_jit_ir.c
    index c10c904a5a6..23953a85d45 100644
    --- a/ext/opcache/jit/zend_jit_ir.c
    +++ b/ext/opcache/jit/zend_jit_ir.c
    @@ -10031,6 +10031,7 @@ static int zend_jit_do_fcall(zend_jit_ctx *jit, const zend_op *opline, const zen
     	const void *exit_addr = NULL;
     	const zend_op *prev_opline;
     	ir_ref rx, func_ref = IR_UNUSED, if_user = IR_UNUSED, user_path = IR_UNUSED;
    +	bool unknown_internal = 0;
     
     	prev_opline = opline - 1;
     	while (prev_opline->opcode == ZEND_EXT_FCALL_BEGIN || prev_opline->opcode == ZEND_TICKS) {
    @@ -10114,14 +10115,39 @@ static int zend_jit_do_fcall(zend_jit_ctx *jit, const zend_op *opline, const zen
     	if (opline->opcode == ZEND_DO_FCALL || opline->opcode == ZEND_DO_FCALL_BY_NAME) {
     		if (!func) {
     			if (trace) {
    -				uint32_t exit_point = zend_jit_trace_get_exit_point(opline, ZEND_JIT_EXIT_TO_VM);
    +				uint32_t exit_point;
     
    +				func_ref = ir_LOAD_A(jit_CALL(rx, func));
    +
    +				if (trace->op == ZEND_JIT_TRACE_DO_ICALL) {
    +					/* The trace recorded a call to an unknown internal function (e.g. a trampoline)
    +					 * and continues in the caller, so the callee must be internal.
    +					 *
    +					 * If a user function is called instead, exit and let a side trace handle it.
    +					 * This is better than emitting a user function code path:
    +					 * - the trace code after the call has a single known state,
    +					 * - the trace doesn't contain code for the user call that is rarely used,
    +					 * - a side trace knows the called function and can inline it. */
    +					exit_point = zend_jit_trace_get_exit_point(opline, ZEND_JIT_EXIT_POLYMORPHISM);
    +					exit_addr = zend_jit_trace_get_exit_addr(exit_point);
    +					if (!exit_addr) {
    +						return 0;
    +					}
    +
    +					ir_GUARD(
    +						ir_EQ(
    +							ir_LOAD_U8(ir_ADD_OFFSET(func_ref, offsetof(zend_function, type))),
    +							ir_CONST_U8(ZEND_INTERNAL_FUNCTION)),
    +						ir_CONST_ADDR(exit_addr));
    +					unknown_internal = 1;
    +				}
    +
    +				exit_point = zend_jit_trace_get_exit_point(opline, ZEND_JIT_EXIT_TO_VM);
     				exit_addr = zend_jit_trace_get_exit_addr(exit_point);
     				if (!exit_addr) {
     					return 0;
     				}
     
    -				func_ref = ir_LOAD_A(jit_CALL(rx, func));
     				ir_GUARD_NOT(
     					ir_AND_U32(
     						ir_LOAD_U32(ir_ADD_OFFSET(func_ref, offsetof(zend_op_array, fn_flags))),
    @@ -10195,6 +10221,7 @@ static int zend_jit_do_fcall(zend_jit_ctx *jit, const zend_op *opline, const zen
     	}
     
     	if (!func
    +	 && !unknown_internal
     	 && opline->opcode != ZEND_DO_UCALL
     	 && opline->opcode != ZEND_DO_ICALL) {
     		ir_ref type_ref = ir_LOAD_U8(ir_ADD_OFFSET(func_ref, offsetof(zend_function, type)));
    @@ -10203,6 +10230,7 @@ static int zend_jit_do_fcall(zend_jit_ctx *jit, const zend_op *opline, const zen
     	}
     
     	if ((!func || func->type == ZEND_USER_FUNCTION)
    +	 && !unknown_internal
     	 && opline->opcode != ZEND_DO_ICALL) {
     		bool recursive_call_through_jmp = 0;
     		uint32_t num_args = 0;
    @@ -10509,7 +10537,7 @@ static int zend_jit_do_fcall(zend_jit_ctx *jit, const zend_op *opline, const zen
     
     	if ((!func || func->type == ZEND_INTERNAL_FUNCTION)
     	 && (opline->opcode != ZEND_DO_UCALL)) {
    -		if (!func && (opline->opcode != ZEND_DO_ICALL)) {
    +		if (!func && !unknown_internal && (opline->opcode != ZEND_DO_ICALL)) {
     			ir_IF_FALSE(if_user);
     		}
     
    
  3. liquidice commented on Oct 13, 2026

    @liquidice
    Author

    Thanks, the patch works.

    Tested on PHP-8.5 @ bbfdd69 (the commit from the report) and @ b04cd86, each with and without the patch. On the fresh Laravel 13.35 + Telescope 5.25 install from the report (demo:mixed, APP_ENV=production, opcache.jit=tracing, n = 250 … 3000, 14 runs per build), the unpatched builds crashed in 12 of 14 runs. The patched builds completed all 14 runs without a crash, and Telescope stored its entries normally. The 10-line reproducer prints bool(false) with the patch, with both tracing and 1054. On bbfdd69, the existing JIT tests (ext/opcache/tests/jit, opcache.jit=tracing) pass with the patch, as they do without it: 493 passed, 0 failed.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions