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

  2. RobertWeideKyos commented on Nov 6, 2026

    @RobertWeideKyos

    We ran into this on a real project: the unit test suite of a Laravel app in GitHub Actions. setup-php turns on opcache.jit=1235 (with a 256M buffer) as soon as opcache.enable_cli=1 is set. With the default opcache.memory_consumption=128, the suite segfaulted on every run. The backtrace showed zend_jit_cleanup_func_info() while JIT-compiling Illuminate\Database\Eloquent\Relations\HasOneOrManyThrough::performJoin(). With opcache.jit=disable it passes. So this also happens with normal settings, not only with very small caches.

    A smaller reproducer, plain functions only (save as repro.php):

    <?php
    $n = (int) $argv[1];
    $file = sys_get_temp_dir() . '/repro_' . getmypid() . '.php';
    $code = "<?php\nfunction caller(\$x) { return callee_last(\$x) + 1; }\n";
    for ($i = 0; $i < $n; $i++) {
        $code .= "function f$i(\$a) { return \$a + $i; }\n";
    }
    file_put_contents($file, $code . "function callee_last(\$x) { return \$x * 2; }\n");
    require $file;
    unlink($file);
    
    $junk = []; // overwrite the freed arena
    for ($i = 0; $i < 400; $i++) {
        $junk[] = str_repeat('A', 60000);
    }
    for ($i = 0; $i < 1000; $i++) {
        caller($i); // caller() gets hot and is JIT-compiled
    }
    docker run --rm -v "$PWD":/r -w /r php:8.5.12-cli sh -c '
      for n in $(seq 2000 1000 30000); do
        php -d opcache.enable_cli=1 -d opcache.jit=1235 -d opcache.jit_buffer_size=32M \
            -d opcache.memory_consumption=8 -d opcache.interned_strings_buffer=1 \
            -d opcache.file_update_protection=0 repro.php $n >/dev/null 2>&1
        echo "$n: $?"
      done'

    On php:8.5.12-cli, sizes 10000 to 14000 exit with 139 on both linux/arm64 and linux/amd64. With opcache.jit=disable, none crash. The exact range depends on the build, so the script tries several sizes.

  3. rafamolina1 commented on Nov 6, 2026

    @rafamolina1

    Thanks for the extra reproducer. This is the same bug: zend_jit_script() leaves func_info pointing into the arena after a jit_extension allocation fails part-way through, and the later hot compile of caller() reads that stale pointer from zend_jit_cleanup_func_info().

    A fix is in #25224 (against PHP-8.4). On the non-script-load failure path it clears FUNC_INFO from the op_arrays that were not set up yet, before the arena is released. Your script crashes on unpatched PHP 8.5.4 for n in 10000–14000 (exit 139); that is the path the patch covers.

  4. added a commit that references this issue on Nov 9, 2026
    3a5d8b0
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