@@ -35,10 +35,18 @@ public function get_error_data(): array {
3535require_once dirname (__DIR__ ) . '/inc/Support/CommandSpec.php ' ;
3636require_once dirname (__DIR__ ) . '/inc/Support/RuntimeCapabilities.php ' ;
3737require_once dirname (__DIR__ ) . '/inc/Support/ProcessRunner.php ' ;
38+ require_once dirname (__DIR__ ) . '/inc/Support/GitRunner.php ' ;
3839
3940use DataMachineCode \Support \CommandSpec ;
41+ use DataMachineCode \Support \GitRunner ;
4042use DataMachineCode \Support \ProcessRunner ;
4143
44+ if ( ! function_exists ('is_wp_error ' ) ) {
45+ function is_wp_error ( mixed $ thing ): bool {
46+ return $ thing instanceof WP_Error;
47+ }
48+ }
49+
4250function process_runner_assert_same ( mixed $ expected , mixed $ actual , string $ message ): void {
4351 if ( $ expected !== $ actual ) {
4452 throw new RuntimeException (sprintf ('%s Expected %s, got %s. ' , $ message , var_export ($ expected , true ), var_export ($ actual , true )));
@@ -51,6 +59,30 @@ function process_runner_assert_not_error( mixed $result, string $message ): void
5159 }
5260}
5361
62+ function process_runner_assert_less_than ( float $ expected , float $ actual , string $ message ): void {
63+ if ( $ actual >= $ expected ) {
64+ throw new RuntimeException (sprintf ('%s Expected less than %.2f, got %.2f. ' , $ message , $ expected , $ actual ));
65+ }
66+ }
67+
68+ function process_runner_assert_stopped ( int $ pid , string $ message ): void {
69+ // A zombie has exited but may remain visible until its parent reaps it.
70+ // Check its state instead of treating kill(pid, 0) as proof it is still running.
71+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
72+ @exec (sprintf ('ps -o stat= -p %d ' , $ pid ), $ states );
73+ if ( ! empty ($ states ) && ! str_starts_with (trim ($ states [0 ]), 'Z ' ) ) {
74+ throw new RuntimeException ($ message );
75+ }
76+ }
77+
78+ function process_runner_assert_running ( int $ pid , string $ message ): void {
79+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.system_calls_exec
80+ @exec (sprintf ('ps -o stat= -p %d ' , $ pid ), $ states );
81+ if ( empty ($ states ) || str_starts_with (trim ($ states [0 ]), 'Z ' ) ) {
82+ throw new RuntimeException ($ message );
83+ }
84+ }
85+
5486$ cwd = sys_get_temp_dir ();
5587
5688$ spec = CommandSpec::from_argv (
@@ -80,6 +112,86 @@ function process_runner_assert_not_error( mixed $result, string $message ): void
80112process_runner_assert_same (true , $ timed_out instanceof WP_Error, 'ProcessRunner should terminate a controlled hanging process. ' );
81113process_runner_assert_same (1 , $ timed_out ->get_error_data ()['timeout ' ] ?? null , 'ProcessRunner timeout result carries the configured budget. ' );
82114
115+ $ timed_out_result = ProcessRunner::run (
116+ CommandSpec::from_argv (array ( PHP_BINARY , '-r ' , 'while (true) {} ' )),
117+ array (
118+ 'timeout_seconds ' => 1 ,
119+ 'poll_interval_microseconds ' => 1000 ,
120+ 'error_as_result ' => true ,
121+ )
122+ );
123+ process_runner_assert_same (false , $ timed_out_result ['success ' ] ?? null , 'Result-mode timeouts report failure. ' );
124+ process_runner_assert_same (true , is_array ($ timed_out_result ['cleanup ' ] ?? null ), 'Result-mode timeouts preserve cleanup diagnostics. ' );
125+
126+ if ( 'Windows ' !== PHP_OS_FAMILY ) {
127+ $ failed_command = ProcessRunner::run (
128+ CommandSpec::from_argv (array ( '/bin/sh ' , '-c ' , 'exit 23 ' )),
129+ array ( 'timeout_seconds ' => 1 )
130+ );
131+ process_runner_assert_same (true , $ failed_command instanceof WP_Error, 'Timed commands preserve non-timeout exit failures. ' );
132+ process_runner_assert_same (23 , $ failed_command ->get_error_data ()['exit_code ' ] ?? null , 'Timed commands preserve the child exit code. ' );
133+
134+ // This process is not part of either timed command and must remain untouched.
135+ $ sibling = proc_open (array ( '/bin/sh ' , '-c ' , 'sleep 5 ' ), array (), $ sibling_pipes );
136+ process_runner_assert_same (true , is_resource ($ sibling ), 'Test sibling process should start. ' );
137+ $ sibling_status = proc_get_status ($ sibling );
138+ $ sibling_pid = (int ) $ sibling_status ['pid ' ];
139+
140+ $ descendant_command = 'printf descendant-ready:; sh -c ' . escapeshellarg ('trap "" TERM; printf "%s" "$$"; sleep 2 ' ) . ' & wait ' ;
141+ $ started = microtime (true );
142+ $ descendant_timeout = ProcessRunner::run (
143+ CommandSpec::from_argv (array ( '/bin/sh ' , '-c ' , $ descendant_command )),
144+ array (
145+ 'timeout_seconds ' => 1 ,
146+ 'poll_interval_microseconds ' => 1000 ,
147+ 'separate_streams ' => true ,
148+ )
149+ );
150+ $ elapsed = microtime (true ) - $ started ;
151+ process_runner_assert_same (true , $ descendant_timeout instanceof WP_Error, 'ProcessRunner should time out an argv command whose shell descendant retains pipes. ' );
152+ $ descendant_stdout = $ descendant_timeout ->get_error_data ()['stdout ' ] ?? '' ;
153+ process_runner_assert_same (true , 1 === preg_match ('/^descendant-ready:(\d+)$/ ' , $ descendant_stdout , $ matches ), 'Timed-out argv commands preserve captured stdout. ' );
154+ $ descendant_cleanup = $ descendant_timeout ->get_error_data ()['cleanup ' ] ?? array ();
155+ usleep (100000 );
156+ if ( ! empty ($ descendant_cleanup ['verified ' ]) ) {
157+ process_runner_assert_same (1 , $ descendant_cleanup ['attempts ' ] ?? null , 'Verified containment uses one force signal. ' );
158+ process_runner_assert_same ('SIGKILL ' , $ descendant_cleanup ['signal ' ] ?? null , 'Verified containment uses one immediate force signal. ' );
159+ process_runner_assert_stopped ((int ) $ matches [1 ], 'Verified containment must terminate pipe-owning descendants. ' );
160+ } else {
161+ process_runner_assert_same ('process ' , $ descendant_cleanup ['containment ' ] ?? null , 'Fallback cleanup reports direct-process containment. ' );
162+ process_runner_assert_same (false , $ descendant_cleanup ['verified ' ] ?? null , 'Fallback cleanup reports unverified descendants. ' );
163+ process_runner_assert_same (2 , $ descendant_cleanup ['attempts ' ] ?? null , 'Fallback cleanup only signals the owned parent. ' );
164+ process_runner_assert_running ((int ) $ matches [1 ], 'Fallback cleanup must not signal an unverified reparented descendant. ' );
165+ }
166+ process_runner_assert_running ($ sibling_pid , 'Timed command cleanup must not signal an unrelated sibling. ' );
167+ process_runner_assert_less_than (3.0 , $ elapsed , 'Timed-out argv commands must not wait for pipe-owning descendants. ' );
168+
169+ $ alias_args = '-c ' . escapeshellarg ('alias.timeout-descendant=!sh -c ' . escapeshellarg ($ descendant_command )) . ' timeout-descendant ' ;
170+ $ started = microtime (true );
171+ $ git_timeout = GitRunner::run (sys_get_temp_dir (), $ alias_args , 1 );
172+ $ elapsed = microtime (true ) - $ started ;
173+ process_runner_assert_same (true , $ git_timeout instanceof WP_Error, 'GitRunner string commands should time out when a git alias leaves a pipe-owning descendant. ' );
174+ process_runner_assert_same ('git_command_timeout ' , $ git_timeout ->get_error_code (), 'GitRunner string commands preserve the timeout error envelope. ' );
175+ $ git_output = $ git_timeout ->get_error_data ()['output ' ] ?? '' ;
176+ process_runner_assert_same (true , 1 === preg_match ('/^descendant-ready:(\d+)$/ ' , $ git_output , $ matches ), 'GitRunner string commands preserve captured output. ' );
177+ $ git_cleanup = $ git_timeout ->get_error_data ()['cleanup ' ] ?? array ();
178+ usleep (100000 );
179+ if ( ! empty ($ git_cleanup ['verified ' ]) ) {
180+ process_runner_assert_same (1 , $ git_cleanup ['attempts ' ] ?? null , 'Verified GitRunner containment uses one force signal. ' );
181+ process_runner_assert_same ('SIGKILL ' , $ git_cleanup ['signal ' ] ?? null , 'Verified GitRunner containment uses one immediate force signal. ' );
182+ process_runner_assert_stopped ((int ) $ matches [1 ], 'Verified GitRunner containment must terminate pipe-owning descendants. ' );
183+ } else {
184+ process_runner_assert_same ('process ' , $ git_cleanup ['containment ' ] ?? null , 'GitRunner fallback cleanup reports direct-process containment. ' );
185+ process_runner_assert_same (false , $ git_cleanup ['verified ' ] ?? null , 'GitRunner fallback cleanup reports unverified descendants. ' );
186+ process_runner_assert_same (2 , $ git_cleanup ['attempts ' ] ?? null , 'GitRunner fallback only signals the owned parent. ' );
187+ process_runner_assert_running ((int ) $ matches [1 ], 'GitRunner fallback cleanup must not signal an unverified reparented descendant. ' );
188+ }
189+ process_runner_assert_running ($ sibling_pid , 'GitRunner cleanup must not signal an unrelated sibling. ' );
190+ process_runner_assert_less_than (3.0 , $ elapsed , 'GitRunner string commands must not wait for pipe-owning descendants. ' );
191+ proc_terminate ($ sibling , 9 );
192+ proc_close ($ sibling );
193+ }
194+
83195$ invalid = CommandSpec::from_argv (array ());
84196process_runner_assert_same (true , $ invalid instanceof WP_Error, 'CommandSpec rejects empty argv. ' );
85197
0 commit comments