1+ <?php
2+ /**
3+ * HTTP test server for async tests using PHP's built-in development server
4+ * Adapted from sapi/cli/tests/php_cli_server.inc
5+ */
6+
7+ class AsyncTestServerInfo {
8+ public function __construct (
9+ public string $ docRoot ,
10+ public $ processHandle ,
11+ public string $ address ,
12+ public int $ port
13+ ) {}
14+ }
15+
16+ function async_test_server_start (?string $ router = null ): AsyncTestServerInfo {
17+ $ php_executable = getenv ('TEST_PHP_EXECUTABLE ' ) ?: PHP_BINARY ;
18+
19+ // Use the common test router by default
20+ if ($ router === null ) {
21+ $ router = __DIR__ . '/test_router.php ' ;
22+ }
23+
24+ // Create dedicated doc root
25+ $ doc_root = __DIR__ . DIRECTORY_SEPARATOR . 'server_ ' . uniqid ();
26+ @mkdir ($ doc_root );
27+
28+ $ cmd = [$ php_executable , '-t ' , $ doc_root , '-n ' , '-S ' , 'localhost:0 ' , $ router ];
29+
30+ $ output_file = tempnam (sys_get_temp_dir (), 'async_test_server_output ' );
31+ $ output_file_fd = fopen ($ output_file , 'ab ' );
32+ if ($ output_file_fd === false ) {
33+ die (sprintf ("Failed opening output file %s \n" , $ output_file ));
34+ }
35+
36+ register_shutdown_function (function () use ($ output_file ) {
37+ @unlink ($ output_file );
38+ });
39+
40+ $ descriptorspec = array (
41+ 0 => STDIN ,
42+ 1 => $ output_file_fd ,
43+ 2 => $ output_file_fd ,
44+ );
45+ $ handle = proc_open ($ cmd , $ descriptorspec , $ pipes , $ doc_root , null , array ("suppress_errors " => true ));
46+
47+ // Wait for the server to start
48+ $ bound = null ;
49+ for ($ i = 0 ; $ i < 60 ; $ i ++) {
50+ usleep (50000 ); // 50ms per try
51+ $ status = proc_get_status ($ handle );
52+ if (empty ($ status ['running ' ])) {
53+ echo "Server failed to start \n" ;
54+ printf ("Server output: \n%s \n" , file_get_contents ($ output_file ));
55+ proc_terminate ($ handle );
56+ exit (1 );
57+ }
58+
59+ $ output = file_get_contents ($ output_file );
60+ if (preg_match ('@PHP \S* Development Server \(https?://(.*?:\d+)\) started@ ' , $ output , $ matches )) {
61+ $ bound = $ matches [1 ];
62+ break ;
63+ }
64+ }
65+
66+ if ($ bound === null ) {
67+ echo "Server did not output startup message \n" ;
68+ printf ("Server output: \n%s \n" , file_get_contents ($ output_file ));
69+ proc_terminate ($ handle );
70+ exit (1 );
71+ }
72+
73+ // Wait for a successful connection
74+ $ error = "Unable to connect to server \n" ;
75+ for ($ i = 0 ; $ i < 60 ; $ i ++) {
76+ usleep (50000 ); // 50ms per try
77+ $ status = proc_get_status ($ handle );
78+ $ fp = @fsockopen ("tcp:// $ bound " );
79+
80+ if (!($ status && $ status ['running ' ])) {
81+ $ error = sprintf ("Server stopped \nServer output: \n%s \n" , file_get_contents ($ output_file ));
82+ break ;
83+ }
84+
85+ if ($ fp ) {
86+ fclose ($ fp );
87+ $ error = '' ;
88+ break ;
89+ }
90+ }
91+
92+ if ($ error ) {
93+ echo $ error ;
94+ proc_terminate ($ handle );
95+ exit (1 );
96+ }
97+
98+ register_shutdown_function (
99+ function ($ handle ) use ($ doc_root , $ output_file ) {
100+ if (is_resource ($ handle ) && get_resource_type ($ handle ) === 'process ' ) {
101+ $ status = proc_get_status ($ handle );
102+ if ($ status !== false && $ status ['running ' ]) {
103+ proc_terminate ($ handle );
104+ }
105+ proc_close ($ handle );
106+
107+ if ($ status !== false && $ status ['exitcode ' ] !== -1 && $ status ['exitcode ' ] !== 0
108+ && !($ status ['exitcode ' ] === 255 && PHP_OS_FAMILY == 'Windows ' )) {
109+ printf ("Server exited with non-zero status: %d \n" , $ status ['exitcode ' ]);
110+ printf ("Server output: \n%s \n" , file_get_contents ($ output_file ));
111+ }
112+ }
113+ @unlink ($ output_file );
114+ remove_directory ($ doc_root );
115+ },
116+ $ handle
117+ );
118+
119+ $ port = (int ) substr ($ bound , strrpos ($ bound , ': ' ) + 1 );
120+
121+ // Define global constants for backward compatibility
122+ if (!defined ('ASYNC_TEST_SERVER_HOSTNAME ' )) {
123+ define ('ASYNC_TEST_SERVER_HOSTNAME ' , 'localhost ' );
124+ define ('ASYNC_TEST_SERVER_PORT ' , $ port );
125+ define ('ASYNC_TEST_SERVER_ADDRESS ' , "localhost: $ port " );
126+ }
127+
128+ return new AsyncTestServerInfo ($ doc_root , $ handle , $ bound , $ port );
129+ }
130+
131+ function async_test_server_start_custom (string $ router_file ): AsyncTestServerInfo {
132+ return async_test_server_start ($ router_file );
133+ }
134+
135+ function async_test_server_connect (AsyncTestServerInfo $ server ) {
136+ $ timeout = 1.0 ;
137+ $ fp = fsockopen ('localhost ' , $ server ->port , $ errno , $ errstr , $ timeout );
138+ if (!$ fp ) {
139+ die ("connect failed: $ errstr ( $ errno) " );
140+ }
141+ return $ fp ;
142+ }
143+
144+ function async_test_server_stop (AsyncTestServerInfo $ server ) {
145+ if ($ server ->processHandle && is_resource ($ server ->processHandle ) && get_resource_type ($ server ->processHandle ) === 'process ' ) {
146+ $ status = proc_get_status ($ server ->processHandle );
147+ if ($ status !== false && $ status ['running ' ]) {
148+ proc_terminate ($ server ->processHandle );
149+ }
150+ proc_close ($ server ->processHandle );
151+ $ server ->processHandle = null ;
152+ }
153+
154+ // Always remove directory, regardless of process state
155+ remove_directory ($ server ->docRoot );
156+ }
157+
158+ function remove_directory ($ dir ) {
159+ if (is_dir ($ dir ) === false ) {
160+ return ;
161+ }
162+
163+ // On Windows, give the process time to release the directory
164+ if (PHP_OS_FAMILY === 'Windows ' ) {
165+ usleep (100000 ); // 100ms delay
166+ }
167+
168+ $ files = new RecursiveIteratorIterator (
169+ new RecursiveDirectoryIterator ($ dir , RecursiveDirectoryIterator::SKIP_DOTS ),
170+ RecursiveIteratorIterator::CHILD_FIRST
171+ );
172+ foreach ($ files as $ fileinfo ) {
173+ $ todo = ($ fileinfo ->isDir () ? 'rmdir ' : 'unlink ' );
174+ @$ todo ($ fileinfo ->getRealPath ());
175+ }
176+ @rmdir ($ dir );
177+ }
178+
179+ function start_test_server_process ($ port = 8088 ) {
180+ $ server_script = __FILE__ ;
181+ $ php_executable = getenv ('TEST_PHP_EXECUTABLE ' ) ?: PHP_BINARY ;
182+ $ cmd = $ php_executable . " $ server_script $ port > /dev/null 2>&1 & echo $! " ;
183+ $ pid = exec ($ cmd );
184+
185+ // Wait a bit for server to start
186+ usleep (100000 ); // 100ms
187+
188+ return (int )$ pid ;
189+ }
190+
191+ function stop_test_server_process ($ pid ) {
192+ if (PHP_OS_FAMILY === 'Windows ' ) {
193+ exec ("taskkill /PID $ pid /F 2>NUL " );
194+ } else {
195+ exec ("kill $ pid 2>/dev/null " );
196+ }
197+ }
198+
199+ function run_test_server ($ port ) {
200+ $ server = stream_socket_server ("tcp://127.0.0.1: $ port " , $ errno , $ errstr );
201+ if (!$ server ) {
202+ die ("Failed to create server: $ errstr ( $ errno) \n" );
203+ }
204+
205+ echo "Test HTTP server running on 127.0.0.1: $ port \n" ;
206+
207+ while (true ) {
208+ $ client = stream_socket_accept ($ server );
209+ if (!$ client ) continue ;
210+
211+ // Read the request
212+ $ request = '' ;
213+ while (!feof ($ client )) {
214+ $ line = fgets ($ client );
215+ $ request .= $ line ;
216+ if (trim ($ line ) === '' ) break ; // End of headers
217+ }
218+
219+ // Parse request line
220+ $ lines = explode ("\n" , $ request );
221+ $ request_line = trim ($ lines [0 ]);
222+ preg_match ('/^(\S+)\s+(\S+)\s+(\S+)$/ ' , $ request_line , $ matches );
223+
224+ if (count ($ matches ) >= 3 ) {
225+ $ method = $ matches [1 ];
226+ $ path = $ matches [2 ];
227+
228+ // Route requests
229+ $ response = route_test_request ($ method , $ path , $ request );
230+ } else {
231+ $ response = http_test_response (400 , "Bad Request " );
232+ }
233+
234+ fwrite ($ client , $ response );
235+ fclose ($ client );
236+ }
237+
238+ fclose ($ server );
239+ }
240+
241+ function route_test_request ($ method , $ path , $ full_request ) {
242+ switch ($ path ) {
243+ case '/ ' :
244+ return http_test_response (200 , "Hello World " );
245+
246+ case '/json ' :
247+ return http_test_response (200 , '{"message":"Hello JSON","status":"ok"} ' , 'application/json ' );
248+
249+ case '/slow ' :
250+ // Simulate slow response (useful for timeout tests)
251+ usleep (500000 ); // 500ms
252+ return http_test_response (200 , "Slow Response " );
253+
254+ case '/very-slow ' :
255+ // Very slow response (for timeout tests)
256+ sleep (2 );
257+ return http_test_response (200 , "Very Slow Response " );
258+
259+ case '/error ' :
260+ return http_test_response (500 , "Internal Server Error " );
261+
262+ case '/not-found ' :
263+ return http_test_response (404 , "Not Found " );
264+
265+ case '/large ' :
266+ // Large response for testing data transfer
267+ return http_test_response (200 , str_repeat ("ABCDEFGHIJ " , 1000 )); // 10KB
268+
269+ case '/post ' :
270+ if ($ method === 'POST ' ) {
271+ // Extract body if present
272+ $ body_start = strpos ($ full_request , "\r\n\r\n" );
273+ $ body = $ body_start !== false ? substr ($ full_request , $ body_start + 4 ) : '' ;
274+ return http_test_response (200 , "POST received: " . strlen ($ body ) . " bytes " );
275+ } else {
276+ return http_test_response (405 , "Method Not Allowed " );
277+ }
278+
279+ case '/headers ' :
280+ // Return request headers as response
281+ $ headers = [];
282+ $ lines = explode ("\n" , $ full_request );
283+ foreach ($ lines as $ line ) {
284+ $ line = trim ($ line );
285+ if ($ line && strpos ($ line , ': ' ) !== false ) {
286+ $ headers [] = $ line ;
287+ }
288+ }
289+ return http_test_response (200 , implode ("\n" , $ headers ));
290+
291+ case '/echo ' :
292+ // Echo back the entire request
293+ return http_test_response (200 , $ full_request , 'text/plain ' );
294+
295+ case '/redirect ' :
296+ // Simple redirect
297+ return "HTTP/1.1 302 Found \r\n" .
298+ "Location: / \r\n" .
299+ "Content-Length: 0 \r\n" .
300+ "Connection: close \r\n" .
301+ "\r\n" ;
302+
303+ default :
304+ return http_test_response (404 , "Not Found " );
305+ }
306+ }
307+
308+ function http_test_response ($ code , $ body , $ content_type = 'text/plain ' ) {
309+ $ status_text = [
310+ 200 => 'OK ' ,
311+ 302 => 'Found ' ,
312+ 400 => 'Bad Request ' ,
313+ 404 => 'Not Found ' ,
314+ 405 => 'Method Not Allowed ' ,
315+ 500 => 'Internal Server Error '
316+ ][$ code ] ?? 'Unknown ' ;
317+
318+ $ length = strlen ($ body );
319+
320+ return "HTTP/1.1 $ code $ status_text \r\n" .
321+ "Content-Type: $ content_type \r\n" .
322+ "Content-Length: $ length \r\n" .
323+ "Connection: close \r\n" .
324+ "Server: AsyncTestServer/1.0 \r\n" .
325+ "\r\n" .
326+ $ body ;
327+ }
328+
329+ // Helper functions for tests
330+ function get_test_server_address ($ port = 8088 ) {
331+ return "127.0.0.1: $ port " ;
332+ }
333+
334+ function get_test_server_url ($ path = '/ ' , $ port = 8088 ) {
335+ return "http://127.0.0.1: $ port$ path " ;
336+ }
337+
338+ // If called directly, run the server
339+ if (basename (__FILE__ ) === basename ($ _SERVER ['SCRIPT_NAME ' ] ?? '' )) {
340+ $ port = $ argv [1 ] ?? 8088 ;
341+ run_test_server ($ port );
342+ }
0 commit comments