self::MAX_PAGES ) { return new \WP_Error( 'psbench_too_many_pages', __( 'A run can include up to 50 pages.', 'mtx-psbench' ) ); } $runs_per_page = min( self::MAX_RUNS, max( 1, $runs_per_page ) ); $label = '' !== trim( $label ) ? $label : sprintf( /* translators: %s: date and time. */ __( 'Run %s', 'mtx-psbench' ), wp_date( 'Y-m-d H:i' ) ); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- run bookkeeping in the plugin's own tables; must be live, so not cached. Schema::runs_table(), array( 'label' => substr( sanitize_text_field( $label ), 0, 191 ), 'status' => 'queued', 'created_by' => $user_id, 'created_at' => current_time( 'mysql', true ), 'pages' => wp_json_encode( $pages ), 'runs_per_page' => $runs_per_page, 'total' => count( $pages ) * $runs_per_page, 'done' => 0, ) ); $run_id = (int) $wpdb->insert_id; self::dispatch( $run_id ); return $run_id; } /** * Stop a run after its current load. * * @param int $run_id Run ID. */ public static function cancel( int $run_id ): void { global $wpdb; $wpdb->query( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. $wpdb->prepare( "UPDATE %i SET status = 'cancelled', finished_at = %s WHERE id = %d AND status IN ('queued', 'running')", Schema::runs_table(), current_time( 'mysql', true ), $run_id ) ); } /** * Restart the worker of runs that stopped reporting (called from Heartbeat). */ public static function revive_stalled(): void { global $wpdb; $stale_before = gmdate( 'Y-m-d H:i:s', time() - self::STALE_AFTER ); $ids = $wpdb->get_col( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. $wpdb->prepare( "SELECT id FROM %i WHERE status IN ('queued', 'running') AND ( heartbeat_at IS NULL OR heartbeat_at < %s ) AND created_at < %s", Schema::runs_table(), $stale_before, $stale_before ) ); foreach ( $ids as $run_id ) { self::dispatch( (int) $run_id ); } } /** * Fire-and-forget request that runs the worker for a run. * * @param int $run_id Run ID. */ public static function dispatch( int $run_id ): void { wp_remote_post( admin_url( 'admin-ajax.php' ), array( 'blocking' => false, 'timeout' => 0.01, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter for loopback requests. 'body' => array( 'action' => self::WORKER_ACTION, 'run_id' => $run_id, 'token' => self::sign( 'worker:' . $run_id ), ), ) ); } /** * Background worker: process loads until done or out of time, then hand over. */ public static function worker(): void { // phpcs:disable WordPress.Security.NonceVerification.Missing -- authenticated by the HMAC token instead (no user session). $run_id = isset( $_POST['run_id'] ) ? absint( $_POST['run_id'] ) : 0; $token = isset( $_POST['token'] ) ? sanitize_text_field( wp_unslash( $_POST['token'] ) ) : ''; // phpcs:enable if ( ! $run_id || ! hash_equals( self::sign( 'worker:' . $run_id ), $token ) ) { wp_die( '', '', array( 'response' => 403 ) ); } ignore_user_abort( true ); if ( function_exists( 'set_time_limit' ) ) { set_time_limit( self::TIME_BUDGET + self::LOAD_TIMEOUT + 30 ); // phpcs:ignore Squiz.PHP.DiscouragedFunctions.Discouraged -- background worker needs time for its loads. } $lock = 'psbench_run_lock_' . $run_id; if ( ! add_option( $lock, time(), '', false ) ) { if ( (int) get_option( $lock ) > time() - self::STALE_AFTER ) { wp_die(); } update_option( $lock, time(), false ); } $more = false; try { $more = self::process( $run_id ); } finally { delete_option( $lock ); } if ( $more ) { self::dispatch( $run_id ); } wp_die(); } /** * Process loads of one run within the time budget. * * @param int $run_id Run ID. * * @return bool Whether loads remain. */ private static function process( int $run_id ): bool { global $wpdb; $run = Repository::run( $run_id ); if ( ! $run || ! in_array( $run['status'], array( 'queued', 'running' ), true ) ) { return false; } $user_id = (int) $run['created_by']; $expires = time() + 15 * MINUTE_IN_SECONDS; $sessions = \WP_Session_Tokens::get_instance( $user_id ); $token = $sessions->create( $expires ); $cookies = self::auth_cookies( $user_id, $expires, $token ); add_action( 'requests-curl.after_request', array( __CLASS__, 'capture_curl_info' ), 10, 2 ); $deadline = microtime( true ) + self::TIME_BUDGET; try { while ( (int) $run['done'] < (int) $run['total'] && microtime( true ) < $deadline ) { $path = $run['pages'][ intdiv( (int) $run['done'], (int) $run['runs_per_page'] ) ]; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. Schema::runs_table(), array( 'status' => 'running', 'current_path' => $path, 'heartbeat_at' => current_time( 'mysql', true ), ), array( 'id' => $run_id ) ); self::load( $run_id, $path, $cookies, $user_id ); $run = Repository::run( $run_id ); if ( ! $run || 'running' !== $run['status'] ) { return false; // Cancelled meanwhile. } $wpdb->query( $wpdb->prepare( 'UPDATE %i SET done = done + 1 WHERE id = %d', Schema::runs_table(), $run_id ) ); // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. $run['done'] = (int) $run['done'] + 1; } } finally { remove_action( 'requests-curl.after_request', array( __CLASS__, 'capture_curl_info' ), 10 ); $sessions->destroy( $token ); } if ( (int) $run['done'] >= (int) $run['total'] ) { $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. Schema::runs_table(), array( 'status' => 'done', 'current_path' => '', 'finished_at' => current_time( 'mysql', true ), ), array( 'id' => $run_id ) ); return false; } return true; } /** * Load one page once and store the client-side numbers. The page's own * request fills in the server-side numbers (see Profiler::record()). * * @param int $run_id Run ID. * @param string $path Site-relative path. * @param array $cookies Auth cookies. * @param int $user_id User ID. */ private static function load( int $run_id, string $path, array $cookies, int $user_id ): void { global $wpdb; $req_id = wp_generate_uuid4(); $wpdb->insert( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery -- run bookkeeping in the plugin's own tables; must be live, so not cached. Schema::samples_table(), array( 'created_at' => current_time( 'mysql', true ), 'source' => 'run', 'run_id' => $run_id, 'req_id' => $req_id, 'request_type' => 'page', 'path' => $path, 'user_id' => $user_id, ) ); self::$last_curl_info = null; $started = microtime( true ); $response = wp_remote_get( home_url( $path ), array( 'timeout' => self::LOAD_TIMEOUT, 'redirection' => 0, 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- WordPress core filter for loopback requests. 'cookies' => $cookies, 'headers' => array( 'X-PSBench-Req' => $req_id, 'X-PSBench-Sig' => self::sign( 'req:' . $req_id ), 'Cache-Control' => 'no-cache', ), ) ); $elapsed = microtime( true ) - $started; $ttfb = isset( self::$last_curl_info['starttransfer_time'] ) ? (float) self::$last_curl_info['starttransfer_time'] : $elapsed; $wpdb->update( // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- run bookkeeping in the plugin's own tables; must be live, so not cached. Schema::samples_table(), array( 'ttfb_ms' => (int) round( $ttfb * 1000 ), 'status' => is_wp_error( $response ) ? 0 : (int) wp_remote_retrieve_response_code( $response ), 'html_bytes' => is_wp_error( $response ) ? null : strlen( (string) wp_remote_retrieve_body( $response ) ), ), array( 'req_id' => $req_id ) ); } /** * Requests hook: keep cURL timing info of the request just made. * * @param mixed $headers Raw headers. * @param mixed $info curl_getinfo() result. */ public static function capture_curl_info( $headers, $info = null ): void { if ( is_array( $info ) ) { self::$last_curl_info = $info; } } /** * Request ID of a verified run load, or null (called before WP is fully loaded). */ public static function verified_request_id(): ?string { $req = isset( $_SERVER['HTTP_X_PSBENCH_REQ'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_PSBENCH_REQ'] ) ) : ''; $sig = isset( $_SERVER['HTTP_X_PSBENCH_SIG'] ) ? sanitize_text_field( wp_unslash( $_SERVER['HTTP_X_PSBENCH_SIG'] ) ) : ''; if ( ! preg_match( '/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/', $req ) ) { return null; } return hash_equals( self::sign( 'req:' . $req ), $sig ) ? $req : null; } /** * HMAC with the site's auth key and salt (usable before pluggable * functions load, so the loads of a run can be verified that early). * * @param string $value Value to sign. */ private static function sign( string $value ): string { return hash_hmac( 'sha256', $value, self::signing_key() ); } /** * The "auth" key and salt, resolved the way wp_salt() resolves them: the * wp-config.php constants when they are set to something real, otherwise * the values WordPress generated and stored as site options. wp_salt() * itself is pluggable and not loaded yet when a run load is verified. * * Without either, the key is random for this request, so no signature * can match: the worker and run loads fail closed rather than accepting * a signature made with an empty key. */ private static function signing_key(): string { static $key = null; if ( null !== $key ) { return $key; } $parts = array(); foreach ( array( 'key', 'salt' ) as $part ) { $const = 'AUTH_' . strtoupper( $part ); $value = defined( $const ) ? (string) constant( $const ) : ''; if ( '' === $value || 'put your unique phrase here' === $value ) { $value = (string) get_site_option( 'auth_' . $part, '' ); } $parts[] = $value; } $key = in_array( '', $parts, true ) ? bin2hex( random_bytes( 32 ) ) : implode( '', $parts ); return $key; } /** * Login cookies for the run's user. * * @param int $user_id User ID. * @param int $expires Expiry timestamp. * @param string $token Session token. */ private static function auth_cookies( int $user_id, int $expires, string $token ): array { $secure = is_ssl() || 0 === strpos( home_url(), 'https://' ); return array( LOGGED_IN_COOKIE => wp_generate_auth_cookie( $user_id, $expires, 'logged_in', $token ), ( $secure ? SECURE_AUTH_COOKIE : AUTH_COOKIE ) => wp_generate_auth_cookie( $user_id, $expires, $secure ? 'secure_auth' : 'auth', $token ), ); } /** * Normalise a user-entered page into a site-relative path. * * @param mixed $page Path or same-site URL. */ public static function clean_path( $page ): string { $page = trim( (string) $page ); if ( '' === $page ) { return ''; } $home = wp_parse_url( home_url() ); $url = wp_parse_url( $page ); if ( isset( $url['host'] ) && ( $url['host'] ?? '' ) !== ( $home['host'] ?? '' ) ) { return ''; } $path = '/' . ltrim( (string) ( $url['path'] ?? '/' ), '/' ); if ( ! empty( $url['query'] ) ) { $path .= '?' . $url['query']; } return substr( esc_url_raw( $path ), 0, 255 ); } }