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}