From 2bc1de07aa7af65f1f80bfe0b42f473f9de6f82b Mon Sep 17 00:00:00 2001 From: Edmond <1571649+edmonddantes@users.noreply.github.com> Date: Mon, 6 Jul 2026 14:54:34 +0000 Subject: [PATCH] opcache: free transient linked class copy on inheritance-cache 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. --- ext/opcache/ZendAccelerator.c | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/ext/opcache/ZendAccelerator.c b/ext/opcache/ZendAccelerator.c index 6f606d9d37d5..d23d3ded8c10 100644 --- a/ext/opcache/ZendAccelerator.c +++ b/ext/opcache/ZendAccelerator.c @@ -2400,6 +2400,12 @@ static zend_class_entry* zend_accel_inheritance_cache_add(zend_class_entry *ce, SHM_PROTECT(); if (!needs_autoload) { zend_map_ptr_extend(ZCSG(map_ptr_last)); + /* Another thread cached this class first; our transient linked + * copy is now unreachable. The MISS path frees it inside persist + * (zend_shared_memdup_free), so the HIT path must free it here. */ + zval zv_ce; + ZVAL_PTR(&zv_ce, ce); + destroy_zend_class(&zv_ce); return entry->ce; } else { return NULL;