| """PHP-FPM daemon lifecycle for PHP tests. |
| |
| mod_php is not built in the test httpd, so the PHP tests (t/php/*.php) are served |
| by routing requests to a PHP-FPM daemon over mod_proxy_fcgi. This module manages |
| that daemon: it generates an FPM config (a single static pool on a chosen port, |
| with the test's htdocs as the document root) and starts/stops ``php-fpm``. |
| |
| The corresponding httpd config (a ``SetHandler proxy:fcgi://...`` for |
| ``htdocs/php``) is emitted by :class:`apache_pytest.config.TestConfig` when a |
| PHP-FPM port is configured. |
| """ |
| |
| from __future__ import annotations |
| |
| import signal |
| import socket |
| import subprocess |
| import time |
| from pathlib import Path |
| |
| |
| class PhpFpm: |
| """Generates an FPM pool config and runs the php-fpm daemon.""" |
| |
| def __init__( |
| self, |
| php_fpm: Path, |
| *, |
| run_dir: Path, |
| port: int = 9001, |
| listen_addr: str = "127.0.0.1", |
| ) -> None: |
| self.php_fpm = php_fpm |
| self.run_dir = run_dir |
| self.port = port |
| self.listen_addr = listen_addr |
| self.proc: subprocess.Popen[bytes] | None = None |
| self.conf = run_dir / "php-fpm.conf" |
| self.pid_file = run_dir / "php-fpm.pid" |
| self.error_log = run_dir / "php-fpm.log" |
| |
| # -- config ----------------------------------------------------------- |
| |
| def generate_conf(self) -> Path: |
| """Write a minimal single-static-pool FPM config under run_dir.""" |
| self.run_dir.mkdir(parents=True, exist_ok=True) |
| self.conf.write_text( |
| "[global]\n" |
| f"pid = {self.pid_file}\n" |
| f"error_log = {self.error_log}\n" |
| "daemonize = no\n" |
| "\n" |
| "[www]\n" |
| f"listen = {self.listen_addr}:{self.port}\n" |
| "pm = static\n" |
| "pm.max_children = 4\n" |
| # Let httpd's ProxyFCGIBackendType FPM pass SCRIPT_FILENAME through; |
| # accept .php scripts from anywhere under the test docroot. |
| "security.limit_extensions = .php\n" |
| # Surface PHP errors/warnings to the response for test visibility. |
| "catch_workers_output = yes\n" |
| "clear_env = no\n" |
| # t/php/arg.php et al require argc/argv in $_SERVER. Under mod_php |
| # this came from extra.conf.in's php_admin_flag, but that's inside |
| # an <IfModule> keyed on a mod_php variant that's never loaded when |
| # running under FPM, so it never applied -- set it here instead. |
| "php_admin_value[register_argc_argv] = On\n" |
| ) |
| return self.conf |
| |
| # -- lifecycle -------------------------------------------------------- |
| |
| def _port_open(self) -> bool: |
| with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: |
| s.settimeout(0.25) |
| return s.connect_ex((self.listen_addr, self.port)) == 0 |
| |
| def start(self, *, timeout: float = 15.0) -> None: |
| self.generate_conf() |
| # -F = foreground (so we own the process), -y = config file. |
| self.proc = subprocess.Popen( # noqa: S603 - trusted php-fpm path |
| [str(self.php_fpm), "-F", "-y", str(self.conf)], |
| stdout=subprocess.DEVNULL, |
| stderr=subprocess.DEVNULL, |
| start_new_session=True, |
| ) |
| deadline = time.monotonic() + timeout |
| while time.monotonic() < deadline: |
| if self.proc.poll() is not None: |
| raise RuntimeError( |
| f"php-fpm exited early (code {self.proc.returncode}); " |
| f"see {self.error_log}" |
| ) |
| if self._port_open(): |
| return |
| time.sleep(0.1) |
| self.stop() |
| raise TimeoutError(f"php-fpm did not start within {timeout}s on {self.port}") |
| |
| def stop(self, *, timeout: float = 10.0) -> None: |
| if self.proc is None: |
| return |
| if self.proc.poll() is None: |
| self.proc.send_signal(signal.SIGTERM) |
| try: |
| self.proc.wait(timeout=timeout) |
| except subprocess.TimeoutExpired: |
| self.proc.kill() |
| self.proc.wait() |
| self.proc = None |
| |
| |
| def php_fpm_available(php_fpm: Path | None) -> bool: |
| """True if a usable php-fpm binary was provided and exists.""" |
| return php_fpm is not None and php_fpm.exists() |