Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
12.60% covered (danger)
12.60%
16 / 127
18.18% covered (danger)
18.18%
2 / 11
CRAP
0.00% covered (danger)
0.00%
0 / 1
PCG_Load_Tester
12.60% covered (danger)
12.60%
16 / 127
18.18% covered (danger)
18.18%
2 / 11
1277.51
0.00% covered (danger)
0.00%
0 / 1
 test
28.21% covered (danger)
28.21%
11 / 39
0.00% covered (danger)
0.00%
0 / 1
38.98
 is_block
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
12
 is_error
0.00% covered (danger)
0.00%
0 / 2
0.00% covered (danger)
0.00%
0 / 1
6
 log_probe_error
0.00% covered (danger)
0.00%
0 / 9
0.00% covered (danger)
0.00%
0 / 1
2
 relative_basenames
0.00% covered (danger)
0.00%
0 / 7
0.00% covered (danger)
0.00%
0 / 1
12
 probe_error_reason
0.00% covered (danger)
0.00%
0 / 1
0.00% covered (danger)
0.00%
0 / 1
2
 build_probe_payload
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 prepare_probe
0.00% covered (danger)
0.00%
0 / 20
0.00% covered (danger)
0.00%
0 / 1
12
 parse_response
0.00% covered (danger)
0.00%
0 / 32
0.00% covered (danger)
0.00%
0 / 1
110
 collect_auth_cookie_header
0.00% covered (danger)
0.00%
0 / 10
0.00% covered (danger)
0.00%
0 / 1
72
 transient_key
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
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 */
12class 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}