Skip to content

OPcache JIT: IR context (malloc) is not freed when zend_bailout() is taken during compilation #24680

Open

Description

@matyapiro31

Description

Description

While auditing callers of _zend_bailout I found that zend_jit() and
zend_jit_trace() own a malloc()-based IR buffer at the time a bailout can
occur, and do not free it. The buffer is not request memory, so it is not
reclaimed at request shutdown. In a long-lived php-fpm worker this leaks at least
(256 + 1024) * sizeof(ir_insn) ≈ 20 KiB per event, and potentially more for
larger functions if the buffer has been grown.

Found by source-code reading on PHP-8.4 (8.4.28-dev); the same code is present
in the 8.5.12 copies of ext/opcache/jit/zend_jit.c and
ext/opcache/jit/zend_jit_trace.c. I have not checked master.

Leak path

zend_jit() / zend_real_jit_func() — ext/opcache/jit/zend_jit.c

/* zend_jit(), called from zend_real_jit_func() */
static int zend_jit(const zend_op_array *op_array, zend_ssa *ssa, ...)
{
    zend_jit_ctx ctx;                        /* LOCAL variable */
    ...
    zend_jit_init_ctx(&ctx, ...);            /* → ir_init() → malloc((256+1024)*sizeof(ir_insn)) */
    zend_jit_start(&ctx, op_array, ssa);

    if (JIT_G(opt_flags) & ...) {
        checkpoint = zend_arena_checkpoint(CG(arena));
        zend_jit_allocate_registers(&ctx, op_array, ssa);
        /*                          ^^^^^^^^^^^^^^^^^^^
         * zend_arena_calloc(&CG(arena), ssa->vars_count, sizeof(zend_jit_reg_var))
         * emalloc-based; can trigger memory_limit fatal → zend_bailout()
         */
    }
    /* further emalloc calls: addr_hash growth (zend_hash_index_lookup, zend_jit_ir.c) */
    ...
jit_failure:
    zend_jit_free_ctx(&ctx);   /* ir_free() → free() the malloc buffer */
    ...
}

The callers wrap zend_real_jit_func() in a zend_try:

/* zend_jit_hot_func() / zend_runtime_jit() — zend_jit.c */
zend_try {
    ...
    zend_real_jit_func(op_array, NULL, NULL, ZEND_JIT_ON_FIRST_EXEC);
    ...
} zend_catch {
    do_bailout = true;
    /* ctx is a local in zend_jit(); this catch cannot reach it.
       zend_jit_free_ctx() is never called. malloc buffer is leaked. */
} zend_end_try();
...
if (do_bailout) zend_bailout();

zend_jit_trace() — ext/opcache/jit/zend_jit_trace.c

The same pattern: ctx is a local inside zend_jit_trace(), initialized
before the zend_try, and freed only at jit_failure: / jit_cleanup:.
The zend_catch only sets do_bailout = 1:

/* zend_jit_trace(), ~line 4076 (8.4) / 4136 (8.5) */
zend_jit_ctx ctx;
...
zend_try {
    zend_jit_trace_start(&ctx, ...);   /* → ir_init() → malloc */
    ...
jit_failure:
    zend_jit_free_ctx(&ctx);
jit_cleanup:;
} zend_catch {
    do_bailout = 1;
    /* zend_jit_free_ctx() NOT called */
} zend_end_try();
...
if (do_bailout) zend_bailout();

Same structure in zend_jit_compile_root_trace() (~line 7440 / 7535) and
zend_jit_compile_side_trace() (~line 8280 / 8382).
zend_jit_blacklist_trace_exit() does not create a ctx and is not affected.

What triggers the bailout

Between ir_init() and jit_failure: there are several emalloc-based
allocations that can hit memory_limit:

  1. zend_arena_calloc(&CG(arena), ssa->vars_count, sizeof(zend_jit_reg_var))
    in zend_jit_allocate_registers() — arena blocks are allocated with
    emalloc; a large function with many SSA vars can push usage over the limit.

  2. First-use initialization of jit->addr_hash:
    zend_hash_index_lookup() → zend_hash_real_init_mixed_ex() →
    fpemalloc(HT_SIZE_EX(...), persistent=false, fallible=false), which calls
    emalloc and throws a fatal error (not returning FAILURE) on OOM.

  3. Subsequent addr_hash growth (zend_hash_do_resize()) follows the same path.

When any of these emalloc calls exceeds memory_limit, php_error_cb raises
E_ERROR | E_DONT_BAIL → ... → zend_bailout().

Why it matters in php-fpm

The IR buffer is allocated with ir_mem_malloc (which maps to malloc, not
emalloc). It is therefore not part of the Zend memory manager and is
not freed by php_request_shutdown(). In a long-lived php-fpm worker:

  • Each request that triggers a JIT compilation bailout leaks ≥ 20 KiB.
  • The initial buffer can be grown with ir_mem_realloc (ir_grow_insns,
    ir_grow_consts in ext/opcache/jit/ir/ir.c), so for complex functions the
    leak is larger.
  • Workers are not recycled until pm.max_requests is reached; the leak
    accumulates across all requests handled by that worker.

Dynamic reproduction

Verified with CDB (Windows debugger) on a debug build (8.4.28-dev,
--enable-opcache-jit). The ir_init (malloc) and zend_jit_free_ctx
(ir_free) call counts were measured with hardware breakpoints:

Normal run (no bailout):
  ir_init calls:           38
  zend_jit_free_ctx calls: 38
  difference (leaked ctxs): 0  ✓

Run with forced E_ERROR inside zend_jit() (after ir_init, before jit_failure:):
  ir_init calls:           34
  zend_jit_free_ctx calls: 33
  bailout catch hit:        1  (zend_runtime_jit, zend_jit.c:3087)
  difference (leaked ctxs): 1  ← malloc buffer not freed

The test: a single zend_error(E_ERROR, ...) injected into zend_jit() just
after zend_jit_start() (which calls ir_init()) — matching the position where
a real memory_limit OOM would fire during zend_jit_allocate_registers(). The
E_ERROR triggers zend_bailout(), which LONGJMPs to the zend_catch block in
zend_runtime_jit. That catch block sets do_bailout = 1 and does not call
zend_jit_free_ctx(), so the malloc buffer is never freed.

A minimal CLI reproducer for the pure memory_limit path was not produced: the
Zend MM minimum slab (~2 MiB) makes it difficult to position memory exactly at
the limit when JIT compilation starts in a single CLI invocation.

I verified:

  • JIT is active and consumes SHM buffer (buffer_free decreases) with
    opcache.validate_timestamps=0 on 8.5.12.
  • The ir_init() source path (malloc, no fallback) is confirmed in
    ext/opcache/jit/ir/ir.c:388-422.
  • The addr_hash path uses emalloc via fpemalloc(..., persistent=false, fallible=false) (Zend/zend_hash.c:180-200).
  • The zend_catch blocks in all four affected functions contain only
    do_bailout = true / do_bailout = 1 and no zend_jit_free_ctx() call.

Suggested fix

The cleanest fix is to add a zend_try around the body of zend_jit() itself
(and similarly zend_jit_trace()), so zend_jit_free_ctx() is reachable
from the catch:

static int zend_jit(...)
{
    zend_jit_ctx ctx;
    bool do_bailout = false;

    zend_jit_init_ctx(&ctx, ...);    /* malloc happens here */

    zend_try {
        zend_jit_start(&ctx, ...);
        ...
        /* all existing code, including jit_failure: and jit_cleanup: */
    } zend_catch {
        do_bailout = true;
    } zend_end_try();

    zend_jit_free_ctx(&ctx);         /* always reached */

    if (do_bailout) zend_bailout();
    ...
}

An alternative is to store the active ctx pointer in JIT_G() so the existing
outer catch blocks (zend_jit_hot_func, zend_runtime_jit) can call
zend_jit_free_ctx() before re-raising.

PHP Version

8.4.28-dev (source); 8.5.12 (source copy, same code). Master not checked.

Operating System

Windows 11

Activity

  1. ndossche commented on Oct 25, 2026

    @ndossche
    Member

    Feel free to open a PR :)

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