sessions = $sessions; $this->loopback = $loopback; $this->aggregator = $aggregator; } /** * Replace the pause used while waiting for rows (tests). * * @param callable $sleeper Receives microseconds. */ public function set_sleeper( callable $sleeper ): void { $this->sleeper = $sleeper; } /** * Start a new automatic session (admin "Run profile", "Try without" or WP-CLI). Refused while a * browse-and-profile recording is in progress: rotating the signing key would end it. * * @param string[] $urls URLs. * @param int $passes Passes. * @param string $exclude Excluded plugin file. * @param string $label Label. * @param int $baseline Session a "try without" run is compared with (0 = none). * @return int|\WP_Error Session id. */ public function start( array $urls, int $passes, string $exclude = '', string $label = '', int $baseline = 0 ) { $urls = UrlSet::sanitize( $urls ); if ( ! $urls ) { return new \WP_Error( 'bloatwright_no_urls', __( 'Add at least one URL on this site.', 'bloatwright' ) ); } $valid = self::validate_exclude( $exclude ); if ( is_wp_error( $valid ) ) { return $valid; } if ( ( new BrowseMode( $this->sessions, $this ) )->active() ) { // Also finishes a recording that has expired. return new \WP_Error( 'bloatwright_browse_active', __( 'A browse-and-profile recording is in progress. Stop it before starting another run.', 'bloatwright' ), array( 'status' => 409 ) ); } $passes = max( 1, min( 10, $passes ) ); Signature::rotate(); $id = $this->sessions->create( $urls, $passes, 'auto', $exclude, $label, $baseline ); self::prune( $this->sessions ); return $id; } /** * Keep exactly the configured number of sessions ("Sessions to keep"), the newest one included. * * @param SessionRepository $sessions Sessions. */ public static function prune( SessionRepository $sessions ): int { return $sessions->prune( max( 1, (int) Settings::get( 'history', 20 ) ) ); } /** * An excluded plugin must be an installed plugin file, never a path (and never this plugin). * * @param string $file Plugin file relative to the plugins directory. * @return true|\WP_Error */ public static function validate_exclude( string $file ) { if ( '' === $file ) { return true; } if ( ! function_exists( 'get_plugins' ) ) { require_once ABSPATH . 'wp-admin/includes/plugin.php'; } if ( 0 !== validate_file( $file ) || BLOATWRIGHT_BASENAME === $file || ! array_key_exists( $file, get_plugins() ) ) { return new \WP_Error( 'bloatwright_invalid_exclude', __( 'Pick an installed plugin to exclude.', 'bloatwright' ), array( 'status' => 400 ) ); } return true; } /** * Run one step (one request). Returns the session row after the step, or the unchanged row when * another request currently holds the step lock. * * @param int $session_id Session. * @return array|\WP_Error */ public function step( int $session_id ) { $s = $this->sessions->find( $session_id ); if ( ! $s ) { return new \WP_Error( 'bloatwright_not_found', __( 'Session not found.', 'bloatwright' ), array( 'status' => 404 ) ); } if ( 'running' !== $s['status'] ) { return $s; } try { $result = $this->with_lock( function () use ( $session_id, $s ) { return $this->do_step( $session_id, $s ); } ); } finally { if ( ! $this->batch ) { $this->loopback->release(); // The session token minted for admin URLs never outlives the step. } } return null === $result ? $s : $result; } /** * Only one request wins the lock because add_option() is an INSERT; a stale lock is taken over. * * @param callable $body Work to run while holding the lock. * @return mixed|null Body result, or null when the lock is held elsewhere. */ private function with_lock( callable $body ) { if ( ! add_option( self::LOCK, (string) time(), '', false ) ) { if ( time() - (int) get_option( self::LOCK ) < self::LOCK_TTL ) { return null; } update_option( self::LOCK, (string) time(), false ); } try { return $body(); } finally { delete_option( self::LOCK ); } } /** * One request, then progress bookkeeping. * * @param int $session_id Session. * @param array $s Session row. * @return array|\WP_Error */ private function do_step( int $session_id, array $s ) { $progress = $s['progress']; $total = count( $s['urls'] ) * $s['passes']; $next = (int) ( $progress['next'] ?? 0 ); if ( $next >= $total ) { return $this->finish( $session_id ); } $url_index = intdiv( $next, $s['passes'] ); $pass = ( $next % $s['passes'] ) + 1; $url = (string) $s['urls'][ $url_index ]; if ( 1 === $pass && Settings::get( 'warmup', true ) ) { $this->loopback->profile( $session_id, $url, 0, $s['exclude_slug'] ); // Warm-up, not recorded. } $r = $this->loopback->profile( $session_id, $url, $pass, $s['exclude_slug'] ); if ( ! empty( $r['skipped'] ) ) { // Admin URL without an acting user: never requested, so not an error of the site. $progress['skipped'][] = array( 'url' => $url, 'pass' => $pass, 'error' => $r['error'], ); } elseif ( ! $r['ok'] || ! $r['profiled'] ) { $progress['errors'][] = array( 'url' => $url, 'pass' => $pass, 'error' => $r['error'], ); if ( $r['ok'] ) { // Reached but not profiled: a cache answered. $progress['cached'] = (int) ( $progress['cached'] ?? 0 ) + 1; } } $progress['next'] = $next + 1; $progress['done'] = $next + 1; $progress['last'] = array( 'url' => $url, 'pass' => $pass, 'ms' => $r['ms'], ); $this->sessions->update( $session_id, array( 'progress' => $progress ) ); if ( $progress['next'] >= $total ) { return $this->finish( $session_id ); } $s['progress'] = $progress; return $s; } /** * Run every remaining step synchronously (CLI). * * @param int $session_id Session. * @param callable|null $progress Callback (done, total). * @return array|\WP_Error */ public function run_all( int $session_id, ?callable $progress = null ) { $guard = 0; $this->batch = true; try { do { $s = $this->step( $session_id ); if ( is_wp_error( $s ) ) { return $s; } if ( $progress ) { $progress( (int) ( $s['progress']['done'] ?? 0 ), (int) ( $s['progress']['total'] ?? 0 ) ); } } while ( 'running' === $s['status'] && $guard++ < 1000 ); } finally { $this->batch = false; $this->loopback->release(); } return $s; } /** * Rows an automatic session should have: every URL/pass that was requested and answered with a * profiled response. * * @param array $s Session row. * @return array */ private static function expected_rows( array $s ): array { $progress = (array) ( $s['progress'] ?? array() ); $passes = max( 1, (int) $s['passes'] ); $steps = min( (int) ( $progress['next'] ?? 0 ), count( (array) $s['urls'] ) * $passes ); $failed = array(); foreach ( array_merge( (array) ( $progress['errors'] ?? array() ), (array) ( $progress['skipped'] ?? array() ) ) as $e ) { $failed[ (string) ( $e['url'] ?? '' ) . '|' . (int) ( $e['pass'] ?? 0 ) ] = true; } $out = array(); for ( $i = 0; $i < $steps; $i++ ) { $url = (string) $s['urls'][ intdiv( $i, $passes ) ]; $pass = ( $i % $passes ) + 1; if ( ! isset( $failed[ $url . '|' . $pass ] ) ) { $out[] = array( 'url' => $url, 'pass' => $pass, ); } } return $out; } /** * Wait (bounded) until every expected row has been written. Returns the rows still missing. * * @param int $session_id Session. * @param array $expected Expected rows. * @return array */ private function await_rows( int $session_id, array $expected ): array { $wait = max( 0.0, (float) apply_filters( 'bloatwright_record_wait', self::RECORD_WAIT, $session_id ) ); $deadline = microtime( true ) + $wait; while ( true ) { $have = array(); foreach ( $this->sessions->recorded( $session_id ) as $row ) { $have[ $row['url'] . '|' . $row['pass'] ] = true; } $missing = array_values( array_filter( $expected, static function ( $e ) use ( $have ) { return ! isset( $have[ Recorder::clean_url( $e['url'] ) . '|' . $e['pass'] ] ); } ) ); $left = $deadline - microtime( true ); if ( ! $missing || $left <= 0 ) { return $missing; } call_user_func( $this->sleeper, (int) min( self::RECORD_POLL_US, ceil( $left * 1e6 ) ) ); } } /** * Aggregate and mark done (or failed when nothing was recorded). * * @param int $session_id Session. * @return array|\WP_Error */ public function finish( int $session_id ) { $this->loopback->release(); $s = $this->sessions->find( $session_id ); if ( ! $s ) { return new \WP_Error( 'bloatwright_not_found', __( 'Session not found.', 'bloatwright' ), array( 'status' => 404 ) ); } if ( 'auto' === $s['mode'] ) { // The last profiled request writes its row after its response was sent: give it time to land. $missing = $this->await_rows( $session_id, self::expected_rows( $s ) ); if ( $missing ) { $s['progress']['missing'] = $missing; $this->sessions->update( $session_id, array( 'progress' => $s['progress'] ) ); } } $requests = $this->sessions->requests( $session_id ); $this->discard_key( $s ); // No token issued for this session verifies from here on. if ( ! $requests ) { $this->sessions->update( $session_id, array( 'status' => 'failed', 'finished_at' => current_time( 'mysql', true ), ) ); $s = $this->sessions->find( $session_id ); return $s ? $s : new \WP_Error( 'bloatwright_failed', __( 'Session failed.', 'bloatwright' ) ); } $summary = $this->aggregator->summarize( $s, $requests ); $summary['missing'] = (array) ( $s['progress']['missing'] ?? array() ); $this->sessions->update( $session_id, array( 'status' => 'done', 'finished_at' => current_time( 'mysql', true ), 'summary' => $summary, ) ); $s = $this->sessions->find( $session_id ); do_action( 'bloatwright_session_complete', $session_id, $summary ); return $s; } /** * Discard the signing key once a session ends, unless a session of the other mode (a recording * for an automatic run, an automatic run for a recording) is still using it. * * @param array $s Session that ends. */ private function discard_key( array $s ): void { $in_use = 'browse' === (string) $s['mode'] ? $this->sessions->running_auto() > 0 : BrowseMode::current( $this->sessions ) > 0; if ( ! $in_use ) { Signature::clear(); } } /** * Cancel. * * @param int $session_id Session. */ public function cancel( int $session_id ): bool { $this->loopback->release(); $s = $this->sessions->find( $session_id ); if ( ! $s || 'running' !== $s['status'] ) { return false; } $this->discard_key( $s ); $this->sessions->update( $session_id, array( 'status' => 'failed', 'finished_at' => current_time( 'mysql', true ), ) ); return true; } }