blob: ac350877d4a229143ff7fc97b429725d38084a1b [file]
"""Launch and stop the httpd test server.
Port of the lifecycle bits of Apache::TestServer. Builds the same command line
(``httpd -d <serverroot> -f <conf> -D APACHE2 -D APACHE2_4 <defines>``), starts
the server, waits for its port to accept connections, and stops it cleanly.
Orphan/stale-state handling mirrors Apache::TestServer's ``sub start`` (which
calls ``sub stop`` up front to clear any prior server) and ``sub stop`` (which
reads the pid file, sends ``SIGTERM``, polls, and finally unlinks the pid
file). Because the event MPM parent forks children, we launch httpd in its own
session (``start_new_session=True``/``setsid``) so the whole process group can
be signalled with ``os.killpg`` -- escalating to ``SIGKILL`` if needed -- and
no children are left holding ports after a failed or interrupted run.
"""
from __future__ import annotations
import contextlib
import errno
import os
import sys
import signal
import socket
import subprocess
import time
from pathlib import Path
from .config import TestConfig
def _pid_alive(pid: int) -> bool:
"""Return True if a process with ``pid`` exists (``kill(0)`` probe).
Mirrors Apache::TestServer's ``kill 0, $pid`` liveness check in ``sub
ping``/``sub stop``.
"""
if pid <= 0:
return False
try:
os.kill(pid, 0)
except OSError as exc:
# ESRCH: no such process. EPERM: exists but not ours (still alive).
return exc.errno == errno.EPERM
return True
def _read_pid(pid_file: Path) -> int:
"""Read the PID from ``pid_file``; return 0 if missing/empty/garbage.
Mirrors Apache::TestServer ``sub pid``, including tolerance for a pid file
that exists but has not been written to yet (returns 0).
"""
try:
text = pid_file.read_text().strip()
except OSError:
return 0
try:
return int(text)
except ValueError:
return 0
def _killpg_or_pid(pid: int, sig: int) -> None:
"""Send ``sig`` to the process group led by ``pid``, falling back to ``pid``.
Children of an httpd parent launched with ``start_new_session=True`` share
the parent's process group id (== parent pid), so signalling the group with
``os.killpg`` reaches the parent and all its workers. If the process is not
a group leader (no such pgid) we fall back to signalling the bare pid.
On Windows there are no process groups; use ``os.kill`` directly.
"""
if pid <= 0:
return
if sys.platform == "win32":
with contextlib.suppress(OSError):
os.kill(pid, sig)
return
try:
os.killpg(pid, sig)
except OSError as exc:
if exc.errno == errno.ESRCH:
return # already gone
# Not a group leader (or no permission for the group): try the pid.
with contextlib.suppress(OSError):
os.kill(pid, sig)
class HttpdServer:
def __init__(self, config: TestConfig) -> None:
self.config = config
self.proc: subprocess.Popen[bytes] | None = None
# -- command line -----------------------------------------------------
def dversion_defines(self) -> list[str]:
"""Version defines for .conf conditionals (TestServer::dversion).
rev 2 always gets ``APACHE2``; the 2.4+ line keeps ``APACHE2_4`` (the
``<IfVersion>`` blocks handle finer-grained 2.5 gating). User ``-D``
defines are appended.
"""
v = self.config.info.version
defs = [f"APACHE{v[0]}"]
if v[0] == 2 and v[1] >= 4:
defs.append("APACHE2_4")
defs += self.config.defines
return defs
def args(self) -> list[str]:
vars_ = self.config.vars
argv = [
str(self.config.info.httpd),
"-d",
vars_["serverroot"],
"-f",
vars_["t_conf_file"],
"-DFOREGROUND",
]
for d in self.dversion_defines():
argv += ["-D", d]
return argv
# -- syntax check -----------------------------------------------------
def configtest(self) -> subprocess.CompletedProcess[str]:
"""Run ``httpd ... -t`` to validate the generated config before launch."""
return subprocess.run( # noqa: S603 - trusted paths
[*self.args(), "-t"], capture_output=True, text=True, check=False
)
# -- lifecycle --------------------------------------------------------
@property
def port(self) -> int:
return int(self.config.vars["port"])
@property
def _pid_file(self) -> Path:
return Path(self.config.vars["t_pid_file"])
def _port_open(self) -> bool:
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
s.settimeout(0.25)
return s.connect_ex(("127.0.0.1", self.port)) == 0
@classmethod
def kill_stale(cls, pid_file: Path, *, timeout: float = 10.0) -> int:
"""Defensively clear a server left behind by a prior/crashed run.
Mirrors the up-front ``$self->stop`` that Apache::TestServer's ``sub
start`` performs: if ``pid_file`` names a live process, signal its
process group with ``SIGTERM`` (escalating to ``SIGKILL`` after
``timeout``), then unlink the (now stale) pid file. Targets only the
PID recorded in the file and that PID's process group -- never a broad
pattern match. Safe to call when no pid file exists.
Returns the PID that was found in the file (0 if none).
"""
pid = _read_pid(pid_file)
if pid and _pid_alive(pid):
_killpg_or_pid(pid, signal.SIGTERM)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if not _pid_alive(pid):
break
time.sleep(0.1)
if _pid_alive(pid):
kill_sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
_killpg_or_pid(pid, kill_sig)
if sys.platform != "win32":
with contextlib.suppress(OSError):
os.waitpid(pid, 0) # reap if it happens to be our child
if pid_file.exists():
with contextlib.suppress(OSError):
pid_file.unlink()
return pid
def start(self, *, timeout: float = 30.0) -> None:
# Like Apache::TestServer::start, clear any server (and stale pid file)
# left over from a prior run before launching a new one.
self.kill_stale(self._pid_file)
check = self.configtest()
if check.returncode != 0:
raise RuntimeError(
f"httpd config test failed:\n{check.stdout}\n{check.stderr}"
)
# start_new_session=True (setsid) puts httpd in its own process group so
# the parent and all forked MPM children can be signalled together via
# os.killpg, guaranteeing no orphaned children survive a failed start.
popen_kwargs = {}
if sys.platform != "win32":
popen_kwargs["start_new_session"] = True
self.proc = subprocess.Popen( # noqa: S603 - trusted paths
self.args(), **popen_kwargs
)
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
if self.proc.poll() is not None:
code = self.proc.returncode
self.stop() # reap any children that did spawn
raise RuntimeError(
f"httpd exited early (code {code}); "
f"see {self.config.vars['t_logs']}/error_log"
)
if self._port_open():
return
time.sleep(0.1)
self.stop()
raise TimeoutError(f"httpd did not start within {timeout}s on port {self.port}")
def stop(self, *, timeout: float = 10.0) -> None:
"""Stop the server, reaping the whole process group, idempotently.
Mirrors Apache::TestServer ``sub stop``: ``SIGTERM`` the server, wait,
then unlink the pid file. We signal the launched process *group* so the
event MPM children die with the parent, escalating to ``SIGKILL`` if
the group does not exit within ``timeout``. Safe to call when the
server was never started; also clears any matching stale pid file.
"""
proc = self.proc
if proc is not None and proc.poll() is None:
pgid_pid = proc.pid
_killpg_or_pid(pgid_pid, signal.SIGTERM)
try:
proc.wait(timeout=timeout)
except subprocess.TimeoutExpired:
kill_sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
_killpg_or_pid(pgid_pid, kill_sig)
with contextlib.suppress(subprocess.TimeoutExpired):
proc.wait(timeout=timeout)
elif proc is not None:
# Process exited on its own; reap it (no-op if already reaped).
with contextlib.suppress(Exception):
proc.wait(timeout=0)
# Whether or not we had a live Popen handle, defensively clear anything
# the pid file still points at and remove the file.
self.kill_stale(self._pid_file, timeout=timeout)
self.proc = None