From 4e9e7bf84d8c6d17da23c7ad2c2f112c4b48a300 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 10:01:25 +0100 Subject: [PATCH 01/11] * test/modules/core/test_007_idle_termination.py: New test suite. Co-Authored-By: Claude Opus 5 (1M context) --- .../modules/core/test_007_idle_termination.py | 143 ++++++++++++++++++ 1 file changed, 143 insertions(+) create mode 100644 test/modules/core/test_007_idle_termination.py diff --git a/test/modules/core/test_007_idle_termination.py b/test/modules/core/test_007_idle_termination.py new file mode 100644 index 00000000000..2f8947c79ba --- /dev/null +++ b/test/modules/core/test_007_idle_termination.py @@ -0,0 +1,143 @@ +import os +import re +import socket +import time + +import pytest + +from pyhttpd.conf import HttpdConf + + +# The directive is implemented by these MPMs only; another one rejects it +# as an unknown command rather than ignoring it. +MPMS_WITH_IDLE_TERMINATION = ['mpm_prefork', 'mpm_worker', 'mpm_event'] + + +class TestIdleTermination: + """IdleTerminationTimeout: the server terminates itself once no worker + has anything to do, so that a socket-activated instance gives its + listening socket back to the service manager instead of sitting idle. + + Liveness here is checked by watching the process, never by making a + request: a request is exactly what resets the timer under test. + """ + + @pytest.fixture(autouse=True, scope='class') + def _class_scope(self, env): + if env.mpm_module not in MPMS_WITH_IDLE_TERMINATION: + pytest.skip(f"{env.mpm_module} has no IdleTerminationTimeout") + yield + conf = HttpdConf(env) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + + def start(self, env, extra=''): + """Install a configuration, start the server, and return its pid.""" + conf = HttpdConf(env, extras={'base': extra}) + conf.add_vhost_test1() + conf.install() + assert env.apache_restart() == 0 + pid = env.read_pid_file() + assert pid, "no pid file after start" + return pid + + @staticmethod + def is_running(pid): + try: + os.kill(pid, 0) + except OSError: + return False + return True + + @classmethod + def wait_for_exit(cls, pid, timeout): + end = time.time() + timeout + while time.time() < end: + if not cls.is_running(pid): + return True + time.sleep(0.2) + return False + + def test_core_007_01_terminates_when_idle(self, env): + pid = self.start(env, 'IdleTerminationTimeout 2') + assert self.wait_for_exit(pid, 15), \ + "the server was still running well after its idle timeout" + assert env.httpd_error_log.scan_recent( + re.compile(r'.*idle timeout reached, shutting down.*'), timeout=5), \ + "no log message explaining the shutdown" + + def test_core_007_02_stays_up_by_default(self, env): + """Without the directive the server never terminates itself.""" + pid = self.start(env) + assert not self.wait_for_exit(pid, 6), \ + "the server terminated with no IdleTerminationTimeout configured" + + @pytest.mark.xfail(reason="idleness is sampled once a second, and a " + "request served between two samples leaves no " + "trace, so a server under steady traffic is " + "counted idle every time and terminates") + def test_core_007_03_requests_reset_the_timer(self, env): + """A server which is being used does not time out.""" + pid = self.start(env, 'IdleTerminationTimeout 3') + for _ in range(6): + r = env.curl_get(env.mkurl("http", "test1", "/")) + assert r.response['status'] == 200, "the server went away early" + time.sleep(1) + assert self.is_running(pid), \ + "the server terminated while it was still serving requests" + # Left alone, it goes away. + assert self.wait_for_exit(pid, 15) + + def test_core_007_04_zero_terminates_at_once(self, env): + """A zero timeout means the first idle moment is enough.""" + pid = self.start(env, 'IdleTerminationTimeout 0') + assert self.wait_for_exit(pid, 10) + + @pytest.mark.xfail(reason="event detaches a keepalive connection from " + "its scoreboard slot and returns the thread to " + "the pool, so idle threads say nothing about " + "open connections, and they are dropped") + def test_core_007_05_open_connection_holds_it_up(self, env): + """A client which is connected but quiet keeps the server alive.""" + pid = self.start(env, 'IdleTerminationTimeout 2') + with socket.create_connection((env.http_addr, env.http_port), 5) as c: + c.sendall(b'GET / HTTP/1.1\r\nHost: test1.' + + env.http_tld.encode() + b'\r\n\r\n') + assert c.recv(64).startswith(b'HTTP/1.1 200') + assert not self.wait_for_exit(pid, 8), \ + "the server terminated with a connection still open" + + @pytest.mark.xfail(reason="the value is parsed with atoi(), so anything " + "unparseable is silently taken as 0, which " + "terminates the server as soon as it is idle") + def test_core_007_06_rejects_a_bad_value(self, env): + conf = HttpdConf(env, extras={'base': 'IdleTerminationTimeout burble'}) + conf.add_vhost_test1() + conf.install() + rv = env.apache_restart() + env.httpd_error_log.ignore_recent() + assert rv != 0, "a non-numeric timeout was accepted" + + @pytest.mark.xfail(reason="the timeout is held in an MPM static which " + "pre_config does not reset, so it survives a " + "reload which no longer configures it") + def test_core_007_07_forgotten_on_reload(self, env): + """Removing the directive and reloading stops the behaviour. + + A graceful reload keeps the same process, which is the point: a + fresh one would start from the compiled-in default whether or not + the MPM forgets the old value. + """ + pid = self.start(env, 'IdleTerminationTimeout 20') + # Rewritten in place rather than through HttpdConf, which stops the + # server before installing and so would lose the process. + conf_file = os.path.join(env.server_conf_dir, 'test.conf') + with open(conf_file) as fd: + kept = [l for l in fd if 'IdleTerminationTimeout' not in l] + with open(conf_file, 'w') as fd: + fd.writelines(kept) + assert env.apache_reload() == 0 + assert env.read_pid_file() == pid, "the reload replaced the process" + assert not self.wait_for_exit(pid, 25), \ + "the server still timed out after the directive was removed" From c58132e88e8c398a3ac8efa42ce0a643810f07b7 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 14:43:27 +0100 Subject: [PATCH 02/11] * test/modules/arch/conftest.py: New, so that the platform-specific test packages below it are collected only on the platform they are named for. [skip ci] * test/modules/arch/linux/README: Note it. Co-Authored-By: Claude Opus 5 (1M context) --- test/modules/arch/conftest.py | 5 +++++ test/modules/arch/linux/README | 9 +++++---- 2 files changed, 10 insertions(+), 4 deletions(-) create mode 100644 test/modules/arch/conftest.py diff --git a/test/modules/arch/conftest.py b/test/modules/arch/conftest.py new file mode 100644 index 00000000000..2d866b50493 --- /dev/null +++ b/test/modules/arch/conftest.py @@ -0,0 +1,5 @@ +import sys + +# Subdirectories here are named for the platform whose interfaces they +# test, and are not collected anywhere else. +collect_ignore = [] if sys.platform.startswith('linux') else ['linux'] diff --git a/test/modules/arch/linux/README b/test/modules/arch/linux/README index 4bdd7af91d1..c79b4c7c42a 100644 --- a/test/modules/arch/linux/README +++ b/test/modules/arch/linux/README @@ -88,10 +88,11 @@ libsystemd httpd itself is linked against. skipped where systemd is older than 253, which is where Type=notify-reload arrived. -The whole package is skipped unless mod_systemd was built, which needs -configure --enable-systemd. A static module is enough for everything -except the test which has to leave mod_systemd out of the configuration; -that one needs --enable-systemd=shared. +These tests are collected on Linux only, which the conftest.py in the +directory above arranges. The package is then skipped unless mod_systemd +was built, which needs configure --enable-systemd. A static module is +enough for everything except the test which has to leave mod_systemd out +of the configuration; that one needs --enable-systemd=shared. Running the systemd suites where there is no user session --------------------------------------------------------- From ebea69282f5bdd90728a9ca0db65d0dc658f2537 Mon Sep 17 00:00:00 2001 From: Alex/AT <85214814+AlexAT@users.noreply.github.com> Date: Sat, 29 Aug 2026 14:47:03 +0100 Subject: [PATCH 03/11] * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c: Add IdleTerminationTimeout, which shuts the server down once all workers have been idle for that many seconds. * changes-entries/idle-termination-timeout.txt: Added. Submitted by: Alex/AT <85214814+AlexAT users.noreply.github.com> Github: closes #529 --- changes-entries/idle-termination-timeout.txt | 5 ++ server/mpm/event/event.c | 46 ++++++++++++++- server/mpm/prefork/prefork.c | 47 ++++++++++++++- server/mpm/worker/worker.c | 62 ++++++++++++++++---- 4 files changed, 143 insertions(+), 17 deletions(-) create mode 100644 changes-entries/idle-termination-timeout.txt diff --git a/changes-entries/idle-termination-timeout.txt b/changes-entries/idle-termination-timeout.txt new file mode 100644 index 00000000000..5f451e01451 --- /dev/null +++ b/changes-entries/idle-termination-timeout.txt @@ -0,0 +1,5 @@ + *) mpm_event, mpm_prefork, mpm_worker: Add IdleTerminationTimeout + directive, which terminates the server once it has had no open + connections and served no requests for the given number of seconds. + Github #529. + [Alex/AT <85214814+AlexAT users.noreply.github.com>] diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 385532d03b4..98e1f43fd3b 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -199,6 +199,9 @@ static fd_queue_info_t *worker_queue_info; static apr_thread_mutex_t *timeout_mutex; +static int idle_termination_timeout = -1; /* never terminate by default */ +static int idle_termination_remaining; + module AP_MODULE_DECLARE_DATA mpm_event_module; /* forward declare */ @@ -444,6 +447,8 @@ typedef struct event_retained_data { */ int *idle_spawn_rate; int hold_off_on_exponential_spawning; + + int idle_timeout; /* did we time out? */ } event_retained_data; static event_retained_data *retained; @@ -3242,6 +3247,7 @@ static void perform_idle_server_maintenance(int child_bucket, { int num_buckets = retained->mpm->num_buckets; int idle_thread_count = 0; + int total_thread_count = 0; process_score *ps; int free_length = 0; int free_slots[MAX_SPAWN_RATE]; @@ -3292,6 +3298,7 @@ static void perform_idle_server_maintenance(int child_bucket, if (status >= SERVER_READY && status < SERVER_GRACEFUL) { ++child_threads_active; } + ++total_thread_count; } active_thread_count += child_threads_active; if (child_threads_active == threads_per_child) { @@ -3338,6 +3345,21 @@ static void perform_idle_server_maintenance(int child_bucket, && retained->total_daemons <= retained->max_daemon_used && retained->max_daemon_used <= server_limit); + if (idle_termination_timeout >= 0) { + if (idle_thread_count == total_thread_count) { + /* we are completely idle, decrease and check the timer */ + if (--idle_termination_remaining < 0) { + /* the termination timeout has expired, inform us we want to terminate immediately */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; + } + } else { + /* not idle, reset the timer */ + idle_termination_remaining = idle_termination_timeout; + } + } + if (idle_thread_count > max_spare_threads / num_buckets) { /* * Child processes that we ask to shut down won't die immediately @@ -3783,8 +3805,15 @@ static int event_run(apr_pool_t * _pconf, apr_pool_t * plog, server_rec * s) if (!child_fatal) { /* cleanup pid file on normal shutdown */ ap_remove_pid(pconf, ap_pid_fname); - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, - ap_server_conf, APLOGNO(00491) "caught SIGTERM, shutting down"); + + /* log message depends on if we are terminating by signal or by idle timeout */ + if (!retained->idle_timeout) { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00491) + "caught SIGTERM, shutting down"); + } else { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00491) + "idle timeout reached, shutting down"); + } } return DONE; @@ -4481,6 +4510,17 @@ static const char *set_worker_factor(cmd_parms * cmd, void *dummy, return NULL; } +static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +{ + const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + if (err != NULL) { + return err; + } + + idle_termination_timeout = atoi(arg); + idle_termination_remaining = idle_termination_timeout; + return NULL; +} static const command_rec event_cmds[] = { LISTEN_COMMANDS, @@ -4504,6 +4544,8 @@ static const command_rec event_cmds[] = { AP_INIT_TAKE1("AsyncRequestWorkerFactor", set_worker_factor, NULL, RSRC_CONF, "How many additional connects will be accepted per idle " "worker thread"), + AP_INIT_TAKE1("IdleTerminationTimeout", set_idle_termination_timeout, NULL, RSRC_CONF, + "Number of seconds to terminate in when the server is idle"), AP_GRACEFUL_SHUTDOWN_TIMEOUT_COMMAND, {NULL} }; diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 37dc2dda4dd..65940b970a0 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -95,6 +95,9 @@ static int ap_daemons_max_free=0; static int ap_daemons_limit=0; /* MaxRequestWorkers */ static int server_limit = 0; +static int idle_termination_timeout = -1; /* never terminate by default */ +static int idle_termination_remaining; + typedef struct prefork_child_bucket { ap_pod_t *pod; ap_listen_rec *listeners; @@ -131,6 +134,8 @@ typedef struct prefork_retained_data { #define MAX_SPAWN_RATE (32) #endif int hold_off_on_exponential_spawning; + + int idle_timeout; /* did we time out? */ } prefork_retained_data; static prefork_retained_data *retained; @@ -866,6 +871,22 @@ static void perform_idle_server_maintenance(apr_pool_t *p) } } retained->max_daemons_limit = last_non_dead + 1; + + if (idle_termination_timeout >= 0) { + if (idle_count == total_non_dead) { + /* we are completely idle, decrease and check the timer */ + if (--idle_termination_remaining < 0) { + /* the termination timeout has expired, inform us we want to terminate immediately */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; + } + } else { + /* not idle, reset the timer */ + idle_termination_remaining = idle_termination_timeout; + } + } + if (idle_count > ap_daemons_max_free) { static int bucket_kill_child_record = -1; /* kill off one child... we use the pod because that'll cause it to @@ -1206,8 +1227,15 @@ static int prefork_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) /* cleanup pid file on normal shutdown */ ap_remove_pid(pconf, ap_pid_fname); - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) - "caught SIGTERM, shutting down"); + + /* log message depends on if we are terminating by signal or by idle timeout */ + if (!retained->idle_timeout) { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) + "caught SIGTERM, shutting down"); + } else { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) + "idle timeout reached, shutting down"); + } return DONE; } @@ -1346,6 +1374,7 @@ static int prefork_pre_config(apr_pool_t *p, apr_pool_t *plog, apr_pool_t *ptemp retained->mpm = ap_unixd_mpm_get_retained_data(); retained->mpm->baton = retained; retained->idle_spawn_rate = 1; + retained->idle_timeout = 0; } else if (retained->mpm->baton != retained) { /* If the MPM changes on restart, be ungraceful */ @@ -1575,6 +1604,18 @@ static const char *set_server_limit (cmd_parms *cmd, void *dummy, const char *ar return NULL; } +static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +{ + const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + if (err != NULL) { + return err; + } + + idle_termination_timeout = atoi(arg); + idle_termination_remaining = idle_termination_timeout; + return NULL; +} + static const command_rec prefork_cmds[] = { LISTEN_COMMANDS, AP_INIT_TAKE1("StartServers", set_daemons_to_start, NULL, RSRC_CONF, @@ -1589,6 +1630,8 @@ AP_INIT_TAKE1("MaxRequestWorkers", set_max_clients, NULL, RSRC_CONF, "Maximum number of children alive at the same time"), AP_INIT_TAKE1("ServerLimit", set_server_limit, NULL, RSRC_CONF, "Maximum value of MaxRequestWorkers for this run of Apache"), +AP_INIT_TAKE1("IdleTerminationTimeout", set_idle_termination_timeout, NULL, RSRC_CONF, + "Number of seconds to terminate in when the server is idle"), AP_GRACEFUL_SHUTDOWN_TIMEOUT_COMMAND, { NULL } }; diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 42b81a8ed1b..7e1d39e40b7 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -138,6 +138,9 @@ static fd_queue_t *worker_queue; static fd_queue_info_t *worker_queue_info; static apr_pollset_t *worker_pollset; +static int idle_termination_timeout = -1; /* never terminate by default */ +static int idle_termination_remaining; + typedef struct worker_child_bucket { ap_pod_t *pod; ap_listen_rec *listeners; @@ -175,6 +178,8 @@ typedef struct worker_retained_data { */ int *idle_spawn_rate; int hold_off_on_exponential_spawning; + + int idle_timeout; /* did we time out? */ } worker_retained_data; static worker_retained_data *retained; @@ -1427,23 +1432,17 @@ static void startup_children(int number_to_start) static void perform_idle_server_maintenance(int child_bucket) { int num_buckets = retained->mpm->num_buckets; - int idle_thread_count; + int idle_thread_count = 0; + int total_thread_count = 0; process_score *ps; - int free_length; + int free_length = 0; int totally_free_length = 0; int free_slots[MAX_SPAWN_RATE]; - int last_non_dead; - int total_non_dead; + int last_non_dead = -1; + int total_non_dead = 0; int active_thread_count = 0; int i, j; - /* initialize the free_list */ - free_length = 0; - - idle_thread_count = 0; - last_non_dead = -1; - total_non_dead = 0; - for (i = 0; i < ap_daemons_limit; ++i) { /* Initialization to satisfy the compiler. It doesn't know * that threads_per_child is always > 0 */ @@ -1491,6 +1490,7 @@ static void perform_idle_server_maintenance(int child_bucket) if (status >= SERVER_READY && status < SERVER_GRACEFUL) { ++child_threads_active; } + ++total_thread_count; } } active_thread_count += child_threads_active; @@ -1558,6 +1558,21 @@ static void perform_idle_server_maintenance(int child_bucket) } } + if (idle_termination_timeout >= 0) { + if (idle_thread_count == total_thread_count) { + /* we are completely idle, decrease and check the timer */ + if (--idle_termination_remaining < 0) { + /* the termination timeout has expired, inform us we want to terminate immediately */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; + } + } else { + /* not idle, reset the timer */ + idle_termination_remaining = idle_termination_timeout; + } + } + if (idle_thread_count > max_spare_threads / num_buckets) { /* Kill off one child */ ap_mpm_podx_signal(retained->buckets[child_bucket].pod, @@ -1983,8 +1998,15 @@ static int worker_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) if (!child_fatal) { /* cleanup pid file on normal shutdown */ ap_remove_pid(pconf, ap_pid_fname); - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, - ap_server_conf, APLOGNO(00295) "caught SIGTERM, shutting down"); + + /* log message depends on if we are terminating by signal or by idle timeout */ + if (!retained->idle_timeout) { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00295) + "caught SIGTERM, shutting down"); + } else { + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00295) + "idle timeout reached, shutting down"); + } } return DONE; } @@ -2484,6 +2506,18 @@ static const char *set_thread_limit (cmd_parms *cmd, void *dummy, const char *ar return NULL; } +static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +{ + const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + if (err != NULL) { + return err; + } + + idle_termination_timeout = atoi(arg); + idle_termination_remaining = idle_termination_timeout; + return NULL; +} + static const command_rec worker_cmds[] = { LISTEN_COMMANDS, AP_INIT_TAKE1("StartServers", set_daemons_to_start, NULL, RSRC_CONF, @@ -2502,6 +2536,8 @@ AP_INIT_TAKE1("ServerLimit", set_server_limit, NULL, RSRC_CONF, "Maximum number of child processes for this run of Apache"), AP_INIT_TAKE1("ThreadLimit", set_thread_limit, NULL, RSRC_CONF, "Maximum number of worker threads per child process for this run of Apache - Upper limit for ThreadsPerChild"), +AP_INIT_TAKE1("IdleTerminationTimeout", set_idle_termination_timeout, NULL, RSRC_CONF, + "Number of seconds to terminate in when the server is idle"), AP_GRACEFUL_SHUTDOWN_TIMEOUT_COMMAND, { NULL } }; From 0f7fc3306c2127455df8a045d15d333e67234228 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 14:47:33 +0100 Subject: [PATCH 04/11] * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c (set_idle_termination_timeout): Reject an IdleTerminationTimeout which is not a non-negative number of seconds, rather than letting atoi() read it as zero. Co-Authored-By: Claude Opus 5 (1M context) --- server/mpm/event/event.c | 11 ++++++++++- server/mpm/prefork/prefork.c | 11 ++++++++++- server/mpm/worker/worker.c | 11 ++++++++++- test/modules/core/test_007_idle_termination.py | 3 --- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 98e1f43fd3b..404916a5ada 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -4513,11 +4513,20 @@ static const char *set_worker_factor(cmd_parms * cmd, void *dummy, static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + char *end; + long secs; + if (err != NULL) { return err; } - idle_termination_timeout = atoi(arg); + secs = strtol(arg, &end, 10); + if (*arg == '\0' || *end != '\0' || secs < 0 || secs > APR_INT32_MAX) { + return "IdleTerminationTimeout must be a non-negative number of " + "seconds"; + } + + idle_termination_timeout = (int)secs; idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 65940b970a0..44f1f37eb62 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -1607,11 +1607,20 @@ static const char *set_server_limit (cmd_parms *cmd, void *dummy, const char *ar static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + char *end; + long secs; + if (err != NULL) { return err; } - idle_termination_timeout = atoi(arg); + secs = strtol(arg, &end, 10); + if (*arg == '\0' || *end != '\0' || secs < 0 || secs > APR_INT32_MAX) { + return "IdleTerminationTimeout must be a non-negative number of " + "seconds"; + } + + idle_termination_timeout = (int)secs; idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 7e1d39e40b7..48388d5dd59 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -2509,11 +2509,20 @@ static const char *set_thread_limit (cmd_parms *cmd, void *dummy, const char *ar static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); + char *end; + long secs; + if (err != NULL) { return err; } - idle_termination_timeout = atoi(arg); + secs = strtol(arg, &end, 10); + if (*arg == '\0' || *end != '\0' || secs < 0 || secs > APR_INT32_MAX) { + return "IdleTerminationTimeout must be a non-negative number of " + "seconds"; + } + + idle_termination_timeout = (int)secs; idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/test/modules/core/test_007_idle_termination.py b/test/modules/core/test_007_idle_termination.py index 2f8947c79ba..f368fc0fd63 100644 --- a/test/modules/core/test_007_idle_termination.py +++ b/test/modules/core/test_007_idle_termination.py @@ -108,9 +108,6 @@ def test_core_007_05_open_connection_holds_it_up(self, env): assert not self.wait_for_exit(pid, 8), \ "the server terminated with a connection still open" - @pytest.mark.xfail(reason="the value is parsed with atoi(), so anything " - "unparseable is silently taken as 0, which " - "terminates the server as soon as it is idle") def test_core_007_06_rejects_a_bad_value(self, env): conf = HttpdConf(env, extras={'base': 'IdleTerminationTimeout burble'}) conf.add_vhost_test1() From 4e6d53502a66aa7a99427b71aace5d1cab6455f1 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 14:48:27 +0100 Subject: [PATCH 05/11] * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c (*_pre_config): Reset idle_termination_timeout with the other tunables, so that a reload which no longer configures it stops terminating the server. Co-Authored-By: Claude Opus 5 (1M context) --- server/mpm/event/event.c | 1 + server/mpm/prefork/prefork.c | 1 + server/mpm/worker/worker.c | 1 + test/modules/core/test_007_idle_termination.py | 3 --- 4 files changed, 3 insertions(+), 3 deletions(-) diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 404916a5ada..944fc010a74 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -4055,6 +4055,7 @@ static int event_pre_config(apr_pool_t * pconf, apr_pool_t * plog, ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; + idle_termination_timeout = -1; min_spare_threads = DEFAULT_MIN_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; max_spare_threads = DEFAULT_MAX_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; server_limit = DEFAULT_SERVER_LIMIT; diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 44f1f37eb62..9ba3a5392f9 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -1403,6 +1403,7 @@ static int prefork_pre_config(apr_pool_t *p, apr_pool_t *plog, apr_pool_t *ptemp ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; + idle_termination_timeout = -1; ap_daemons_min_free = DEFAULT_MIN_FREE_DAEMON; ap_daemons_max_free = DEFAULT_MAX_FREE_DAEMON; server_limit = DEFAULT_SERVER_LIMIT; diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 48388d5dd59..88dc5b750f1 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -2172,6 +2172,7 @@ static int worker_pre_config(apr_pool_t *pconf, apr_pool_t *plog, ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; + idle_termination_timeout = -1; min_spare_threads = DEFAULT_MIN_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; max_spare_threads = DEFAULT_MAX_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; server_limit = DEFAULT_SERVER_LIMIT; diff --git a/test/modules/core/test_007_idle_termination.py b/test/modules/core/test_007_idle_termination.py index f368fc0fd63..fdf0e72a0ba 100644 --- a/test/modules/core/test_007_idle_termination.py +++ b/test/modules/core/test_007_idle_termination.py @@ -116,9 +116,6 @@ def test_core_007_06_rejects_a_bad_value(self, env): env.httpd_error_log.ignore_recent() assert rv != 0, "a non-numeric timeout was accepted" - @pytest.mark.xfail(reason="the timeout is held in an MPM static which " - "pre_config does not reset, so it survives a " - "reload which no longer configures it") def test_core_007_07_forgotten_on_reload(self, env): """Removing the directive and reloading stops the behaviour. From 0d45b06500a594511308a729bd4ab009f514060c Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 14:56:15 +0100 Subject: [PATCH 06/11] * server/mpm/event/event.c (listener_thread): Publish the async connection counts on every pass, not only when a timeout queue expires. Co-Authored-By: Claude Opus 5 (1M context) --- server/mpm/event/event.c | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 944fc010a74..70715945b30 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -2397,6 +2397,18 @@ static void * APR_THREAD_FUNC listener_thread(apr_thread_t * thd, void *dummy) apr_thread_mutex_unlock(timeout_mutex); ps->keep_alive = 0; } + else { + /* No queue maintenance was due, but these counts are all that + * the parent and mod_status can see, and a connection can sit + * in a queue for as long as its timeout without either. + * Publish them on every pass rather than only on an expiry. */ + ps->wait_io = apr_atomic_read32(waitio_q->total); + ps->write_completion = apr_atomic_read32(write_completion_q->total); + ps->keep_alive = apr_atomic_read32(keepalive_q->total); + ps->lingering_close = apr_atomic_read32(&lingering_count); + ps->suspended = apr_atomic_read32(&suspended_count); + ps->connections = apr_atomic_read32(&connection_count); + } /* If there are some lingering closes to defer (to a worker), schedule * them now. We might wakeup a worker spuriously if another one empties From 1aad2161405fb5188d2f3898c7be11fd1bbfe21a Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 15:18:22 +0100 Subject: [PATCH 07/11] * server/mpm/event/event.c (publish_connection_count, decrement_connection_count, process_socket): Publish the open connection count where it changes. * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c (server_is_idle, perform_idle_server_maintenance): Base IdleTerminationTimeout on whether anything has happened since the last check, timed from a timestamp rather than counted in maintenance cycles. A server under steady traffic terminated, connections held open by an async MPM were dropped, and the timeout expired early. * .github/workflows/linux.yml: Run the pytest suites under prefork too. Co-Authored-By: Claude Opus 5 (1M context) --- .github/workflows/linux.yml | 13 +++ server/mpm/event/event.c | 83 ++++++++++++++----- server/mpm/prefork/prefork.c | 63 ++++++++++---- server/mpm/worker/worker.c | 61 ++++++++++---- .../modules/core/test_007_idle_termination.py | 16 ++-- 5 files changed, 178 insertions(+), 58 deletions(-) diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml index c35f84611ec..35b3f4a96a2 100644 --- a/.github/workflows/linux.yml +++ b/.github/workflows/linux.yml @@ -310,6 +310,19 @@ jobs: PYHTTPD_TARGETS=modules/arch PYTEST_ARGS=--only=pyhttpd # ------------------------------------------------------------------------- + # The pytest suites again under prefork, whose idle and shutdown + # handling is a separate implementation from event's. Worker is + # close enough to event not to be worth a third job. + - name: Python pytest test suites, prefork + config: --enable-mods-shared=reallyall --enable-mpms-shared=all --with-mpm=prefork --enable-systemd=shared + pkgs: nghttp2-client libsystemd-dev + env: | + MPM=prefork + NO_TEST_FRAMEWORK=1 + TEST_PYTEST=1 + PYHTTPD_TARGETS=modules/arch modules/core + PYTEST_ARGS=--only=pyhttpd + # ------------------------------------------------------------------------- ### TODO if: *condition_not_24x ### TODO: Fails because :i386 packages are not being found. # - name: i386 Shared MPMs, most modules, maintainer-mode w/-Werror diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 70715945b30..74473012d28 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -200,7 +200,8 @@ static fd_queue_info_t *worker_queue_info; static apr_thread_mutex_t *timeout_mutex; static int idle_termination_timeout = -1; /* never terminate by default */ -static int idle_termination_remaining; +static apr_time_t idle_termination_since; /* when the server went quiet */ +static unsigned long idle_termination_accesses; /* requests counted then */ module AP_MODULE_DECLARE_DATA mpm_event_module; @@ -841,6 +842,16 @@ static void just_die(int sig) static int child_fatal; +/* The parent and mod_status see this count only through the scoreboard, + * and the listener can sleep for a whole keepalive timeout without + * passing through its queue maintenance, so publish it where it changes + * rather than there. */ +static void publish_connection_count(void) +{ + ap_scoreboard_image->parent[ap_child_slot].connections = + apr_atomic_read32(&connection_count); +} + static apr_status_t decrement_connection_count(void *cs_) { int is_last_connection; @@ -864,6 +875,7 @@ static apr_status_t decrement_connection_count(void *cs_) * now accept new connections. */ is_last_connection = !apr_atomic_dec32(&connection_count); + publish_connection_count(); if (listener_is_wakeable && ((is_last_connection && listener_may_exit) || should_enable_listensocks())) { @@ -1072,6 +1084,7 @@ static void process_socket(apr_thread_t *thd, apr_pool_t * p, apr_socket_t * soc return; } apr_atomic_inc32(&connection_count); + publish_connection_count(); apr_pool_cleanup_register(c->pool, cs, decrement_connection_count, apr_pool_cleanup_null); ap_set_module_config(c->conn_config, &mpm_event_module, cs); @@ -2398,16 +2411,15 @@ static void * APR_THREAD_FUNC listener_thread(apr_thread_t * thd, void *dummy) ps->keep_alive = 0; } else { - /* No queue maintenance was due, but these counts are all that - * the parent and mod_status can see, and a connection can sit - * in a queue for as long as its timeout without either. - * Publish them on every pass rather than only on an expiry. */ + /* No queue maintenance was due, but the counts above are what + * the parent and mod_status see, and a connection can sit in a + * queue for as long as its timeout without either. Publish + * them on every pass instead of only when a queue expires. */ ps->wait_io = apr_atomic_read32(waitio_q->total); ps->write_completion = apr_atomic_read32(write_completion_q->total); ps->keep_alive = apr_atomic_read32(keepalive_q->total); ps->lingering_close = apr_atomic_read32(&lingering_count); ps->suspended = apr_atomic_read32(&suspended_count); - ps->connections = apr_atomic_read32(&connection_count); } /* If there are some lingering closes to defer (to a worker), schedule @@ -3254,6 +3266,39 @@ static void startup_children(int number_to_start) } } +/* Whether the server has nothing whatever to do. An idle worker count is + * not enough on its own: this MPM hands a connection back to the listener + * between requests, so open connections occupy no worker, and a request + * which starts and finishes between two calls here leaves every worker + * idle at both of them. */ +static int server_is_idle(int workers_busy) +{ + unsigned long accesses = 0; + apr_uint32_t connections = 0; + int i, j; + + for (i = 0; i < server_limit; i++) { + process_score *ps = ap_get_scoreboard_process(i); + + if (ps->pid == 0) { + continue; + } + connections += ps->connections; + for (j = 0; j < thread_limit; j++) { + accesses += ap_scoreboard_image->servers[i][j].access_count; + } + } + + if (workers_busy || connections + || accesses != idle_termination_accesses) { + idle_termination_accesses = accesses; + idle_termination_since = 0; + return 0; + } + + return 1; +} + static void perform_idle_server_maintenance(int child_bucket, int *max_daemon_used) { @@ -3357,18 +3402,17 @@ static void perform_idle_server_maintenance(int child_bucket, && retained->total_daemons <= retained->max_daemon_used && retained->max_daemon_used <= server_limit); - if (idle_termination_timeout >= 0) { - if (idle_thread_count == total_thread_count) { - /* we are completely idle, decrease and check the timer */ - if (--idle_termination_remaining < 0) { - /* the termination timeout has expired, inform us we want to terminate immediately */ - retained->mpm->shutdown_pending = 1; - retained->mpm->is_ungraceful = 1; - retained->idle_timeout = 1; - } - } else { - /* not idle, reset the timer */ - idle_termination_remaining = idle_termination_timeout; + if (idle_termination_timeout >= 0 + && server_is_idle(idle_thread_count != total_thread_count)) { + if (!idle_termination_since) { + idle_termination_since = apr_time_now(); + } + if (apr_time_now() - idle_termination_since + >= apr_time_from_sec(idle_termination_timeout)) { + /* terminate immediately: nothing is going on to wait for */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; } } @@ -4068,6 +4112,8 @@ static int event_pre_config(apr_pool_t * pconf, apr_pool_t * plog, ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; idle_termination_timeout = -1; + idle_termination_since = 0; + idle_termination_accesses = 0; min_spare_threads = DEFAULT_MIN_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; max_spare_threads = DEFAULT_MAX_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; server_limit = DEFAULT_SERVER_LIMIT; @@ -4540,7 +4586,6 @@ static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, co } idle_termination_timeout = (int)secs; - idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 9ba3a5392f9..85b689879c3 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -96,7 +96,8 @@ static int ap_daemons_limit=0; /* MaxRequestWorkers */ static int server_limit = 0; static int idle_termination_timeout = -1; /* never terminate by default */ -static int idle_termination_remaining; +static apr_time_t idle_termination_since; /* when the server went quiet */ +static unsigned long idle_termination_accesses; /* requests counted then */ typedef struct prefork_child_bucket { ap_pod_t *pod; @@ -824,6 +825,38 @@ static void startup_children(int number_to_start) } } +/* Whether the server has nothing whatever to do. Idle workers are not + * enough to go on: a request which starts and finishes between two calls + * here leaves every worker idle at both of them, and an async MPM holds + * open connections without occupying a worker at all. */ +static int server_is_idle(int workers_busy) +{ + unsigned long accesses = 0; + apr_uint32_t connections = 0; + int i, j; + + for (i = 0; i < server_limit; i++) { + process_score *ps = ap_get_scoreboard_process(i); + + if (ps->pid == 0) { + continue; + } + connections += ps->connections; + for (j = 0; j < 1; j++) { + accesses += ap_scoreboard_image->servers[i][j].access_count; + } + } + + if (workers_busy || connections + || accesses != idle_termination_accesses) { + idle_termination_accesses = accesses; + idle_termination_since = 0; + return 0; + } + + return 1; +} + static void perform_idle_server_maintenance(apr_pool_t *p) { int i; @@ -872,21 +905,20 @@ static void perform_idle_server_maintenance(apr_pool_t *p) } retained->max_daemons_limit = last_non_dead + 1; - if (idle_termination_timeout >= 0) { - if (idle_count == total_non_dead) { - /* we are completely idle, decrease and check the timer */ - if (--idle_termination_remaining < 0) { - /* the termination timeout has expired, inform us we want to terminate immediately */ - retained->mpm->shutdown_pending = 1; - retained->mpm->is_ungraceful = 1; - retained->idle_timeout = 1; - } - } else { - /* not idle, reset the timer */ - idle_termination_remaining = idle_termination_timeout; + if (idle_termination_timeout >= 0 + && server_is_idle(idle_count != total_non_dead)) { + if (!idle_termination_since) { + idle_termination_since = apr_time_now(); + } + if (apr_time_now() - idle_termination_since + >= apr_time_from_sec(idle_termination_timeout)) { + /* terminate immediately: nothing is going on to wait for */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; } } - + if (idle_count > ap_daemons_max_free) { static int bucket_kill_child_record = -1; /* kill off one child... we use the pod because that'll cause it to @@ -1404,6 +1436,8 @@ static int prefork_pre_config(apr_pool_t *p, apr_pool_t *plog, apr_pool_t *ptemp ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; idle_termination_timeout = -1; + idle_termination_since = 0; + idle_termination_accesses = 0; ap_daemons_min_free = DEFAULT_MIN_FREE_DAEMON; ap_daemons_max_free = DEFAULT_MAX_FREE_DAEMON; server_limit = DEFAULT_SERVER_LIMIT; @@ -1622,7 +1656,6 @@ static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, co } idle_termination_timeout = (int)secs; - idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 88dc5b750f1..7ce9d017432 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -139,7 +139,8 @@ static fd_queue_info_t *worker_queue_info; static apr_pollset_t *worker_pollset; static int idle_termination_timeout = -1; /* never terminate by default */ -static int idle_termination_remaining; +static apr_time_t idle_termination_since; /* when the server went quiet */ +static unsigned long idle_termination_accesses; /* requests counted then */ typedef struct worker_child_bucket { ap_pod_t *pod; @@ -1429,6 +1430,38 @@ static void startup_children(int number_to_start) } } +/* Whether the server has nothing whatever to do. Idle workers are not + * enough to go on: a request which starts and finishes between two calls + * here leaves every worker idle at both of them, and an async MPM holds + * open connections without occupying a worker at all. */ +static int server_is_idle(int workers_busy) +{ + unsigned long accesses = 0; + apr_uint32_t connections = 0; + int i, j; + + for (i = 0; i < server_limit; i++) { + process_score *ps = ap_get_scoreboard_process(i); + + if (ps->pid == 0) { + continue; + } + connections += ps->connections; + for (j = 0; j < thread_limit; j++) { + accesses += ap_scoreboard_image->servers[i][j].access_count; + } + } + + if (workers_busy || connections + || accesses != idle_termination_accesses) { + idle_termination_accesses = accesses; + idle_termination_since = 0; + return 0; + } + + return 1; +} + static void perform_idle_server_maintenance(int child_bucket) { int num_buckets = retained->mpm->num_buckets; @@ -1558,18 +1591,17 @@ static void perform_idle_server_maintenance(int child_bucket) } } - if (idle_termination_timeout >= 0) { - if (idle_thread_count == total_thread_count) { - /* we are completely idle, decrease and check the timer */ - if (--idle_termination_remaining < 0) { - /* the termination timeout has expired, inform us we want to terminate immediately */ - retained->mpm->shutdown_pending = 1; - retained->mpm->is_ungraceful = 1; - retained->idle_timeout = 1; - } - } else { - /* not idle, reset the timer */ - idle_termination_remaining = idle_termination_timeout; + if (idle_termination_timeout >= 0 + && server_is_idle(idle_thread_count != total_thread_count)) { + if (!idle_termination_since) { + idle_termination_since = apr_time_now(); + } + if (apr_time_now() - idle_termination_since + >= apr_time_from_sec(idle_termination_timeout)) { + /* terminate immediately: nothing is going on to wait for */ + retained->mpm->shutdown_pending = 1; + retained->mpm->is_ungraceful = 1; + retained->idle_timeout = 1; } } @@ -2173,6 +2205,8 @@ static int worker_pre_config(apr_pool_t *pconf, apr_pool_t *plog, ap_listen_pre_config(); ap_daemons_to_start = DEFAULT_START_DAEMON; idle_termination_timeout = -1; + idle_termination_since = 0; + idle_termination_accesses = 0; min_spare_threads = DEFAULT_MIN_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; max_spare_threads = DEFAULT_MAX_FREE_DAEMON * DEFAULT_THREADS_PER_CHILD; server_limit = DEFAULT_SERVER_LIMIT; @@ -2524,7 +2558,6 @@ static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, co } idle_termination_timeout = (int)secs; - idle_termination_remaining = idle_termination_timeout; return NULL; } diff --git a/test/modules/core/test_007_idle_termination.py b/test/modules/core/test_007_idle_termination.py index fdf0e72a0ba..b28bdb3ca18 100644 --- a/test/modules/core/test_007_idle_termination.py +++ b/test/modules/core/test_007_idle_termination.py @@ -73,10 +73,6 @@ def test_core_007_02_stays_up_by_default(self, env): assert not self.wait_for_exit(pid, 6), \ "the server terminated with no IdleTerminationTimeout configured" - @pytest.mark.xfail(reason="idleness is sampled once a second, and a " - "request served between two samples leaves no " - "trace, so a server under steady traffic is " - "counted idle every time and terminates") def test_core_007_03_requests_reset_the_timer(self, env): """A server which is being used does not time out.""" pid = self.start(env, 'IdleTerminationTimeout 3') @@ -94,13 +90,13 @@ def test_core_007_04_zero_terminates_at_once(self, env): pid = self.start(env, 'IdleTerminationTimeout 0') assert self.wait_for_exit(pid, 10) - @pytest.mark.xfail(reason="event detaches a keepalive connection from " - "its scoreboard slot and returns the thread to " - "the pool, so idle threads say nothing about " - "open connections, and they are dropped") def test_core_007_05_open_connection_holds_it_up(self, env): - """A client which is connected but quiet keeps the server alive.""" - pid = self.start(env, 'IdleTerminationTimeout 2') + """A client which is connected but quiet keeps the server alive. + + KeepAliveTimeout has to outlast the wait below, or the server + closes the connection itself and is then genuinely idle. + """ + pid = self.start(env, 'IdleTerminationTimeout 2\nKeepAliveTimeout 30') with socket.create_connection((env.http_addr, env.http_port), 5) as c: c.sendall(b'GET / HTTP/1.1\r\nHost: test1.' + env.http_tld.encode() + b'\r\n\r\n') From a8c71f06783498deb1ee715f97a16f9e7735f8d1 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 15:32:50 +0100 Subject: [PATCH 08/11] * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c: Give the idle timeout shutdown message its own APLOGNO. Co-Authored-By: Claude Opus 5 (1M context) --- docs/log-message-tags/intended-duplicates | 1 + server/mpm/event/event.c | 2 +- server/mpm/prefork/prefork.c | 2 +- server/mpm/worker/worker.c | 2 +- 4 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/log-message-tags/intended-duplicates b/docs/log-message-tags/intended-duplicates index 6886322e87f..f991ffab44e 100644 --- a/docs/log-message-tags/intended-duplicates +++ b/docs/log-message-tags/intended-duplicates @@ -8,3 +8,4 @@ 00812 # mod_socache_dbm.c 00814 # mod_socache_dbm.c 00815 # mod_socache_dbm.c +10620 # "idle timeout reached" in the prefork, worker and event MPMs diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 74473012d28..22f29a061cb 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -3867,7 +3867,7 @@ static int event_run(apr_pool_t * _pconf, apr_pool_t * plog, server_rec * s) ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00491) "caught SIGTERM, shutting down"); } else { - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00491) + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } } diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 85b689879c3..4d11682890a 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -1265,7 +1265,7 @@ static int prefork_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) "caught SIGTERM, shutting down"); } else { - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 7ce9d017432..1294b3aa7d0 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -2036,7 +2036,7 @@ static int worker_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00295) "caught SIGTERM, shutting down"); } else { - ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00295) + ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } } From d6058e028cb7da7d5abfe6dde74aaaff24c1f736 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sun, 30 Aug 2026 08:37:19 +0100 Subject: [PATCH 09/11] * server/mpm/event/event.c, server/mpm/prefork/prefork.c, server/mpm/worker/worker.c: Code style tweaks only, no functional change. [skip ci] Co-Authored-By: Claude Opus 5 (1M context) --- server/mpm/event/event.c | 5 +++-- server/mpm/prefork/prefork.c | 5 +++-- server/mpm/worker/worker.c | 5 +++-- 3 files changed, 9 insertions(+), 6 deletions(-) diff --git a/server/mpm/event/event.c b/server/mpm/event/event.c index 22f29a061cb..b9d46ced542 100644 --- a/server/mpm/event/event.c +++ b/server/mpm/event/event.c @@ -3866,7 +3866,8 @@ static int event_run(apr_pool_t * _pconf, apr_pool_t * plog, server_rec * s) if (!retained->idle_timeout) { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00491) "caught SIGTERM, shutting down"); - } else { + } + else { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } @@ -4569,7 +4570,7 @@ static const char *set_worker_factor(cmd_parms * cmd, void *dummy, return NULL; } -static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +static const char *set_idle_termination_timeout(cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); char *end; diff --git a/server/mpm/prefork/prefork.c b/server/mpm/prefork/prefork.c index 4d11682890a..93c7d10ba45 100644 --- a/server/mpm/prefork/prefork.c +++ b/server/mpm/prefork/prefork.c @@ -1264,7 +1264,8 @@ static int prefork_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) if (!retained->idle_timeout) { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00169) "caught SIGTERM, shutting down"); - } else { + } + else { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } @@ -1639,7 +1640,7 @@ static const char *set_server_limit (cmd_parms *cmd, void *dummy, const char *ar return NULL; } -static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +static const char *set_idle_termination_timeout(cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); char *end; diff --git a/server/mpm/worker/worker.c b/server/mpm/worker/worker.c index 1294b3aa7d0..135ac0299db 100644 --- a/server/mpm/worker/worker.c +++ b/server/mpm/worker/worker.c @@ -2035,7 +2035,8 @@ static int worker_run(apr_pool_t *_pconf, apr_pool_t *plog, server_rec *s) if (!retained->idle_timeout) { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(00295) "caught SIGTERM, shutting down"); - } else { + } + else { ap_log_error(APLOG_MARK, APLOG_NOTICE, 0, ap_server_conf, APLOGNO(10620) "idle timeout reached, shutting down"); } @@ -2541,7 +2542,7 @@ static const char *set_thread_limit (cmd_parms *cmd, void *dummy, const char *ar return NULL; } -static const char *set_idle_termination_timeout (cmd_parms *cmd, void *dummy, const char *arg) +static const char *set_idle_termination_timeout(cmd_parms *cmd, void *dummy, const char *arg) { const char *err = ap_check_cmd_context(cmd, GLOBAL_ONLY); char *end; From e6a5eabef31200dd0e8d8274fdd5d82fc19cdc5d Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sat, 29 Aug 2026 17:00:45 +0100 Subject: [PATCH 10/11] * test/pyhttpd/env.py (is_live, is_dead): Give the curl probes a --max-time. A server which accepts a connection and then never answers it hung the whole test run rather than failing it. Co-Authored-By: Claude Opus 5 (1M context) --- test/pyhttpd/env.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/test/pyhttpd/env.py b/test/pyhttpd/env.py index cba8cd4eda9..7ee56fc4c1d 100644 --- a/test/pyhttpd/env.py +++ b/test/pyhttpd/env.py @@ -742,7 +742,10 @@ def is_live(self, url: str = None, timeout: timedelta = None): while datetime.now() < try_until: # noinspection PyBroadException try: - r = self.curl_get(url, insecure=True) + # --max-time, or a server which accepts a connection and + # then never answers hangs the probe rather than failing it. + r = self.curl_get(url, insecure=True, + options=['--max-time', '5']) if r.exit_code == 0: return True time.sleep(.1) @@ -767,7 +770,7 @@ def is_dead(self, url: str = None, timeout: timedelta = None): while datetime.now() < try_until: # noinspection PyBroadException try: - r = self.curl_get(url) + r = self.curl_get(url, options=['--max-time', '5']) if r.exit_code != 0: return True time.sleep(.1) From df4ef3f957e6665aa67bd30306b71f27bb961f30 Mon Sep 17 00:00:00 2001 From: Joe Orton Date: Sun, 30 Aug 2026 07:51:09 +0100 Subject: [PATCH 11/11] * docs/manual/mod/mpm_common.xml: Document IdleTerminationTimeout. Co-Authored-By: Claude Opus 5 (1M context) --- docs/manual/mod/mpm_common.xml | 41 ++++++++++++++++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docs/manual/mod/mpm_common.xml b/docs/manual/mod/mpm_common.xml index 2493a3bda78..7524a3ad7c3 100644 --- a/docs/manual/mod/mpm_common.xml +++ b/docs/manual/mod/mpm_common.xml @@ -134,6 +134,47 @@ will exit. + +IdleTerminationTimeout +Specify a timeout after which an idle server will terminate +itself. +IdleTerminationTimeout seconds +Disabled, the server never terminates itself +server config +eventworker +prefork +Available in Apache HTTP Server 2.5.1 and later + + +

The IdleTerminationTimeout directive + specifies how many seconds the server should remain idle before + shutting itself down. The server is only considered idle while it + has no open connections and has served no requests; idle workers + alone are not enough, since a connection can be open without + occupying a worker, and a request can start and finish between two + checks. Setting this value to zero means that the server terminates + as soon as it is found to be idle.

+ +

The argument must be a non-negative number of seconds; any other + value is a fatal configuration error. The directive may only be used + in the global server configuration. If it is not used at all, the + server never terminates itself.

+ +

The shutdown is not graceful: nothing is in flight when the + timeout expires, so there is nothing left to drain and the server + exits immediately. Instead of the usual caught SIGTERM, + shutting down message, a notice is logged reading idle + timeout reached, shutting down.

+ +

This is primarily useful with systemd socket activation, where + the listening sockets belong to the service manager rather than to + the server: an idle server can exit and leave the sockets with + systemd, which starts it again when the next connection arrives.

+
+mod_systemd +GracefulShutdownTimeout +
+ PidFile File where the server records the process ID