Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
9.82% |
16 / 163 |
|
16.67% |
2 / 12 |
CRAP | |
0.00% |
0 / 1 |
| PCG_Load_Tester | |
9.82% |
16 / 163 |
|
16.67% |
2 / 12 |
2440.08 | |
0.00% |
0 / 1 |
| test | |
28.21% |
11 / 39 |
|
0.00% |
0 / 1 |
38.98 | |||
| is_block | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
12 | |||
| is_error | |
0.00% |
0 / 2 |
|
0.00% |
0 / 1 |
6 | |||
| log_probe_error | |
0.00% |
0 / 9 |
|
0.00% |
0 / 1 |
2 | |||
| relative_basenames | |
0.00% |
0 / 7 |
|
0.00% |
0 / 1 |
12 | |||
| probe_error_reason | |
0.00% |
0 / 1 |
|
0.00% |
0 / 1 |
2 | |||
| build_probe_payload | |
100.00% |
4 / 4 |
|
100.00% |
1 / 1 |
2 | |||
| prepare_probe | |
0.00% |
0 / 23 |
|
0.00% |
0 / 1 |
12 | |||
| parse_response | |
0.00% |
0 / 43 |
|
0.00% |
0 / 1 |
156 | |||
| collect_auth_cookie_header | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
| build_same_host_cookie_hook | |
0.00% |
0 / 22 |
|
0.00% |
0 / 1 |
156 | |||
| transient_key | |
100.00% |
1 / 1 |
|
100.00% |
1 / 1 |
1 | |||
| 1 | <?php |
| 2 | /** |
| 3 | * HTTP-based plugin-load probe. |
| 4 | * |
| 5 | * @package automattic/jetpack-mu-wpcom |
| 6 | */ |
| 7 | |
| 8 | /** |
| 9 | * Runs a plugin's main file in a separate HTTP self-request and |
| 10 | * returns the probe verdict as an associative array. |
| 11 | */ |
| 12 | class PCG_Load_Tester { |
| 13 | |
| 14 | const PROBE_TIMEOUT = 15; |
| 15 | const TOKEN_LIFETIME = 30; |
| 16 | |
| 17 | /** Activation guard: plugins are inactive; endpoint require_once's each. */ |
| 18 | const MODE_ACTIVATION = 'activation'; |
| 19 | |
| 20 | /** |
| 21 | * Post-update healthcheck: plugins are already loaded by WP's bootstrap; |
| 22 | * endpoint skips require_once (would fatal with "Cannot redeclare"). |
| 23 | */ |
| 24 | const MODE_UPDATE = 'update'; |
| 25 | |
| 26 | /** |
| 27 | * Probe a batch of plugin main files in one loopback request pair. |
| 28 | * |
| 29 | * Fires front-end + admin probes in parallel; front-end auth cookies are |
| 30 | * forwarded so admin_init can fire. Fatal from either wins; otherwise |
| 31 | * front-end's verdict. On fatal/throwable, the verdict's `plugin` key |
| 32 | * names the file the endpoint was loading at the time. |
| 33 | * |
| 34 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 35 | * @param string $mode self::MODE_ACTIVATION or self::MODE_UPDATE. |
| 36 | * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string} |
| 37 | */ |
| 38 | public function test( array $plugin_mains, $mode = self::MODE_ACTIVATION ) { |
| 39 | $plugin_mains = array_values( |
| 40 | array_filter( |
| 41 | array_map( static fn( $p ) => (string) $p, $plugin_mains ), |
| 42 | static fn( $p ) => '' !== $p && is_file( $p ) && is_readable( $p ) |
| 43 | ) |
| 44 | ); |
| 45 | if ( empty( $plugin_mains ) ) { |
| 46 | return array( |
| 47 | 'status' => 'error', |
| 48 | 'reason' => 'No probable plugin main files supplied.', |
| 49 | ); |
| 50 | } |
| 51 | |
| 52 | $front = $this->prepare_probe( $plugin_mains, home_url( '/' ), false, $mode ); |
| 53 | $admin = $this->prepare_probe( $plugin_mains, admin_url( 'index.php' ), true, $mode ); |
| 54 | |
| 55 | try { |
| 56 | $responses = \WpOrg\Requests\Requests::request_multiple( |
| 57 | array( |
| 58 | 'front' => $front['request'], |
| 59 | 'admin' => $admin['request'], |
| 60 | ), |
| 61 | array( |
| 62 | 'timeout' => self::PROBE_TIMEOUT, |
| 63 | // Match `wp_remote_get`'s default; covers http->https, |
| 64 | // force_ssl_admin's scheme bounce, and locale redirects. |
| 65 | // `build_same_host_cookie_hook` keeps admin auth from |
| 66 | // leaking if the redirect points off-host. |
| 67 | 'redirects' => 5, |
| 68 | ) |
| 69 | ); |
| 70 | } catch ( \Throwable $t ) { |
| 71 | return array( |
| 72 | 'status' => 'error', |
| 73 | 'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ), |
| 74 | ); |
| 75 | } finally { |
| 76 | delete_transient( self::transient_key( $front['token'] ) ); |
| 77 | delete_transient( self::transient_key( $admin['token'] ) ); |
| 78 | } |
| 79 | |
| 80 | $front_result = $this->parse_response( $responses['front'] ); |
| 81 | $admin_result = $this->parse_response( $responses['admin'] ); |
| 82 | |
| 83 | // Log transport-level errors (most often timeouts at PROBE_TIMEOUT) |
| 84 | // so we can see how often they fire before deciding whether to |
| 85 | // scale the timeout with batch size. |
| 86 | if ( $this->is_error( $front_result ) || $this->is_error( $admin_result ) ) { |
| 87 | $this->log_probe_error( $mode, $plugin_mains, $front_result, $admin_result ); |
| 88 | } |
| 89 | |
| 90 | // fatal/throwable wins; an inconclusive `error` from one probe must |
| 91 | // not shadow a real fatal from the other. Front-end is the canonical |
| 92 | // "site works" signal when neither probe captured a fatal. |
| 93 | if ( $this->is_block( $front_result ) ) { |
| 94 | return $front_result; |
| 95 | } |
| 96 | if ( $this->is_block( $admin_result ) ) { |
| 97 | return $admin_result; |
| 98 | } |
| 99 | return $front_result; |
| 100 | } |
| 101 | |
| 102 | /** |
| 103 | * Whether a verdict is a captured fatal that should block the activation. |
| 104 | * |
| 105 | * @param array $result Probe verdict. |
| 106 | * @return bool |
| 107 | */ |
| 108 | protected function is_block( $result ) { |
| 109 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 110 | return 'fatal' === $status || 'throwable' === $status; |
| 111 | } |
| 112 | |
| 113 | /** |
| 114 | * Whether a verdict is a transport-level error (timeout, connection |
| 115 | * failure, non-JSON body). Distinct from `is_block` — errors are |
| 116 | * inconclusive and don't block activation, but are worth logging. |
| 117 | * |
| 118 | * @param array $result Probe verdict. |
| 119 | * @return bool |
| 120 | */ |
| 121 | protected function is_error( $result ) { |
| 122 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 123 | return 'error' === $status; |
| 124 | } |
| 125 | |
| 126 | /** |
| 127 | * Log a probe transport error to logstash whenever either probe came |
| 128 | * back as `error` (timeout at PROBE_TIMEOUT, connection failure, |
| 129 | * non-JSON body). Lets us measure timeout frequency vs. batch size |
| 130 | * before deciding whether to scale `PROBE_TIMEOUT` with N. |
| 131 | * |
| 132 | * @param string $mode Probe mode constant. |
| 133 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 134 | * @param array $front_result Front-end probe verdict. |
| 135 | * @param array $admin_result Admin probe verdict. |
| 136 | * @return void |
| 137 | */ |
| 138 | protected function log_probe_error( $mode, array $plugin_mains, array $front_result, array $admin_result ) { |
| 139 | pcg_log_event( |
| 140 | 'Probe transport error', |
| 141 | array( |
| 142 | 'mode' => $mode, |
| 143 | 'plugins' => $this->relative_basenames( $plugin_mains ), |
| 144 | 'front' => $this->probe_error_reason( $front_result ), |
| 145 | 'admin' => $this->probe_error_reason( $admin_result ), |
| 146 | ) |
| 147 | ); |
| 148 | } |
| 149 | |
| 150 | /** |
| 151 | * Strip `WP_PLUGIN_DIR/` from each absolute path so log entries carry |
| 152 | * the canonical plugin basename (e.g. `akismet/akismet.php`). |
| 153 | * |
| 154 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 155 | * @return string[] |
| 156 | */ |
| 157 | protected function relative_basenames( array $plugin_mains ) { |
| 158 | $prefix = WP_PLUGIN_DIR . '/'; |
| 159 | $out = array(); |
| 160 | foreach ( $plugin_mains as $path ) { |
| 161 | $out[] = str_starts_with( (string) $path, $prefix ) |
| 162 | ? substr( (string) $path, strlen( $prefix ) ) |
| 163 | : (string) $path; |
| 164 | } |
| 165 | return $out; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * One-line reason from a probe verdict — `reason` if set, else |
| 170 | * `status`, else empty. Used for diagnostic logs. |
| 171 | * |
| 172 | * @param array $result Probe verdict. |
| 173 | * @return string |
| 174 | */ |
| 175 | protected function probe_error_reason( array $result ) { |
| 176 | return (string) ( $result['reason'] ?? $result['status'] ?? '' ); |
| 177 | } |
| 178 | |
| 179 | /** |
| 180 | * Build the transient payload that the probe endpoint will consume. |
| 181 | * |
| 182 | * Exposed for unit tests so they can assert the stash shape without |
| 183 | * needing a live HTTP loopback. Not part of the public API. |
| 184 | * |
| 185 | * @internal |
| 186 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 187 | * @param string $mode Probe mode constant. |
| 188 | * @return array{plugins:string[],mode:string} |
| 189 | */ |
| 190 | public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION ) { |
| 191 | return array( |
| 192 | 'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ), |
| 193 | 'mode' => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION, |
| 194 | ); |
| 195 | } |
| 196 | |
| 197 | /** |
| 198 | * Stash a probe transient and build the `Requests::request_multiple` |
| 199 | * descriptor for one of the two parallel probes. |
| 200 | * |
| 201 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 202 | * @param string $base_url Front-end or admin base URL. |
| 203 | * @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies. |
| 204 | * @param string $mode Probe mode constant. |
| 205 | * @return array{token:string,request:array} |
| 206 | */ |
| 207 | protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION ) { |
| 208 | $token = wp_generate_password( 32, false ); |
| 209 | set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode ), self::TOKEN_LIFETIME ); |
| 210 | |
| 211 | $query = array( |
| 212 | 'pcg_probe' => '1', |
| 213 | 'token' => $token, |
| 214 | ); |
| 215 | $headers = array(); |
| 216 | $options = array(); |
| 217 | if ( $is_admin ) { |
| 218 | $query['pcg_admin'] = '1'; |
| 219 | $cookie_header = $this->collect_auth_cookie_header(); |
| 220 | if ( '' !== $cookie_header ) { |
| 221 | $headers['Cookie'] = $cookie_header; |
| 222 | $options['hooks'] = $this->build_same_host_cookie_hook( $base_url ); |
| 223 | } |
| 224 | } |
| 225 | |
| 226 | return array( |
| 227 | 'token' => $token, |
| 228 | 'request' => array( |
| 229 | 'url' => add_query_arg( $query, $base_url ), |
| 230 | 'type' => 'GET', |
| 231 | 'headers' => $headers, |
| 232 | 'options' => $options, |
| 233 | ), |
| 234 | ); |
| 235 | } |
| 236 | |
| 237 | /** |
| 238 | * Translate a `Requests::request_multiple` response into a probe verdict. |
| 239 | * |
| 240 | * @param mixed $response A `WpOrg\Requests\Response`, or an exception |
| 241 | * thrown for that single request. |
| 242 | * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string} |
| 243 | */ |
| 244 | protected function parse_response( $response ) { |
| 245 | if ( $response instanceof \Throwable ) { |
| 246 | // Bootstrap was healthy enough to issue several redirects in |
| 247 | // a row, so treat redirect-budget exhaustion as inconclusive |
| 248 | // rather than an error. |
| 249 | if ( $response instanceof \WpOrg\Requests\Exception && 'toomanyredirects' === $response->getType() ) { |
| 250 | return array( |
| 251 | 'status' => 'ok-inconclusive', |
| 252 | 'reason' => 'Probe exceeded redirect budget; treating as inconclusive ok.', |
| 253 | ); |
| 254 | } |
| 255 | return array( |
| 256 | 'status' => 'error', |
| 257 | 'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ), |
| 258 | ); |
| 259 | } |
| 260 | |
| 261 | $code = (int) ( $response->status_code ?? 0 ); |
| 262 | $body = (string) ( $response->body ?? '' ); |
| 263 | $redirect_count = (int) ( $response->redirects ?? 0 ); |
| 264 | |
| 265 | $decoded = json_decode( $body, true ); |
| 266 | if ( is_array( $decoded ) && isset( $decoded['status'] ) ) { |
| 267 | return $decoded; |
| 268 | } |
| 269 | |
| 270 | // 3xx that Requests refused to follow (cross-scheme downgrade, |
| 271 | // malformed Location). Treat as ok — bootstrap completed enough |
| 272 | // to emit one. |
| 273 | if ( $code >= 300 && $code < 400 ) { |
| 274 | return array( |
| 275 | 'status' => 'ok-inconclusive', |
| 276 | 'reason' => sprintf( 'Probe redirected (HTTP %d); treating as inconclusive ok.', $code ), |
| 277 | ); |
| 278 | } |
| 279 | |
| 280 | if ( 500 === $code ) { |
| 281 | return array( |
| 282 | 'status' => 'fatal', |
| 283 | 'message' => 'Probe request returned HTTP 500 without a JSON verdict; the plugin likely fatals during load.', |
| 284 | ); |
| 285 | } |
| 286 | |
| 287 | if ( $code >= 200 && $code < 300 ) { |
| 288 | // Followed a redirect whose destination dropped the probe |
| 289 | // query. Bootstrap rendered cleanly, so don't block. |
| 290 | if ( $redirect_count > 0 ) { |
| 291 | return array( |
| 292 | 'status' => 'ok-inconclusive', |
| 293 | 'reason' => sprintf( 'Probe followed %d redirect(s) but destination dropped the probe query; treating as inconclusive ok.', $redirect_count ), |
| 294 | ); |
| 295 | } |
| 296 | // Probe endpoint always emits JSON; a 2xx without one and no |
| 297 | // redirect means the bootstrap was terminated mid-flight |
| 298 | // (exit/die during load/init/admin_init). |
| 299 | return array( |
| 300 | 'status' => 'fatal', |
| 301 | 'message' => sprintf( |
| 302 | '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.', |
| 303 | $code |
| 304 | ), |
| 305 | ); |
| 306 | } |
| 307 | |
| 308 | return array( |
| 309 | 'status' => 'error', |
| 310 | 'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ), |
| 311 | ); |
| 312 | } |
| 313 | |
| 314 | /** |
| 315 | * `Cookie:` header from the current request's WP auth cookies, so the |
| 316 | * admin loopback authenticates as the same user. Empty if none found. |
| 317 | * |
| 318 | * @return string |
| 319 | */ |
| 320 | protected function collect_auth_cookie_header() { |
| 321 | if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { |
| 322 | return ''; |
| 323 | } |
| 324 | $pairs = array(); |
| 325 | foreach ( $_COOKIE as $name => $value ) { |
| 326 | if ( ! is_string( $name ) || ! is_string( $value ) ) { |
| 327 | continue; |
| 328 | } |
| 329 | if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) { |
| 330 | continue; |
| 331 | } |
| 332 | $pairs[] = $name . '=' . wp_unslash( $value ); |
| 333 | } |
| 334 | return implode( '; ', $pairs ); |
| 335 | } |
| 336 | |
| 337 | /** |
| 338 | * Build a Hooks instance that strips the forwarded `Cookie:` header on |
| 339 | * any redirect that leaves the original origin: off-host, or an |
| 340 | * https→http scheme downgrade on the same host. Defends against |
| 341 | * leaking admin auth cookies (we forward `Cookie:` manually, so |
| 342 | * Requests won't enforce browser `Secure` semantics for us). |
| 343 | * Relative redirects inherit the original origin and pass through. |
| 344 | * |
| 345 | * @param string $original_url Initial probe URL whose origin is the trust boundary. |
| 346 | * @return \WpOrg\Requests\Hooks |
| 347 | */ |
| 348 | protected function build_same_host_cookie_hook( $original_url ) { |
| 349 | $original = wp_parse_url( $original_url ); |
| 350 | $original_host = isset( $original['host'] ) ? strtolower( (string) $original['host'] ) : ''; |
| 351 | $original_scheme = isset( $original['scheme'] ) ? strtolower( (string) $original['scheme'] ) : ''; |
| 352 | |
| 353 | $hooks = new \WpOrg\Requests\Hooks(); |
| 354 | $hooks->register( |
| 355 | 'requests.before_redirect', |
| 356 | static function ( &$location, &$req_headers, &$req_data, &$options, $return_value ) use ( $original_host, $original_scheme ) { |
| 357 | unset( $req_data, $options, $return_value ); |
| 358 | if ( ! is_array( $req_headers ) ) { |
| 359 | return; |
| 360 | } |
| 361 | $next = wp_parse_url( (string) $location ); |
| 362 | $next_host = isset( $next['host'] ) ? strtolower( (string) $next['host'] ) : $original_host; |
| 363 | $next_scheme = isset( $next['scheme'] ) ? strtolower( (string) $next['scheme'] ) : $original_scheme; |
| 364 | $same_host = '' !== $next_host && $next_host === $original_host; |
| 365 | $scheme_downgrade = 'https' === $original_scheme && 'https' !== $next_scheme; |
| 366 | if ( ! $same_host || $scheme_downgrade ) { |
| 367 | foreach ( array_keys( $req_headers ) as $key ) { |
| 368 | if ( 0 === strcasecmp( (string) $key, 'Cookie' ) ) { |
| 369 | unset( $req_headers[ $key ] ); |
| 370 | } |
| 371 | } |
| 372 | } |
| 373 | } |
| 374 | ); |
| 375 | return $hooks; |
| 376 | } |
| 377 | |
| 378 | /** |
| 379 | * Transient key for a probe token. Shared with the endpoint. |
| 380 | * |
| 381 | * @param string $token Random probe token. |
| 382 | * @return string |
| 383 | */ |
| 384 | public static function transient_key( $token ) { |
| 385 | return 'pcg_probe_' . md5( (string) $token ); |
| 386 | } |
| 387 | } |