flarum / flarum/framework

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

Open
#4,764 0 comments 0 reactions 0 assignees View on GitHub
type/bug
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

Open the contributing 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

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.