Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
Significance: patch
Type: changed

Plugin Conflicts Guardian: probe all selected plugins together in one loopback request pair so bulk activation cost no longer scales with the number of plugins.
Original file line number Diff line number Diff line change
Expand Up @@ -23,11 +23,11 @@ Ships dark. Three independent filters, all default `false`:
## Activation flow

1. Admin submits an Activate request (`plugins.php?action=activate`, `…=activate-selected`, or `update.php?action=activate-plugin`).
2. `activation-guard.php` intercepts on `load-plugins.php` / `load-update.php` priority 0, verifies the nonce, and for each plugin calls `PCG_Load_Tester::test()`.
3. The load tester stashes `{ plugin, mode }` in a short-lived transient keyed by a random token, then `wp_remote_get`s `?pcg_probe=1&token=…` on this same site. Activation flows pass `mode = activation`; the post-update health check passes `mode = update` (see "Post-update health check" below).
4. `probe-endpoint.php` runs synchronously at require time (already inside `plugins_loaded` priority 10 via `load_features()`), validates + consumes the token, gates on the per-mode filter (`pcg_guard_activation` for activation, `pcg_guard_updates` for update), defines `WP_SANDBOX_SCRAPING` so core's fatal handler steps aside, arms a shutdown handler, and (in activation mode only) `require`s the plugin's main file. In update mode the file is already loaded by WP's normal bootstrap and re-requiring would fatal with "Cannot redeclare class/function" — the probe just verifies that bootstrap completed cleanly.
2. `activation-guard.php` intercepts on `load-plugins.php` / `load-update.php` priority 0, verifies the nonce, filters the request down to eligible plugins (passes `validate_file`, not already active, file exists), and calls `PCG_Load_Tester::test()` once with the full batch.
3. The load tester stashes `{ plugins, mode }` in a short-lived transient keyed by a random token, then fires the probe against `?pcg_probe=1&token=…` on this same site. Activation flows pass `mode = activation`; the post-update health check passes `mode = update` (see "Post-update health check" below).
4. `probe-endpoint.php` runs synchronously at require time (already inside `plugins_loaded` priority 10 via `load_features()`), validates + consumes the token, gates on the per-mode filter (`pcg_guard_activation` for activation, `pcg_guard_updates` for update), defines `WP_SANDBOX_SCRAPING` so core's fatal handler steps aside, arms a shutdown handler, and in activation mode `require_once`s each plugin's main file in order under that single request. Probe cost is constant regardless of how many plugins are activated, and conflicts that only fire when two plugins load together (duplicate class, shared global) are caught — which a per-plugin probe model couldn't see. In update mode the files are already loaded by WP's normal bootstrap and re-requiring would fatal with "Cannot redeclare class/function" — the probe just verifies that bootstrap completed cleanly.
5. Two probes fire in parallel via `\WpOrg\Requests\Requests::request_multiple()`: one against `home_url('/')` (front-end) and one against `admin_url('index.php')` with `pcg_admin=1` and the admin's WP auth cookies forwarded so `auth_redirect()` clears. The admin probe defers its verdict to `admin_init` priority `PHP_INT_MAX`; the front-end probe emits on `wp_loaded`. A captured `fatal` / `throwable` from either probe wins; otherwise the front-end verdict is returned. A 302 on the admin probe (cookies missing/expired) becomes a distinct `ok-inconclusive` status that's still treated as a non-blocking pass — that way transport quirks don't break activation, but the signal can be measured separately from a clean `ok`.
6. If any plugin failed, the guard stashes reasons in a per-user transient and redirects to `plugins.php?pcg_blocked=1`; the admin notice reads the transient and renders it.
6. On a fatal/throwable the guard attributes the failure to one plugin in the batch — preferring the explicit `plugin` field (set when a `Throwable` is caught around the `require`), then falling back to matching the captured `file` against each plugin's directory. The whole batch is blocked as a unit; the notice tells the admin which plugin caused the fatal so they can retry without it.

```
Admin click Activate
Expand All @@ -36,9 +36,9 @@ Ships dark. Three independent filters, all default `false`:
activation-guard.php ──► verify nonce + capability
PCG_Load_Tester::test()
PCG_Load_Tester::test( [paths…] )
│ stash { plugin, mode } in transient (random token)
│ stash { plugins, mode } in transient (random token)
GET /?pcg_probe=1&token=… ◄── HTTP self-request
Expand All @@ -49,8 +49,9 @@ Ships dark. Three independent filters, all default `false`:
(pcg_guard_activation | pcg_guard_updates)
define WP_SANDBOX_SCRAPING
register shutdown handler
require $plugin_main (activation mode only — update mode
skips this; plugin already loaded by WP)
foreach $plugin_main: require_once (activation mode only — update
mode skips this; plugins already
loaded by WP's bootstrap)
├───► fatal / throwable ──► {status: fatal|throwable} (HTTP 200)
│ │
Expand Down Expand Up @@ -96,7 +97,7 @@ Gated on `pcg_guard_updates`. Runs *after* files are swapped, in a fresh HTTP re

1. `upgrader_pre_install` — `PCG_Snapshot::capture()` reads the current plugin's `Version` and `is_plugin_active()`, stashes them in a transient keyed by the plugin basename, **and copies the live plugin files to `<get_temp_dir()>/pcg-backups/<unique>/<asset>`** (override via the `pcg_backup_root` filter) so we can restore offline without re-downloading.
2. Core extracts + copies the new files (the original copy is still safely tucked away under `pcg-backups/`).
3. `upgrader_process_complete` (priority 99) — `update-healthcheck.php` drains the snapshots for every plugin in `hook_extra['plugins']`, keeps the ones that were active and whose new files are still on disk, and runs **one** `PCG_Load_Tester::test( $candidate, PCG_Load_Tester::MODE_UPDATE )` for the whole batch. MODE_UPDATE checks whether the site as a whole bootstraps; it doesn't isolate a specific plugin, so a single probe is enough. The probe endpoint skips the `require` in update mode and just observes whether the (already-loaded) new code completes the bootstrap cleanly.
3. `upgrader_process_complete` (priority 99) — `update-healthcheck.php` drains the snapshots for every plugin in `hook_extra['plugins']`, keeps the ones that were active and whose new files are still on disk, and runs **one** `PCG_Load_Tester::test( $plugin_mains, PCG_Load_Tester::MODE_UPDATE )` for the whole batch. MODE_UPDATE checks whether the site as a whole bootstraps; it doesn't isolate a specific plugin, so a single probe is enough. The probe endpoint skips the `require_once` in update mode and just observes whether the (already-loaded) new code completes the bootstrap cleanly.
4. On `ok` (or any inconclusive non-fatal status), every backup in the batch is deleted and we're done.
5. On `fatal` / `throwable`, `PCG_Rollback::to_snapshot()` runs for **every** snapshot in the batch — deactivating each broken plugin, **swapping the new files for the saved local backup** via rename (or copy + delete-source as a fallback for cross-fs cases), and reactivating if the plugin was active. We can't tell which plugin in the batch caused the fatal, so restoring the whole batch is the safe call.
6. If a local backup is missing or the swap fails, `PCG_Rollback` falls back to fetching `https://downloads.wordpress.org/plugin/{slug}.{old_version}.zip` and reinstalling via `Plugin_Upgrader`. This still helps for .org plugins on hosts where the local backup couldn't be created (full disk, restrictive perms).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -74,15 +74,20 @@ function pcg_guard_maybe_block_activation() {
}

/**
* Probe each plugin; return map of basename => reason for those that failed.
* Probe the requested plugins together in a single loopback request pair
* and return a map of basename => reason for any that fail.
*
* Eligible plugins (passes `validate_file`, not already active, file
* exists on disk) are passed to `PCG_Load_Tester::test()` as one batch,
* so probe cost is constant in N rather than 2N round-trips. As a side
* effect this also surfaces conflicts that only fire when two plugins
* load together (duplicate class, shared global, etc.).
*
* @param string[] $plugins Plugin basenames (e.g. "akismet/akismet.php").
* @return array<string,string>
*/
function pcg_guard_evaluate_plugins( $plugins ) {
$blocked = array();
$tester = new PCG_Load_Tester();

$paths = array();
foreach ( $plugins as $plugin ) {
if ( 0 !== validate_file( $plugin ) ) {
continue;
Expand All @@ -94,14 +99,71 @@ function pcg_guard_evaluate_plugins( $plugins ) {
if ( ! is_file( $path ) ) {
continue;
}
$result = $tester->test( $path );
$status = (string) ( $result['status'] ?? '' );
if ( 'fatal' === $status || 'throwable' === $status ) {
$blocked[ $plugin ] = pcg_guard_format_block_reason( $result );
$paths[ $plugin ] = $path;
}
if ( empty( $paths ) ) {
return array();
}

$tester = new PCG_Load_Tester();
$result = $tester->test( array_values( $paths ) );
$status = (string) ( $result['status'] ?? '' );
if ( 'fatal' !== $status && 'throwable' !== $status ) {
return array();
}

$blocked_plugin = pcg_guard_get_blocked_plugin( $result, $paths );
return array(
$blocked_plugin => pcg_guard_format_block_reason( $result ),
);
}

/**
* Map a fatal/throwable verdict back to the plugin basename that caused
* it. Tries, in order: the explicit `plugin` field on the verdict (set
* when a `Throwable` was caught around `require`), an exact match of
* the captured `file` against a plugin's main file (covers flat-file
* plugins like `hello.php`), and a prefix match of the captured `file`
* against a plugin's own subdirectory under `WP_PLUGIN_DIR`. Falls back
* to the first plugin in the batch when none of those match — the
* batch is blocked as a unit either way.
*
* @param array $result A fatal/throwable probe verdict.
* @param array<string,string> $paths Map of plugin basename => absolute main file path.
* @return string Plugin basename to attribute the failure to.
*/
function pcg_guard_get_blocked_plugin( $result, $paths ) {
$explicit = (string) ( $result['plugin'] ?? '' );
if ( '' !== $explicit ) {
foreach ( $paths as $basename => $path ) {
if ( $path === $explicit ) {
return $basename;
}
}
}

$fatal_file = (string) ( $result['file'] ?? '' );
if ( '' !== $fatal_file ) {
foreach ( $paths as $basename => $path ) {
if ( $path === $fatal_file ) {
return $basename;
}
}
// Subdirectory plugins only — a flat-file plugin's dirname is
// `WP_PLUGIN_DIR`, which would prefix-match every other plugin's
// files in the batch and produce false attributions.
foreach ( $paths as $basename => $path ) {
$plugin_dir = dirname( $path );
if ( WP_PLUGIN_DIR === $plugin_dir ) {
continue;
}
if ( str_starts_with( $fatal_file, $plugin_dir . '/' ) ) {
return $basename;
}
}
}

return $blocked;
return (string) array_key_first( $paths );
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In cases where the $result payload doesn't contain plugin or file (for instance, https://github.com/Automattic/jetpack/pull/48402/changes#diff-babcd1ba3a7b2e3b238816b14f26e591dfd6bfed2dcb3578bb05b6b0ceb4eacaR190-R195), then returning the first plugin might lead to wrong attribution. The batch would still be blocked, but the notice might point to an incorrect plugin.

Maybe we can have a batch-level attribution, something like "One of these plugins caused a fatal error during the pre-flight check: A, B, C. Investigate before trying again.".

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 59f6cc9pcg_guard_get_blocked_plugin returns '' when attribution can't be pinned; caller emits a batch-level notice line: "One of these plugins caused a fatal during the pre-flight check: A, B, C. Reason: …". Renderer drops the <code>plugin</code> prefix when the key is empty.

}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,45 +14,43 @@ class PCG_Load_Tester {
const PROBE_TIMEOUT = 15;
const TOKEN_LIFETIME = 30;

/**
* Probe mode: file is currently inactive and the endpoint must
* `require` it to exercise its load path. Used by the activation guard.
*/
/** Activation guard: plugins are inactive; endpoint require_once's each. */
const MODE_ACTIVATION = 'activation';

/**
* Probe mode: file is already loaded by WP's normal bootstrap (it was
* an active plugin before the just-completed update). The endpoint
* must NOT re-`require` the file — doing so would fatal with "Cannot
* redeclare class/function" — and instead just verifies that the
* bootstrap completed cleanly with the new code.
* Post-update healthcheck: plugins are already loaded by WP's bootstrap;
* endpoint skips require_once (would fatal with "Cannot redeclare").
*/
const MODE_UPDATE = 'update';

/**
* Run the probe against a plugin main file.
* Probe a batch of plugin main files in one loopback request pair.
*
* Fires two loopback requests in parallel: one against `home_url('/')`
* (front-end) and one against `admin_url('index.php')` (so `admin_init`
* fires). The admin probe forwards the current admin's WP auth cookies
* so the loopback can clear `auth_redirect()`. A captured fatal from
* either probe wins; otherwise the front-end verdict is returned.
* Fires front-end + admin probes in parallel; front-end auth cookies are
* forwarded so admin_init can fire. Fatal from either wins; otherwise
* front-end's verdict. On fatal/throwable, the verdict's `plugin` key
* names the file the endpoint was loading at the time.
*
* @param string $plugin_main Absolute path to the plugin's main PHP file.
* @param string $mode Probe mode: self::MODE_ACTIVATION (default) or
* self::MODE_UPDATE. See class constants.
* @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int}
* @param string[] $plugin_mains Absolute paths to plugin main PHP files.
* @param string $mode self::MODE_ACTIVATION or self::MODE_UPDATE.
* @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
*/
public function test( $plugin_main, $mode = self::MODE_ACTIVATION ) {
if ( '' === (string) $plugin_main || ! is_file( $plugin_main ) ) {
public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
$plugin_mains = array_values(
array_filter(
array_map( static fn( $p ) => (string) $p, $plugin_mains ),
static fn( $p ) => '' !== $p && is_file( $p )
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's also check for is_readable() following pcg_maybe_handle_probe()

Suggested change
static fn( $p ) => '' !== $p && is_file( $p )
static fn( $p ) => '' !== $p && is_file( $p ) && is_readable( $p )

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 39711d8 — added is_readable($p) to the up-front filter.

)
);
if ( empty( $plugin_mains ) ) {
return array(
'status' => 'error',
'reason' => 'Plugin main file not found for load probe.',
'reason' => 'No probable plugin main files supplied.',
);
}

$front = $this->prepare_probe( $plugin_main, home_url( '/' ), false, $mode );
$admin = $this->prepare_probe( $plugin_main, admin_url( 'index.php' ), true, $mode );
$front = $this->prepare_probe( $plugin_mains, home_url( '/' ), false, $mode );
$admin = $this->prepare_probe( $plugin_mains, admin_url( 'index.php' ), true, $mode );

try {
$responses = \WpOrg\Requests\Requests::request_multiple(
Expand All @@ -75,8 +73,8 @@ public function test( $plugin_main, $mode = self::MODE_ACTIVATION ) {
delete_transient( self::transient_key( $admin['token'] ) );
}

$front_result = $this->parse_response( $responses['front'], $plugin_main, false );
$admin_result = $this->parse_response( $responses['admin'], $plugin_main, true );
$front_result = $this->parse_response( $responses['front'], false );
$admin_result = $this->parse_response( $responses['admin'], true );

// fatal/throwable wins; an inconclusive `error` from one probe must
// not shadow a real fatal from the other. Front-end is the canonical
Expand Down Expand Up @@ -108,30 +106,30 @@ protected function is_block( $result ) {
* needing a live HTTP loopback. Not part of the public API.
*
* @internal
* @param string $plugin_main Absolute path to the plugin's main PHP file.
* @param string $mode Probe mode constant.
* @return array{plugin:string,mode:string}
* @param string[] $plugin_mains Absolute paths to plugin main PHP files.
* @param string $mode Probe mode constant.
* @return array{plugins:string[],mode:string}
*/
public static function build_probe_payload( $plugin_main, $mode = self::MODE_ACTIVATION ) {
public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION ) {
return array(
'plugin' => (string) $plugin_main,
'mode' => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION,
'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ),
'mode' => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION,
);
}

/**
* Stash a probe transient and build the request descriptor for
* `Requests::request_multiple`.
* Stash a probe transient and build the `Requests::request_multiple`
* descriptor for one of the two parallel probes.
*
* @param string $plugin_main Absolute path to the plugin's main PHP file.
* @param string $base_url Base URL to probe (front-end or admin).
* @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies.
* @param string $mode Probe mode constant.
* @param string[] $plugin_mains Absolute paths to plugin main PHP files.
* @param string $base_url Front-end or admin base URL.
* @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies.
* @param string $mode Probe mode constant.
* @return array{token:string,request:array}
*/
protected function prepare_probe( $plugin_main, $base_url, $is_admin, $mode = self::MODE_ACTIVATION ) {
protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION ) {
$token = wp_generate_password( 32, false );
set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_main, $mode ), self::TOKEN_LIFETIME );
set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode ), self::TOKEN_LIFETIME );

$query = array(
'pcg_probe' => '1',
Expand Down Expand Up @@ -159,13 +157,12 @@ protected function prepare_probe( $plugin_main, $base_url, $is_admin, $mode = se
/**
* Translate a `Requests::request_multiple` response into a probe verdict.
*
* @param mixed $response A `WpOrg\Requests\Response`, or an exception
* thrown for that single request.
* @param string $plugin_main Plugin main file (for fallback diagnostics).
* @param bool $is_admin True when this was the admin probe.
* @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int}
* @param mixed $response A `WpOrg\Requests\Response`, or an exception
* thrown for that single request.
* @param bool $is_admin True when this was the admin probe.
* @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string}
*/
protected function parse_response( $response, $plugin_main, $is_admin ) {
protected function parse_response( $response, $is_admin ) {
if ( $response instanceof \Throwable ) {
return array(
'status' => 'error',
Expand Down Expand Up @@ -194,8 +191,6 @@ protected function parse_response( $response, $plugin_main, $is_admin ) {
return array(
'status' => 'fatal',
'message' => 'Probe request returned HTTP 500 without a JSON verdict; the plugin likely fatals during load.',
'file' => basename( $plugin_main ),
'line' => 0,
);
}

Expand All @@ -206,11 +201,9 @@ protected function parse_response( $response, $plugin_main, $is_admin ) {
return array(
'status' => 'fatal',
'message' => sprintf(
'Probe completed without a verdict (HTTP %d, non-JSON body). The plugin may have terminated the request during load, init, or admin_init.',
'Probe completed without a verdict (HTTP %d, non-JSON body). A plugin in the batch may have terminated the request during load, init, or admin_init.',
$code
),
'file' => basename( $plugin_main ),
'line' => 0,
);
}

Expand Down
Loading
Loading