phper_test/
fpm.rs

1// Copyright (c) 2022 PHPER Framework Team
2// PHPER is licensed under Mulan PSL v2.
3// You can use this software according to the terms and conditions of the Mulan
4// PSL v2. You may obtain a copy of Mulan PSL v2 at:
5//          http://license.coscl.org.cn/MulanPSL2
6// THIS SOFTWARE IS PROVIDED ON AN "AS IS" BASIS, WITHOUT WARRANTIES OF ANY
7// KIND, EITHER EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO
8// NON-INFRINGEMENT, MERCHANTABILITY OR FIT FOR A PARTICULAR PURPOSE.
9// See the Mulan PSL v2 for more details.
10
11//! Test tools for php fpm program.
12use crate::{context::Context, utils::spawn_command};
13use fastcgi_client::{Client, Params, Request};
14use libc::{SIGTERM, atexit, kill, pid_t};
15use log::debug;
16use std::{
17    borrow::Cow,
18    fs,
19    net::TcpListener,
20    path::Path,
21    process::Child,
22    sync::{Mutex, Once, OnceLock},
23    time::Duration,
24};
25use tempfile::NamedTempFile;
26use tokio::{io, net::TcpStream};
27
28static FPM_HANDLE: OnceLock<FpmHandle> = OnceLock::new();
29
30/// A handle for managing a PHP-FPM (FastCGI Process Manager) instance.
31///
32/// This struct provides functionality to start, manage, and interact with a
33/// PHP-FPM process for testing purposes. It maintains the FPM process lifecycle
34/// and provides methods to send FastCGI requests to the running FPM instance.
35///
36/// The FpmHandle is designed as a singleton - only one instance can exist at a
37/// time, and it's automatically cleaned up when the program exits.
38pub struct FpmHandle {
39    /// The running PHP-FPM child process
40    fpm_child: Child,
41    /// Temporary configuration file for PHP-FPM
42    fpm_conf_file: Mutex<Option<NamedTempFile>>,
43    /// The port number that PHP-FPM is listening on
44    port: u16,
45}
46
47impl FpmHandle {
48    /// Finds an available port on localhost.
49    ///
50    /// # Returns
51    ///
52    /// An available port number
53    ///
54    /// # Panics
55    ///
56    /// Panics if no available port can be found
57    fn find_available_port() -> u16 {
58        TcpListener::bind("127.0.0.1:0")
59            .expect("Failed to bind to an available port")
60            .local_addr()
61            .expect("Failed to get local address")
62            .port()
63    }
64
65    /// Sets up and starts a PHP-FPM process for testing.
66    ///
67    /// This method creates a singleton FpmHandle instance that manages a
68    /// PHP-FPM process with the specified PHP extension loaded. The FPM
69    /// process is configured to listen on port 9000 and uses a temporary
70    /// configuration file.
71    ///
72    /// # Arguments
73    ///
74    /// * `lib_path` - Path to the PHP extension library file (.so) to be loaded
75    ///
76    /// # Returns
77    ///
78    /// A static reference to the FpmHandle instance
79    ///
80    /// # Panics
81    ///
82    /// Panics if:
83    /// - PHP-FPM binary cannot be found
84    /// - FPM process fails to start
85    /// - FpmHandle has already been initialized
86    pub fn setup(lib_path: impl AsRef<Path>, log_path: impl AsRef<Path>) -> &'static FpmHandle {
87        if FPM_HANDLE.get().is_some() {
88            panic!("FPM_HANDLE has set");
89        }
90
91        let lib_path = lib_path.as_ref().to_owned();
92        let port = Self::find_available_port();
93
94        // Run php-fpm.
95        let context = Context::get_global();
96        let php_fpm = context.find_php_fpm().unwrap();
97        let fpm_conf_file = context.create_tmp_fpm_conf_file(port, log_path.as_ref());
98
99        let argv = [
100            &*php_fpm,
101            "-F",
102            "-n",
103            "-d",
104            &format!("extension={}", lib_path.display()),
105            "-y",
106            &fpm_conf_file.path().display().to_string(),
107        ];
108        debug!(argv:% = argv.join(" "), port:% = port; "setup php-fpm");
109
110        let child = spawn_command(&argv, Some(Duration::from_secs(3)));
111        let log = fs::read_to_string(log_path.as_ref()).unwrap();
112        debug!(log:%; "php-fpm log");
113
114        let handle = FpmHandle {
115            fpm_child: child,
116            fpm_conf_file: Mutex::new(Some(fpm_conf_file)),
117            port,
118        };
119
120        // shutdown hook.
121        static TEARDOWN: Once = Once::new();
122        TEARDOWN.call_once(|| unsafe {
123            atexit(teardown);
124        });
125
126        if FPM_HANDLE.set(handle).is_err() {
127            panic!("FPM_HANDLE has set");
128        }
129
130        FPM_HANDLE.get().unwrap()
131    }
132
133    /// Sends a FastCGI request to the PHP-FPM process and validates the
134    /// response.
135    ///
136    /// This method executes a FastCGI request to the running PHP-FPM instance
137    /// using the specified parameters. It establishes a TCP connection to
138    /// the FPM process and sends the request with the provided HTTP method,
139    /// script path, and optional content.
140    ///
141    /// The method automatically constructs the necessary FastCGI parameters
142    /// including script filename, server information, and remote address
143    /// details. After receiving the response, it validates that no errors
144    /// occurred during processing.
145    ///
146    /// # Arguments
147    ///
148    /// * `method` - HTTP method for the request (e.g., "GET", "POST", "PUT")
149    /// * `root` - Document root directory where PHP scripts are located
150    /// * `request_uri` - The URI being requested (e.g.,
151    ///   "/test.php?param=value")
152    /// * `content_type` - Optional Content-Type header for the request
153    /// * `body` - Optional request body as bytes
154    ///
155    /// # Panics
156    ///
157    /// Panics if:
158    /// - FpmHandle has not been initialized via `setup()` first
159    /// - Cannot connect to the FPM process on port
160    /// - The PHP script execution results in errors (stderr is not empty)
161    pub async fn test_fpm_request(
162        &self, method: &str, root: impl AsRef<Path>, request_uri: &str,
163        content_type: Option<String>, body: Option<Vec<u8>>,
164    ) {
165        let root = root.as_ref();
166        let script_name = request_uri.split('?').next().unwrap();
167
168        let mut tmp = root.to_path_buf();
169        tmp.push(script_name.trim_start_matches('/'));
170        let script_filename = tmp.as_path().to_str().unwrap();
171
172        let stream = TcpStream::connect(("127.0.0.1", self.port)).await.unwrap();
173        let local_addr = stream.local_addr().unwrap();
174        let peer_addr = stream.peer_addr().unwrap();
175        let local_ip = local_addr.ip().to_string();
176        let local_port = local_addr.port();
177        let peer_ip = peer_addr.ip().to_string();
178        let peer_port = peer_addr.port();
179
180        let client = Client::new(stream);
181        let mut params = Params::default()
182            .request_method(method)
183            .script_name(request_uri)
184            .script_filename(script_filename)
185            .request_uri(request_uri)
186            .document_uri(script_name)
187            .remote_addr(&local_ip)
188            .remote_port(local_port)
189            .server_addr(&peer_ip)
190            .server_port(peer_port)
191            .server_name("phper-test");
192        if let Some(content_type) = &content_type {
193            params = params.content_type(content_type);
194        }
195        if let Some(body) = &body {
196            params = params.content_length(body.len());
197        }
198
199        let response = if let Some(body) = body {
200            client
201                .execute_once(Request::new(params, body.as_ref()))
202                .await
203        } else {
204            client
205                .execute_once(Request::new(params, &mut io::empty()))
206                .await
207        };
208
209        let output = response.unwrap();
210        let stdout = output.stdout.unwrap_or_default();
211        let stderr = output.stderr.unwrap_or_default();
212
213        let no_error = stderr.is_empty();
214
215        let f = |out| {
216            let out = String::from_utf8_lossy(out);
217            if out.is_empty() {
218                Cow::Borrowed("<empty>")
219            } else {
220                out
221            }
222        };
223
224        debug!(uri:% = request_uri, stdout:% = f(&stdout), stderr:% = f(&stderr); "test php request");
225
226        assert!(no_error, "request not success: {}", request_uri);
227    }
228}
229
230/// Cleanup function called on program exit to properly shutdown the PHP-FPM
231/// process.
232///
233/// This function is automatically registered as an exit handler and is
234/// responsible for:
235/// - Cleaning up the temporary FPM configuration file
236/// - Sending a SIGTERM signal to the FPM process to gracefully shutdown
237///
238/// # Safety
239///
240/// This function is marked as `unsafe` because it:
241/// - Directly manipulates the global FPM_HANDLE singleton
242/// - Uses raw system calls to send signals to processes
243/// - Is called from an exit handler context where normal safety guarantees may
244///   not apply
245extern "C" fn teardown() {
246    unsafe {
247        let fpm_handle = FPM_HANDLE.get().unwrap();
248        drop(fpm_handle.fpm_conf_file.lock().unwrap().take());
249
250        let id = fpm_handle.fpm_child.id();
251        kill(id as pid_t, SIGTERM);
252    }
253}