[php-src] Issue #24130: ZTS + Apache event/worker MPM: every MaxConnectionsPerChild recycle kills the child with SIGTERM, aborting its in-flight
requests
| From: | trcyberoptic | Date: | Mon, 05 Oct 2026 07:03:11 +0000 |
| Subject: | [php-src] Issue #24130: ZTS + Apache event/worker MPM: every MaxConnectionsPerChild recycle kills the child with SIGTERM, aborting its in-flight requests |
||
| Groups: | php.bugs | ||
| Request: | Send a blank email to php-bugs+get-252904@lists.php.net to get a copy of this message | ||
Issue: https://github.com/php/php-src/issues/24130
Author: trcyberoptic
### 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
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:
```apache
LoadModule php_module modules/libphp.so
AddType application/x-httpd-php .php
KeepAlive Off
<IfModule mpm_event_module>
StartServers 2
ThreadsPerChild 8
MinSpareThreads 4
MaxSpareThreads 16
MaxConnectionsPerChild 20
</IfModule>
```
The following code (ok.php):
```php
<?php printf("ok\n");
```
requested 100 times:
```sh
for i in $(seq 1 100); do
curl -s -o /dev/null -m 5 -w '%{http_code}\n' http://127.0.0.1:18080/ok.php
done | sort | uniq -c
```
Resulted in this output:
```
4 000
96 200
```
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:
```
547761 kill(547750, SIGTERM) = 0 # listener thread wakes the main thread
547750 --- SIGTERM {si_signo=SIGTERM, si_code=SI_USER, si_pid=547750, si_uid=1000} ---
547750 rt_sigaction(SIGTERM, NULL, {sa_handler=0x7fae9743ba90, ...})
547750 rt_sigaction(SIGTERM, {sa_handler=SIG_DFL, sa_mask=[], ...}, ...)
... # idle worker threads exit
547750 tgkill(547750, 547750, SIGTERM)
547750 --- SIGTERM {si_signo=SIGTERM, si_code=SI_TKILL, si_pid=547750, si_uid=1000} ---
547761 +++ killed by SIGTERM +++
547758 +++ killed by SIGTERM +++
547750 +++ killed by SIGTERM +++
```
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.
#### Related
- #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.
- #9649 and #9766: handling of signals delivered to threads PHP does not manage.
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.
### Operating System
Debian GNU/Linux 13 (trixie), x86_64