Skip to content

ZTS + Apache event/worker MPM: every MaxConnectionsPerChild recycle kills the child with SIGTERM, aborting its in-flight requests #24130

Description

@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:

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 printf("ok\n");

requested 100 times:

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 in fix(sharedmem): recover shared-memory mutexes abandoned by a dead holder We-Amp/mod_pagespeed#13.

Related

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

No activity

Activity on this issue will appear here.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions