Repository navigation
OPcache JIT: IR context (malloc) is not freed when zend_bailout() is taken during compilation #24680
Description
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:
-
zend_arena_calloc(&CG(arena), ssa->vars_count, sizeof(zend_jit_reg_var))
inzend_jit_allocate_registers()— arena blocks are allocated with
emalloc; a large function with many SSA vars can push usage over the limit. -
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
emallocand throws a fatal error (not returning FAILURE) on OOM. -
Subsequent
addr_hashgrowth (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_constsinext/opcache/jit/ir/ir.c), so for complex functions the
leak is larger. - Workers are not recycled until
pm.max_requestsis 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_freedecreases) with
opcache.validate_timestamps=0on 8.5.12. - The
ir_init()source path (malloc, no fallback) is confirmed in
ext/opcache/jit/ir/ir.c:388-422. - The
addr_hashpath usesemallocviafpemalloc(..., persistent=false, fallible=false)(Zend/zend_hash.c:180-200). - The
zend_catchblocks in all four affected functions contain only
do_bailout = true/do_bailout = 1and nozend_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
ndossche commentedon Oct 25, 2026
Feel free to open a PR :)