From f12250b80f0ece99e45b216cc15163a2443b2cb4 Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:11:49 +0300 Subject: [PATCH 1/6] Build/Test Tools: Propagate the exit status of the local environment commands. `start.js` runs `docker compose up` through `spawnSync` and never inspects the result, so a failed pull does not fail the script. Execution continues into `composer update -W` with containers that may not exist, and the error surfaces later and in the wrong place. `docker.js` calls `process.exit( returns.status )`, and `status` is `null` when Docker cannot be spawned. `process.exit( null )` exits 0, so `npm run env:pull` reports success when the Docker CLI is missing. Reproduce with: LOCAL_PHP=this-tag-does-not-exist npm run env:start; echo $? See #65745. --- tools/local-env/scripts/docker.js | 8 +++++++- tools/local-env/scripts/start.js | 9 ++++++++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index c7b11f0058424..5eaead5fd09d4 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -38,4 +38,10 @@ const returns = spawnSync( { stdio: 'inherit' } ); -process.exit( returns.status ); +if ( returns.error ) { + console.error( `Could not run Docker Compose. ${ returns.error.message }` ); +} + +// `status` is null when Docker could not be spawned at all, or was killed by a signal. Ctrl+C on +// a long-running command such as `env:logs` is not a failure worth an npm error block. +process.exit( returns.signal ? 0 : ( returns.status ?? 1 ) ); diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js index 66559d4c10b85..110201c74d4bb 100644 --- a/tools/local-env/scripts/start.js +++ b/tools/local-env/scripts/start.js @@ -32,7 +32,7 @@ if ( process.env.LOCAL_PHP_MEMCACHED === 'true' ) { containers.push( 'memcached' ); } -spawnSync( +const up = spawnSync( 'docker', [ 'compose', @@ -45,6 +45,13 @@ spawnSync( { stdio: 'inherit' } ); +if ( up.status !== 0 ) { + console.error( `Could not start the Docker containers.${ up.error ? ` ${ up.error.message }` : '' }` ); + + // `status` is null when Docker could not be spawned at all, or was killed by a signal. + process.exit( up.status ?? 1 ); +} + // If Docker Toolbox is being used, we need to manually forward LOCAL_PORT to the Docker VM. if ( process.env.DOCKER_TOOLBOX_INSTALL_PATH ) { // VBoxManage is added to the PATH on every platform except Windows. From a88479c520c417cd6a9f5fbd710625ea46ef9133 Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:49:54 +0300 Subject: [PATCH 2/6] Build/Test Tools: Only treat SIGINT as a clean exit of a local environment command. `docker.js` exited 0 for any signal, so a command killed by SIGTERM or SIGKILL reported success. That reintroduced the false success this ticket set out to remove: the retry loop in `reusable-phpunit-tests-v3.yml` branches on the status of `npm run env:pull` and would treat a killed pull as a completed one. Restrict the exemption to SIGINT, which is how a long-running command such as `env:logs` is normally ended. Report every other signal and exit non-zero. `start.js` exempts no signal, because `env:start` runs `composer update -W` next and that must not run against containers that never came up. Say so in the comment, so the difference between the two files is deliberate. Also fold the unreachable `up.error` branch in `start.js` into the failure message. The `docker info` check above it already throws when the Docker CLI is missing or the daemon is down. --- tools/local-env/scripts/docker.js | 9 ++++++--- tools/local-env/scripts/start.js | 6 +++++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index 5eaead5fd09d4..ce71a4c075cd6 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -40,8 +40,11 @@ const returns = spawnSync( if ( returns.error ) { console.error( `Could not run Docker Compose. ${ returns.error.message }` ); +} else if ( returns.signal && returns.signal !== 'SIGINT' ) { + console.error( `Docker Compose was terminated by ${ returns.signal }.` ); } -// `status` is null when Docker could not be spawned at all, or was killed by a signal. Ctrl+C on -// a long-running command such as `env:logs` is not a failure worth an npm error block. -process.exit( returns.signal ? 0 : ( returns.status ?? 1 ) ); +// `status` is null when Docker could not be spawned at all, or was killed by a signal. SIGINT is +// how a long-running command such as `env:logs` is normally ended, so it is not a failure worth an +// npm error block. Every other signal means the command was killed before it finished. +process.exit( returns.signal === 'SIGINT' ? 0 : ( returns.status ?? 1 ) ); diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js index 110201c74d4bb..a8e5dc868bf9e 100644 --- a/tools/local-env/scripts/start.js +++ b/tools/local-env/scripts/start.js @@ -45,8 +45,12 @@ const up = spawnSync( { stdio: 'inherit' } ); +// No signal is exempt here, unlike in `docker.js`: `env:start` runs `composer update -W` next, and +// that must not run against containers that never came up. if ( up.status !== 0 ) { - console.error( `Could not start the Docker containers.${ up.error ? ` ${ up.error.message }` : '' }` ); + const reason = up.signal ? `It was terminated by ${ up.signal }.` : up.error?.message ?? ''; + + console.error( `Could not start the Docker containers. ${ reason }`.trim() ); // `status` is null when Docker could not be spawned at all, or was killed by a signal. process.exit( up.status ?? 1 ); From 5fd43cf8df8549ce20a85eb60ff4ccbeca09f60c Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:04:10 +0300 Subject: [PATCH 3/6] Build/Test Tools: Retry the local environment commands that reach a registry. Moves the retry loop from [63163] into `utils.js` so `start.js` can use it too, and adds `ensure_env_file()` so every script creates `.env` before Compose reads it. `docker.js` retries `pull` and any command containing `composer`. `start.js` retries `docker compose up`. Folds in the changes from PR #12736. See #65745. --- tools/local-env/scripts/docker.js | 40 +++------------- tools/local-env/scripts/install.js | 2 + tools/local-env/scripts/start.js | 22 ++------- tools/local-env/scripts/utils.js | 77 +++++++++++++++++++++++++++++- 4 files changed, 87 insertions(+), 54 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index 0d86b8112d51f..c72ef348d4561 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -2,12 +2,11 @@ const dotenv = require( 'dotenv' ); const dotenvExpand = require( 'dotenv-expand' ); -const { spawnSync } = require( 'child_process' ); const local_env_utils = require( './utils' ); -dotenvExpand.expand( dotenv.config() ); +local_env_utils.ensure_env_file(); -const composeFiles = local_env_utils.get_compose_files(); +dotenvExpand.expand( dotenv.config() ); if ( process.argv.includes( '--coverage-html' ) ) { process.env.LOCAL_PHP_XDEBUG = 'true'; @@ -25,40 +24,13 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock dockerCommand.push( '--defaults' ); } -const composeArgs = [ - 'compose', - ...composeFiles - .map( ( composeFile ) => [ '-f', composeFile ] ) - .flat(), - ...dockerCommand, -]; - // Failures during image pulls are re-attempted to rule out registry rate limits and network issues. -const maxAttempts = 'pull' === dockerCommand[0] ? 3 : 1; +// Composer runs are re-attempted for the same reason: they reach repo.packagist.org, and both +// `composer install` and `composer update` are safe to repeat. +const retryable = 'pull' === dockerCommand[0] || dockerCommand.includes( 'composer' ); // Execute any Docker compose command passed to this script. -let returns; -for ( let attempt = 1; attempt <= maxAttempts; attempt++ ) { - returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } ); - - if ( 0 === returns.status ) { - break; - } - - if ( attempt === maxAttempts ) { - if ( maxAttempts > 1 ) { - console.log( `\ndocker compose ${ dockerCommand[0] } failed after ${ attempt } attempts.` ); - } - - break; - } - - const delay = attempt * 10; - console.log( `\ndocker compose ${ dockerCommand[0] } failed (attempt ${ attempt } of ${ maxAttempts }). Retrying in ${ delay } seconds...\n` ); - - // Sleep synchronously so the retry loop stays in order without going async. - Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 ); -} +const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 ); if ( returns.error ) { console.error( `Could not run Docker Compose. ${ returns.error.message }` ); diff --git a/tools/local-env/scripts/install.js b/tools/local-env/scripts/install.js index 0578545c11fec..17053b84d17f2 100644 --- a/tools/local-env/scripts/install.js +++ b/tools/local-env/scripts/install.js @@ -7,6 +7,8 @@ const { execSync } = require( 'child_process' ); const { readFileSync, writeFileSync } = require( 'fs' ); const local_env_utils = require( './utils' ); +local_env_utils.ensure_env_file(); + dotenvExpand.expand( dotenv.config() ); // Create wp-config.php. This verifies the database connection, so retrying it doubles as the diff --git a/tools/local-env/scripts/start.js b/tools/local-env/scripts/start.js index 98d1a8109fc21..984e06bee5617 100644 --- a/tools/local-env/scripts/start.js +++ b/tools/local-env/scripts/start.js @@ -4,17 +4,11 @@ const dotenv = require( 'dotenv' ); const dotenvExpand = require( 'dotenv-expand' ); const { execSync, spawnSync } = require( 'child_process' ); const local_env_utils = require( './utils' ); -const { copyFileSync, existsSync } = require( 'node:fs' ); -// Copy the default .env file when one is not present. -if ( ! existsSync( '.env' ) ) { - copyFileSync( '.env.example', '.env' ); -} +local_env_utils.ensure_env_file(); dotenvExpand.expand( dotenv.config() ); -const composeFiles = local_env_utils.get_compose_files(); - // Check if the Docker service is running. try { execSync( 'docker info' ); @@ -32,18 +26,8 @@ if ( process.env.LOCAL_PHP_MEMCACHED === 'true' ) { containers.push( 'memcached' ); } -const up = spawnSync( - 'docker', - [ - 'compose', - ...composeFiles.map( ( composeFile ) => [ '-f', composeFile ] ).flat(), - 'up', - '--quiet-pull', - '-d', - ...containers, - ], - { stdio: 'inherit' } -); +// `up` pulls any image that is missing, so it is re-attempted for the same reasons as `env:pull`. +const up = local_env_utils.compose_with_retry( [ 'up', '--quiet-pull', '-d', ...containers ], 3 ); // No signal is exempt here, unlike in `docker.js`: `env:start` runs `composer update -W` next, and // that must not run against containers that never came up. diff --git a/tools/local-env/scripts/utils.js b/tools/local-env/scripts/utils.js index 51f02e32a1d2d..302aab64279c3 100644 --- a/tools/local-env/scripts/utils.js +++ b/tools/local-env/scripts/utils.js @@ -1,9 +1,84 @@ /* jshint node:true */ -const { existsSync } = require( 'node:fs' ); +const { spawnSync } = require( 'node:child_process' ); +const { constants, copyFileSync, existsSync } = require( 'node:fs' ); +const { join } = require( 'node:path' ); + +const repo_root = join( __dirname, '..', '..', '..' ); const local_env_utils = { + /** + * Creates the .env file from .env.example when one is not present. + * + * Docker Compose reads this file to resolve the image tags, so it must exist before any + * Compose command runs, not just before the containers are started. + */ + ensure_env_file: function() { + try { + copyFileSync( join( repo_root, '.env.example' ), join( repo_root, '.env' ), constants.COPYFILE_EXCL ); + } catch ( e ) { + // A .env that is already there is the common case and needs no warning. Any other + // failure means the scripts run without the settings from .env, which is worth + // reporting, but is never a reason to refuse to run a command such as `env:stop`. + if ( e.code !== 'EEXIST' ) { + console.warn( `Could not create a .env file from .env.example. ${ e.message }` ); + } + } + }, + + /** + * Runs a Docker Compose command, re-attempting it when it fails. + * + * Any command that reaches a registry can fail for reasons that clear on their own, such as + * rate limits and transient network errors. + * + * @param {string[]} args The Compose command and its arguments, such as `[ 'pull' ]`. + * @param {number} attempts How many times to run the command before giving up. + * + * @return {Object} The result of the last attempt. + */ + compose_with_retry: function( args, attempts ) { + const composeArgs = [ + 'compose', + ...local_env_utils.get_compose_files() + .map( ( composeFile ) => [ '-f', composeFile ] ) + .flat(), + ...args, + ]; + + let returns; + + for ( let attempt = 1; attempt <= attempts; attempt++ ) { + returns = spawnSync( 'docker', composeArgs, { stdio: 'inherit' } ); + + if ( 0 === returns.status ) { + break; + } + + // A command killed by a signal was cancelled, not failed. Do not run it again. + if ( returns.signal ) { + break; + } + + if ( attempt === attempts ) { + if ( attempts > 1 ) { + console.log( `\ndocker compose ${ args[0] } failed after ${ attempt } attempts.` ); + } + + break; + } + + const delay = attempt * 10; + console.log( `\ndocker compose ${ args[0] } failed (attempt ${ attempt } of ${ attempts }). Retrying in ${ delay } seconds...\n` ); + + // Sleep synchronously so the retry loop stays in order without going async. + Atomics.wait( new Int32Array( new SharedArrayBuffer( 4 ) ), 0, 0, delay * 1000 ); + } + + return returns; + }, + /** * Determines which Docker compose files are required to properly configure the local environment given the * specified PHP version, database type, and database version. From 75be36c3a2c76482928ba6883e6d4140b739e6d4 Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Thu, 13 Aug 2026 16:32:07 +0300 Subject: [PATCH 4/6] Build/Test Tools: Narrow which local environment commands are re-attempted. Stop the retry loop when the command could not be spawned at all. That error fails the same way every time, so re-attempting it only delays the report by 30 seconds. Re-attempt `composer install` and `composer update`, rather than every Composer run. `typecheck:php` and `typecheck:php:baselines` reach no registry, so a PHPStan failure is a real result and was being reported three times. See #65745. --- tools/local-env/scripts/docker.js | 16 ++++++++++------ tools/local-env/scripts/utils.js | 5 +++-- 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index c72ef348d4561..f9836959bf856 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -25,9 +25,12 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock } // Failures during image pulls are re-attempted to rule out registry rate limits and network issues. -// Composer runs are re-attempted for the same reason: they reach repo.packagist.org, and both -// `composer install` and `composer update` are safe to repeat. -const retryable = 'pull' === dockerCommand[0] || dockerCommand.includes( 'composer' ); +// `composer install` and `composer update` are re-attempted for the same reason: they reach +// repo.packagist.org, and both are safe to repeat. Every other Composer script, such as `phpstan`, +// runs once: it reaches no registry, so a failure is a real result rather than a transient one. +const composerCommand = dockerCommand[ dockerCommand.indexOf( 'composer' ) + 1 ]; +const retryable = 'pull' === dockerCommand[0] || + ( 'run' === dockerCommand[0] && [ 'install', 'update' ].includes( composerCommand ) ); // Execute any Docker compose command passed to this script. const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 ); @@ -38,7 +41,8 @@ if ( returns.error ) { console.error( `Docker Compose was terminated by ${ returns.signal }.` ); } -// `status` is null when Docker could not be spawned at all, or was killed by a signal. SIGINT is -// how a long-running command such as `env:logs` is normally ended, so it is not a failure worth an -// npm error block. Every other signal means the command was killed before it finished. +// `status` is null when Docker could not be spawned at all, or was killed by a signal. Ctrl-C +// signals the whole process group, so this script usually dies alongside Compose without reaching +// here. This covers a signal sent to Compose alone: SIGINT means the command was cancelled, as +// when ending `env:logs`, and every other signal means it was killed before it finished. process.exit( returns.signal === 'SIGINT' ? 0 : ( returns.status ?? 1 ) ); diff --git a/tools/local-env/scripts/utils.js b/tools/local-env/scripts/utils.js index 302aab64279c3..58c02716241d5 100644 --- a/tools/local-env/scripts/utils.js +++ b/tools/local-env/scripts/utils.js @@ -56,8 +56,9 @@ const local_env_utils = { break; } - // A command killed by a signal was cancelled, not failed. Do not run it again. - if ( returns.signal ) { + // A command killed by a signal was cancelled rather than failed, and a command that + // could not be spawned at all fails the same way every time. Do not run either again. + if ( returns.signal || returns.error ) { break; } From 3d99050c138f619b27db8042f7dd59d9e71d395e Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Fri, 14 Aug 2026 11:08:31 +0300 Subject: [PATCH 5/6] Build/Test Tools: Match the Composer subcommand anywhere in the command. Composer accepts global options before the subcommand, so `env:composer -- -n update` placed `-n` where the subcommand was expected and lost the retry. See #65745. --- tools/local-env/scripts/docker.js | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index f9836959bf856..ba6b493879831 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -25,12 +25,13 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock } // Failures during image pulls are re-attempted to rule out registry rate limits and network issues. -// `composer install` and `composer update` are re-attempted for the same reason: they reach -// repo.packagist.org, and both are safe to repeat. Every other Composer script, such as `phpstan`, -// runs once: it reaches no registry, so a failure is a real result rather than a transient one. -const composerCommand = dockerCommand[ dockerCommand.indexOf( 'composer' ) + 1 ]; +// `composer install` and `composer update` reach repo.packagist.org for the same reason, and both +// are safe to repeat. Composer accepts global options before the subcommand, so these are matched +// anywhere in the command rather than by position. Every other Composer script, such as `phpstan`, +// reaches no registry and runs once: a failure there is a real result rather than a transient one. const retryable = 'pull' === dockerCommand[0] || - ( 'run' === dockerCommand[0] && [ 'install', 'update' ].includes( composerCommand ) ); + ( 'run' === dockerCommand[0] && dockerCommand.includes( 'composer' ) && + ( dockerCommand.includes( 'install' ) || dockerCommand.includes( 'update' ) ) ); // Execute any Docker compose command passed to this script. const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 ); From e6f190274683b38e00b9e0df9a13a32a8bcdb842 Mon Sep 17 00:00:00 2001 From: Adrian Moldovan <3854374+adimoldovan@users.noreply.github.com> Date: Fri, 14 Aug 2026 12:13:24 +0300 Subject: [PATCH 6/6] Build/Test Tools: Identify the Composer command before re-attempting it. Global options can precede the command, and `--working-dir` takes a separate value, so searching the arguments matched tokens that were never the command. Both `env:composer -- --working-dir update validate` and `typecheck:php -- update` were re-attempted three times despite reaching no registry. See #65745. --- tools/local-env/scripts/docker.js | 21 +++++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/tools/local-env/scripts/docker.js b/tools/local-env/scripts/docker.js index ba6b493879831..a6b0f4e0af38e 100644 --- a/tools/local-env/scripts/docker.js +++ b/tools/local-env/scripts/docker.js @@ -26,12 +26,25 @@ if ( dockerCommand.includes( 'cli' ) && dockerCommand.includes( 'db' ) && ! dock // Failures during image pulls are re-attempted to rule out registry rate limits and network issues. // `composer install` and `composer update` reach repo.packagist.org for the same reason, and both -// are safe to repeat. Composer accepts global options before the subcommand, so these are matched -// anywhere in the command rather than by position. Every other Composer script, such as `phpstan`, -// reaches no registry and runs once: a failure there is a real result rather than a transient one. +// are safe to repeat. Every other Composer command runs once, so a failure such as a PHPStan error +// is reported as the real result it is. +const composerArgs = dockerCommand.slice( dockerCommand.indexOf( 'composer' ) + 1 ); +let composerCommand; + +for ( let i = 0; i < composerArgs.length; i++ ) { + // Global options precede the command. `--working-dir` is the only one that takes a separate + // value, so it is the only value that could otherwise be mistaken for the command itself. + if ( '-d' === composerArgs[i] || '--working-dir' === composerArgs[i] ) { + i++; + } else if ( ! composerArgs[i].startsWith( '-' ) ) { + composerCommand = composerArgs[i]; + break; + } +} + const retryable = 'pull' === dockerCommand[0] || ( 'run' === dockerCommand[0] && dockerCommand.includes( 'composer' ) && - ( dockerCommand.includes( 'install' ) || dockerCommand.includes( 'update' ) ) ); + [ 'install', 'update' ].includes( composerCommand ) ); // Execute any Docker compose command passed to this script. const returns = local_env_utils.compose_with_retry( dockerCommand, retryable ? 3 : 1 );