From 7dd2c47116257d7d847773196a35218b42bbcd70 Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 18 May 2026 10:18:28 +0100 Subject: [PATCH 1/4] Fix Windows blueprint import cleanup --- .../Steps/class-importcontentstep.php | 3 +- components/Blueprints/class-runner.php | 17 +++++--- .../Filesystem/class-localfilesystem.php | 41 +++++++++++++++++-- 3 files changed, 49 insertions(+), 12 deletions(-) diff --git a/components/Blueprints/Steps/class-importcontentstep.php b/components/Blueprints/Steps/class-importcontentstep.php index b09088560..490e61b89 100644 --- a/components/Blueprints/Steps/class-importcontentstep.php +++ b/components/Blueprints/Steps/class-importcontentstep.php @@ -67,7 +67,7 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker $import_process = $runtime->create_php_sub_process( $importer_script . <<<'PHP' - 'wxr', 'execution_context_root' => getenv('EXECUTION_CONTEXT') ? getenv('EXECUTION_CONTEXT') : null, @@ -75,7 +75,6 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker // @TODO: Support arbitrary media URLs to enable fetching assets during import. // 'media_url' => 'https://pd.w.org/' ]); -?> PHP , array( diff --git a/components/Blueprints/class-runner.php b/components/Blueprints/class-runner.php index 1daa3495a..e337a8b90 100644 --- a/components/Blueprints/class-runner.php +++ b/components/Blueprints/class-runner.php @@ -49,6 +49,7 @@ use WordPress\Blueprints\VersionStrings\WordPressVersion; use WordPress\ByteStream\ReadStream\FileReadStream; use WordPress\Filesystem\Filesystem; +use WordPress\Filesystem\FilesystemException; use WordPress\Filesystem\InMemoryFilesystem; use WordPress\Filesystem\LocalFilesystem; use WordPress\HttpClient\ByteStream\RequestReadStream; @@ -288,12 +289,16 @@ public function run(): void { $progress->finish(); } finally { // TODO: Optionally preserve workspace in case of error? Support resuming after error? - LocalFilesystem::create( $temp_root )->rmdir( - '/', - array( - 'recursive' => true, - ) - ); + try { + LocalFilesystem::create( $temp_root )->rmdir( + '/', + array( + 'recursive' => true, + ) + ); + } catch ( FilesystemException $exception ) { + // Do not fail or mask Blueprint execution because temporary cleanup failed. + } } } diff --git a/components/Filesystem/class-localfilesystem.php b/components/Filesystem/class-localfilesystem.php index 42617aa15..3103c59d0 100644 --- a/components/Filesystem/class-localfilesystem.php +++ b/components/Filesystem/class-localfilesystem.php @@ -153,11 +153,44 @@ public function rm( $path ) { } protected function rmdir_single( $path, $options = array() ) { - if ( false === @rmdir( $path ) ) { - throw new FilesystemException( - sprintf( 'Failed to remove directory: %s', $path ) - ); + $attempts = '\\' === DIRECTORY_SEPARATOR ? 10 : 1; + + for ( $attempt = 1; $attempt <= $attempts; $attempt++ ) { + if ( false !== @rmdir( $path ) ) { + return; + } + + clearstatcache( true, $path ); + + if ( $attempt === $attempts || ! is_dir( $path ) || ! $this->is_empty_dir( $path ) ) { + break; + } + + // Windows may briefly retain a directory handle after a subprocess exits. + usleep( 100000 ); + } + + throw new FilesystemException( + sprintf( 'Failed to remove directory: %s', $path ) + ); + } + + private function is_empty_dir( $path ) { + $handle = @opendir( $path ); + + if ( false === $handle ) { + return false; } + + while ( false !== ( $entry = readdir( $handle ) ) ) { + if ( '.' !== $entry && '..' !== $entry ) { + closedir( $handle ); + return false; + } + } + + closedir( $handle ); + return true; } public function put_contents( $path, $data, $options = array() ) { From edd04c6bb2dfd7c13e923adb4d720e376e4852ed Mon Sep 17 00:00:00 2001 From: Bernardo Cotrim Date: Mon, 18 May 2026 16:19:51 +0100 Subject: [PATCH 2/4] Narrow Windows blueprint cleanup fix --- .../Filesystem/class-localfilesystem.php | 41 ++----------------- 1 file changed, 4 insertions(+), 37 deletions(-) diff --git a/components/Filesystem/class-localfilesystem.php b/components/Filesystem/class-localfilesystem.php index 3103c59d0..42617aa15 100644 --- a/components/Filesystem/class-localfilesystem.php +++ b/components/Filesystem/class-localfilesystem.php @@ -153,44 +153,11 @@ public function rm( $path ) { } protected function rmdir_single( $path, $options = array() ) { - $attempts = '\\' === DIRECTORY_SEPARATOR ? 10 : 1; - - for ( $attempt = 1; $attempt <= $attempts; $attempt++ ) { - if ( false !== @rmdir( $path ) ) { - return; - } - - clearstatcache( true, $path ); - - if ( $attempt === $attempts || ! is_dir( $path ) || ! $this->is_empty_dir( $path ) ) { - break; - } - - // Windows may briefly retain a directory handle after a subprocess exits. - usleep( 100000 ); - } - - throw new FilesystemException( - sprintf( 'Failed to remove directory: %s', $path ) - ); - } - - private function is_empty_dir( $path ) { - $handle = @opendir( $path ); - - if ( false === $handle ) { - return false; - } - - while ( false !== ( $entry = readdir( $handle ) ) ) { - if ( '.' !== $entry && '..' !== $entry ) { - closedir( $handle ); - return false; - } + if ( false === @rmdir( $path ) ) { + throw new FilesystemException( + sprintf( 'Failed to remove directory: %s', $path ) + ); } - - closedir( $handle ); - return true; } public function put_contents( $path, $data, $options = array() ) { From 49b4f35837ea852acc0a2954b643333b03c4ee22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 8 Jun 2026 14:00:02 +0200 Subject: [PATCH 3/4] Log Blueprint temp cleanup failures --- .../Tests/Unit/RunnerCleanupTest.php | 158 ++++++++++++++++++ .../Unit/Steps/ImportContentStepTest.php | 80 +++++++++ components/Blueprints/class-runner.php | 8 +- 3 files changed, 245 insertions(+), 1 deletion(-) create mode 100644 components/Blueprints/Tests/Unit/RunnerCleanupTest.php create mode 100644 components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php diff --git a/components/Blueprints/Tests/Unit/RunnerCleanupTest.php b/components/Blueprints/Tests/Unit/RunnerCleanupTest.php new file mode 100644 index 000000000..7401d6916 --- /dev/null +++ b/components/Blueprints/Tests/Unit/RunnerCleanupTest.php @@ -0,0 +1,158 @@ +paths_to_remove ) as $path ) { + $this->remove_directory( $path ); + } + } + + public function test_removes_temporary_workspace_after_successful_run() { + $logger = new RecordingLogger(); + $runner = $this->create_runner_for_existing_site( $logger ); + + $runner->run(); + + $this->assertFalse( is_dir( $runner->runtime->get_temp_root() ) ); + $this->assertSame( array(), $logger->warnings ); + } + + public function test_logs_cleanup_failure_without_failing_successful_run() { + if ( PHP_OS_FAMILY !== 'Windows' && function_exists( 'posix_geteuid' ) && 0 === posix_geteuid() ) { + $this->markTestSkipped( 'This test needs filesystem permissions to reject a temporary workspace cleanup.' ); + } + + $logger = new RecordingLogger(); + $runner = $this->create_runner_for_existing_site( $logger ); + $temp_root = null; + $unremovable_dir = null; + $unremovable_file = null; + $open_handle = null; + + add_action( + 'blueprint.target_resolved', + function () use ( $runner, &$temp_root, &$unremovable_dir, &$unremovable_file, &$open_handle ) { + $temp_root = $runner->runtime->get_temp_root(); + $unremovable_dir = wp_join_unix_paths( $temp_root, 'unremovable' ); + $unremovable_file = wp_join_unix_paths( $unremovable_dir, 'locked.txt' ); + + mkdir( $unremovable_dir ); + file_put_contents( $unremovable_file, 'locked' ); + $open_handle = fopen( $unremovable_file, 'r' ); + + if ( PHP_OS_FAMILY !== 'Windows' ) { + chmod( $unremovable_dir, 0555 ); + } + } + ); + + try { + $runner->run(); + } finally { + if ( $open_handle ) { + fclose( $open_handle ); + } + if ( $unremovable_dir && is_dir( $unremovable_dir ) ) { + chmod( $unremovable_dir, 0777 ); + } + if ( $temp_root ) { + $this->remove_directory( $temp_root ); + } + } + + $this->assertCount( 1, $logger->warnings ); + $this->assertStringContainsString( 'Failed to remove temporary Blueprint workspace ', $logger->warnings[0] ); + $this->assertStringContainsString( $temp_root, $logger->warnings[0] ); + } + + private function create_runner_for_existing_site( RecordingLogger $logger ) { + $site_root = wp_join_unix_paths( wp_unix_sys_get_temp_dir(), 'blueprint_cleanup_test_' . uniqid() ); + $this->paths_to_remove[] = $site_root; + + mkdir( wp_join_unix_paths( $site_root, 'wp-content/plugins/sqlite-database-integration' ), 0777, true ); + file_put_contents( + wp_join_unix_paths( $site_root, 'wp-load.php' ), + <<<'STUBPHP' +set_blueprint( array( 'version' => 2 ) ) + ->set_execution_mode( Runner::EXECUTION_MODE_APPLY_TO_EXISTING_SITE ) + ->set_target_site_root( $site_root ) + ->set_target_site_url( 'http://example.com' ) + ->set_database_engine( 'sqlite' ) + ->set_logger( $logger ) + ->set_wp_cli_reference( + new InlineFile( + array( + 'filename' => 'wp-cli.phar', + 'content' => '', + ) + ) + ); + + return new Runner( $config ); + } + + private function remove_directory( $dir ) { + if ( ! is_dir( $dir ) ) { + return; + } + + chmod( $dir, 0777 ); + $objects = scandir( $dir ); + foreach ( $objects as $object ) { + if ( '.' === $object || '..' === $object ) { + continue; + } + + $path = $dir . DIRECTORY_SEPARATOR . $object; + if ( is_dir( $path ) ) { + $this->remove_directory( $path ); + } else { + @unlink( $path ); + } + } + @rmdir( $dir ); + } +} + +class RecordingLogger extends NoopLogger { + /** + * @var string[] + */ + public $warnings = array(); + + public function warning( $message, array $context = array() ): void { + $this->warnings[] = $message; + } +} diff --git a/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php b/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php new file mode 100644 index 000000000..025c6426d --- /dev/null +++ b/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php @@ -0,0 +1,80 @@ + 'content.xml', + 'content' => '', + ) + ); + $step = new ImportContentStep( + array( + array( + 'type' => 'wxr', + 'source' => $source, + ), + ) + ); + + $step->run( $runtime, new Tracker() ); + + $importer_script = file_get_contents( __DIR__ . '/../../../Steps/scripts/import-content.php' ); + $appended_code = substr( $runtime->captured_code, strlen( $importer_script ) ); + + $this->assertSame( 0, strpos( $appended_code, "\nrun_content_import([" ) ); + $this->assertStringNotContainsString( 'assertStringNotContainsString( '?>', $appended_code ); + } +} + +class CapturingImportRuntime extends Runtime { + /** + * @var string + */ + public $captured_code; + + public function __construct() { + } + + public function create_php_sub_process( + $code, + $env = null, + $input = null, + $timeout = 60 + ) { + $this->captured_code = $code; + + return new SuccessfulImportProcess(); + } + + public function get_execution_context_root(): ?string { + return null; + } +} + +class SuccessfulImportProcess { + public function start() { + } + + public function getOutputStream( $pipe ) { + return new MemoryPipe( '{"type":"completion"}' . "\n" ); + } + + public function getExitCode() { + return 0; + } + + public function stop() { + } +} diff --git a/components/Blueprints/class-runner.php b/components/Blueprints/class-runner.php index e337a8b90..1838e884b 100644 --- a/components/Blueprints/class-runner.php +++ b/components/Blueprints/class-runner.php @@ -297,7 +297,13 @@ public function run(): void { ) ); } catch ( FilesystemException $exception ) { - // Do not fail or mask Blueprint execution because temporary cleanup failed. + $this->configuration->get_logger()->warning( + sprintf( + 'Failed to remove temporary Blueprint workspace %s: %s', + $temp_root, + $exception->getMessage() + ) + ); } } } From 19501dc82fb35343e07f8e461a5995b3da17f546 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Adam=20Zieli=C5=84ski?= Date: Mon, 8 Jun 2026 15:44:04 +0200 Subject: [PATCH 4/4] Align Blueprint import script append with trunk --- components/Blueprints/Steps/class-importcontentstep.php | 1 - .../Blueprints/Tests/Unit/Steps/ImportContentStepTest.php | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/components/Blueprints/Steps/class-importcontentstep.php b/components/Blueprints/Steps/class-importcontentstep.php index 490e61b89..86911614d 100644 --- a/components/Blueprints/Steps/class-importcontentstep.php +++ b/components/Blueprints/Steps/class-importcontentstep.php @@ -67,7 +67,6 @@ private function importWxr( Runtime $runtime, array $content_definition, Tracker $import_process = $runtime->create_php_sub_process( $importer_script . <<<'PHP' - run_content_import([ 'mode' => 'wxr', 'execution_context_root' => getenv('EXECUTION_CONTEXT') ? getenv('EXECUTION_CONTEXT') : null, diff --git a/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php b/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php index 025c6426d..f33ae46d4 100644 --- a/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php +++ b/components/Blueprints/Tests/Unit/Steps/ImportContentStepTest.php @@ -32,7 +32,7 @@ public function test_wxr_import_appends_call_inside_existing_php_script() { $importer_script = file_get_contents( __DIR__ . '/../../../Steps/scripts/import-content.php' ); $appended_code = substr( $runtime->captured_code, strlen( $importer_script ) ); - $this->assertSame( 0, strpos( $appended_code, "\nrun_content_import([" ) ); + $this->assertSame( 0, strpos( ltrim( $appended_code ), 'run_content_import([' ) ); $this->assertStringNotContainsString( 'assertStringNotContainsString( '?>', $appended_code ); }