PHP 8.5.12 启动期 delayed OOM:uri 模块 RINIT 失败让 worker 直接 exit(1)
从 8.5.12 起,越过 memory_limit 不再在分配点当场 bailout,zend_mm_check_limit() 把 delayed OOM 挂到 EG(delayed_oom),等安全点再抛。代价是 php_request_startup() 中间留了一段没有 OOM 检查的窗口,一段特定大小的 multipart 上传请求正好落在窗口里,worker 在请求启动中途直接 exit(1),不发送任何响应,已经移进 upload_tmp_dir 的文件留在原地。
背景:bailout 重构把 OOM 延后了
自 bailout 重构(commit 31c0ea7,随 8.5.12、8.4.27、8.3.36 和 8.2.35 发布)之后,越过 memory_limit 不再在分配点当场 bailout。zend_mm_check_limit() 把 delayed OOM 挂到 EG(delayed_oom),越界的那次分配照常成功,错误延后到安全点再抛。走 fallible allocator 的分配则直接返回 NULL。
php_request_startup() 在 POST handler(sapi_handle_post(),main/SAPI.c:161-170)之后会抛出 pending OOM,但在 php_hash_environment() 与 zend_activate_modules() 之间(main/main.c:1976-1977)没有第二次检查。_COOKIE、_FILES 自动全局变量、符号表更新和第一批 RINIT handler 都在这段 gap 里分配。如果 limit 恰好在这里被越过,OOM 就一直挂着:
PHP_RINIT_FUNCTION(uri_parser_whatwg)调用lexbor_mraw_init(),它的 allocator 现在是 fallible 的,拿到失败状态就返回 FAILURE(ext/uri/uri_parser_whatwg.c:569)。PHP_RINIT_FUNCTION(pcre)在需要重试 PCRE2 context 初始化时,也会经由pcre2_general_context_create()走同样的失败路径(ext/pcre/php_pcre.c:447-457)。
zend_activate_modules() 对 RINIT 失败的反应是 zend_error(E_WARNING, "request_startup() for %s module failed") 然后 exit(1)(Zend/zend_API.c:3443-3445)。
worker 在请求启动中途以 exit status 1 终止。php_request_shutdown() 和 php_module_shutdown() 都不会执行,不发送任何响应;rfc1867 已经移到 upload_tmp_dir 的文件也留在那儿,因为 destroy_uploaded_files_hash() 只能从 sapi_deactivate() 到达。
改动之前,同样的输入在 lexbor 的分配处撞到 memory_limit,当场抛 fatal error,SAPI 把它当作普通的请求启动失败处理。对 mod_php 而言,那意味着一个 fatal error 响应、完整的 request shutdown,以及活下来的 worker。
复现
构建:release tarball 8.5.12 与 8.5.11,--disable-all --enable-debug --enable-session --enable-filter --enable-mbstring --disable-mbregex,外加 --enable-fpm 或 --with-apxs2;gcc 15,WSL2 x86_64 上的 Ubuntu,Apache 2.4 prefork。debug build 只改变命中时对应的字段数。
用来让条件容易命中的 INI:
memory_limit=8M
post_max_size=64M
upload_max_filesize=64M
max_input_vars=20000
log_errors=On
display_errors=On
upload_tmp_dir=<空目录>
FPM pool:pm=static、pm.max_children=1。Apache:StartServers 1、MaxSpareServers 1。
脚本:
<?php
echo "OK post=", count($_POST), " files=", count($_FILES), " cookie=", count($_COOKIE), "\n";
请求:一个 multipart/form-data POST,先三个小文件 part(8000、4000、100 字节),后面跟 N 个 4000 字节的文本字段,再加一个 7000 字节的 Cookie 头。N 以 4 为步长扫描。分配器按 2 MB chunk 记账,所以扫描是在找那个 N——在它上面,越界发生在 POST handler 返回之后、RINIT 结束之前。FPM 这边用最小 FastCGI 客户端直接发(链路上没有 web server),mod_php 用 curl。生成器是几行 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"
表里的列:leftover 是请求结束后 upload_tmp_dir 里剩下的文件数;child 是请求前和请求后 1.5 秒时 Apache master 下那个 prefork 子进程的 PID。
PHP 8.5.12,php-fpm
| N | leftover | 结果 |
|---|---|---|
| 920 | 0 | END_REQUEST, OK post=920 files=3 cookie=1 mem=8388608 |
| 924 | 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) |
| 928 | 3 | no END_REQUEST, connection closed |
| 932 | 3 | no END_REQUEST, connection closed |
| 936 | 3 | no END_REQUEST, connection closed |
| 940 | 3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at Zend/zend_string.h:189 (tried to allocate 2097152 bytes) |
N=928 到 936 的 FPM master 日志:
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:
PHP Warning: request_startup() for uri module failed in Unknown on line 0
不带 Cookie 头时同样的 N 值也会触发退出(N=932:child exited with code 1)。Cookie 只是把 gap 加宽了。
PHP 8.5.11,php-fpm,同构建选项、同请求
| N | leftover | 结果 |
|---|---|---|
| 928 | 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) |
| 932 | 3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/lexbor/php_lexbor.c:35 (tried to allocate 32800 bytes) |
| 936 | 3 | END_REQUEST, Fatal: Allowed memory size of 8388608 bytes exhausted at ext/lexbor/php_lexbor.c:35 (tried to allocate 24608 bytes) |
每个请求都收到了 fatal error 响应,子进程以 code 70 退出(FPM_EXIT_SOFTWARE,sapi/fpm/fpm/fpm_main.c:1838 处正常的 php_request_startup() 失败路径)。没有任何请求产生 exit code 1。8.5.11 的 fatal error 是在 lexbor 内部抛出的,而那次分配在 8.5.12 里变成返回 NULL。
PHP 8.5.12,mod_php(Apache 2.4 prefork)
| N | leftover | 结果 | child |
|---|---|---|---|
| 920 | 0 | HTTP 200, Fatal at Zend/zend_language_scanner.c:609 | 180541 -> 180541 |
| 924 | 0 | HTTP 200, Fatal at ext/opcache/zend_accelerator_util_funcs.c:371 | 180541 -> 180541 |
| 928 | 3 | 100 Continue then empty reply (curl exit 52) | 180541 -> 186411 |
| 932 | 3 | 100 Continue then empty reply (curl exit 52) | 186411 -> 190189 |
| 936 | 3 | 100 Continue then empty reply (curl exit 52) | 190189 -> 193005 |
| 940 | 0 | HTTP 200, Fatal at Zend/zend_string.h:189 | 193005 -> 193005 |
落在这段 gap 里的每个请求都杀掉了服务它的 prefork 子进程,客户端只收到一个中间的 100 Continue 就被断开,三个上传文件留在 upload_tmp_dir。PHP error log 里每个都对应一条 request_startup() for uri module failed。gap 之外的请求照常拿到 fatal error,子进程存活,upload_tmp_dir 被清理。
PHP 8.5.11,mod_php,同请求
| N | leftover | 结果 | child |
|---|---|---|---|
| 928 | 0 | HTTP 200, Fatal at Zend/zend_arena.h:36 (32800 bytes) | 1051 -> 1051 |
| 932 | 0 | HTTP 200, Fatal at ext/lexbor/php_lexbor.c:35 (32800 bytes) | 1051 -> 1051 |
| 936 | 0 | HTTP 200, Fatal at ext/lexbor/php_lexbor.c:35 (24608 bytes) | 1051 -> 1051 |
从 N=920 到 N=1000 的七个请求:每一个都返回 fatal error,全程同一个子进程 PID,每个请求之后 upload_tmp_dir 都是空的。
影响面
mod_php:回归。 改动之前,启动期撞到 memory_limit 的请求会产生一个 fatal error 响应,worker 继续工作。改动之后,越界落在启动 gap 里的请求会终止 Apache 子进程(prefork 下只影响这条连接;threaded MPM 配 ZTS build 会把这个进程上的所有连接一起带走),客户端什么也收不到,上传的临时文件留在原地。每重试一次,就再杀一个 worker。
php-fpm 与 php-cgi: 进程本来就会在请求启动失败时退出(code 70)。现在它以 code 1 退出,不发送 FastCGI 响应(web server 报 502,而不是带 fatal error 的 500),并且跳过 php_module_shutdown()。
upload_tmp_dir 残留: 对 FPM 和 CGI 是既有行为,因为这些 SAPI 在启动失败时直接退出,不走 php_request_shutdown()。对 mod_php 是新问题,因为 exit(1) 跳过了原本由 sapi/apache2handler/sapi_apache2.c:672-674 的 zend_first_try handler 执行的 php_apache_request_dtor()。每个失败请求最多留下 max_file_uploads 个文件,每个最大 upload_max_filesize。
触发条件: 需要让 RINIT 之前就解析的请求数据(POST 字段和 cookie;文件内容走磁盘)把分配器推到距 memory_limit 一个 2 MB chunk 之内,所以 post_max_size 必须占 memory_limit 的很大比例。php.ini-production 的默认值(8M / 128M)达不到。为传大文件调高 post_max_size、而 memory_limit 保持在相近量级的部署很常见,那里的 gap 只有几十 KB 宽,靠步进 body 大小能找到。
修复建议
下面任一条都能补上这段 gap:
- 在
php_request_startup()里,php_hash_environment()之后检查ZEND_HAS_OOM()并调用zend_handle_delayed_oom(),位置放在zend_activate_modules()之前,让 pending OOM 走已有的 bailout 路径抛成常规 fatal error。这和sapi_handle_post()对 POST handler 做的事一致。 - 在
zend_activate_modules()里,当 RINIT handler 失败且ZEND_HAS_OOM()为真时,调用zend_handle_delayed_oom(),而不是exit(1)。
另外,FPM 和 CGI 的启动失败路径可以在退出前调用 destroy_uploaded_files_hash(),避免失败请求在 upload_tmp_dir 里留下条目。这部分早于 bailout 重构。
测试范围
测过:8.5.12 和 8.5.11 上的 php-fpm 与 mod_php(prefork),上面那套 N 扫描;FPM master 日志的退出码;每个请求前后的 Apache 子进程 PID;upload_tmp_dir 内容;log_errors=On 下的 PHP error log。
没测:ZTS build 与 threaded MPM、Windows、CLI server、8.4 / 8.3 / 8.2 发布分支(同一个改动,但没有重新构建),以及 FPM 前面的 web server(502 是从缺失 END_REQUEST 推出来的)。
Issue 状态:Verified,已指派给 arnaud-lb;修复 commit 7336674("Fix phpGH-24787 delayed OOM during request startup")于 11 月 2 日合入 master。