Make WordPress Core


Ignore:
Timestamp:
01/09/2019 08:04:55 PM (6 years ago)
Author:
flixos90
Message:

Bootstrap/Load: Introduce fatal error recovery mechanism allowing users to still log in to their admin dashboard.

This changeset introduces a WP_Shutdown_Handler class that detects fatal errors and which extension (plugin or theme) causes them. Such an error is then recorded, and an error message is displayed. Subsequently, in certain protected areas, for example the admin, the broken extension will be paused, ensuring that the website is still usable in the respective area. The major benefit is that this mechanism allows site owners to still log in to their website, to fix the problem by either disabling the extension or solving the bug and then resuming the extension.

Extensions are only paused in certain designated areas. The frontend for example stays unaffected, as it is impossible to know what pausing the extension would cause to be missing, so it might be preferrable to clearly see that the website is temporarily not accessible instead.

The fatal error recovery is especially important in scope of encouraging the switch to a maintained PHP version, as not necessarily every WordPress extension is compatible with all PHP versions. If problems occur now, non-technical site owners that do not have immediate access to the codebase are not locked out of their site and can at least temporarily solve the problem quickly.

Websites that have custom requirements in that regard can implement their own shutdown handler by adding a shutdown-handler.php drop-in that returns the handler instance to use, which must be based on a class that inherits WP_Shutdown_Handler. That handler will then be used in place of the default one.

Websites that would like to modify specifically the error template displayed in the frontend can add a php-error.php drop-in that works similarly to the existing db-error.php drop-in.

Props afragen, bradleyt, flixos90, ocean90, schlessera, SergeyBiryukov, spacedmonkey.
Fixes #44458.

File:
1 edited

Legend:

Unmodified
Added
Removed
  • trunk/src/wp-admin/includes/plugin.php

    r43361 r44524  
    439439function _get_dropins() {
    440440    $dropins = array(
    441         'advanced-cache.php' => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE
    442         'db.php'             => array( __( 'Custom database class.' ), true ), // auto on load
    443         'db-error.php'       => array( __( 'Custom database error message.' ), true ), // auto on error
    444         'install.php'        => array( __( 'Custom installation script.' ), true ), // auto on installation
    445         'maintenance.php'    => array( __( 'Custom maintenance message.' ), true ), // auto on maintenance
    446         'object-cache.php'   => array( __( 'External object cache.' ), true ), // auto on load
     441        'advanced-cache.php'   => array( __( 'Advanced caching plugin.' ), 'WP_CACHE' ), // WP_CACHE
     442        'db.php'               => array( __( 'Custom database class.' ), true ), // auto on load
     443        'db-error.php'         => array( __( 'Custom database error message.' ), true ), // auto on error
     444        'install.php'          => array( __( 'Custom installation script.' ), true ), // auto on installation
     445        'maintenance.php'      => array( __( 'Custom maintenance message.' ), true ), // auto on maintenance
     446        'object-cache.php'     => array( __( 'External object cache.' ), true ), // auto on load
     447        'php-error.php'        => array( __( 'Custom PHP error message.' ), true ), // auto on error
     448        'shutdown-handler.php' => array( __( 'Custom PHP shutdown handler.' ), true ), // auto on error
    447449    );
    448450
     
    495497function is_plugin_inactive( $plugin ) {
    496498    return ! is_plugin_active( $plugin );
     499}
     500
     501/**
     502 * Determines whether a plugin is technically active but was paused while
     503 * loading.
     504 *
     505 * For more information on this and similar theme functions, check out
     506 * the {@link https://developer.wordpress.org/themes/basics/conditional-tags/
     507 * Conditional Tags} article in the Theme Developer Handbook.
     508 *
     509 * @since 5.1.0
     510 *
     511 * @param string $plugin Path to the plugin file relative to the plugins directory.
     512 * @return bool True, if in the list of paused plugins. False, not in the list.
     513 */
     514function is_plugin_paused( $plugin ) {
     515    if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     516        return false;
     517    }
     518
     519    if ( ! is_plugin_active( $plugin ) && ! is_plugin_active_for_network( $plugin ) ) {
     520        return false;
     521    }
     522
     523    list( $plugin ) = explode( '/', $plugin );
     524
     525    return array_key_exists( $plugin, $GLOBALS['_paused_plugins'] );
     526}
     527
     528/**
     529 * Gets the error that was recorded for a paused plugin.
     530 *
     531 * @since 5.1.0
     532 *
     533 * @param string $plugin Path to the plugin file relative to the plugins
     534 *                       directory.
     535 * @return array|false Array of error information as it was returned by
     536 *                     `error_get_last()`, or false if none was recorded.
     537 */
     538function wp_get_plugin_error( $plugin ) {
     539    if ( ! isset( $GLOBALS['_paused_plugins'] ) ) {
     540        return false;
     541    }
     542
     543    list( $plugin ) = explode( '/', $plugin );
     544
     545    if ( ! array_key_exists( $plugin, $GLOBALS['_paused_plugins'] ) ) {
     546        return false;
     547    }
     548
     549    return $GLOBALS['_paused_plugins'][ $plugin ];
     550}
     551
     552/**
     553 * Gets the number of sites on which a specific plugin is paused.
     554 *
     555 * @since 5.1.0
     556 *
     557 * @param string $plugin Path to the plugin file relative to the plugins directory.
     558 * @return int Site count.
     559 */
     560function count_paused_plugin_sites_for_network( $plugin ) {
     561    if ( ! is_multisite() ) {
     562        return is_plugin_paused( $plugin ) ? 1 : 0;
     563    }
     564
     565    list( $plugin ) = explode( '/', $plugin );
     566
     567    $query_args = array(
     568        'count'      => true,
     569        'number'     => 0,
     570        'network_id' => get_current_network_id(),
     571        'meta_query' => array(
     572            wp_paused_plugins()->get_site_meta_query_clause( $plugin ),
     573        ),
     574    );
     575
     576    return get_sites( $query_args );
    497577}
    498578
     
    692772        if ( ! is_plugin_active( $plugin ) ) {
    693773            continue;
     774        }
     775
     776        // Clean up the database before deactivating the plugin.
     777        if ( is_plugin_paused( $plugin ) ) {
     778            resume_plugin( $plugin );
    694779        }
    695780
     
    888973        }
    889974
     975        // Clean up the database before removing the plugin.
     976        if ( is_plugin_paused( $plugin_file ) ) {
     977            resume_plugin( $plugin_file );
     978        }
     979
    890980        /**
    891981         * Fires immediately before a plugin deletion attempt.
     
    9551045
    9561046        return new WP_Error( 'could_not_remove_plugin', sprintf( $message, implode( ', ', $errors ) ) );
     1047    }
     1048
     1049    return true;
     1050}
     1051
     1052/**
     1053 * Tries to resume a single plugin.
     1054 *
     1055 * If a redirect was provided, we first ensure the plugin does not throw fatal
     1056 * errors anymore.
     1057 *
     1058 * The way it works is by setting the redirection to the error before trying to
     1059 * include the plugin file. If the plugin fails, then the redirection will not
     1060 * be overwritten with the success message and the plugin will not be resumed.
     1061 *
     1062 * @since 5.1.0
     1063 *
     1064 * @param string $plugin       Single plugin to resume.
     1065 * @param string $redirect     Optional. URL to redirect to. Default empty string.
     1066 * @param bool   $network_wide Optional. Whether to resume the plugin for the entire
     1067 *                             network. Default false.
     1068 * @return bool|WP_Error True on success, false if `$plugin` was not paused,
     1069 *                       `WP_Error` on failure.
     1070 */
     1071function resume_plugin( $plugin, $redirect = '', $network_wide = false ) {
     1072    /*
     1073     * We'll override this later if the plugin could be included without
     1074     * creating a fatal error.
     1075     */
     1076    if ( ! empty( $redirect ) ) {
     1077        wp_redirect(
     1078            add_query_arg(
     1079                '_error_nonce',
     1080                wp_create_nonce( 'plugin-resume-error_' . $plugin ),
     1081                $redirect
     1082            )
     1083        );
     1084
     1085        // Load the plugin to test whether it throws a fatal error.
     1086        ob_start();
     1087        plugin_sandbox_scrape( $plugin );
     1088        ob_clean();
     1089    }
     1090
     1091    $result = wp_forget_extension_error( 'plugins', $plugin, $network_wide );
     1092
     1093    if ( ! $result ) {
     1094        return new WP_Error(
     1095            'could_not_resume_plugin',
     1096            __( 'Could not resume the plugin.' )
     1097        );
    9571098    }
    9581099
     
    20672208    WP_Privacy_Policy_Content::add( $plugin_name, $policy_text );
    20682209}
     2210
     2211/**
     2212 * Renders an admin notice in case some plugins have been paused due to errors.
     2213 *
     2214 * @since 5.1.0
     2215 */
     2216function paused_plugins_notice() {
     2217    if ( 'plugins.php' === $GLOBALS['pagenow'] ) {
     2218        return;
     2219    }
     2220
     2221    if ( ! current_user_can( 'deactivate_plugins' ) ) {
     2222        return;
     2223    }
     2224
     2225    if ( ! isset( $GLOBALS['_paused_plugins'] ) || empty( $GLOBALS['_paused_plugins'] ) ) {
     2226        return;
     2227    }
     2228
     2229    printf(
     2230        '<div class="notice notice-error"><p><strong>%s</strong><br>%s</p><p>%s</p></div>',
     2231        __( 'One or more plugins failed to load properly.' ),
     2232        __( 'You can find more details and make changes on the Plugins screen.' ),
     2233        sprintf(
     2234            '<a href="%s">%s</a>',
     2235            admin_url( 'plugins.php?plugin_status=paused' ),
     2236            'Go to the Plugins screen'
     2237        )
     2238    );
     2239}
Note: See TracChangeset for help on using the changeset viewer.