From ec25affa404fc9a7de0a8cfed3771a452c3ece37 Mon Sep 17 00:00:00 2001 From: Ernest Defoe Date: Fri, 4 Sep 2026 09:17:06 -0500 Subject: [PATCH] fix: swap the vendor directory with renames instead of a delete and a move MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Safe mode builds the new dependency tree in temp-vendor and then swaps it in by deleting vendor and moving temp-vendor over it. Between those two steps the forum has no vendor directory, for as long as it takes to delete and then move several hundred megabytes of packages. That happens at the very end of a job that can be killed — a Horizon worker timeout, a memory limit, a container restart. A process that dies inside the window leaves the site with no vendor directory at all and a temp-vendor nobody thinks to look for, and the forum stops booting until somebody completes the move by hand. Renaming twice closes the window: it is effectively instantaneous on the same filesystem, and the working tree survives under vendor-previous until the new one is in place. If the second rename fails, the old tree is put back and the failure is reported on the task, so an admin sees a failed update rather than a forum that has stopped responding. Co-Authored-By: Claude Opus 5 --- .../src/Composer/ComposerAdapter.php | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/extensions/package-manager/src/Composer/ComposerAdapter.php b/extensions/package-manager/src/Composer/ComposerAdapter.php index 49b3c08fd4..b04ee2c7f4 100644 --- a/extensions/package-manager/src/Composer/ComposerAdapter.php +++ b/extensions/package-manager/src/Composer/ComposerAdapter.php @@ -58,10 +58,38 @@ public function run(InputInterface $input, ?Task $task = null, bool $safeMode = // Move the temporary vendor directory to the real vendor directory. if ($this->filesystem->isDirectory($temporaryVendorDir) && count($this->filesystem->allFiles($temporaryVendorDir))) { $vendorDir = $this->paths->vendor; - if (file_exists($vendorDir)) { - $this->filesystem->deleteDirectory($vendorDir); + $previousVendorDir = $vendorDir.'-previous'; + + // Left over from a run that was interrupted before it could clean + // up after itself. + if ($this->filesystem->isDirectory($previousVendorDir)) { + $this->filesystem->deleteDirectory($previousVendorDir); + } + + // Two renames, rather than deleting the live vendor directory and + // then moving the new one into place. Deleting first leaves the + // forum with no vendor directory for as long as it takes to remove + // and then move several hundred megabytes of packages, and this + // runs at the very end of a long job that can be killed by a + // worker timeout, a memory limit or a container restart. A process + // that dies inside that window leaves the site with no vendor + // directory at all, and a temp-vendor nobody thinks to look for. + // Renaming is effectively instantaneous on the same filesystem, + // and keeps the working tree until the new one is in place. + if ($this->filesystem->isDirectory($vendorDir)) { + $this->filesystem->moveDirectory($vendorDir, $previousVendorDir); } - $this->filesystem->moveDirectory($temporaryVendorDir, $vendorDir); + + if (! $this->filesystem->moveDirectory($temporaryVendorDir, $vendorDir)) { + // Put the working tree back rather than leave the site without + // one. The caller records this on the task, so the admin sees a + // failed update instead of a forum that has stopped booting. + $this->filesystem->moveDirectory($previousVendorDir, $vendorDir); + + throw new \RuntimeException('Failed to move the new vendor directory into place.'); + } + + $this->filesystem->deleteDirectory($previousVendorDir); } Config::$defaultConfig['vendor-dir'] = $this->paths->vendor; }