Skip to content

fix: swap the vendor directory with renames instead of a delete and a move - #5034

Open
ernestdefoe wants to merge 1 commit into
flarum:2.xfrom
ernestdefoe:fix/package-manager-atomic-vendor-swap
Open

ernestdefoe wants to merge 1 commit into
flarum:2.xfrom
ernestdefoe:fix/package-manager-atomic-vendor-swap

Conversation

@ernestdefoe

Copy link
Copy Markdown

Fixes

Safe mode can leave a forum with no vendor/ directory at all, and the site stops booting until somebody finishes the move by hand.

Changes

ComposerAdapter::run() builds the new dependency tree in temp-vendor and then swaps it in like this:

$vendorDir = $this->paths->vendor;
if (file_exists($vendorDir)) {
    $this->filesystem->deleteDirectory($vendorDir);
}
$this->filesystem->moveDirectory($temporaryVendorDir, $vendorDir);

Between those two statements the forum has no vendor/, for as long as it takes to delete and then move several hundred megabytes of packages. That is not a small window on a real install, and it is the very last thing a long-running job does — a job that can be killed by a Horizon worker timeout, a memory limit, or a container restart.

A process that dies inside the window leaves vendor/ gone and temp-vendor/ orphaned. The forum returns 500s, and nothing in the admin explains why: the task row still says running, because the job never reached the code that would have marked it failed.

I hit this on a live forum while debugging an unrelated update failure. Recovery was mv temp-vendor vendor followed by composer install, which is fine if you have shell access — but this extension exists largely for people who do not.

This changes the swap to two renames:

vendor        -> vendor-previous
temp-vendor   -> vendor
delete vendor-previous

Renames are effectively instantaneous on the same filesystem, so the window shrinks from "however long it takes to move hundreds of megabytes" to a single syscall. The working tree also survives under vendor-previous until the new one is in place, so an interrupted run leaves something recoverable rather than nothing.

If the second rename fails, the old tree is moved back and a RuntimeException is thrown. ComposerCommandJob already catches Throwable and records it on the task, so the admin sees a failed update instead of a forum that has stopped responding.

No behaviour changes on the success path.

Reviewer notes

  • Filesystem::moveDirectory() returns false when the underlying @rename() fails, which is what the restore path keys off.
  • vendor-previous is removed at the end of a successful swap, and any leftover from an interrupted earlier run is removed before the next one starts.
  • Both directories are siblings under the installation root, so the renames stay on one filesystem.

… move

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 <noreply@anthropic.com>
@ernestdefoe
ernestdefoe requested a review from a team as a code owner September 4, 2026 14:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant