Skip to content

fix(extension-manager): stop re-checking for updates in the same process that ran composer update (#4764) - #5043

Open
wakqasahmed wants to merge 1 commit into
flarum:2.xfrom
wakqasahmed:fix/issue-4764-global-update-recheck-crash
Open

fix(extension-manager): stop re-checking for updates in the same process that ran composer update (#4764)#5043
wakqasahmed wants to merge 1 commit into
flarum:2.xfrom
wakqasahmed:fix/issue-4764-global-update-recheck-crash

Conversation

@wakqasahmed

Copy link
Copy Markdown

Fixes #4764.

GlobalUpdateHandler, MinorUpdateHandler, and MajorUpdateHandler all run composer update in-process — ComposerAdapter::run() calls Composer's own Application::run() directly rather than shelling out — then synchronously dispatch FlarumUpdated. ReCheckForUpdates responds to that by immediately dispatching CheckForUpdates, whose handler constructor-injects a Guzzle Client. Constructing that client touches whatever HTTP handler class Guzzle picks, and if the update just bumped Guzzle itself onto a version with a new class — GuzzleHttp\Handler\CurlVersion, added in 7.12.0 — the still-running process's autoloader has no idea that class exists yet, and it fatals mid-request. That's exactly the reported crash upgrading to v2.0.0-rc.4.

I looked at just queuing the recheck instead, since Job\Dispatcher already exists and is used for the outer GlobalUpdate/CheckForUpdates commands. It doesn't actually solve this: Job\Dispatcher::dispatch() only defers to the queue when the configured queue isn't SyncQueue, and Flarum defaults to SyncQueue for any install that hasn't set up Redis or a database queue — which just runs the job immediately, in the same process, identical to today. So for the common case (no queue configured), queuing wouldn't have changed anything.

The one process boundary that's reliable regardless of queue configuration is a fresh HTTP request. The frontend already does a full window.location.reload() after a synchronous global update succeeds (ControlSectionState.ts, updateGlobally()), which is a genuinely new PHP-FPM/php-cgi worker in essentially every real deployment.

So this skips the recheck entirely when handling FlarumUpdated — global, minor, and major core updates all share the identical in-process-composer-then-synchronous-dispatch shape, confirmed by reading all three handlers. The Updated event (single-extension installs) is untouched; that path wasn't reported broken and is a narrower composer operation on its own. LastUpdateRun still records the run as successful; the limitedPackages diff it used to compute by comparing before/after update-check results is left empty, since there's no safe way to get that comparison without constructing the Guzzle client in this same process. Happy to revisit if there's a way to compute it I'm missing, or if you'd rather see this handled differently — e.g. an explicit "recovery/recheck" action surfaced in the panel after a global update, or genuinely running composer in a subprocess instead of in-process, which would be a much bigger change.

Added a unit test covering all three FlarumUpdated types (asserting the bus is never dispatched for any of them, while LastUpdateRun still records success) and confirming the Updated path is untouched, and updated the one existing integration test that asserted on the old limitedPackages behavior.

Verified with a disposable php:8.3-cli container: the new unit test suite passes (4 tests, 14 assertions), and php -l on all three changed files. I couldn't get the actual integration-level "run composer update against a live fixture" test working in this environment — its fixture pins dependencies that only declare support through PHP 8.1, and Composer's current security-advisory check blocks installing that combination under PHP 8.3 here. That's a pre-existing limitation of the test fixture/environment, not something this change introduces, but I want to flag it rather than claim more verification than I actually got.

…ess that ran composer update

GlobalUpdateHandler, MinorUpdateHandler, and MajorUpdateHandler all run
composer update in-process (ComposerAdapter runs Composer's own
Application::run() directly, not a subprocess), then synchronously
dispatch FlarumUpdated. ReCheckForUpdates' listener responds to that by
immediately dispatching CheckForUpdates, which constructor-injects a
Guzzle Client -- constructing it touches whatever HTTP handler class
Guzzle picks, and if composer just bumped Guzzle itself to a version
with a new class (e.g. GuzzleHttp\Handler\CurlVersion, added in
7.12.0), the still-running process's autoloader has no idea that class
exists, and it fatals.

This isn't fixable by making the recheck a queued job: Job\Dispatcher
only defers to the queue when it isn't the sync driver, and Flarum
defaults to SyncQueue for any install that hasn't configured Redis or
a database queue -- which just runs the job immediately, in the same
process, same as today. The one process boundary that's actually
reliable regardless of queue config is a fresh request: the frontend
already does a full window.location.reload() after a synchronous
global update succeeds.

So this skips the recheck entirely for FlarumUpdated (all three types
share the exact same in-process-composer-then-synchronous-dispatch
shape), while leaving the Updated event (single extension installs)
untouched -- that path wasn't reported broken and is a narrower
composer operation. LastUpdateRun still records a successful run;
the limitedPackages diff it used to compute from the now-skipped check
is left empty, since there's no safe way to compute it without
touching Guzzle in this same process.
@wakqasahmed
wakqasahmed requested a review from a team as a code owner September 9, 2026 05:37
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.

[2.x] extension-manager: in-process re-check after global update can fatal with a torn autoloader

1 participant