Skip to content

opcache: free transient linked class copy on inheritance-cache race (ZTS)#14

Open
EdmondDantes wants to merge 1 commit into
masterfrom
opcache-inheritance-cache-hit-race-leak
Open

opcache: free transient linked class copy on inheritance-cache race (ZTS)#14
EdmondDantes wants to merge 1 commit into
masterfrom
opcache-inheritance-cache-hit-race-leak

Conversation

@EdmondDantes

Copy link
Copy Markdown

Summary

In a multi-threaded (ZTS) SAPI where worker threads share the opcache SHM inheritance cache but each keeps its own class_table, two threads that link the same immutable class concurrently can leak one linked class copy. The leak is small per class (~1 KB) but accumulates for the life of every worker thread, so long-lived servers grow memory steadily — most visibly across worker reloads, where each fresh worker re-links the whole framework against an already-populated shared cache.

The offending code is entirely stock opcache/Zend — no async/threading extension is involved in the leaking path. It just needs concurrent class linking on top of a shared SHM inheritance cache.

How the leak happens

zend_do_link_class() links an immutable (SHM) class in three phases:

  • A — early cache check (zend_inheritance_cache_get, zend_inheritance.c): on hit, return the cached linked class directly; no working copy is made.
  • B — on miss: zend_lazy_class_load() emallocs a private, writable copy of the class (default properties, static members, function table, properties_info, constants table, …) and links it against its parent.
  • C — late insert (zend_inheritance_cache_add): store the linked class into SHM. On the MISS path, zend_persist_class_entry() copies the tables into SHM via zend_shared_memdup_free() — the _free suffix frees the working copy's original tables. No leak.

The leak is the C-HIT path. It is reachable only when A missed but by the time we reach C the entry already exists — i.e. another thread linked and cached the same class in between:

// zend_accel_inheritance_cache_add(), cache HIT branch
if (entry) {
    zend_shared_alloc_unlock();
    SHM_PROTECT();
    if (!needs_autoload) {
        zend_map_ptr_extend(ZCSG(map_ptr_last));
        return entry->ce;      // <-- persist() NOT called => memdup_free NOT called
    }                          //     the caller's working copy is abandoned & leaks
    ...
}

The caller then does Z_CE_P(zv) = entry->ce and drops its own ce. Since zend_persist_class_entry() never ran for it, nothing frees its emalloc'd tables.

Why single-process PHP never hits it

In a classic single-process SAPI a given class is linked once: phase A and phase C observe the same (empty) cache state, so C always MISSes and the copy is freed during persist. The C-HIT branch is only reachable as a race between two threads linking the same class — i.e. a ZTS-only condition. opcache is designed to be shared across ZTS threads, so this is a latent concurrency bug rather than a design limitation.

The fix

On the cache-HIT return in zend_accel_inheritance_cache_add(), free the caller's now-unreachable working copy with destroy_zend_class() before returning the shared entry. This mirrors what the MISS path already does implicitly (via zend_shared_memdup_free in persist) and, being inside cache_add, covers both zend_inheritance_cache_add() call sites (normal linking and the delayed-early-binding path).

zend_map_ptr_extend(ZCSG(map_ptr_last));
zval zv_ce;
ZVAL_PTR(&zv_ce, ce);
destroy_zend_class(&zv_ce);   // free the losing thread's transient copy
return entry->ce;

Testing

Reproduced and verified under a DEBUG (report_memleaks=1, Zend MM) ZTS build driving a threaded HTTP server (16 workers) with concurrent request traffic and repeated worker reloads:

  • Before: every retired worker reports dozens of zend_lazy_class_load / inheritance emalloc blocks as leaked. Instrumentation showed the leaked classes are exactly the ones that took the cache-HIT branch (MISS→freed, HIT→leaked).
  • After: === Total 0 memory leaks detected === across 20 reloads under load; no double-free, assertion, or crash.

Notes for reviewers

  • The free is placed inside zend_accel_inheritance_cache_add() because only there is the HIT vs MISS distinction known: on MISS the copy's tables are already freed by persist, so freeing at the call site would double-free. If you'd prefer a different structure (e.g. signalling HIT/MISS back to zend_do_link_class), happy to adjust.
  • Found while investigating unbounded memory growth across worker reloads in a threaded async HTTP server, then reduced to this stock opcache + ZTS race.

…ZTS)

In a multi-threaded (ZTS) SAPI where worker threads share the opcache SHM
inheritance cache but each keeps its own class_table, two threads can link
the same immutable class concurrently and leak one linked copy.

zend_do_link_class() links an immutable (SHM) class in three phases:

  A. early check: zend_inheritance_cache_get(). On hit, return the cached
     class directly; no working copy is made.
  B. on miss: zend_lazy_class_load() emalloc's a private, writable copy of
     the class and links it against its parent.
  C. late insert: zend_inheritance_cache_add() stores the linked class in
     SHM. On the MISS path zend_persist_class_entry() copies the tables into
     SHM via zend_shared_memdup_free(), which frees the working copy.

The leak is the C-HIT path. It is reachable only when A missed but, by the
time we reach C, the entry already exists, i.e. another thread linked and
cached the same class in between. zend_accel_inheritance_cache_add() then
returns the existing entry->ce without calling zend_persist_class_entry(),
so zend_shared_memdup_free() never runs and the losing thread's working
copy (default properties/static members, function table, properties_info,
constants table, interfaces) is abandoned without being freed.

In a single-process SAPI this path is unreachable: a class is linked once,
A and C see the same empty cache, so C always MISSes and the copy is freed
during persist. It only surfaces in long-lived multi-threaded ZTS runtimes
with concurrent class linking, where the copy accumulates on the worker's
heap for the lifetime of the thread.

Fix: on the cache-HIT return in zend_accel_inheritance_cache_add(), free
the caller's unreachable working copy via destroy_zend_class() before
returning the shared entry. This mirrors the MISS path's implicit free and
covers both zend_inheritance_cache_add() call sites.

Found while investigating unbounded memory growth across worker reloads in
a threaded async HTTP server; reduced to a stock opcache + ZTS race. Under
a DEBUG (report_memleaks) build the leak drops from dozens of blocks per
retired worker to zero, with no double-free or crash under reload/traffic
stress.
@EdmondDantes EdmondDantes force-pushed the opcache-inheritance-cache-hit-race-leak branch from 8e809e7 to 2bc1de0 Compare July 6, 2026 15:01
@EdmondDantes

Copy link
Copy Markdown
Author

The issue with the test.

As for reproducing it in a test, the problem only occurs when PHP is used with threads; specifically, PHP threads. Since threading isn't a built-in PHP feature, I'm not sure yet how to write a proper test for it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant