[php-src] Issue #24680: OPcache JIT: IR context (malloc) is not freed when zend_bailout() is taken during compilation
| From: | matyapiro31 | Date: | Sun, 25 Oct 2026 11:27:51 +0000 |
| Subject: | [php-src] Issue #24680: OPcache JIT: IR context (malloc) is not freed when zend_bailout() is taken during compilation | ||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-253177@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24680
Author: 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
```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:
```c
/* 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:
```c
/* 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:
```c
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
```plain
8.4.28-dev (source); 8.5.12 (source copy, same code). Master not checked.
```
### Operating System
Windows 11