From cf9dc1afad0c4673fcbce9a47364eaf70ab643c2 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Wed, 21 Oct 2026 11:57:44 +0200 Subject: [PATCH 1/2] Zend: Add opt-in request-scoped allocation no-reuse mode Enable with ZEND_MM_NO_REUSE=1 before engine startup. Clear and retain freed small allocations, page runs, and huge mappings until heap shutdown. Move reallocations to fresh storage and prevent allocator GC from reclaiming retired ranges. Keep retained mappings charged against memory_limit and reject incompatible allocator overrides rather than silently allowing reuse. This mitigates UAF exploitation through allocation reclamation like CVE-2026-109618 and CVE-2026-109619, at the cost of memory usage approaching cumulative allocation volume per request. Add tests for address non-overlap, clearing, reallocations, shutdown, accounting, and configuration errors. Document usage and limitations. This provides a nice alternative to #14304. --- Zend/README.md | 48 +++++++ Zend/tests/zend_mm_no_reuse_accounting.phpt | 38 ++++++ .../tests/zend_mm_no_reuse_configuration.phpt | 44 +++++++ Zend/tests/zend_mm_no_reuse_memory_limit.phpt | 18 +++ Zend/zend_alloc.c | 78 ++++++++++- docs/source/miscellaneous/running-tests.rst | 19 +++ ext/zend_test/test.c | 122 ++++++++++++++++++ ext/zend_test/test.stub.php | 2 + ext/zend_test/test_arginfo.h | 6 +- ext/zend_test/test_decl.h | 8 +- ext/zend_test/test_legacy_arginfo.h | 6 +- ext/zend_test/tests/zend_mm_no_reuse.phpt | 20 +++ 12 files changed, 401 insertions(+), 8 deletions(-) create mode 100644 Zend/tests/zend_mm_no_reuse_accounting.phpt create mode 100644 Zend/tests/zend_mm_no_reuse_configuration.phpt create mode 100644 Zend/tests/zend_mm_no_reuse_memory_limit.phpt create mode 100644 ext/zend_test/tests/zend_mm_no_reuse.phpt diff --git a/Zend/README.md b/Zend/README.md index 26f3613c34bb..352b4bd10ed2 100644 --- a/Zend/README.md +++ b/Zend/README.md @@ -21,6 +21,54 @@ Zend MM disabled: USE_ZEND_ALLOC=0 valgrind --leak-check=full sapi/cli/php -r 'leak();' ``` +### Request-scoped no-reuse mode + +Set `ZEND_MM_NO_REUSE=1` in the process environment before starting PHP to +prevent native Zend MM from reusing freed allocation ranges within a request: + +```bash +ZEND_MM_NO_REUSE=1 sapi/cli/php script.php +``` + +The default is disabled: leave the variable unset or set it to `0`. Only the +literal values `0` and `1` are accepted; other values cause an allocator panic. +This is not a `php.ini` option. It is read at engine startup, so changing it with +`putenv()` has no effect on the running engine. For web SAPIs, set it in the +launcher or service environment before PHP starts, and restart PHP processes +after changing it. + +With the mode enabled: + +- Freed small allocations, page runs, and huge mappings are cleared and retained + until their heap shuts down, after request shutdown callbacks have finished. + Retired payloads are not used to store allocator free-list links. +- Reallocations of existing blocks always move to fresh storage, including + shrinking and unchanged-size reallocations. The original storage is retired. +- Allocator garbage collection does not reclaim retired storage. Addresses may + be reused after heap shutdown, normally in the next request. +- Retained mappings remain charged against `memory_limit`. Reaching the limit + fails through the normal allocation-error path; it never enables reuse. + `memory_get_usage(false)` can decrease after freeing an allocation even though + the underlying storage remains retained. + +Memory consumption approaches cumulative allocation volume rather than peak +live memory. Clearing freed storage and moving reallocations also add memory +bandwidth and copying costs. The mode is intended for selected short-lived +requests, not long-running scripts or application workers that do not perform +normal PHP request shutdown. + +The mode requires native Zend MM. Combining it with `USE_ZEND_ALLOC=0`, custom +heap or storage handlers, or `ZEND_MM_DEBUG` allocator wrappers causes an +allocator panic rather than silently bypassing the protection. + +This is exploit mitigation, not memory safety or a sandbox. Invalid accesses +to retired storage may still occur without trapping. Persistent allocations and +extension-owned allocations outside native Zend MM are not covered, nor are +dangling references that survive heap shutdown. + +See [Testing ZendMM No-Reuse Mode](../docs/source/miscellaneous/running-tests.rst#testing-zendmm-no-reuse-mode) +for regression-test instructions. + ### Shared extensions Since PHP 5.3.11 it is possible to prevent shared extensions from unloading so diff --git a/Zend/tests/zend_mm_no_reuse_accounting.phpt b/Zend/tests/zend_mm_no_reuse_accounting.phpt new file mode 100644 index 000000000000..0b54a27ec62a --- /dev/null +++ b/Zend/tests/zend_mm_no_reuse_accounting.phpt @@ -0,0 +1,38 @@ +--TEST-- +ZendMM no-reuse mode keeps retired huge mappings accounted for +--ENV-- +USE_ZEND_ALLOC=1 +ZEND_MM_NO_REUSE=1 +--FILE-- += 3 * 1024 * 1024); +var_dump($allocated - $released >= 3 * 1024 * 1024); +var_dump($retained === $committed); +var_dump($collected === 0); +var_dump(memory_get_usage(true) === $retained); + +register_shutdown_function(function () use ($retained) { + var_dump(memory_get_usage(true) >= $retained); + $buffer = str_repeat("y", 3 * 1024 * 1024); + unset($buffer); + var_dump(memory_get_usage(true) > $retained); +}); +?> +--EXPECT-- +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) +bool(true) diff --git a/Zend/tests/zend_mm_no_reuse_configuration.phpt b/Zend/tests/zend_mm_no_reuse_configuration.phpt new file mode 100644 index 000000000000..c67bc4ea0657 --- /dev/null +++ b/Zend/tests/zend_mm_no_reuse_configuration.phpt @@ -0,0 +1,44 @@ +--TEST-- +ZendMM no-reuse mode rejects invalid settings and incompatible allocators +--SKIPIF-- + +--FILE-- + "invalid", "USE_ZEND_ALLOC" => "1"], + ["ZEND_MM_NO_REUSE" => "1", "USE_ZEND_ALLOC" => "0"], + ["ZEND_MM_NO_REUSE" => "1", "USE_ZEND_ALLOC" => "1", "ZEND_MM_DEBUG" => "poison_free=0"], +]; +foreach ($cases as $case) { + $process = proc_open( + [PHP_BINARY, "-n", "-r", "echo 'Unexpected startup';"], + [0 => ["pipe", "r"], 1 => ["pipe", "w"], 2 => ["pipe", "w"]], + $pipes, + null, + array_replace($env, $case) + ); + fclose($pipes[0]); + $output = stream_get_contents($pipes[1]); + $error = stream_get_contents($pipes[2]); + fclose($pipes[1]); + fclose($pipes[2]); + var_dump(proc_close($process) !== 0); + var_dump($output === ""); + echo strtok($error, "\r\n"), "\n"; +} +?> +--EXPECT-- +bool(true) +bool(true) +ZEND_MM_NO_REUSE must be 0 or 1 +bool(true) +bool(true) +ZEND_MM_NO_REUSE requires native ZendMM (USE_ZEND_ALLOC=1) +bool(true) +bool(true) +ZEND_MM_NO_REUSE does not support custom heap handlers diff --git a/Zend/tests/zend_mm_no_reuse_memory_limit.phpt b/Zend/tests/zend_mm_no_reuse_memory_limit.phpt new file mode 100644 index 000000000000..328752218440 --- /dev/null +++ b/Zend/tests/zend_mm_no_reuse_memory_limit.phpt @@ -0,0 +1,18 @@ +--TEST-- +ZendMM no-reuse mode charges retired allocations against the memory limit +--ENV-- +USE_ZEND_ALLOC=1 +ZEND_MM_NO_REUSE=1 +--INI-- +memory_limit=16M +--FILE-- + +--EXPECTF-- +Fatal error: Allowed memory size of 16777216 bytes exhausted %s diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index b293ee1a51dc..d6751e82ebf4 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -191,6 +191,7 @@ typedef zend_ulong zend_mm_bitset; /* 4-byte or 8-byte integer */ typedef zend_mm_bitset zend_mm_page_map[ZEND_MM_PAGE_MAP_LEN]; /* 64B */ #define ZEND_MM_IS_FRUN 0x00000000 +#define ZEND_MM_IS_RETIRED 0x20000000 #define ZEND_MM_IS_LRUN 0x40000000 #define ZEND_MM_IS_SRUN 0x80000000 @@ -237,6 +238,7 @@ typedef struct _zend_mm_huge_list zend_mm_huge_list; static bool zend_mm_use_huge_pages = false; static bool zend_mm_max_allocs_reached = false; +static bool zend_mm_no_reuse = false; /* * Memory is retrieved from OS by chunks of fixed size 2MB. @@ -298,6 +300,7 @@ struct _zend_mm_heap { bool fallible; zend_mm_huge_list *huge_list; /* list of huge allocated blocks */ + zend_mm_huge_list *retired_huge_list; zend_mm_chunk *main_chunk; zend_mm_chunk *cached_chunks; /* list of unused chunks */ @@ -1392,6 +1395,12 @@ static zend_always_inline void zend_mm_free_large(zend_mm_heap *heap, zend_mm_ch #if ZEND_MM_STAT heap->size -= pages_count * ZEND_MM_PAGE_SIZE; #endif + if (UNEXPECTED(zend_mm_no_reuse)) { + ZEND_MM_CHECK(!(chunk->map[page_num] & ZEND_MM_IS_RETIRED), "zend_mm_heap corrupted (double free)"); + chunk->map[page_num] |= ZEND_MM_IS_RETIRED; + memset(ZEND_MM_PAGE_ADDR(chunk, page_num), 0, pages_count * ZEND_MM_PAGE_SIZE); + return; + } zend_mm_free_pages(heap, chunk, page_num, pages_count); } @@ -1632,6 +1641,11 @@ static zend_always_inline void zend_mm_free_small(zend_mm_heap *heap, void *ptr, } while (0); #endif + if (UNEXPECTED(zend_mm_no_reuse)) { + memset(ptr, 0, bin_data_size[bin_num]); + return; + } + p = (zend_mm_free_slot*)ptr; #if ZEND_MM_HEAP_PROTECTION /* Catch the most common double-free pattern for free. */ @@ -1892,6 +1906,11 @@ static zend_always_inline void *zend_mm_realloc_heap(zend_mm_heap *heap, void *p zend_mm_debug_info *dbg; #endif + if (UNEXPECTED(zend_mm_no_reuse) && ptr != NULL) { + old_size = zend_mm_size(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); + return zend_mm_realloc_slow(heap, ptr, size, MIN(old_size, MIN(size, copy_size)) ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); + } + page_offset = ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE); if (UNEXPECTED(page_offset == 0)) { if (EXPECTED(ptr == NULL)) { @@ -2118,6 +2137,12 @@ static size_t zend_mm_del_huge_block(zend_mm_heap *heap, void *ptr ZEND_FILE_LIN list->next->prev = list->prev; } size = list->size; + if (UNEXPECTED(zend_mm_no_reuse)) { + list->next = heap->retired_huge_list; + list->prev = NULL; + heap->retired_huge_list = list; + return size; + } zend_mm_free_heap(heap, list ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); return size; } @@ -2229,10 +2254,14 @@ static void zend_mm_free_huge(zend_mm_heap *heap, void *ptr ZEND_FILE_LINE_DC ZE ZEND_MM_CHECK(ZEND_MM_ALIGNED_OFFSET(ptr, ZEND_MM_CHUNK_SIZE) == 0, "zend_mm_heap corrupted"); size = zend_mm_del_huge_block(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); zend_mm_check_huge_block_size(heap, size); - zend_mm_chunk_free(heap, ptr, size); + if (UNEXPECTED(zend_mm_no_reuse)) { + memset(ptr, 0, size); + } else { + zend_mm_chunk_free(heap, ptr, size); #if ZEND_MM_STAT || ZEND_MM_LIMIT - heap->real_size -= size; + heap->real_size -= size; #endif + } #if ZEND_MM_STAT heap->size -= size; #endif @@ -2334,6 +2363,7 @@ static zend_mm_heap *zend_mm_init(void) heap->storage = NULL; #endif heap->huge_list = NULL; + heap->retired_huge_list = NULL; #if ZEND_DEBUG heap->pid = getpid(); #endif @@ -2351,6 +2381,10 @@ ZEND_API size_t zend_mm_gc(zend_mm_heap *heap) bool has_free_pages; size_t collected = 0; + if (UNEXPECTED(zend_mm_no_reuse)) { + return 0; + } + #if ZEND_MM_CUSTOM if (heap->use_custom_heap) { size_t (*gc)(void) = heap->custom_heap._gc; @@ -2515,6 +2549,10 @@ static zend_long zend_mm_find_leaks(zend_mm_heap *heap, zend_mm_chunk *p, uint32 i += bin_pages[bin_num]; } else /* if (p->map[i] & ZEND_MM_IS_LRUN) */ { int pages_count = ZEND_MM_LRUN_PAGES(p->map[i]); + if (p->map[i] & ZEND_MM_IS_RETIRED) { + i += pages_count; + continue; + } zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)p + ZEND_MM_PAGE_SIZE * (i + pages_count) - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info))); if (dbg->filename == leak->filename && dbg->lineno == leak->lineno) { @@ -2634,6 +2672,10 @@ static void zend_mm_check_leaks(zend_mm_heap *heap) i += bin_pages[bin_num]; } else /* if (p->map[i] & ZEND_MM_IS_LRUN) */ { int pages_count = ZEND_MM_LRUN_PAGES(p->map[i]); + if (p->map[i] & ZEND_MM_IS_RETIRED) { + i += pages_count; + continue; + } zend_mm_debug_info *dbg = (zend_mm_debug_info*)((char*)p + ZEND_MM_PAGE_SIZE * (i + pages_count) - ZEND_MM_ALIGNED_SIZE(sizeof(zend_mm_debug_info))); leak.addr = (void*)((char*)p + ZEND_MM_PAGE_SIZE * i); @@ -2722,6 +2764,21 @@ ZEND_API void zend_mm_shutdown(zend_mm_heap *heap, bool full, bool silent) } #endif + /* Retired mappings stay reserved until all request callbacks have finished. */ + list = heap->retired_huge_list; + heap->retired_huge_list = NULL; + while (list != NULL) { + zend_mm_huge_list *next = list->next; + size_t size = list->size; + zend_mm_check_huge_block_size(heap, size); + zend_mm_chunk_free(heap, list->ptr, size); +#if ZEND_MM_STAT || ZEND_MM_LIMIT + heap->real_size -= size; +#endif + zend_mm_free_heap(heap, list ZEND_FILE_LINE_CC ZEND_FILE_LINE_EMPTY_CC); + list = next; + } + #if ZEND_DEBUG if (!silent) { char *tmp = getenv("ZEND_ALLOC_PRINT_LEAKS"); @@ -3736,6 +3793,9 @@ static void alloc_globals_ctor(zend_alloc_globals *alloc_globals) #if ZEND_MM_CUSTOM tmp = getenv("USE_ZEND_ALLOC"); if (tmp && !ZEND_ATOL(tmp)) { + if (zend_mm_no_reuse) { + zend_mm_panic("ZEND_MM_NO_REUSE requires native ZendMM (USE_ZEND_ALLOC=1)"); + } bool tracked = (tmp = getenv("USE_TRACKED_ALLOC")) && ZEND_ATOL(tmp); zend_mm_heap *mm_heap = alloc_globals->mm_heap = malloc(sizeof(zend_mm_heap)); memset(mm_heap, 0, sizeof(zend_mm_heap)); @@ -3787,6 +3847,12 @@ static void alloc_globals_dtor(zend_alloc_globals *alloc_globals) ZEND_API void start_memory_manager(void) { + const char *no_reuse = getenv("ZEND_MM_NO_REUSE"); + if (no_reuse && strcmp(no_reuse, "0") != 0 && strcmp(no_reuse, "1") != 0) { + zend_mm_panic("ZEND_MM_NO_REUSE must be 0 or 1"); + } + zend_mm_no_reuse = no_reuse && strcmp(no_reuse, "1") == 0; + #ifndef _WIN32 # if defined(_SC_PAGESIZE) REAL_PAGE_SIZE = sysconf(_SC_PAGESIZE); @@ -3854,6 +3920,10 @@ ZEND_API void zend_mm_set_custom_handlers_ex(zend_mm_heap *heap, #if ZEND_MM_CUSTOM zend_mm_heap *_heap = (zend_mm_heap*)heap; + if (UNEXPECTED(zend_mm_no_reuse) && (_malloc || _free || _realloc || _gc || _shutdown)) { + zend_mm_panic("ZEND_MM_NO_REUSE does not support custom heap handlers"); + } + if (!_malloc && !_free && !_realloc) { _heap->use_custom_heap = ZEND_MM_CUSTOM_HEAP_NONE; } else { @@ -3933,6 +4003,9 @@ ZEND_API zend_mm_heap *zend_mm_startup(void) ZEND_API zend_mm_heap *zend_mm_startup_ex(const zend_mm_handlers *handlers, void *data, size_t data_size) { + if (UNEXPECTED(zend_mm_no_reuse)) { + zend_mm_panic("ZEND_MM_NO_REUSE does not support custom storage handlers"); + } #if ZEND_MM_STORAGE zend_mm_storage *storage; zend_mm_storage tmp_storage = { @@ -3985,6 +4058,7 @@ ZEND_API zend_mm_heap *zend_mm_startup_ex(const zend_mm_handlers *handlers, void #endif heap->storage = &tmp_storage; heap->huge_list = NULL; + heap->retired_huge_list = NULL; memset(heap->free_slot, 0, sizeof(heap->free_slot)); storage = _zend_mm_alloc(heap, sizeof(zend_mm_storage) + data_size ZEND_FILE_LINE_CC ZEND_FILE_LINE_CC); if (!storage) { diff --git a/docs/source/miscellaneous/running-tests.rst b/docs/source/miscellaneous/running-tests.rst index bb2c56dcecd2..035161164ba3 100644 --- a/docs/source/miscellaneous/running-tests.rst +++ b/docs/source/miscellaneous/running-tests.rst @@ -83,6 +83,25 @@ The test runner can run tests in parallel, by using the ``-j`` option: php run-tests.php -j24 ext/date/*.phpt +Testing ZendMM No-Reuse Mode +============================ + +The ``ZEND_MM_NO_REUSE`` environment option is documented in the `Zend memory-manager README +`__. Build +with ``--enable-zend-test`` and run its dedicated regression tests: + +.. code:: shell + + sapi/cli/php run-tests.php Zend/tests/zend_mm_no_reuse_*.phpt \ + ext/zend_test/tests/zend_mm_no_reuse.phpt + +These tests enable the mode in their own test processes. They check address-range non-overlap, +clearing, reallocations, GC, repeated heap shutdown, memory accounting, and incompatible +configurations. + +Most ordinary tests should still be run in the default allocator mode; tests relying on allocation +recycling or bounded memory usage can behave differently with no-reuse enabled. + ************** Test results ************** diff --git a/ext/zend_test/test.c b/ext/zend_test/test.c index 4867f2f41c69..7c16daad578f 100644 --- a/ext/zend_test/test.c +++ b/ext/zend_test/test.c @@ -269,6 +269,128 @@ static ZEND_FUNCTION(zend_test_callback) free(p); } +typedef struct { + uintptr_t start; + size_t size; +} zend_test_mm_range; + +static bool zend_test_mm_is_fresh(const zend_test_mm_range *retired, size_t count, void *ptr, size_t size) +{ + uintptr_t start = (uintptr_t) ptr; + for (size_t i = 0; i < count; i++) { + if (start < retired[i].start + retired[i].size && retired[i].start < start + size) { + return false; + } + } + return true; +} + +static ZEND_FUNCTION(zend_test_mm_no_reuse) +{ + ZEND_PARSE_PARAMETERS_NONE(); + + const char *enabled = getenv("ZEND_MM_NO_REUSE"); + if (!enabled || strcmp(enabled, "1") != 0) { + zend_throw_error(NULL, "Start PHP with ZEND_MM_NO_REUSE=1"); + RETURN_THROWS(); + } + +#define ZEND_TEST_MM_SIZE(num, size, elements, pages, x, y) size, + const size_t sizes[] = { + ZEND_MM_BINS_INFO(ZEND_TEST_MM_SIZE, x, y) + 4096, 16384, 1024 * 1024, ZEND_MM_MAX_LARGE_SIZE, 3 * 1024 * 1024 + }; +#undef ZEND_TEST_MM_SIZE + const size_t realloc_sizes[] = {1, 64, 3072, 4096, 16384, 3 * 1024 * 1024}; + zend_test_mm_range retired[1024]; + size_t count = 0; + bool fresh = true, collected = true, moved = true, preserved = true, cleared = true; + zend_mm_heap *heap = zend_mm_startup(); + + for (size_t i = 0; i < 512; i++) { + void *ptr = zend_mm_alloc(heap, 64); + size_t capacity = zend_mm_block_size(heap, ptr); + fresh &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + memset(ptr, 0x5a, 64); + zend_mm_free(heap, ptr); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + if (i % 32 == 0) { + collected &= zend_mm_gc(heap) == 0; + } + } + for (size_t pass = 0; pass < 2; pass++) { + for (size_t i = 0; i < sizeof(sizes) / sizeof(*sizes); i++) { + void *ptr = zend_mm_alloc(heap, sizes[i]); + size_t capacity = zend_mm_block_size(heap, ptr); + fresh &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + memset(ptr, 0x5a, sizes[i]); + zend_mm_free(heap, ptr); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + collected &= zend_mm_gc(heap) == 0; + } + } + for (size_t i = 0; i < sizeof(realloc_sizes) / sizeof(*realloc_sizes); i++) { + for (size_t j = 0; j < sizeof(realloc_sizes) / sizeof(*realloc_sizes); j++) { + size_t old_size = realloc_sizes[i], new_size = realloc_sizes[j]; + void *ptr = zend_mm_realloc2(heap, NULL, old_size, 0); + size_t capacity = zend_mm_block_size(heap, ptr); + fresh &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + memset(ptr, 0x5a, old_size); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + if (j % 2 == 0) { + ptr = zend_mm_realloc(heap, ptr, new_size); + } else { + ptr = zend_mm_realloc2(heap, ptr, new_size, MIN(old_size, new_size)); + } + capacity = zend_mm_block_size(heap, ptr); + moved &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + for (size_t k = 0; k < MIN(old_size, new_size); k++) { + preserved &= ((unsigned char *) ptr)[k] == 0x5a; + } + zend_mm_free(heap, ptr); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + } + } + + zend_mm_heap *orig_heap = zend_mm_set_heap(heap); + void *ptr = emalloc(1); + size_t capacity = zend_mem_block_size(ptr); + fresh &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + efree_size(ptr, 1); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + ptr = emalloc(8192); + capacity = zend_mem_block_size(ptr); + fresh &= zend_test_mm_is_fresh(retired, count, ptr, capacity); + efree_size(ptr, 8192); + retired[count++] = (zend_test_mm_range) {(uintptr_t) ptr, capacity}; + zend_mm_set_heap(orig_heap); + + ZEND_ASSERT(count <= sizeof(retired) / sizeof(*retired)); + zend_mm_refresh_key_child(heap); + for (size_t i = 0; i < count; i++) { + const unsigned char *bytes = (const unsigned char *) retired[i].start; + for (size_t j = 0; j < retired[i].size; j++) { + cleared &= bytes[j] == 0; + } + } + zend_mm_shutdown(heap, false, false); + ptr = zend_mm_alloc(heap, 64); + bool reset = (uintptr_t) ptr == retired[0].start; + zend_mm_free(heap, ptr); + zend_mm_shutdown(heap, false, false); + ptr = zend_mm_alloc(heap, 3 * 1024 * 1024); + zend_mm_free(heap, ptr); + zend_mm_shutdown(heap, true, false); + + array_init(return_value); + add_assoc_bool(return_value, "non-overlap", fresh); + add_assoc_bool(return_value, "gc", collected); + add_assoc_bool(return_value, "realloc", moved); + add_assoc_bool(return_value, "contents", preserved); + add_assoc_bool(return_value, "cleared", cleared); + add_assoc_bool(return_value, "shutdown", reset); +} + /* Leak a refcounted variable */ static ZEND_FUNCTION(zend_leak_variable) { diff --git a/ext/zend_test/test.stub.php b/ext/zend_test/test.stub.php index db5ca629ade2..31c4a5bb530d 100644 --- a/ext/zend_test/test.stub.php +++ b/ext/zend_test/test.stub.php @@ -301,6 +301,8 @@ function zend_delref(mixed $variable): void {} function zend_test_erealloc_block_size(int $old_size, int $new_size): array {} + function zend_test_mm_no_reuse(): array {} + function zend_bool(bool $param): bool {} function zend_bool_or_null(bool|null $param): bool|null {} function zend_bool_slow_zpp(bool $param): bool {} diff --git a/ext/zend_test/test_arginfo.h b/ext/zend_test/test_arginfo.h index 87622c185cff..fd328fcb478c 100644 --- a/ext/zend_test/test_arginfo.h +++ b/ext/zend_test/test_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit test.stub.php instead. - * Stub hash: ea2674e5d5dbf60aa5efab31132064b52a07bddd + * Stub hash: c9809fbf1f24e193d0d9a68600ca3a2f74a59981 * Has decl header: yes */ #include "zend_attributes.h" @@ -64,6 +64,8 @@ ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_zend_test_erealloc_block_size, 0 ZEND_ARG_TYPE_INFO(0, new_size, IS_LONG, 0) ZEND_END_ARG_INFO() +#define arginfo_zend_test_mm_no_reuse arginfo_zend_test_array_return + ZEND_BEGIN_ARG_WITH_RETURN_TYPE_INFO_EX(arginfo_zend_bool, 0, 1, _IS_BOOL, 0) ZEND_ARG_TYPE_INFO(0, param, _IS_BOOL, 0) ZEND_END_ARG_INFO() @@ -463,6 +465,7 @@ static ZEND_FUNCTION(zend_leak_variable); static ZEND_FUNCTION(zend_leak_bytes); static ZEND_FUNCTION(zend_delref); static ZEND_FUNCTION(zend_test_erealloc_block_size); +static ZEND_FUNCTION(zend_test_mm_no_reuse); static ZEND_FUNCTION(zend_bool); static ZEND_FUNCTION(zend_bool_or_null); static ZEND_FUNCTION(zend_bool_slow_zpp); @@ -645,6 +648,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(zend_leak_bytes, arginfo_zend_leak_bytes) ZEND_FE(zend_delref, arginfo_zend_delref) ZEND_FE(zend_test_erealloc_block_size, arginfo_zend_test_erealloc_block_size) + ZEND_FE(zend_test_mm_no_reuse, arginfo_zend_test_mm_no_reuse) ZEND_FE(zend_bool, arginfo_zend_bool) ZEND_FE(zend_bool_or_null, arginfo_zend_bool_or_null) ZEND_FE(zend_bool_slow_zpp, arginfo_zend_bool_slow_zpp) diff --git a/ext/zend_test/test_decl.h b/ext/zend_test/test_decl.h index db7652512c83..ba6044980dbb 100644 --- a/ext/zend_test/test_decl.h +++ b/ext/zend_test/test_decl.h @@ -1,8 +1,8 @@ /* This is a generated file, edit test.stub.php instead. - * Stub hash: ea2674e5d5dbf60aa5efab31132064b52a07bddd */ + * Stub hash: c9809fbf1f24e193d0d9a68600ca3a2f74a59981 */ -#ifndef ZEND_TEST_DECL_ea2674e5d5dbf60aa5efab31132064b52a07bddd_H -#define ZEND_TEST_DECL_ea2674e5d5dbf60aa5efab31132064b52a07bddd_H +#ifndef ZEND_TEST_DECL_c9809fbf1f24e193d0d9a68600ca3a2f74a59981_H +#define ZEND_TEST_DECL_c9809fbf1f24e193d0d9a68600ca3a2f74a59981_H typedef enum zend_enum_ZendTestUnitEnum { ZEND_ENUM_ZendTestUnitEnum_Foo = 1, @@ -27,4 +27,4 @@ typedef enum zend_enum_ZendTestEnumWithInterface { ZEND_ENUM_ZendTestEnumWithInterface_Bar = 2, } zend_enum_ZendTestEnumWithInterface; -#endif /* ZEND_TEST_DECL_ea2674e5d5dbf60aa5efab31132064b52a07bddd_H */ +#endif /* ZEND_TEST_DECL_c9809fbf1f24e193d0d9a68600ca3a2f74a59981_H */ diff --git a/ext/zend_test/test_legacy_arginfo.h b/ext/zend_test/test_legacy_arginfo.h index a673b373142a..01635b099ab5 100644 --- a/ext/zend_test/test_legacy_arginfo.h +++ b/ext/zend_test/test_legacy_arginfo.h @@ -1,5 +1,5 @@ /* This is a generated file, edit test.stub.php instead. - * Stub hash: ea2674e5d5dbf60aa5efab31132064b52a07bddd + * Stub hash: c9809fbf1f24e193d0d9a68600ca3a2f74a59981 * Has decl header: yes */ #include "zend_constants.h" @@ -59,6 +59,8 @@ ZEND_BEGIN_ARG_INFO_EX(arginfo_zend_test_erealloc_block_size, 0, 0, 2) ZEND_ARG_INFO(0, new_size) ZEND_END_ARG_INFO() +#define arginfo_zend_test_mm_no_reuse arginfo_zend_trigger_bailout + ZEND_BEGIN_ARG_INFO_EX(arginfo_zend_bool, 0, 0, 1) ZEND_ARG_INFO(0, param) ZEND_END_ARG_INFO() @@ -387,6 +389,7 @@ static ZEND_FUNCTION(zend_leak_variable); static ZEND_FUNCTION(zend_leak_bytes); static ZEND_FUNCTION(zend_delref); static ZEND_FUNCTION(zend_test_erealloc_block_size); +static ZEND_FUNCTION(zend_test_mm_no_reuse); static ZEND_FUNCTION(zend_bool); static ZEND_FUNCTION(zend_bool_or_null); static ZEND_FUNCTION(zend_bool_slow_zpp); @@ -541,6 +544,7 @@ static const zend_function_entry ext_functions[] = { ZEND_FE(zend_leak_bytes, arginfo_zend_leak_bytes) ZEND_FE(zend_delref, arginfo_zend_delref) ZEND_FE(zend_test_erealloc_block_size, arginfo_zend_test_erealloc_block_size) + ZEND_FE(zend_test_mm_no_reuse, arginfo_zend_test_mm_no_reuse) ZEND_FE(zend_bool, arginfo_zend_bool) ZEND_FE(zend_bool_or_null, arginfo_zend_bool_or_null) ZEND_FE(zend_bool_slow_zpp, arginfo_zend_bool_slow_zpp) diff --git a/ext/zend_test/tests/zend_mm_no_reuse.phpt b/ext/zend_test/tests/zend_mm_no_reuse.phpt new file mode 100644 index 000000000000..fa8486502091 --- /dev/null +++ b/ext/zend_test/tests/zend_mm_no_reuse.phpt @@ -0,0 +1,20 @@ +--TEST-- +ZendMM no-reuse mode retains and clears freed storage until heap shutdown +--EXTENSIONS-- +zend_test +--ENV-- +USE_ZEND_ALLOC=1 +ZEND_MM_NO_REUSE=1 +--FILE-- + $passed) { + echo "$check: ", $passed ? "ok" : "failed", "\n"; +} +?> +--EXPECT-- +non-overlap: ok +gc: ok +realloc: ok +contents: ok +cleared: ok +shutdown: ok From 0504a04071020ef5b55c91f525dda1462d1bf895 Mon Sep 17 00:00:00 2001 From: jvoisin Date: Fri, 23 Oct 2026 18:01:34 +0200 Subject: [PATCH 2/2] Zend: Reclaim retired no-reuse pages with madvise Use MADV_DONTNEED for complete OS pages in retired large runs and huge mappings. This releases resident memory while keeping virtual addresses reserved, preserving the no-reuse guarantee and memory-limit accounting. Continue clearing partial pages and fall back to memset() when madvise() is unavailable or fails. On Linux/aarch64 with 16 KiB pages, 40 alternating benchmark runs showed: - 256 x 1 MiB allocations: RSS fell from 275,200 KiB to 17,120 KiB (-94%); median time changed from 31.156 ms to 30.262 ms. - 32 x 8 MiB allocations: RSS fell from 271,632 KiB to 8,944 KiB (-97%); median time changed from 30.997 ms to 29.545 ms. - 1,000,000 x 64-byte allocations were unaffected: RSS remained about 104 MiB and median time changed from 49.878 ms to 49.973 ms. Reserved memory reported by memory_get_usage(true) remained unchanged. --- Zend/README.md | 13 ++++++++----- Zend/zend_alloc.c | 43 +++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/Zend/README.md b/Zend/README.md index 352b4bd10ed2..f2c860f37c75 100644 --- a/Zend/README.md +++ b/Zend/README.md @@ -41,6 +41,8 @@ With the mode enabled: - Freed small allocations, page runs, and huge mappings are cleared and retained until their heap shuts down, after request shutdown callbacks have finished. + OS pages belonging to retired page runs and huge mappings are returned to the + kernel on supported platforms while their virtual addresses remain reserved. Retired payloads are not used to store allocator free-list links. - Reallocations of existing blocks always move to fresh storage, including shrinking and unchanged-size reallocations. The original storage is retired. @@ -51,11 +53,12 @@ With the mode enabled: `memory_get_usage(false)` can decrease after freeing an allocation even though the underlying storage remains retained. -Memory consumption approaches cumulative allocation volume rather than peak -live memory. Clearing freed storage and moving reallocations also add memory -bandwidth and copying costs. The mode is intended for selected short-lived -requests, not long-running scripts or application workers that do not perform -normal PHP request shutdown. +Reserved virtual memory and allocator accounting approach cumulative allocation +volume rather than peak live memory. Resident memory may be lower where the +operating system can discard fully retired pages. Clearing freed storage and +moving reallocations also add memory bandwidth, system-call, and copying costs. +The mode is intended for selected short-lived requests, not long-running scripts +or application workers that do not perform normal PHP request shutdown. The mode requires native Zend MM. Combining it with `USE_ZEND_ALLOC=0`, custom heap or storage handlers, or `ZEND_MM_DEBUG` allocator wrappers causes an diff --git a/Zend/zend_alloc.c b/Zend/zend_alloc.c index d6751e82ebf4..315a48c55593 100644 --- a/Zend/zend_alloc.c +++ b/Zend/zend_alloc.c @@ -883,6 +883,45 @@ static zend_always_inline void zend_mm_hugepage(void* ptr, size_t size) #endif } +static zend_always_inline void zend_mm_no_reuse_clear(void *ptr, size_t size) +{ +#if defined(MADV_DONTNEED) + char *p = (char *) ptr; + size_t offset = ZEND_MM_ALIGNED_OFFSET(p, REAL_PAGE_SIZE); + size_t prefix = offset != 0 ? REAL_PAGE_SIZE - offset : 0; + + if (prefix > size) { + prefix = size; + } + if (prefix != 0) { + memset(p, 0, prefix); + p += prefix; + size -= prefix; + } + + size_t discard_size = size - ZEND_MM_ALIGNED_OFFSET(size, REAL_PAGE_SIZE); + if (discard_size != 0) { +#ifdef __linux__ + /* Private anonymous pages fault back as zeroes while their addresses stay reserved. */ + if (madvise(p, discard_size, MADV_DONTNEED) != 0) { + memset(p, 0, discard_size); + } +#else + /* MADV_DONTNEED does not guarantee zero-fill on every supported platform. */ + memset(p, 0, discard_size); + (void)madvise(p, discard_size, MADV_DONTNEED); +#endif + p += discard_size; + size -= discard_size; + } + if (size != 0) { + memset(p, 0, size); + } +#else + memset(ptr, 0, size); +#endif +} + static void *zend_mm_chunk_alloc_int(size_t size, size_t alignment) { void *ptr = zend_mm_mmap(size); @@ -1398,7 +1437,7 @@ static zend_always_inline void zend_mm_free_large(zend_mm_heap *heap, zend_mm_ch if (UNEXPECTED(zend_mm_no_reuse)) { ZEND_MM_CHECK(!(chunk->map[page_num] & ZEND_MM_IS_RETIRED), "zend_mm_heap corrupted (double free)"); chunk->map[page_num] |= ZEND_MM_IS_RETIRED; - memset(ZEND_MM_PAGE_ADDR(chunk, page_num), 0, pages_count * ZEND_MM_PAGE_SIZE); + zend_mm_no_reuse_clear(ZEND_MM_PAGE_ADDR(chunk, page_num), pages_count * ZEND_MM_PAGE_SIZE); return; } zend_mm_free_pages(heap, chunk, page_num, pages_count); @@ -2255,7 +2294,7 @@ static void zend_mm_free_huge(zend_mm_heap *heap, void *ptr ZEND_FILE_LINE_DC ZE size = zend_mm_del_huge_block(heap, ptr ZEND_FILE_LINE_RELAY_CC ZEND_FILE_LINE_ORIG_RELAY_CC); zend_mm_check_huge_block_size(heap, size); if (UNEXPECTED(zend_mm_no_reuse)) { - memset(ptr, 0, size); + zend_mm_no_reuse_clear(ptr, size); } else { zend_mm_chunk_free(heap, ptr, size); #if ZEND_MM_STAT || ZEND_MM_LIMIT