You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ZTS + Apache event/worker MPM: every MaxConnectionsPerChild recycle kills the child with SIGTERM, aborting its in-flight requests #24130
With PHP built with ZTS (Zend signal handling enabled, the default) and loaded into Apache httpd with a threaded MPM (event or worker), every child process that reaches MaxConnectionsPerChild is killed by SIGTERM instead of shutting down gracefully. Every request that child is serving at that moment is aborted (the client gets an empty reply or a connection reset), and none of the child's cleanup (pool cleanups, module child-exit work) runs. Apache does not log it.
Steps to reproduce, with Apache 2.4 (event MPM) and a ZTS build of PHP as mod_php:
One request per recycled child gets no response at all (curl: "Empty reply from server" or "Recv failure: Connection reset by peer").
But I expected this output instead:
100 200
That is what I get with the same setup when requesting a static file instead of the PHP script, and with MaxConnectionsPerChild 0.
What happens
When a child reaches MaxConnectionsPerChild, the MPM's listener thread wakes the child's main thread with kill(ap_my_pid, SIGTERM) (httpd 2.4.67: server/mpm/event/event.c:1331, server/mpm/worker/worker.c:729). The main thread expects this: it installs a no-op dummy_signal_handler for SIGTERM before it waits for that wake-up (event.c:2761-2766: "if one of the other threads in the process needs to take us down (e.g., for MaxConnectionsPerChild) it will send us SIGTERM"). It then joins the worker threads and exits normally.
PHP replaces that handler. zend_signal_activate(), called from php_request_startup() in a worker thread, registers zend_signal_handler_defer for SIGTERM, process-wide, and zend_signal_deactivate() leaves it in place. The wake-up signal is then delivered to the child's main thread. That thread is managed by TSRM (tsrm_is_managed_thread() returns true for it, presumably because it inherits the parent's TSRM context through fork()), but it never runs a request. So zend_signal_handler() looks SIGTERM up in that thread's SIGG(handlers), finds SIG_DFL, installs it and raises the signal again. The whole process dies.
strace of one child (pid 547750) at its 20th connection, abridged:
gdb on a child of the same setup: info symbol 0x7fae9743ba90 gives zend_signal_handler_defer in section .text of libphp.so. In the main thread tsrm_is_managed_thread() returns 1, in a worker thread that has not run PHP yet it returns 0. global_orig_handlers[SIGTERM - 1] holds Apache's just_die; it is not used here because the main thread counts as managed.
The code paths in Zend/zend_signal.c (zend_signal_handler_defer, zend_signal_handler, zend_signal_activate) are the same in 8.4.21 and on master as of 2026-10-05.
Impact
In-flight requests of the recycled child are aborted. With MaxConnectionsPerChild 2000 and 64 threads per child, as on the server where we found it, that is up to 64 requests (plus HTTP/2 streams) several times a day, at random.
The child gets no chance to clean up. Apache's parent treats SIGTERM as an expected way for a child to end and does not log it (ap_process_child_status()), so nothing points to the cause. Modules that keep locks in shared memory can be left with locks held by the dead process. With mod_pagespeed this caused hangs that lasted until the next restart; that part is being fixed on the mod_pagespeed side in fix(sharedmem): recover shared-memory mutexes abandoned by a dead holder We-Amp/mod_pagespeed#13.
Related
Apache SIGTERM causes module shutdown to run at runtime #17348: prefork and a SIGTERM from the parent at shutdown. There zend_signal_handler() forwards to just_die and runs module shutdown inside the signal handler. This report has a different trigger (a threaded MPM waking itself during normal operation) and a different outcome (the process is killed outright on every recycle), but the same underlying question: what zend signals should do with a host signal that arrives outside a PHP request.
Possible directions, not tested: forward a signal that arrives in a thread without an active request to the handler that zend_signal_activate() replaced, rather than to SIG_DFL; or have apache2handler keep zend signals away from the signals the MPM uses itself (SIGTERM, SIGHUP, SIGUSR1).
Workarounds: MaxConnectionsPerChild 0 (the Apache default), or building PHP with --disable-zend-signals.
PHP Version
PHP 8.4.21 (cli) (built: May 7 2026 21:41:26) (ZTS)
Copyright (c) The PHP Group
Zend Engine v4.4.21, Copyright (c) Zend Technologies
with Zend OPcache v8.4.21, Copyright (c), by Zend Technologies
Same build as the libphp.so Apache module. Apache httpd 2.4.67 (event MPM), both built from source.
Description
With PHP built with ZTS (Zend signal handling enabled, the default) and loaded into Apache httpd with a threaded MPM (event or worker), every child process that reaches
MaxConnectionsPerChildis killed bySIGTERMinstead of shutting down gracefully. Every request that child is serving at that moment is aborted (the client gets an empty reply or a connection reset), and none of the child's cleanup (pool cleanups, module child-exit work) runs. Apache does not log it.Steps to reproduce, with Apache 2.4 (event MPM) and a ZTS build of PHP as
mod_php:The following code (
ok.php):requested 100 times:
Resulted in this output:
One request per recycled child gets no response at all (
curl: "Empty reply from server" or "Recv failure: Connection reset by peer").But I expected this output instead:
That is what I get with the same setup when requesting a static file instead of the PHP script, and with
MaxConnectionsPerChild 0.What happens
When a child reaches
MaxConnectionsPerChild, the MPM's listener thread wakes the child's main thread withkill(ap_my_pid, SIGTERM)(httpd 2.4.67:server/mpm/event/event.c:1331,server/mpm/worker/worker.c:729). The main thread expects this: it installs a no-opdummy_signal_handlerfor SIGTERM before it waits for that wake-up (event.c:2761-2766: "if one of the other threads in the process needs to take us down (e.g., for MaxConnectionsPerChild) it will send us SIGTERM"). It then joins the worker threads and exits normally.PHP replaces that handler.
zend_signal_activate(), called fromphp_request_startup()in a worker thread, registerszend_signal_handler_deferfor SIGTERM, process-wide, andzend_signal_deactivate()leaves it in place. The wake-up signal is then delivered to the child's main thread. That thread is managed by TSRM (tsrm_is_managed_thread()returns true for it, presumably because it inherits the parent's TSRM context throughfork()), but it never runs a request. Sozend_signal_handler()looks SIGTERM up in that thread'sSIGG(handlers), findsSIG_DFL, installs it and raises the signal again. The whole process dies.strace of one child (pid 547750) at its 20th connection, abridged:
gdb on a child of the same setup:
info symbol 0x7fae9743ba90giveszend_signal_handler_defer in section .text of libphp.so. In the main threadtsrm_is_managed_thread()returns 1, in a worker thread that has not run PHP yet it returns 0.global_orig_handlers[SIGTERM - 1]holds Apache'sjust_die; it is not used here because the main thread counts as managed.The code paths in
Zend/zend_signal.c(zend_signal_handler_defer,zend_signal_handler,zend_signal_activate) are the same in 8.4.21 and onmasteras of 2026-10-05.Impact
MaxConnectionsPerChild 2000and 64 threads per child, as on the server where we found it, that is up to 64 requests (plus HTTP/2 streams) several times a day, at random.ap_process_child_status()), so nothing points to the cause. Modules that keep locks in shared memory can be left with locks held by the dead process. With mod_pagespeed this caused hangs that lasted until the next restart; that part is being fixed on the mod_pagespeed side in fix(sharedmem): recover shared-memory mutexes abandoned by a dead holder We-Amp/mod_pagespeed#13.Related
zend_signal_handler()forwards tojust_dieand runs module shutdown inside the signal handler. This report has a different trigger (a threaded MPM waking itself during normal operation) and a different outcome (the process is killed outright on every recycle), but the same underlying question: what zend signals should do with a host signal that arrives outside a PHP request.Possible directions, not tested: forward a signal that arrives in a thread without an active request to the handler that
zend_signal_activate()replaced, rather than toSIG_DFL; or have apache2handler keep zend signals away from the signals the MPM uses itself (SIGTERM, SIGHUP, SIGUSR1).Workarounds:
MaxConnectionsPerChild 0(the Apache default), or building PHP with--disable-zend-signals.PHP Version
Same build as the
libphp.soApache module. Apache httpd 2.4.67 (event MPM), both built from source.Operating System
Debian GNU/Linux 13 (trixie), x86_64