Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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,87 @@ 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 );
if ( '' !== $blocked_plugin ) {
return array(
$blocked_plugin => pcg_guard_format_block_reason( $result ),
);
}

// Verdict didn't pin a specific plugin (e.g. probe terminated without a
// JSON body, or the captured `file` was outside any candidate's tree).
// Surface a batch-level message so we don't blame an arbitrary plugin.
$reason = sprintf(
/* translators: 1: locale-formatted list of plugin basenames; 2: probe verdict reason. */
__( 'One of these plugins caused a fatal during the pre-flight check: %1$s. Reason: %2$s', 'jetpack-mu-wpcom' ),
wp_sprintf_l( '%l', array_keys( $paths ) ),
pcg_guard_format_block_reason( $result )
);
return array( '' => $reason );
}

/**
* 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`.
*
* Returns `''` when none of those match (e.g. the verdict has no
* `file`/`plugin`, or `file` lies outside any candidate's tree). The
* caller is expected to surface a batch-level message in that case
* rather than guessing a plugin.
*
* @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, or '' if undetermined.
*/
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 '';
}

/**
Expand Down Expand Up @@ -165,10 +243,16 @@ function pcg_guard_render_block_notice() {
<p><strong><?php esc_html_e( 'WordPress.com blocked activation because the pre-flight check detected a fatal:', 'jetpack-mu-wpcom' ); ?></strong></p>
<ul style="list-style:disc;padding-inline-start:24px;">
<?php foreach ( $messages as $plugin => $reason ) : ?>
<li><code><?php echo esc_html( $plugin ); ?></code> — <?php echo esc_html( $reason ); ?></li>
<li>
<?php if ( '' !== (string) $plugin ) : ?>
<code><?php echo esc_html( $plugin ); ?></code> — <?php echo esc_html( $reason ); ?>
<?php else : ?>
<?php echo esc_html( $reason ); ?>
<?php endif; ?>
</li>
<?php endforeach; ?>
</ul>
<p><?php esc_html_e( 'The plugin was not activated. Investigate the error before trying again.', 'jetpack-mu-wpcom' ); ?></p>
<p><?php esc_html_e( 'No plugins were activated to prevent a site crash. Investigate the error before trying again.', 'jetpack-mu-wpcom' ); ?></p>
</div>
<?php
}
Loading