[php-src] Issue #24680: OPcache JIT: IR context (malloc) is not freed when zend_bailout() is taken during compilation

From: 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

« previous php.bugs (#253177) next »