@@ -26,7 +26,7 @@ final class ProcessRunner {
2626 *
2727 * @param string $command Shell command to execute.
2828 * @param array<string,mixed> $options Execution options.
29- * @return array{success: bool, output: string, exit_code: int} |\WP_Error
29+ * @return array< string,mixed> |\WP_Error
3030 */
3131 public static function run ( string $ command , array $ options = array () ): array |\WP_Error {
3232 $ timeout_seconds = max (0 , (int ) ( $ options ['timeout_seconds ' ] ?? 0 ));
@@ -53,7 +53,7 @@ public static function run( string $command, array $options = array() ): array|\
5353
5454 /**
5555 * @param array<string,mixed> $options
56- * @return array{success: bool, output: string, exit_code: int} |\WP_Error
56+ * @return array< string,mixed> |\WP_Error
5757 */
5858 private static function run_via_exec ( string $ command , array $ options , int $ output_cap ): array |\WP_Error {
5959 $ shell = RuntimeCapabilities::shell_diagnostic ();
@@ -87,7 +87,7 @@ private static function run_via_exec( string $command, array $options, int $outp
8787 * @param array<string,mixed> $options
8888 * @param callable|null $on_output
8989 * @param array<string,mixed>|null $env
90- * @return array{success: bool, output: string, exit_code: int} |\WP_Error
90+ * @return array< string,mixed> |\WP_Error
9191 */
9292 private static function run_via_proc_open ( string $ command , array $ options , int $ timeout_seconds , int $ output_cap , ?callable $ on_output , ?string $ cwd , ?array $ env ): array |\WP_Error {
9393 $ descriptor_spec = array (
@@ -104,13 +104,20 @@ private static function run_via_proc_open( string $command, array $options, int
104104 stream_set_blocking ($ pipes [1 ], false );
105105 stream_set_blocking ($ pipes [2 ], false );
106106
107- $ output = '' ;
108- $ deadline = $ timeout_seconds > 0 ? microtime (true ) + $ timeout_seconds : null ;
109- $ exit_code = null ;
107+ $ separate_streams = ! empty ($ options ['separate_streams ' ]);
108+ $ stdout = '' ;
109+ $ stderr = '' ;
110+ $ output = '' ;
111+ $ deadline = $ timeout_seconds > 0 ? microtime (true ) + $ timeout_seconds : null ;
112+ $ exit_code = 0 ;
110113
111114 while ( true ) {
112- $ chunk = (string ) stream_get_contents ($ pipes [1 ]) . (string ) stream_get_contents ($ pipes [2 ]);
115+ $ stdout_chunk = (string ) stream_get_contents ($ pipes [1 ]);
116+ $ stderr_chunk = (string ) stream_get_contents ($ pipes [2 ]);
117+ $ chunk = $ stdout_chunk . $ stderr_chunk ;
113118 if ( '' !== $ chunk ) {
119+ $ stdout .= $ stdout_chunk ;
120+ $ stderr .= $ stderr_chunk ;
114121 $ output .= $ chunk ;
115122 if ( null !== $ on_output ) {
116123 $ on_output ($ chunk );
@@ -119,75 +126,103 @@ private static function run_via_proc_open( string $command, array $options, int
119126
120127 $ status = proc_get_status ($ process );
121128 if ( empty ($ status ['running ' ]) ) {
122- $ exit_code = isset ( $ status [ ' exitcode ' ]) ? ( int ) $ status ['exitcode ' ] : null ;
129+ $ exit_code = ( int ) $ status ['exitcode ' ];
123130 break ;
124131 }
125132
126133 if ( null !== $ deadline && microtime (true ) >= $ deadline ) {
127- $ output = self ::terminate_timed_out_process ($ process , $ pipes , $ output );
134+ $ remaining = self ::terminate_timed_out_process ($ process , $ pipes , $ output, $ stdout , $ stderr );
128135 return self ::error (
129136 $ options ,
130137 sprintf ('Process command timed out after %d second(s). ' , $ timeout_seconds ),
131138 array (
132139 'timeout ' => $ timeout_seconds ,
133- 'output ' => self ::cap_output (trim ($ output ), $ output_cap ),
140+ 'output ' => self ::cap_output (trim ($ remaining ['output ' ]), $ output_cap ),
141+ 'stdout ' => self ::cap_output (trim ($ remaining ['stdout ' ]), $ output_cap ),
142+ 'stderr ' => self ::cap_output (trim ($ remaining ['stderr ' ]), $ output_cap ),
134143 )
135144 );
136145 }
137146
138147 usleep ( (int ) ( $ options ['poll_interval_microseconds ' ] ?? 50000 ) );
139148 }
140149
141- $ output .= (string ) stream_get_contents ($ pipes [1 ]) . (string ) stream_get_contents ($ pipes [2 ]);
150+ $ stdout_tail = (string ) stream_get_contents ($ pipes [1 ]);
151+ $ stderr_tail = (string ) stream_get_contents ($ pipes [2 ]);
152+ $ stdout .= $ stdout_tail ;
153+ $ stderr .= $ stderr_tail ;
154+ $ output .= $ stdout_tail . $ stderr_tail ;
142155 foreach ( $ pipes as $ pipe ) {
143156 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
144157 fclose ($ pipe );
145158 }
146159
147160 $ close_code = proc_close ($ process );
148- if ( null === $ exit_code ) {
161+ if ( - 1 === $ exit_code ) {
149162 $ exit_code = $ close_code ;
150163 }
151164
152165 $ output = self ::cap_output (trim (str_replace ("\r" , "\n" , $ output )), $ output_cap );
166+ $ stdout = self ::cap_output (trim (str_replace ("\r" , "\n" , $ stdout )), $ output_cap );
167+ $ stderr = self ::cap_output (trim (str_replace ("\r" , "\n" , $ stderr )), $ output_cap );
153168 if ( 0 !== $ exit_code ) {
169+ $ data = array (
170+ 'exit_code ' => $ exit_code ,
171+ 'output ' => $ output ,
172+ );
173+ if ( $ separate_streams ) {
174+ $ data ['stdout ' ] = $ stdout ;
175+ $ data ['stderr ' ] = $ stderr ;
176+ }
177+
154178 return self ::error (
155179 $ options ,
156180 sprintf ('Process command failed (exit %d): %s ' , $ exit_code , $ output ),
157- array (
158- 'exit_code ' => $ exit_code ,
159- 'output ' => $ output ,
160- )
181+ $ data
161182 );
162183 }
163184
164- return array (
185+ $ result = array (
165186 'success ' => true ,
166187 'output ' => $ output ,
167188 'exit_code ' => 0 ,
168189 );
190+ if ( $ separate_streams ) {
191+ $ result ['stdout ' ] = $ stdout ;
192+ $ result ['stderr ' ] = $ stderr ;
193+ }
194+
195+ return $ result ;
169196 }
170197
171198 /**
172199 * @param resource $process
173200 * @param array<int,resource> $pipes
174201 */
175- private static function terminate_timed_out_process ( $ process , array $ pipes , string $ output ): string {
202+ private static function terminate_timed_out_process ( $ process , array $ pipes , string $ output, string $ stdout = '' , string $ stderr = '' ): array {
176203 proc_terminate ($ process );
177204 usleep (100000 );
178205 $ status = proc_get_status ($ process );
179206 if ( ! empty ($ status ['running ' ]) ) {
180207 proc_terminate ($ process , 9 );
181208 }
182209
183- $ output .= (string ) stream_get_contents ($ pipes [1 ]) . (string ) stream_get_contents ($ pipes [2 ]);
210+ $ stdout_tail = (string ) stream_get_contents ($ pipes [1 ]);
211+ $ stderr_tail = (string ) stream_get_contents ($ pipes [2 ]);
212+ $ stdout .= $ stdout_tail ;
213+ $ stderr .= $ stderr_tail ;
214+ $ output .= $ stdout_tail . $ stderr_tail ;
184215 foreach ( $ pipes as $ pipe ) {
185216 // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_fclose -- Process pipes are not WordPress filesystem paths.
186217 fclose ($ pipe );
187218 }
188219 proc_close ($ process );
189220
190- return $ output ;
221+ return array (
222+ 'output ' => $ output ,
223+ 'stdout ' => $ stdout ,
224+ 'stderr ' => $ stderr ,
225+ );
191226 }
192227
193228 private static function cap_output ( string $ output , int $ output_cap ): string {
@@ -201,14 +236,23 @@ private static function cap_output( string $output, int $output_cap ): string {
201236 /**
202237 * @param array<string,mixed> $options
203238 * @param array<string,mixed> $data
239+ * @return array<string,mixed>|\WP_Error
204240 */
205241 private static function error ( array $ options , string $ message , array $ data = array () ): array |\WP_Error {
206242 if ( ! empty ($ options ['error_as_result ' ]) ) {
207- return array (
243+ $ result = array (
208244 'success ' => false ,
209245 'output ' => (string ) ( $ data ['output ' ] ?? $ message ),
210246 'exit_code ' => (int ) ( $ data ['exit_code ' ] ?? 1 ),
211247 );
248+ if ( array_key_exists ('stdout ' , $ data ) ) {
249+ $ result ['stdout ' ] = (string ) $ data ['stdout ' ];
250+ }
251+ if ( array_key_exists ('stderr ' , $ data ) ) {
252+ $ result ['stderr ' ] = (string ) $ data ['stderr ' ];
253+ }
254+
255+ return $ result ;
212256 }
213257
214258 $ code = isset ($ options ['error_code ' ]) && is_string ($ options ['error_code ' ]) && '' !== $ options ['error_code ' ] ? $ options ['error_code ' ] : 'process_command_failed ' ;
0 commit comments