Skip to content

Fix GH-24682: JIT IR context (malloc) is not freed on zend_bailout() - #24682

Open
matyapiro31 wants to merge 3 commits into
php:PHP-8.4from
matyapiro31:fix/jit-bailout-ir-leak
Open

matyapiro31 wants to merge 3 commits into
php:PHP-8.4from
matyapiro31:fix/jit-bailout-ir-leak

Conversation

@matyapiro31

Copy link
Copy Markdown
Contributor

Summary

When zend_bailout() is taken during JIT compilation, the ir_mem_malloc()-based
IR context (zend_jit_ctx) is never freed. The buffer is not managed by the
Zend memory manager and is not reclaimed by php_request_shutdown(), so in a
long-lived php-fpm worker each bailout event leaks ≥ 20 KiB (more if the buffer
has been grown with ir_mem_realloc).

Root cause

zend_jit() and zend_jit_trace() hold a zend_jit_ctx ctx as a local variable.
The outer zend_catch blocks in their callers (zend_jit_hot_func,
zend_runtime_jit, zend_jit_compile_root_trace, zend_jit_compile_side_trace,
zend_jit_blacklist_trace_exit) only set do_bailout and never call
zend_jit_free_ctx(), so the malloc buffer leaks.

What triggers the bailout

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

  • zend_arena_calloc() in zend_jit_allocate_registers() (arena uses emalloc)
  • zend_hash_index_lookup() → zend_hash_real_init_mixed_ex() for addr_hash
    (uses fpemalloc(..., persistent=false, fallible=false))

Fix

Add zend_jit_ctx *active_ctx to zend_jit_globals. After ir_init (via
zend_jit_start / zend_jit_trace_start / zend_jit_deoptimizer_start) set
JIT_G(active_ctx) = &ctx. Clear it to NULL before each normal
zend_jit_free_ctx() call. Extend every outer zend_catch block:

} zend_catch {
    do_bailout = true;
    if (JIT_G(active_ctx)) {
        zend_jit_free_ctx(JIT_G(active_ctx));
        JIT_G(active_ctx) = NULL;
    }
} zend_end_try();

The field is zero-initialized at module startup. In ZTS builds JIT_G() is already
per-thread (TSRMG), so no additional locking is needed. The internals of
zend_jit() and zend_jit_trace() are unchanged.

Verification

Measured with CDB on a debug build (8.4.28-dev, --enable-opcache-jit), counting
ir_init (malloc) vs zend_jit_free_ctx (free) calls with a forced E_ERROR
injected after ir_init inside zend_jit():

Scenario ir_init zend_jit_free_ctx diff
No bailout 38 38 0 ✓
Bailout, before patch 34 33 1 ← leak
Bailout, after patch 34 34 0 ✓

Files changed

  • ext/opcache/jit/zend_jit.h — forward declaration + active_ctx field
  • ext/opcache/jit/zend_jit.c — set/clear in zend_jit(), extend 2 catch blocks
  • ext/opcache/jit/zend_jit_trace.c — set/clear in zend_jit_trace() and
    zend_jit_trace_exit_to_vm(), extend 4 catch blocks

🤖 Generated with Claude Code

When zend_bailout() is taken during JIT compilation, the IR context
(allocated with ir_mem_malloc / malloc, not emalloc) is never freed
because the outer zend_catch blocks in zend_jit_hot_func(),
zend_runtime_jit(), zend_jit_compile_root_trace(),
zend_jit_compile_side_trace(), zend_jit_blacklist_trace_exit(), and
zend_jit_trace() only set do_bailout without calling zend_jit_free_ctx().

The ir_mem_malloc buffer is not part of the Zend memory manager and is
not reclaimed by php_request_shutdown(), so in a long-lived php-fpm
worker each bailout event leaks >= 20 KiB (and more if the buffer has
been grown with ir_mem_realloc).

Fix: add zend_jit_ctx *active_ctx to zend_jit_globals. After ir_init
(via zend_jit_start / zend_jit_trace_start / zend_jit_deoptimizer_start)
set JIT_G(active_ctx) = &ctx. Clear it to NULL before each normal
zend_jit_free_ctx() call. Extend every outer zend_catch block to call
zend_jit_free_ctx(JIT_G(active_ctx)) and set it back to NULL.

The field is zero-initialized at module startup; in ZTS builds JIT_G()
is already per-thread (TSRMG), so no additional locking is needed. The
internals of zend_jit() and zend_jit_trace() are unchanged.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@matyapiro31
matyapiro31 force-pushed the fix/jit-bailout-ir-leak branch from d968511 to 19a4afb Compare October 25, 2026 12:26
@matyapiro31 matyapiro31 changed the title Fix GH-XXXXX: JIT IR context (malloc) is not freed on zend_bailout() Fix GH-24682: JIT IR context (malloc) is not freed on zend_bailout() Oct 25, 2026
orig_trigger was assigned inside the zend_try block (after the setjmp
point), so under the C standard its value in zend_catch is unspecified
if longjmp is taken.  Move the save to before the zend_try and add an
explicit restore in both zend_catch blocks so that JIT_G(trigger) is
always left in its pre-compilation state even when zend_jit_trace()
raises a bailout.

Affected functions:
 - zend_jit_compile_root_trace(): orig_trigger moved before zend_try {}
 - zend_jit_compile_side_trace(): orig_trigger moved before SHM_UNPROTECT()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@matyapiro31

Copy link
Copy Markdown
Contributor Author

Added commit 8b09ff8f5c8 to address a secondary B-type longjmp side effect found during a broader review of all zend_bailout() call sites.

Problem: JIT_G(trigger) not restored on bailout

In both zend_jit_compile_root_trace() and zend_jit_compile_side_trace(), the pattern was:

zend_try {
    orig_trigger = JIT_G(trigger);          // ← assigned INSIDE try (after setjmp)
    JIT_G(trigger) = ZEND_JIT_ON_HOT_TRACE;
    handler = zend_jit_trace(...);
    JIT_G(trigger) = orig_trigger;          // restore on success path only
} zend_catch {
    do_bailout = 1;
    // ← orig_trigger NOT restored here
    ...
} zend_end_try();

Per the C standard (CERT MSC22-C), automatic variables modified between setjmp and longjmp have unspecified values in the catch block. So on the bailout path, orig_trigger could hold its initialization value (0 = ZEND_JIT_ON_SCRIPT_LOAD) rather than the correct saved value — meaning JIT_G(trigger) would be silently overwritten with the wrong mode.

Fix: Move orig_trigger = JIT_G(trigger) to before zend_try (before SHM_UNPROTECT() for the side-trace variant), and add JIT_G(trigger) = orig_trigger to each zend_catch block.

Note: JIT_G(tracing) has a similar but lower-severity issue (left as true on bailout) — it is already reset to 0 by zend_jit_activate() at every request start via zend_jit_trace_reset_caches(), so its corruption does not persist across requests.

…_compile_file()

`orig_compiler_options` was assigned inside the `zend_try` block, making
it an automatic variable modified after setjmp().  Per CERT MSC22-C, the
value of such a variable is indeterminate in the `zend_catch` handler.
At -O2 the compiler may keep it in a register; after longjmp() that
register is restored to its setjmp()-time value (zero from the
initialiser), so the catch block could restore CG(compiler_options) to 0
instead of the original value.

Move the save before `zend_try` so `orig_compiler_options` holds the
correct value regardless of when a bailout occurs.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@matyapiro31

matyapiro31 commented Oct 25, 2026 •

Copy link
Copy Markdown
Contributor Author

Follow-up commit: orig_compiler_options could be read as zero in the zend_catch handler

orig_compiler_options was declared outside the zend_try block (initialised to 0), but its actual value was saved inside the block — after the implicit setjmp() call that zend_try expands to:

uint32_t orig_compiler_options = 0;   // ← initialised to 0 here
...
zend_try {
    orig_compiler_options = CG(compiler_options);  // ← saved AFTER setjmp()
    CG(compiler_options) |= ...;
    ...
    op_array = accelerator_orig_compile_file(...);  // ← bailout most likely here
    CG(compiler_options) = orig_compiler_options;
} zend_catch {
    CG(compiler_options) = orig_compiler_options;  // ← which value does the compiler use?
} zend_end_try();

The C standard (C11 §7.13.2.1 p3) says that after a longjmp(), the value of a non-volatile automatic variable that was modified between setjmp() and longjmp() is indeterminate. At -O2 the compiler is allowed to keep orig_compiler_options in a callee-saved register; longjmp() restores all callee-saved registers to their setjmp()-time state, so the register reverts to 0. The catch block then sets CG(compiler_options) = 0, clearing all flags rather than restoring the original value.

Fix: move the save to before zend_try, so orig_compiler_options is written before setjmp() and the C standard guarantees its value is visible in the catch handler.

CERT C: Rec. MSC22-C

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant