From 4e697827c7585a0208d985f03c9d8d2c547ddcca Mon Sep 17 00:00:00 2001 From: Alain Schlesser Date: Mon, 2 Nov 2026 10:44:13 +0100 Subject: [PATCH] Remove the Docker containers of runs that were stopped or killed A run removed its containers in shutdown functions, which PHP skips when a signal ends it, so Ctrl+C or a timeout left the database containers running. Exit through the signal handlers when the pcntl extension is available, so the shutdown functions run. Label the containers with the host and process of their run, and remove those of runs on this host whose process is gone before starting new ones, which covers runs that were killed outright. Containers of runs that are still going, of other hosts and those without the label are left alone. Co-Authored-By: Claude Opus 5.5 --- .readme-partials/USING.md | 2 + utils/behat-parallel.php | 98 +++++++++++++++++++++++++++++++++++++-- 2 files changed, 96 insertions(+), 4 deletions(-) diff --git a/.readme-partials/USING.md b/.readme-partials/USING.md index b6269223..38d52338 100644 --- a/.readme-partials/USING.md +++ b/.readme-partials/USING.md @@ -280,6 +280,8 @@ Docker is not used on Windows, when collecting code coverage, when Docker is not Each worker gets its own network, temporary directory and database server, of the same type and version as the one configured through the `WP_CLI_TEST_DB*` variables (or set the image through `WP_CLI_TEST_DOCKER_DB`, e.g. `mysql:8.0`, so that the host needs neither a database server nor a client). Nothing is shared between workers, so they can run any scenario. The database servers keep their data in memory. Scenarios selected by path or name run in a single container. +The containers are removed when the run ends, also when it is stopped with Ctrl+C or a termination signal (with the `pcntl` extension). Containers of a run that was killed outright are removed by the next run on the same host, which only removes those whose run is no longer going. + The containers run the same PHP version as the host, unless `WP_CLI_TEST_DOCKER_PHP` sets another one: ```bash WP_CLI_TEST_DOCKER=1 WP_CLI_TEST_DOCKER_PHP=7.4 WP_CLI_TEST_PARALLEL=4 composer behat diff --git a/utils/behat-parallel.php b/utils/behat-parallel.php index 30a01077..3de8b7b3 100644 --- a/utils/behat-parallel.php +++ b/utils/behat-parallel.php @@ -451,6 +451,65 @@ function wp_cli_tests_docker_mounts( $base_dir, $vendor = null ) { return $mounts; } +/** + * Get the label value that marks the Docker containers of this run, by host and process. + * + * @return string + */ +function wp_cli_tests_docker_owner() { + return php_uname( 'n' ) . ':' . getmypid(); +} + +/** + * Get whether a process still runs. + * + * @param int $pid + * @return bool|null Null if that cannot be told. + */ +function wp_cli_tests_process_exists( $pid ) { + if ( function_exists( 'posix_kill' ) ) { + // A process of another user cannot be signalled, but exists. + return posix_kill( $pid, 0 ) || ( function_exists( 'posix_get_last_error' ) && 1 === posix_get_last_error() ); + } + + if ( is_dir( '/proc/self' ) ) { + return is_dir( "/proc/{$pid}" ); + } + + return null; +} + +/** + * Remove the containers that earlier runs on this host left behind, such as when they were killed. + * + * Only containers labelled by a run whose process is gone are removed, so runs that are still + * going keep theirs, as do containers this script did not start. + */ +function wp_cli_tests_docker_remove_abandoned() { + exec( 'docker ps -a --filter label=wp-cli-tests.owner --format ' . escapeshellarg( '{{.ID}} {{.Label "wp-cli-tests.owner"}}' ) . ' 2>/dev/null', $lines, $exit_code ); + if ( 0 !== $exit_code ) { + return; + } + + $host = php_uname( 'n' ); + $abandoned = []; + foreach ( $lines as $line ) { + $parts = explode( ' ', trim( $line ), 2 ); + if ( 2 !== count( $parts ) || ! preg_match( '/^(.+):(\d+)$/', $parts[1], $matches ) || $host !== $matches[1] || getmypid() === (int) $matches[2] ) { + continue; + } + + if ( false === wp_cli_tests_process_exists( (int) $matches[2] ) ) { + $abandoned[] = $parts[0]; + } + } + + if ( $abandoned ) { + exec( 'docker rm -f ' . implode( ' ', array_map( 'escapeshellarg', $abandoned ) ) . ' > /dev/null 2>&1' ); + fwrite( STDERR, sprintf( "Removed %d Docker containers that earlier runs left behind.\n", count( $abandoned ) ) ); + } +} + /** * Start the containers of a worker and get the command that runs its scenarios. * @@ -464,10 +523,11 @@ function wp_cli_tests_docker_mounts( $base_dir, $vendor = null ) { */ function wp_cli_tests_docker_worker_command( $name, $php_image, $db_image, array $mounts, array $user_args, $scenario_list ) { $network = ''; + $label = ' --label ' . escapeshellarg( 'wp-cli-tests.owner=' . wp_cli_tests_docker_owner() ); if ( null !== $db_image ) { exec( - 'docker run -d --rm --name ' . escapeshellarg( "{$name}-db" ) + 'docker run -d --rm --name ' . escapeshellarg( "{$name}-db" ) . $label . ' --tmpfs /var/lib/mysql' . ' -e MYSQL_ROOT_PASSWORD=root -e MYSQL_USER=wp_cli_test -e MYSQL_PASSWORD=password1' . ' ' . escapeshellarg( $db_image ) . ' ' . wp_cli_tests_docker_db_options( $db_image ) . ' 2>&1' @@ -489,7 +549,7 @@ function wp_cli_tests_docker_worker_command( $name, $php_image, $db_image, array } } - $command = 'docker run --rm --name ' . escapeshellarg( $name ) . $network + $command = 'docker run --rm --name ' . escapeshellarg( $name ) . $label . $network . ' --tmpfs /tmp:exec,mode=1777' . ' -u ' . escapeshellarg( getmyuid() . ':' . getmygid() ) . ' -w ' . escapeshellarg( (string) getcwd() ); @@ -596,6 +656,28 @@ function wp_cli_tests_docker_worker_command( $name, $php_image, $db_image, array } } +if ( $docker ) { + wp_cli_tests_docker_remove_abandoned(); + + // Ending the run through exit() on Ctrl+C or a termination signal removes its containers. + if ( function_exists( 'pcntl_async_signals' ) ) { + pcntl_async_signals( true ); + $exit_codes = [ + SIGINT => 130, + SIGTERM => 143, + SIGHUP => 129, + ]; + foreach ( $exit_codes as $signal => $code ) { + pcntl_signal( + $signal, + static function () use ( $code ) { + exit( $code ); + } + ); + } + } +} + // Kept between runs, so that the workers' caches stay warm and failed scenarios can be inspected. $base_dir = sys_get_temp_dir() . '/wp-cli-test-parallel-' . substr( md5( (string) getcwd() ), 0, 8 ); @@ -610,8 +692,16 @@ static function () use ( $name ) { exec( 'docker rm -f ' . escapeshellarg( $name ) . ' ' . escapeshellarg( "{$name}-db" ) . ' > /dev/null 2>&1' ); } ); - passthru( wp_cli_tests_docker_worker_command( $name, (string) $php_image, $db_image, wp_cli_tests_docker_mounts( $base_dir, $vendor ), $user_args, null ), $exit_code ); - exit( $exit_code ); + // Waiting in PHP rather than in passthru() lets the signal handlers run. + $proc = proc_open( wp_cli_tests_docker_worker_command( $name, (string) $php_image, $db_image, wp_cli_tests_docker_mounts( $base_dir, $vendor ), $user_args, null ), [ STDIN, STDOUT, STDERR ], $pipes ); + if ( ! is_resource( $proc ) ) { + exit( 1 ); + } + do { + usleep( 200000 ); + $status = proc_get_status( $proc ); + } while ( $status['running'] ); + exit( $status['exitcode'] ); } require getcwd() . '/vendor/autoload.php';