Code Coverage |
||||||||||
Lines |
Functions and Methods |
Classes and Traits |
||||||||
| Total | |
12.60% |
16 / 127 |
|
18.18% |
2 / 11 |
CRAP | |
0.00% |
0 / 1 |
| PCG_Load_Tester | |
12.60% |
16 / 127 |
|
18.18% |
2 / 11 |
1277.51 | |
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 / 20 |
|
0.00% |
0 / 1 |
12 | |||
| parse_response | |
0.00% |
0 / 32 |
|
0.00% |
0 / 1 |
110 | |||
| collect_auth_cookie_header | |
0.00% |
0 / 10 |
|
0.00% |
0 / 1 |
72 | |||
| 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 | 'redirects' => 0, |
| 64 | ) |
| 65 | ); |
| 66 | } catch ( \Throwable $t ) { |
| 67 | return array( |
| 68 | 'status' => 'error', |
| 69 | 'reason' => sprintf( 'Probe request failed: %s', $t->getMessage() ), |
| 70 | ); |
| 71 | } finally { |
| 72 | delete_transient( self::transient_key( $front['token'] ) ); |
| 73 | delete_transient( self::transient_key( $admin['token'] ) ); |
| 74 | } |
| 75 | |
| 76 | $front_result = $this->parse_response( $responses['front'], false ); |
| 77 | $admin_result = $this->parse_response( $responses['admin'], true ); |
| 78 | |
| 79 | // Log transport-level errors (most often timeouts at PROBE_TIMEOUT) |
| 80 | // so we can see how often they fire before deciding whether to |
| 81 | // scale the timeout with batch size. |
| 82 | if ( $this->is_error( $front_result ) || $this->is_error( $admin_result ) ) { |
| 83 | $this->log_probe_error( $mode, $plugin_mains, $front_result, $admin_result ); |
| 84 | } |
| 85 | |
| 86 | // fatal/throwable wins; an inconclusive `error` from one probe must |
| 87 | // not shadow a real fatal from the other. Front-end is the canonical |
| 88 | // "site works" signal when neither probe captured a fatal. |
| 89 | if ( $this->is_block( $front_result ) ) { |
| 90 | return $front_result; |
| 91 | } |
| 92 | if ( $this->is_block( $admin_result ) ) { |
| 93 | return $admin_result; |
| 94 | } |
| 95 | return $front_result; |
| 96 | } |
| 97 | |
| 98 | /** |
| 99 | * Whether a verdict is a captured fatal that should block the activation. |
| 100 | * |
| 101 | * @param array $result Probe verdict. |
| 102 | * @return bool |
| 103 | */ |
| 104 | protected function is_block( $result ) { |
| 105 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 106 | return 'fatal' === $status || 'throwable' === $status; |
| 107 | } |
| 108 | |
| 109 | /** |
| 110 | * Whether a verdict is a transport-level error (timeout, connection |
| 111 | * failure, non-JSON body). Distinct from `is_block` — errors are |
| 112 | * inconclusive and don't block activation, but are worth logging. |
| 113 | * |
| 114 | * @param array $result Probe verdict. |
| 115 | * @return bool |
| 116 | */ |
| 117 | protected function is_error( $result ) { |
| 118 | $status = is_array( $result ) ? (string) ( $result['status'] ?? '' ) : ''; |
| 119 | return 'error' === $status; |
| 120 | } |
| 121 | |
| 122 | /** |
| 123 | * Log a probe transport error to logstash whenever either probe came |
| 124 | * back as `error` (timeout at PROBE_TIMEOUT, connection failure, |
| 125 | * non-JSON body). Lets us measure timeout frequency vs. batch size |
| 126 | * before deciding whether to scale `PROBE_TIMEOUT` with N. |
| 127 | * |
| 128 | * @param string $mode Probe mode constant. |
| 129 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 130 | * @param array $front_result Front-end probe verdict. |
| 131 | * @param array $admin_result Admin probe verdict. |
| 132 | * @return void |
| 133 | */ |
| 134 | protected function log_probe_error( $mode, array $plugin_mains, array $front_result, array $admin_result ) { |
| 135 | pcg_log_event( |
| 136 | 'Probe transport error', |
| 137 | array( |
| 138 | 'mode' => $mode, |
| 139 | 'plugins' => $this->relative_basenames( $plugin_mains ), |
| 140 | 'front' => $this->probe_error_reason( $front_result ), |
| 141 | 'admin' => $this->probe_error_reason( $admin_result ), |
| 142 | ) |
| 143 | ); |
| 144 | } |
| 145 | |
| 146 | /** |
| 147 | * Strip `WP_PLUGIN_DIR/` from each absolute path so log entries carry |
| 148 | * the canonical plugin basename (e.g. `akismet/akismet.php`). |
| 149 | * |
| 150 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 151 | * @return string[] |
| 152 | */ |
| 153 | protected function relative_basenames( array $plugin_mains ) { |
| 154 | $prefix = WP_PLUGIN_DIR . '/'; |
| 155 | $out = array(); |
| 156 | foreach ( $plugin_mains as $path ) { |
| 157 | $out[] = str_starts_with( (string) $path, $prefix ) |
| 158 | ? substr( (string) $path, strlen( $prefix ) ) |
| 159 | : (string) $path; |
| 160 | } |
| 161 | return $out; |
| 162 | } |
| 163 | |
| 164 | /** |
| 165 | * One-line reason from a probe verdict — `reason` if set, else |
| 166 | * `status`, else empty. Used for diagnostic logs. |
| 167 | * |
| 168 | * @param array $result Probe verdict. |
| 169 | * @return string |
| 170 | */ |
| 171 | protected function probe_error_reason( array $result ) { |
| 172 | return (string) ( $result['reason'] ?? $result['status'] ?? '' ); |
| 173 | } |
| 174 | |
| 175 | /** |
| 176 | * Build the transient payload that the probe endpoint will consume. |
| 177 | * |
| 178 | * Exposed for unit tests so they can assert the stash shape without |
| 179 | * needing a live HTTP loopback. Not part of the public API. |
| 180 | * |
| 181 | * @internal |
| 182 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 183 | * @param string $mode Probe mode constant. |
| 184 | * @return array{plugins:string[],mode:string} |
| 185 | */ |
| 186 | public static function build_probe_payload( array $plugin_mains, $mode = self::MODE_ACTIVATION ) { |
| 187 | return array( |
| 188 | 'plugins' => array_values( array_map( static fn( $p ) => (string) $p, $plugin_mains ) ), |
| 189 | 'mode' => self::MODE_UPDATE === $mode ? self::MODE_UPDATE : self::MODE_ACTIVATION, |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | /** |
| 194 | * Stash a probe transient and build the `Requests::request_multiple` |
| 195 | * descriptor for one of the two parallel probes. |
| 196 | * |
| 197 | * @param string[] $plugin_mains Absolute paths to plugin main PHP files. |
| 198 | * @param string $base_url Front-end or admin base URL. |
| 199 | * @param bool $is_admin Adds `pcg_admin=1` and forwards auth cookies. |
| 200 | * @param string $mode Probe mode constant. |
| 201 | * @return array{token:string,request:array} |
| 202 | */ |
| 203 | protected function prepare_probe( array $plugin_mains, $base_url, $is_admin, $mode = self::MODE_ACTIVATION ) { |
| 204 | $token = wp_generate_password( 32, false ); |
| 205 | set_transient( self::transient_key( $token ), self::build_probe_payload( $plugin_mains, $mode ), self::TOKEN_LIFETIME ); |
| 206 | |
| 207 | $query = array( |
| 208 | 'pcg_probe' => '1', |
| 209 | 'token' => $token, |
| 210 | ); |
| 211 | $headers = array(); |
| 212 | if ( $is_admin ) { |
| 213 | $query['pcg_admin'] = '1'; |
| 214 | $cookie_header = $this->collect_auth_cookie_header(); |
| 215 | if ( '' !== $cookie_header ) { |
| 216 | $headers['Cookie'] = $cookie_header; |
| 217 | } |
| 218 | } |
| 219 | |
| 220 | return array( |
| 221 | 'token' => $token, |
| 222 | 'request' => array( |
| 223 | 'url' => add_query_arg( $query, $base_url ), |
| 224 | 'type' => 'GET', |
| 225 | 'headers' => $headers, |
| 226 | ), |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | /** |
| 231 | * Translate a `Requests::request_multiple` response into a probe verdict. |
| 232 | * |
| 233 | * @param mixed $response A `WpOrg\Requests\Response`, or an exception |
| 234 | * thrown for that single request. |
| 235 | * @param bool $is_admin True when this was the admin probe. |
| 236 | * @return array{status:string,reason?:string,errno?:int,class?:string,message?:string,file?:string,line?:int,plugin?:string} |
| 237 | */ |
| 238 | protected function parse_response( $response, $is_admin ) { |
| 239 | if ( $response instanceof \Throwable ) { |
| 240 | return array( |
| 241 | 'status' => 'error', |
| 242 | 'reason' => sprintf( 'Probe request failed: %s', $response->getMessage() ), |
| 243 | ); |
| 244 | } |
| 245 | |
| 246 | $code = (int) ( $response->status_code ?? 0 ); |
| 247 | $body = (string) ( $response->body ?? '' ); |
| 248 | |
| 249 | $decoded = json_decode( $body, true ); |
| 250 | if ( is_array( $decoded ) && isset( $decoded['status'] ) ) { |
| 251 | return $decoded; |
| 252 | } |
| 253 | |
| 254 | // Admin probe bounced to login (no/expired cookie). Distinct status so |
| 255 | // we can measure how often it fires; treated as ok by callers. |
| 256 | if ( $is_admin && ( 301 === $code || 302 === $code ) ) { |
| 257 | return array( |
| 258 | 'status' => 'ok-inconclusive', |
| 259 | 'reason' => 'Admin probe redirected; treating as inconclusive ok.', |
| 260 | ); |
| 261 | } |
| 262 | |
| 263 | if ( 500 === $code ) { |
| 264 | return array( |
| 265 | 'status' => 'fatal', |
| 266 | 'message' => 'Probe request returned HTTP 500 without a JSON verdict; the plugin likely fatals during load.', |
| 267 | ); |
| 268 | } |
| 269 | |
| 270 | // Probe endpoint always emits JSON; a 2xx without one means the |
| 271 | // bootstrap was terminated mid-flight (exit/die during load/init/admin_init). |
| 272 | // Block, since the same termination would affect matching future requests. |
| 273 | if ( $code >= 200 && $code < 300 ) { |
| 274 | return array( |
| 275 | 'status' => 'fatal', |
| 276 | 'message' => sprintf( |
| 277 | '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.', |
| 278 | $code |
| 279 | ), |
| 280 | ); |
| 281 | } |
| 282 | |
| 283 | return array( |
| 284 | 'status' => 'error', |
| 285 | 'reason' => sprintf( 'Probe returned HTTP %d without a verdict payload.', $code ), |
| 286 | ); |
| 287 | } |
| 288 | |
| 289 | /** |
| 290 | * `Cookie:` header from the current request's WP auth cookies, so the |
| 291 | * admin loopback authenticates as the same user. Empty if none found. |
| 292 | * |
| 293 | * @return string |
| 294 | */ |
| 295 | protected function collect_auth_cookie_header() { |
| 296 | if ( empty( $_COOKIE ) || ! is_array( $_COOKIE ) ) { |
| 297 | return ''; |
| 298 | } |
| 299 | $pairs = array(); |
| 300 | foreach ( $_COOKIE as $name => $value ) { |
| 301 | if ( ! is_string( $name ) || ! is_string( $value ) ) { |
| 302 | continue; |
| 303 | } |
| 304 | if ( ! str_starts_with( $name, 'wordpress_' ) && ! str_starts_with( $name, 'wp-' ) ) { |
| 305 | continue; |
| 306 | } |
| 307 | $pairs[] = $name . '=' . wp_unslash( $value ); |
| 308 | } |
| 309 | return implode( '; ', $pairs ); |
| 310 | } |
| 311 | |
| 312 | /** |
| 313 | * Transient key for a probe token. Shared with the endpoint. |
| 314 | * |
| 315 | * @param string $token Random probe token. |
| 316 | * @return string |
| 317 | */ |
| 318 | public static function transient_key( $token ) { |
| 319 | return 'pcg_probe_' . md5( (string) $token ); |
| 320 | } |
| 321 | } |