From 15d04dafd838172813153b4ae2954b7900b61ad1 Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Fri, 30 Oct 2026 20:07:04 +0100 Subject: [PATCH] Trim fixed per-scenario overhead Remove the run directory and the suite cache directory of a scenario in the background, as no later scenario uses their unique paths. The next scenario no longer waits for thousands of files to be deleted. A background process that is expected to listen on a port no longer waits a full second before the scenario continues, but only until it accepts connections. If the port is already taken before the process starts, the full second is still waited, so that a process failing to bind to it is noticed. Co-Authored-By: Claude Opus 5.5 --- src/Context/FeatureContext.php | 84 ++++++++++++++++++++++++++++++++-- 1 file changed, 81 insertions(+), 3 deletions(-) diff --git a/src/Context/FeatureContext.php b/src/Context/FeatureContext.php index ba8ae9f5..8ad9d887 100644 --- a/src/Context/FeatureContext.php +++ b/src/Context/FeatureContext.php @@ -1066,7 +1066,7 @@ public function afterScenario( AfterScenarioScope $scope ): void { $is_ci = getenv( 'CI' ); $is_debug = getenv( 'WP_CLI_TEST_DEBUG_BEHAT_ENV' ); if ( $scope->getTestResult()->getResultCode() <= 10 || ( $is_ci && ! $is_debug ) ) { - self::remove_dir( self::$run_dir ); + self::remove_dir_in_background( self::$run_dir ); } self::$run_dir = null; } @@ -1078,7 +1078,7 @@ public function afterScenario( AfterScenarioScope $scope ): void { // Remove SUITE_CACHE_DIR if any. if ( self::$suite_cache_dir ) { - self::remove_dir( self::$suite_cache_dir ); + self::remove_dir_in_background( self::$suite_cache_dir ); self::$suite_cache_dir = null; } @@ -1700,13 +1700,20 @@ public function background_proc( $cmd ): void { ]; } + // If something else is already listening on the port, the process failing to bind to it + // would go unnoticed when waiting for the port, so fall back to waiting the full second. + $port = self::get_listening_port( $cmd ); + if ( null !== $port && self::is_port_open( $port ) ) { + $port = null; + } + $proc = proc_open( $cmd, $descriptors, $pipes, $this->variables['RUN_DIR'], self::get_process_env_variables() ); if ( Utils\is_windows() ) { fclose( $pipes[0] ); } - sleep( 1 ); + self::wait_for_background_proc( $proc, $port ); $status = proc_get_status( $proc ); @@ -1731,6 +1738,61 @@ public function background_proc( $cmd ): void { } } + /** + * Give a background process up to a second to start. + * + * Returns early once the process accepts connections on the given port. + * + * @param resource $proc + * @param int|null $port Port the process is expected to listen on, if known. + */ + private static function wait_for_background_proc( $proc, $port ): void { + $deadline = microtime( true ) + 1; + + while ( microtime( true ) < $deadline ) { + $status = proc_get_status( $proc ); + + if ( ! $status['running'] || ( null !== $port && self::is_port_open( $port ) ) ) { + return; + } + + usleep( 50000 ); + } + } + + /** + * Get the local port a background command is going to listen on, if it can be told from the command. + * + * @param string $cmd + * @return int|null + */ + private static function get_listening_port( $cmd ): ?int { + if ( preg_match( '/(?:localhost|127\.0\.0\.1):(\d+)/', $cmd, $matches ) || preg_match( '/--port[= ](\d+)/', $cmd, $matches ) ) { + return (int) $matches[1]; + } + + return null; + } + + /** + * Whether a local port accepts connections. + * + * @param int $port + * @return bool + */ + private static function is_port_open( $port ): bool { + // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- A refused connection is the expected answer for a closed port. + $connection = @fsockopen( 'localhost', $port, $errno, $errstr, 0.1 ); + + if ( false === $connection ) { + return false; + } + + fclose( $connection ); + + return true; + } + /** * Clean up temporary files safely. * @@ -1787,6 +1849,22 @@ public static function remove_dir( $dir ): void { @rmdir( $dir ); } + /** + * Remove a directory without waiting for it to be gone. + * + * Only meant for directories with a unique name, which no later scenario is going to use. + * + * @param string $dir + */ + private static function remove_dir_in_background( $dir ): void { + if ( Utils\is_windows() || ! is_dir( $dir ) ) { + self::remove_dir( $dir ); + return; + } + + exec( 'rm -rf ' . escapeshellarg( $dir ) . ' > /dev/null 2>&1 &' ); + } + /** * Copy a directory (recursive). Destination directory must exist. *