Make WordPress Core


Ignore:
Timestamp:
05/10/2024 11:46:51 AM (2 years ago)
Author:
johnbillion
Message:

Upgrade/Install: Automatically roll back to the previous version when an automatic plugin update results in a fatal error on the front end of the site.

This builds on the temporary backup system introduced in 6.3 to allow automatic updates to benefit from fatal error protection. A loopback request is performed to the home page of the site and the plugin is rolled back to its backed up version if a fatal error is observed.

For debugging and observability during beta, this change includes several calls to error_log() during the upgrade and rollback stages. These calls can be removed or placed behind a flag once we're ready for RC1.

Props costdev, johnbillion, mukesh27, afragen, audrasjb, justlevine, kirasong, peterwilsoncc

Fixes #58281

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/class-wp-automatic-updater.php

    r56549 r58128  
    447447                }
    448448
     449                if ( 'plugin' === $type ) {
     450                        $was_active = is_plugin_active( $upgrader_item );
     451                        error_log( '    Upgrading plugin ' . var_export( $item->slug, true ) . '...' );
     452                }
     453
     454                if ( 'theme' === $type ) {
     455                        error_log( '    Upgrading theme ' . var_export( $item->theme, true ) . '...' );
     456                }
     457
     458                /*
     459                 * Enable maintenance mode before upgrading the plugin or theme.
     460                 *
     461                 * This avoids potential non-fatal errors being detected
     462                 * while scraping for a fatal error if some files are still
     463                 * being moved.
     464                 *
     465                 * While these checks are intended only for plugins,
     466                 * maintenance mode is enabled for all upgrade types as any
     467                 * update could contain an error or warning, which could cause
     468                 * the scrape to miss a fatal error in the plugin update.
     469                 */
     470                $upgrader->maintenance_mode( true );
     471
    449472                // Boom, this site's about to get a whole new splash of paint!
    450473                $upgrade_result = $upgrader->upgrade(
    … …  
    461484                );
    462485
     486                /*
     487                 * After WP_Upgrader::upgrade() completes, maintenance mode is disabled.
     488                 *
     489                 * Re-enable maintenance mode while attempting to detect fatal errors
     490                 * and potentially rolling back.
     491                 *
     492                 * This avoids errors if the site is visited while fatal errors exist
     493                 * or while files are still being moved.
     494                 */
     495                $upgrader->maintenance_mode( true );
     496
    463497                // If the filesystem is unavailable, false is returned.
    464498                if ( false === $upgrade_result ) {
    … …  
    487521                }
    488522
     523                if ( 'theme' === $type ) {
     524                        error_log( '    Theme ' . var_export( $item->theme, true ) . ' has been upgraded.' );
     525                }
     526
     527                if ( 'plugin' === $type ) {
     528                        error_log( '    Plugin ' . var_export( $item->slug, true ) . ' has been upgraded.' );
     529                        if ( is_plugin_inactive( $upgrader_item ) ) {
     530                                error_log( '    ' . var_export( $upgrader_item, true ) . ' is inactive and will not be checked for fatal errors.' );
     531                        }
     532
     533                        if ( $was_active && ! is_wp_error( $upgrade_result ) ) {
     534
     535                                /*
     536                                 * The usual time limit is five minutes. However, as a loopback request
     537                                 * is about to be performed, increase the time limit to account for this.
     538                                 */
     539                                if ( function_exists( 'set_time_limit' ) ) {
     540                                        set_time_limit( 10 * MINUTE_IN_SECONDS );
     541                                }
     542
     543                                /*
     544                                 * Avoids a race condition when there are 2 sequential plugins that have
     545                                 * fatal errors. It seems a slight delay is required for the loopback to
     546                                 * use the updated plugin code in the request. This can cause the second
     547                                 * plugin's fatal error checking to be inaccurate, and may also affect
     548                                 * subsequent plugin checks.
     549                                 */
     550                                sleep( 2 );
     551
     552                                if ( $this->has_fatal_error() ) {
     553                                        $upgrade_result = new WP_Error();
     554                                        $temp_backup    = array(
     555                                                array(
     556                                                        'dir'  => 'plugins',
     557                                                        'slug' => $item->slug,
     558                                                        'src'  => WP_PLUGIN_DIR,
     559                                                ),
     560                                        );
     561
     562                                        $backup_restored = $upgrader->restore_temp_backup( $temp_backup );
     563                                        if ( is_wp_error( $backup_restored ) ) {
     564                                                $upgrade_result->add(
     565                                                        'plugin_update_fatal_error_rollback_failed',
     566                                                        sprintf(
     567                                                                /* translators: %s: The plugin's slug. */
     568                                                                __( "The update for '%s' contained a fatal error. The previously installed version could not be restored." ),
     569                                                                $item->slug
     570                                                        )
     571                                                );
     572
     573                                                $upgrade_result->merge_from( $backup_restored );
     574                                        } else {
     575                                                $upgrade_result->add(
     576                                                        'plugin_update_fatal_error_rollback_successful',
     577                                                        sprintf(
     578                                                                /* translators: %s: The plugin's slug. */
     579                                                                __( "The update for '%s' contained a fatal error. The previously installed version has been restored." ),
     580                                                                $item->slug
     581                                                        )
     582                                                );
     583
     584                                                $backup_deleted = $upgrader->delete_temp_backup( $temp_backup );
     585                                                if ( is_wp_error( $backup_deleted ) ) {
     586                                                        $upgrade_result->merge_from( $backup_deleted );
     587                                                }
     588                                        }
     589
     590                                        /*
     591                                         * Should emails not be working, log the message(s) so that
     592                                         * the log file contains context for the fatal error,
     593                                         * and whether a rollback was performed.
     594                                         *
     595                                         * `trigger_error()` is not used as it outputs a stack trace
     596                                         * to this location rather than to the fatal error, which will
     597                                         * appear above this entry in the log file.
     598                                         */
     599                                        error_log( '    ' . implode( "\n", $upgrade_result->get_error_messages() ) );
     600                                } else {
     601                                        error_log( '    The update for ' . var_export( $item->slug, true ) . ' has no fatal errors.' );
     602                                }
     603                        }
     604                }
     605
     606                // All processes are complete. Allow visitors to browse the site again.
     607                $upgrader->maintenance_mode( false );
     608
    489609                $this->update_results[ $type ][] = (object) array(
    490610                        'item'     => $item,
    … …  
    515635                }
    516636
     637                error_log( 'Automatic updates starting...' );
     638
    517639                // Don't automatically run these things, as we'll handle it ourselves.
    518640                remove_action( 'upgrader_process_complete', array( 'Language_Pack_Upgrader', 'async_upgrade' ), 20 );
    … …  
    525647                $plugin_updates = get_site_transient( 'update_plugins' );
    526648                if ( $plugin_updates && ! empty( $plugin_updates->response ) ) {
     649                        error_log( '  Automatic plugin updates starting...' );
     650
    527651                        foreach ( $plugin_updates->response as $plugin ) {
    528652                                $this->update( 'plugin', $plugin );
    529653                        }
     654
    530655                        // Force refresh of plugin update information.
    531656                        wp_clean_plugins_cache();
     657
     658                        error_log( '  Automatic plugin updates complete.' );
    532659                }
    533660
    … …  
    536663                $theme_updates = get_site_transient( 'update_themes' );
    537664                if ( $theme_updates && ! empty( $theme_updates->response ) ) {
     665                        error_log( '  Automatic theme updates starting...' );
     666
    538667                        foreach ( $theme_updates->response as $theme ) {
    539668                                $this->update( 'theme', (object) $theme );
    … …  
    541670                        // Force refresh of theme update information.
    542671                        wp_clean_themes_cache();
    543                 }
     672
     673                        error_log( '  Automatic theme updates complete.' );
     674                }
     675
     676                error_log( 'Automatic updates complete.' );
    544677
    545678                // Next, process any core update.
    … …  
    11641297                        // List failed plugin updates.
    11651298                        if ( ! empty( $failed_updates['plugin'] ) ) {
    1166                                 $body[] = __( 'These plugins failed to update:' );
     1299                                $body[] = __( 'The following plugins failed to update. If there was a fatal error in the update, the previously installed version has been restored.' );
    11671300
    11681301                                foreach ( $failed_updates['plugin'] as $item ) {
    … …  
    15521685                wp_mail( $email['to'], wp_specialchars_decode( $email['subject'] ), $email['body'], $email['headers'] );
    15531686        }
     1687
     1688        /**
     1689         * Performs a loopback request to check for potential fatal errors.
     1690         *
     1691         * Fatal errors cannot be detected unless maintenance mode is enabled.
     1692         *
     1693         * @since 6.6.0
     1694         *
     1695         * @global int $upgrading The Unix timestamp marking when upgrading WordPress began.
     1696         *
     1697         * @return bool Whether a fatal error was detected.
     1698         */
     1699        protected function has_fatal_error() {
     1700                global $upgrading;
     1701
     1702                $maintenance_file = ABSPATH . '.maintenance';
     1703                if ( ! file_exists( $maintenance_file ) ) {
     1704                        return false;
     1705                }
     1706
     1707                require $maintenance_file;
     1708                if ( ! is_int( $upgrading ) ) {
     1709                        return false;
     1710                }
     1711
     1712                $scrape_key   = md5( $upgrading );
     1713                $scrape_nonce = (string) $upgrading;
     1714                $transient    = 'scrape_key_' . $scrape_key;
     1715                set_transient( $transient, $scrape_nonce, 30 );
     1716
     1717                $cookies       = wp_unslash( $_COOKIE );
     1718                $scrape_params = array(
     1719                        'wp_scrape_key'   => $scrape_key,
     1720                        'wp_scrape_nonce' => $scrape_nonce,
     1721                );
     1722                $headers       = array(
     1723                        'Cache-Control' => 'no-cache',
     1724                );
     1725
     1726                /** This filter is documented in wp-includes/class-wp-http-streams.php */
     1727                $sslverify = apply_filters( 'https_local_ssl_verify', false );
     1728
     1729                // Include Basic auth in the loopback request.
     1730                if ( isset( $_SERVER['PHP_AUTH_USER'] ) && isset( $_SERVER['PHP_AUTH_PW'] ) ) {
     1731                        $headers['Authorization'] = 'Basic ' . base64_encode( wp_unslash( $_SERVER['PHP_AUTH_USER'] ) . ':' . wp_unslash( $_SERVER['PHP_AUTH_PW'] ) );
     1732                }
     1733
     1734                // Time to wait for loopback request to finish.
     1735                $timeout = 50; // 50 seconds.
     1736
     1737                error_log( '    Scraping home page...' );
     1738
     1739                $needle_start = "###### wp_scraping_result_start:$scrape_key ######";
     1740                $needle_end   = "###### wp_scraping_result_end:$scrape_key ######";
     1741                $url          = add_query_arg( $scrape_params, home_url( '/' ) );
     1742                $response     = wp_remote_get( $url, compact( 'cookies', 'headers', 'timeout', 'sslverify' ) );
     1743
     1744                if ( is_wp_error( $response ) ) {
     1745                        error_log( 'Loopback request failed: ' . $response->get_error_message() );
     1746                        return true;
     1747                }
     1748
     1749                // If this outputs `true` in the log, it means there were no fatal errors detected.
     1750                error_log( var_export( substr( $response['body'], strpos( $response['body'], '###### wp_scraping_result_start:' ) ), true ) );
     1751
     1752                $body                   = wp_remote_retrieve_body( $response );
     1753                $scrape_result_position = strpos( $body, $needle_start );
     1754                $result                 = null;
     1755
     1756                if ( false !== $scrape_result_position ) {
     1757                        $error_output = substr( $body, $scrape_result_position + strlen( $needle_start ) );
     1758                        $error_output = substr( $error_output, 0, strpos( $error_output, $needle_end ) );
     1759                        $result       = json_decode( trim( $error_output ), true );
     1760                }
     1761
     1762                delete_transient( $transient );
     1763
     1764                // Only fatal errors will result in a 'type' key.
     1765                return isset( $result['type'] );
     1766        }
    15541767}
Note: See TracChangeset for help on using the changeset viewer.