Skip to content

Hot-counter JIT segfaults when opcache shared memory runs out during zend_jit_script() (stale func_info after jit_failure) #25220

Open

Description

@RyanWyse

Description

With opcache.jit=1235 (hot-function counters), a CLI process segfaults while JIT-compiling a hot function once opcache's shared memory is nearly full. The fault is in type inference or zend_jit_cleanup_func_info(), reading a callee's func_info.

My reading of the cause: zend_jit_script() builds the call graph in CG(arena), which points every op_array's func_info slot at arena memory. Then, for hot-counter triggers, it calls zend_jit_op_array() → zend_jit_setup_hot_counters(), which allocates each jit_extension with zend_shared_alloc(). zend_accel_script_persist_calc() doesn't count those allocations. So near a full cache the script itself persists, but an extension allocation returns NULL partway through the op_arrays. That jumps to jit_failure. For hot triggers, jit_failure only releases the arena; it doesn't clear func_info. So the remaining op_arrays keep pointers into freed arena memory. When a function from that script later gets hot, zend_real_jit_func() reads the callee's stale func_info, through zend_get_func_info() in type inference or through zend_jit_cleanup_func_info(), and crashes.

Clearing the remaining op_arrays' func_info on that path fixes the reproducer below: 12 of 12 runs clean on a patched 8.5.12, against 6 of 6 crashing unpatched.

--- a/ext/opcache/jit/zend_jit.c
+++ b/ext/opcache/jit/zend_jit.c
@@ int zend_jit_script(zend_script *script)
 jit_failure:
 	if (JIT_G(trigger) == ZEND_JIT_ON_SCRIPT_LOAD) {
 		for (i = 0; i < call_graph.op_arrays_count; i++) {
 			ZEND_SET_FUNC_INFO(call_graph.op_arrays[i], NULL);
 		}
+	} else {
+		/* op_arrays not set up yet still point at func_info in the arena released below */
+		for (; i < call_graph.op_arrays_count; i++) {
+			ZEND_SET_FUNC_INFO(call_graph.op_arrays[i], NULL);
+		}
 	}
 	zend_arena_release(&CG(arena), checkpoint);
 	return FAILURE;

Reproducer

gen.php writes 600 classes of 150 methods; m0() calls every other method:

<?php
// Generates $argv[2] classes of $argv[3] methods; m0 calls every other method.
[, $dir, $n, $methods] = $argv;
@mkdir($dir, 0777, true);
for ($i = 0; $i < (int) $n; $i++) {
    $m = '';
    $calls = '';
    for ($k = 1; $k < (int) $methods; $k++) {
        $calls .= "        \$a += \$this->m$k(\$x + $k);\n";
        $m .= "    public function m$k(int \$x): int { \$t = 0; for (\$j = 0; \$j < 3; \$j++) { \$t += (\$x * $k + \$j) % 7; } return \$t > 3 ? \$t : \$t + $k; }\n";
    }
    file_put_contents("$dir/C$i.php", "<?php\nfinal class C$i\n{\n    public function m0(int \$x): int\n    {\n        \$a = 0;\n$calls        return \$a;\n    }\n$m}\n");
}

run.php loads them and calls each until hot:

<?php
// Loads the generated classes one by one and calls each until its methods are hot,
// revisiting earlier classes so their functions compile after opcache has filled.
$dir = $argv[1];
$n = (int) $argv[2];
$sum = 0;
$order = range(0, $n - 1);
mt_srand((int) ($argv[3] ?? 0));
shuffle($order);
foreach ($order as $i) {
    require "$dir/C$i.php";
    $class = "C$i";
    $o = new $class();
    for ($r = 0; $r < 300; $r++) {
        $sum += $o->m0($r);
    }
    $back = "C".$order[mt_rand(0, array_search($i, $order))];
    $p = new $back();
    for ($r = 0; $r < 300; $r++) {
        $sum += $p->m0($r);
    }
    if ($i % 500 === 0) {
        $s = opcache_get_status(false);
        fprintf(STDERR, "%d full=%d free=%d jit=%s\n", $i, $s['cache_full'], $s['memory_usage']['free_memory'], json_encode($s['jit']['buffer_free'] ?? null));
    }
}
// Second pass: every class again, so the one script whose JIT setup ran out of
// shared memory part-way gets its hot functions compiled.
for ($pass = 0; $pass < 2; $pass++) {
    for ($i = 0; $i < $n; $i++) {
        $class = "C$i";
        $o = new $class();
        for ($r = 0; $r < 300; $r++) {
            $sum += $o->m0($r);
        }
    }
    $s = opcache_get_status(false);
    fprintf(STDERR, "pass %d jit_free=%d\n", $pass, $s['jit']['buffer_free']);
}
echo "done $sum\n";
docker run --rm -v "$PWD":/r -w /r php:8.5.12-cli bash -c '
  php gen.php /tmp/c 600 150
  php -d opcache.enable_cli=1 -d opcache.jit=1235 -d opcache.jit_buffer_size=64M \
      -d opcache.memory_consumption=16 -d opcache.max_accelerated_files=20000 \
      -d opcache.interned_strings_buffer=1 -d opcache.file_update_protection=0 \
      -d opcache.validate_timestamps=0 -d memory_limit=-1 run.php /tmp/c 600 1; echo rc=$?'

Result: Segmentation fault, rc=139. With opcache.jit=off and everything else the same: completes.

The crash depends on where the memory boundary falls, so it's sensitive to the sizes. Two settings that crash every time here:

  • memory_consumption=16, max_accelerated_files=20000
  • memory_consumption=8, max_accelerated_files=100000

Neighboring sizes (9–12M at 100000) don't crash.

Build 20000 / 16M 100000 / 8M
php:8.5.12-cli (official image, aarch64) 3/3 crash 3/3 crash
php-8.5.12 built from source, -g -O2 --disable-all 6/6 crash 6/6 crash
PHP-8.5 @ 65f37bb 3/3 crash 3/3 crash
master @ f3cb1d3 0/3 3/3 crash
php-8.5.12 + patch above 0/6 0/6
php:8.5.12-cli, linux/amd64 (emulated, 1 run each) 0/1 1/1 crash

opcache.jit=function and tracing don't crash with this script; only the hot-counter trigger (1235) does.

Backtrace (php-8.5.12 from source, 20000 / 16M):

Program received signal SIGSEGV, Segmentation fault.
_zend_update_type_info (op_array=0xaaaaa69d21d0, ssa=..., script=0x0, ..., optimization_level=2147270655)
3903        UPDATE_SSA_OBJ_TYPE(ce, ce_is_instanceof, ssa_op->result_def);
#0  _zend_update_type_info (...) Zend/Optimizer/zend_inference.c
#1  zend_infer_types_ex (...)
#2  zend_infer_types (...)
#3  zend_ssa_inference (...)
#4  zend_jit_op_array_analyze2 (...) ext/opcache/jit/zend_jit.c
#5  zend_real_jit_func (op_array=..., script=0x0, rt_opline=..., trigger=3) ext/opcache/jit/zend_jit.c:3091
#6  zend_jit_hot_func (...) ext/opcache/jit/zend_jit.c:3234

In a large application's test suite (paratest workers, PHP 8.5.12 aarch64, opcache at default sizes with ~36k PHP files), the same configuration also crashed inside zend_jit_cleanup_func_info(), recursing compiled function → callee → callee's caller_info → garbage caller_op_array:

#0 zend_jit_cleanup_func_info (op_array=<optimized out>) at ext/opcache/jit/zend_jit.c:3013
#1 zend_jit_cleanup_func_info (op_array=<optimized out>) at ext/opcache/jit/zend_jit.c:3035
#2 zend_jit_cleanup_func_info (op_array=...) at ext/opcache/jit/zend_jit.c:3041
#3 zend_real_jit_func (op_array=..., script=0x0, rt_opline=..., trigger=3) at ext/opcache/jit/zend_jit.c:3103
#4 zend_jit_hot_func (...) at ext/opcache/jit/zend_jit.c:3234

Raising opcache.memory_consumption / max_accelerated_files well past the codebase made those crashes disappear (22 crashes in 66 suite runs → 0 in 47).

PHP Version

PHP 8.5.12 (cli) (NTS); also PHP-8.5 @ 65f37bb and master @ f3cb1d3

Operating System

Debian (official php:8.5.12-cli image) and Ubuntu 24.04, aarch64; also reproduced on linux/amd64 under emulation

Activity

  1. rafamolina1 commented on Nov 5, 2026

    @rafamolina1

    Opened a fix against PHP-8.4: #25224

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions