[php-src] Issue #24787: Pending delayed OOM across zend_activate_modules() makes the uri RINIT fail and the worker exit(1) during request startup
(regression in 8.5.12)

From: Date: Tue, 27 Oct 2026 13:59:51 +0000
Subject: [php-src] Issue #24787: Pending delayed OOM across zend_activate_modules() makes the uri RINIT fail and the worker exit(1) during request startup
(regression in 8.5.12)
Groups: php.bugs 
Request: Send a blank email to php-bugs+get-253239@lists.php.net to get a copy of this message
Issue: https://github.com/php/php-src/issues/24787 Author: ihopenre-eng ### Description Since the bailout rework (31c0ea7d7ff1, shipped in 8.5.12, 8.4.27, 8.3.36 and 8.2.35), crossing memory_limit no longer bails out at the allocation site. zend_mm_check_limit() queues a delayed OOM in EG(delayed_oom), the allocation that crossed the limit still succeeds, and the error is raised later at a safe point. Allocations made through the fallible allocators return NULL instead. php_request_startup() raises a pending OOM right after the POST handler (sapi_handle_post(), main/SAPI.c:161-170), but nothing checks again between php_hash_environment() and zend_activate_modules() (main/main.c:1976-1977). The _COOKIE and _FILES auto globals, the symbol table updates and the first RINIT handlers all allocate inside that gap. When the limit is crossed there, the OOM stays pending and: PHP_RINIT_FUNCTION(uri_parser_whatwg) calls lexbor_mraw_init(), whose allocator is now fallible, gets a failure status and returns FAILURE (ext/uri/uri_parser_whatwg.c:569). PHP_RINIT_FUNCTION(pcre) can fail the same way through pcre2_general_context_create() when it has to retry the PCRE2 context setup (ext/pcre/php_pcre.c:447-457). zend_activate_modules() reacts to the RINIT failure with zend_error(E_WARNING, "request_startup() for %s module failed") followed by exit(1) (Zend/zend_API.c:3443-3445). The worker process terminates with exit status 1 in the middle of request startup. php_request_shutdown() and php_module_shutdown() never run, no response is sent, and files that rfc1867 already moved to upload_tmp_dir stay there because destroy_uploaded_files_hash() is only reached from sapi_deactivate(). Before this change the same input hit the memory limit inside the lexbor allocation, raised the fatal error on the spot, and the SAPI handled it as a normal request startup failure. For mod_php that meant a fatal error response, a full request shutdown and a surviving worker. Reproduction Builds: release tarballs 8.5.12 and 8.5.11, --disable-all --enable-debug --enable-session --enable-filter --enable-mbstring --disable-mbregex plus --enable-fpm or --with-apxs2, gcc 15, Ubuntu on WSL2 x86_64, Apache 2.4 prefork. The debug build only moves the exact field count at which the limit is crossed. INI used to make the condition cheap to hit: memory_limit=8M post_max_size=64M upload_max_filesize=64M max_input_vars=20000 log_errors=On display_errors=On upload_tmp_dir=<empty directory> FPM pool: pm=static, pm.max_children=1. Apache: StartServers 1, MaxSpareServers 1. Script: <?php echo "OK post=", count($_POST), " files=", count($_FILES), " cookie=", count($_COOKIE), "\n"; Request: one multipart/form-data POST with three small file parts (8000, 4000 and 100 bytes) followed by N text fields of 4000 bytes each, plus a 7000 byte Cookie header. N is swept in steps of 4. The allocator accounts memory in 2 MB chunks, so the sweep is looking for the N at which the chunk that crosses the limit is requested after the POST handler returned and before RINIT finished. For FPM the request was sent with a minimal FastCGI client (no web server in the loop), for mod_php with curl. The generator is a few lines of Python: B = b"----oomb" def part(n, v, fn=None): h = b"--" + B + b"\r\nContent-Disposition: form-data; name=\"" + n + b"\"" if fn: h += b"; filename=\"" + fn + b"\"\r\nContent-Type: application/octet-stream" return h + b"\r\n\r\n" + v + b"\r\n" body = part(b"f1", b"D"*8000, b"x.bin") + part(b"f2", b"E"*4000, b"y.bin") + part(b"f3", b"F"*100, b"z.bin") body += b"".join(part(b"s%d" % i, b"y"*4000) for i in range(N)) body += b"--" + B + b"--\r\n" Columns below: leftover is the number of files left in upload_tmp_dir after the request; child is the prefork child PID under the Apache master before and 1.5 s after the request. PHP 8.5.12, php-fpm N=920 leftover=0 | END_REQUEST, OK post=920 files=3 cookie=1 mem=8388608 N=924 leftover=0 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/opcache/zend_accelerator_util_funcs.c:371 (tried to allocate 2097152 bytes) N=928 leftover=3 | no END_REQUEST, connection closed N=932 leftover=3 | no END_REQUEST, connection closed N=936 leftover=3 | no END_REQUEST, connection closed N=940 leftover=3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at Zend/zend_string.h:189 (tried to allocate 2097152 bytes) FPM master log for N=928 to 936: WARNING: [pool www] child 91070 exited with code 1 after 1.127312 seconds from start WARNING: [pool www] child 92837 exited with code 1 after 0.222585 seconds from start PHP error log for one of them: PHP Warning: request_startup() for uri module failed in Unknown on line 0 The same N values trigger the exit without the Cookie header (N=932: child exited with code 1). The cookie only widens the gap. PHP 8.5.11, php-fpm, same build options and requests N=928 leftover=0 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/opcache/zend_accelerator_util_funcs.c:371 (tried to allocate 320 bytes) N=932 leftover=3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/lexbor/php_lexbor.c:35 (tried to allocate 32800 bytes) N=936 leftover=3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/lexbor/php_lexbor.c:35 (tried to allocate 24608 bytes) Every request received the fatal error response and the child exited with code 70 (FPM_EXIT_SOFTWARE, the normal php_request_startup() failure path in sapi/fpm/fpm/fpm_main.c:1838). No request produced exit code 1. The 8.5.11 fatal error is raised inside lexbor, which is the allocation that now returns NULL in 8.5.12. PHP 8.5.12, mod_php (Apache 2.4 prefork) N=920 leftover=0 | HTTP 200, Fatal at Zend/zend_language_scanner.c:609 | child 180541 -> 180541 N=924 leftover=0 | HTTP 200, Fatal at ext/opcache/zend_accelerator_util_funcs.c:371 | child 180541 -> 180541 N=928 leftover=3 | 100 Continue then empty reply (curl exit 52) | child 180541 -> 186411 N=932 leftover=3 | 100 Continue then empty reply (curl exit 52) | child 186411 -> 190189 N=936 leftover=3 | 100 Continue then empty reply (curl exit 52) | child 190189 -> 193005 N=940 leftover=0 | HTTP 200, Fatal at Zend/zend_string.h:189 | child 193005 -> 193005 Each request in the gap killed the prefork child that served it, the client got only the interim 100 Continue before the connection closed, and the three uploaded files stayed in upload_tmp_dir. The PHP error log shows request_startup() for uri module failed for each of them. Outside the gap the fatal error is delivered, the child survives and upload_tmp_dir is cleaned. PHP 8.5.11, mod_php, same requests N=928 leftover=0 | HTTP 200, Fatal at Zend/zend_arena.h:36 (32800 bytes) | child 1051 -> 1051 N=932 leftover=0 | HTTP 200, Fatal at ext/lexbor/php_lexbor.c:35 (32800 bytes) | child 1051 -> 1051 N=936 leftover=0 | HTTP 200, Fatal at ext/lexbor/php_lexbor.c:35 (24608 bytes) | child 1051 -> 1051 Seven requests from N=920 to N=1000: every one answered with the fatal error, same child PID throughout, upload_tmp_dir empty after every request. Impact mod_php: regression. Before the change a request that hit memory_limit during startup produced a fatal error response and the worker continued. After the change a request that lands the crossing in the startup gap terminates the Apache child (prefork: that connection; a threaded MPM with a ZTS build would take every connection of that process with it), returns nothing to the client, and leaves the uploaded temporary files behind. Each repeated request kills another worker. php-fpm and php-cgi: the process already exited on a request startup failure (code 70). It now exits with code 1, sends no FastCGI response (the web server reports 502 instead of a 500 carrying the fatal error) and skips php_module_shutdown(). upload_tmp_dir leftovers: pre-existing for FPM and CGI, since those SAPIs exit on a startup failure without php_request_shutdown(). New for mod_php, because exit(1) skips the php_apache_request_dtor() that used to run from the zend_first_try handler in sapi/apache2handler/sapi_apache2.c:672-674. Each failed request can leave up to max_file_uploads files of up to upload_max_filesize each. The trigger needs request data that is parsed before RINIT (POST fields and cookies; file bodies go to disk) to bring the allocator to within one 2 MB chunk of memory_limit, so post_max_size has to be a large fraction of memory_limit. The php.ini-production defaults (8M / 128M) do not allow it. Deployments that raise post_max_size for large uploads while leaving memory_limit at a similar size are common, and there the gap is a few tens of kilobytes wide and is found by stepping the body size. Suggested fix Either of these closes the gap: In php_request_startup(), after php_hash_environment(), check ZEND_HAS_OOM() and call zend_handle_delayed_oom() before zend_activate_modules(), so the pending OOM is raised as the usual fatal error through the existing bailout path. This mirrors what sapi_handle_post() already does for the POST handler. In zend_activate_modules(), when a RINIT handler fails and ZEND_HAS_OOM() is true, call zend_handle_delayed_oom() instead of exit(1). Separately, the FPM and CGI startup failure paths could call destroy_uploaded_files_hash() before exiting, so a failed request does not leave upload_tmp_dir entries behind. That part predates the bailout rework. What was and was not tested Tested: php-fpm and mod_php (prefork) on 8.5.12 and 8.5.11 with the sweep above, FPM master log exit codes, Apache child PIDs before and after each request, upload_tmp_dir contents, PHP error log with log_errors=On. Not tested: ZTS builds and threaded MPMs, Windows, the CLI server, the 8.4 / 8.3 / 8.2 release branches (same change, not rebuilt), and a web server in front of FPM (the 502 is inferred from the missing END_REQUEST). ### PHP Version ```plain PHP 8.5.12 (fpm-fcgi and apache2handler, NTS DEBUG). 8.5.11 used as the comparison baseline. ``` ### Operating System Ubuntu (WSL2) x86_64, gcc 15, Apache 2.4 prefork

« previous php.bugs (#253239) next »