[2.x] extension-manager: in-process re-check after global update can fatal with a torn autoloader
- Dominant language
- PHP
- Stars
- 6.7k
- Forks
- 883
- Avg merge
- 15h 16m
- Merged PRs (30d)
- 73
Description
### Description
When the Extension Manager performs a global update (`POST /api/extension-manager/global-update`), the post-update "re-check for updates" runs **synchronously, in the same PHP request that just rewrote `vendor/`**. If that `composer update` bumped a dependency, the request is now executing against a half-swapped `vendor/` (new PHP files on disk, but the autoloader/opcache for the live process still reflect the old set), and constructing a class from the freshly-updated dependency fatals — taking down the request before the update flow can finish.
A user reported being **unable to complete the upgrade** to `v2.0.0-rc.4`, hitting:
```
Error: Class "GuzzleHttp\Handler\CurlVersion" not found
in vendor/guzzlehttp/guzzle/src/Utils.php:108
```
full stack trace (trimmed)
```
#0 …/guzzle/src/HandlerStack.php(49): GuzzleHttp\Utils::chooseHandler()
#1 …/guzzle/src/Client.php(69): GuzzleHttp\HandlerStack::create()
#2 …/illuminate/container/Container.php(1171): GuzzleHttp\Client->__construct(Array)
…
#13 …/extension-manager/src/Listener/ReCheckForUpdates.php(49): Illuminate\Bus\Dispatcher->dispatch(CheckForUpdates)
#14 …/illuminate/events/Dispatcher.php(519): ReCheckForUpdates->handle(FlarumUpdated)
#17 …/extension-manager/src/Command/GlobalUpdateHandler.php(55): Dispatcher->dispatch(FlarumUpdated)
#24 …/extension-manager/src/Api/Controller/GlobalUpdateController.php(35): Job\Dispatcher->dispatch(GlobalUpdate)
```
### Root cause
`GuzzleHttp\Handler\CurlVersion` was introduced in **Guzzle 7.12.0** (released 2026-06-16; 7.12.1 followed on 2026-06-18) — both stable releases. It does **not** exist in 7.11.x. Core requires `guzzlehttp/guzzle: ^7.7`, so a global update legitimately bumps an older install straight onto the new stable 7.12. 7.12's `Utils::chooseHandler()` references the new `CurlVersion` class — which the running process can't autoload, because it started under 7.11.x.
This is **not** a minimum-stability / dev-version problem — the user pulled a normal stable release. Guzzle 7.12.0 was published on 2026-06-16, ~1.5 days *before* rc.4 (2026-06-18), so the 7.12 bump was available to any `composer update` independently of rc.4; rc.4 is not what introduced the exposure.
The chain, all within the one request:
1. `GlobalUpdateHandler::handle()` runs `composer update --with-all-dependencies`, rewriting `vendor/` in place — bumping Guzzle 7.11.x → 7.12.x.
2. It then synchronously dispatches `FlarumUpdated` (`GlobalUpdateHandler.php:56`).
3. `ReCheckForUpdates::handle()` synchronously dispatches `CheckForUpdates` (`ReCheckForUpdates.php:49`).
4. The container builds `CheckForUpdatesHandler`, which has a constructor-injected `GuzzleHttp\Client $http` — constructing it calls `Utils::chooseHandler()`, which needs `GuzzleHttp\Handler\CurlVersion`. The process is still running with 7.11.x's autoloader/class map → `Class not found` fatal.
Composer having returned exit 0 to step 1 does **not** mean the install is in a usable state: the request dies partway through the flow, the same process can no longer load the new classes, and the admin is left with a 500 and the upgrade not completed.
This won't reproduce on every upgrade — it needs the update to actually move a dependency whose new code the already-booted process can't load (as Guzzle 7.12 does here). But when it hits, the user cannot complete the update from the panel (see below).
### Impact
- The upgrade fails: a fatal/500 is surfaced to the admin and the update flow does not complete.
- The Extension Manager exposes **no repair/reinstall action** — only update/require/remove — so re-running "Update" just re-enters the same `GlobalUpdateHandler` chain and can fail the same way. A user without CLI or host-panel composer access has no in-panel way to recover.
### Fix
Don't perform the post-update re-check (the `FlarumUpdated` → `ReCheckForUpdates` → Guzzle `Client` work) in the same request that rewrote `vendor/`:
- Defer it to a queued job / fresh process (the extension already has queue machinery in `Job/Dispatcher` with sync/async support), or
- Return after `composer update` and let a subsequent, fresh request perform the re-check.
More broadly, anything that constructs or touches freshly-updated `vendor/` classes within the same request that ran `composer update` is unsafe and should run in a new process. Worth also considering a recovery path for a half-applied update that doesn't require CLI access.
### Environment
- `flarum/extension-manager` 2.x
- Reported while upgrading to `flarum/core` `v2.0.0-rc.4`, but the trigger is `guzzlehttp/guzzle` bumping 7.11.x → 7.12.x (7.12.0 released 2026-06-16, 7.12.1 on 2026-06-18). Any global update that moves Guzzle onto 7.12 can hit this; it is independent of the Flarum release being installed.
Contributor guide
Research direction
Read extension-manager/src/Command/GlobalUpdateHandler.php and extension-manager/src/Listener/ReCheckForUpdates.php to trace the synchronous FlarumUpdated flow, then inspect Job/Dispatcher and extension-manager/src/Api/Controller/GlobalUpdateController.php for existing dispatch behavior. Confirm the global update can finish without constructing freshly updated vendor classes in the same request, and that the post-update re-check runs in a fresh process or request.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- php
- Domain
- api, backend
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Quiet
- Clarity
- Mostly clear
- Newbie friendliness
- 55/100