Skip to content

Tracing JIT: SIGSEGV via mis-targeted side-exit into execute_ex (VM-stack call-frame alloc), PHP 8.5.6 #22102

Open

Description

@XYZPatrick

Description

Under production load, PHP-FPM workers reliably SIGSEGV with tracing JIT enabled. Disabling JIT (opcache.jit=disable) eliminates the crash entirely. The same code on the CLI SAPI (JIT off) never crashes, and the crashes began immediately upon upgrading from 8.1 to 8.5 (the 8.1 build, same application, never segfaulted).

The crash is intermittent and clusters: several workers die within a few minutes, then it goes quiet after an FPM restart flushes the JIT buffer, and recurs once the hot path is re-warmed.

Symbolized core analysis (matching php8.5-fpm-dbgsym, exact Build-Id 3ee70b5feebaea49aaae01c20bb6d0b372acb4dc):

  • Kernel: php-fpm8.5: segfault at 2 ip <pc> error 6 in php-fpm8.5[...] — a user-mode write to address 0x2.
  • Faulting PC = execute_ex + 16310, inside the inlined VM call-frame allocation fast path — it loads EG(vm_stack_top) / EG(vm_stack_end) from executor_globals (+0x1e0 / +0x1e8), computes a zval-sized bump (<< 4), and branches to the zend_vm_stack_extend slow path (jb -> execute_ex + 42820) when full.
  • The recorded instruction pointer is mid-instruction (2 bytes into mov 0x1e8(%rcx),%rax), %rdx = 0x2 where a pointer is expected, frame new test cases for bug #55169 #1 return address = 0x0, and gdb prints "Unable to read JIT descriptor from remote memory."

Interpretation: a tracing-JIT trace side-exit appears to return into execute_ex at a misaligned address with a clobbered register, so the VM-stack push-call-frame code dereferences/writes near-null → SIGSEGV. The combination of FPM-only (JIT on) vs CLI (JIT off) crashing, the regression appearing exactly at the 8.5 upgrade, the self-heal on JIT-buffer flush, and the broken/misaligned control flow all point at the tracing JIT rather than application logic or an extension (fault IP is in the core binary .text, not in any loaded .so).

Loaded extensions at crash time included opcache (compiled-in), redis, zip, xsl, xmlreader/writer, tokenizer, sysvshm (standard LAMP set). The triggering request was a question-rendering endpoint of a large PHP app (IMathAS).

The following code:

No minimal standalone reproducer yet — the crash only manifests inside a large
application's question-evaluation code path after the JIT hot threshold is reached.
A matching core dump + debug symbols are available, and I can run any requested
gdb commands against the core.

Resulting in this output:

Program terminated with signal SIGSEGV, Segmentation fault.
#0  execute_ex + 16310   (inlined VM call-frame alloc: EG(vm_stack_top/end) bump)
#1  0x0000000000000000
rdx = 0x2 ; fault = user write to 0x2 ; recorded IP is mid-instruction
gdb: "Unable to read JIT descriptor from remote memory"

But I expected this output:

No crash (and indeed: with opcache.jit=disable there is no crash).

PHP Version

PHP 8.5.6 (fpm-fcgi, NTS), 8.5.6-3+ubuntu24.04.1+deb.sury.org+1 (deb.sury.org build)

Operating System

Ubuntu 24.04 (noble), x86-64. opcache.jit=tracing, opcache.jit_buffer_size=128M, opcache.memory_consumption=256, opcache.validate_timestamps=1, opcache.revalidate_freq=120.

Activity

  1. ndossche commented on Oct 6, 2026

    @ndossche
    Member

    It's difficult to say what exactly the root cause may be.
    This has some similarities conceptually with GH-21710 (fixed in 8.5.11) and GH-22443 (fixed in 8.5.9).
    Could you please retry on PHP 8.5.11?
    If it still crashes, please check whether any classes in the hot path extend or implement something declared in eval() or in an uncached file.

  2. pavimus commented on Oct 14, 2026

    @pavimus

    Prepared with an AI assistant (Claude); the text below is AI-generated (quoted, as asked in CONTRIBUTING.md).

    This may be the same bug as #24333. With opcache.jit=tracing, a trace that calls a method of a
    class whose file is not cached (OPcache memory full, blacklisted file, eval()) remembers that
    function and guards only on its op_array.opcodes address, which is request memory. In a later
    request another function can get the same address, and the trace then pushes a call frame of the
    wrong size; the overlapping frames give wrong values, false TypeErrors or crashes when a frame is
    left or pushed. If opcache_get_status() shows cache_full (or the hot path uses eval()), that
    would match. The PR has a test and a small fix for 8.4.

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