WordPress wp_update_plugins Deep Dive

When using a private update service for premium WordPress plugins, some version tests get left behind leaving users with a half-updated plugin stack.   When operating a freemium model, like Store Locator Plus, where the free base plugin may be updated and impact how the premium add ons work having all update notifications arrive at once is critical.     There are times when version 3 of the main plugin will ONLY work with version 2 of a premium add on.    Within Store Locator Plus there are “pre-flight” checks built into both the base plugin and the premium add ons to ensure the entire WordPress site stays running if there is a conflict.

Premium Updates

WordPress does a great job of checking plugins, themes, and language files for available updates if the content is hosted in the public WordPress directory.   WordPress does NOT allow for premium plugins to be hosted in their directory which leaves freemium authors to serve up their own update information.      Part of the process involves hooking into the WordPress transients processor to latch onto the I/O for the ‘update_plugins’ transient that keeps track of plugin version information and a timestamp of when it was last checked against the “master server” for an update.

One of the primary vehicles for processing update checks happens to run through the wp_update_plugins module.   This will run automatically on a site timer via WP Cron, usually every 12 hours, and use the notifications to indicate when updates are available.    If a premium plugin has hooked into this system properly the updates from the private plugin servers will appear on the updates-are-ready-for-you list alongside standard WordPress-directory hosted plugins.

Unfortunately it does not always work as intended.    Sometimes a new version is available from a premium service that is not shown on the admin panel.   Investigating this anomaly will lead straight to the wp_update_plugins() function in WordPress Core that handles this.

wp_update_plugins

wp_update_plugins is a core function ; it is not part of a class but is instead loaded as procedural code whenever the wp-includes/update.php file is called.     It is activated by multiple processes including the plugin upgrader class via hooks as well as several direct calls.

The WordPress 5.0-alpha-42191 code in all it’s glory is below.   This is what we want to discuss:

/**
 * Check plugin versions against the latest versions hosted on WordPress.org.
 *
 * The WordPress version, PHP version, and Locale is sent along with a list of
 * all plugins installed. Checks against the WordPress server at
 * api.wordpress.org. Will only check if WordPress isn't installing.
 *
 * @since 2.3.0
 * @global string $wp_version Used to notify the WordPress version.
 *
 * @param array $extra_stats Extra statistics to report to the WordPress.org API.
 */
function wp_update_plugins( $extra_stats = array() ) {
    if ( wp_installing() ) {
        return;
    }

    // include an unmodified $wp_version
    include( ABSPATH . WPINC . '/version.php' );

    // If running blog-side, bail unless we've not checked in the last 12 hours
    if ( !function_exists( 'get_plugins' ) )
        require_once( ABSPATH . 'wp-admin/includes/plugin.php' );

    $plugins = get_plugins();
    $translations = wp_get_installed_translations( 'plugins' );

    $active  = get_option( 'active_plugins', array() );
    $current = get_site_transient( 'update_plugins' );
    if ( ! is_object($current) )
        $current = new stdClass;

    $new_option = new stdClass;
    $new_option->last_checked = time();

    $doing_cron = wp_doing_cron();

    // Check for update on a different schedule, depending on the page.
    switch ( current_filter() ) {
        case 'upgrader_process_complete' :
            $timeout = 0;
            break;
        case 'load-update-core.php' :
            $timeout = MINUTE_IN_SECONDS;
            break;
        case 'load-plugins.php' :
        case 'load-update.php' :
            $timeout = HOUR_IN_SECONDS;
            break;
        default :
            if ( $doing_cron ) {
                $timeout = 0;
            } else {
                $timeout = 12 * HOUR_IN_SECONDS;
            }
    }

    $time_not_changed = isset( $current->last_checked ) && $timeout > ( time() - $current->last_checked );

    if ( $time_not_changed && ! $extra_stats ) {
        $plugin_changed = false;
        foreach ( $plugins as $file => $p ) {
            $new_option->checked[ $file ] = $p['Version'];

            if ( !isset( $current->checked[ $file ] ) || strval($current->checked[ $file ]) !== strval($p['Version']) )
                $plugin_changed = true;
        }

        if ( isset ( $current->response ) && is_array( $current->response ) ) {
            foreach ( $current->response as $plugin_file => $update_details ) {
                if ( ! isset($plugins[ $plugin_file ]) ) {
                    $plugin_changed = true;
                    break;
                }
            }
        }

        // Bail if we've checked recently and if nothing has changed
        if ( ! $plugin_changed ) {
            return;
        }
    }

    // Update last_checked for current to prevent multiple blocking requests if request hangs
    $current->last_checked = time();
    set_site_transient( 'update_plugins', $current );

    $to_send = compact( 'plugins', 'active' );

    $locales = array_values( get_available_languages() );

    /**
     * Filters the locales requested for plugin translations.
     *
     * @since 3.7.0
     * @since 4.5.0 The default value of the `$locales` parameter changed to include all locales.
     *
     * @param array $locales Plugin locales. Default is all available locales of the site.
     */
    $locales = apply_filters( 'plugins_update_check_locales', $locales );
    $locales = array_unique( $locales );

    if ( $doing_cron ) {
        $timeout = 30;
    } else {
        // Three seconds, plus one extra second for every 10 plugins
        $timeout = 3 + (int) ( count( $plugins ) / 10 );
    }

    $options = array(
        'timeout' => $timeout,
        'body' => array(
            'plugins'      => wp_json_encode( $to_send ),
            'translations' => wp_json_encode( $translations ),
            'locale'       => wp_json_encode( $locales ),
            'all'          => wp_json_encode( true ),
        ),
        'user-agent' => 'WordPress/' . $wp_version . '; ' . home_url( '/' )
    );

    if ( $extra_stats ) {
        $options['body']['update_stats'] = wp_json_encode( $extra_stats );
    }

    $url = $http_url = 'http://api.wordpress.org/plugins/update-check/1.1/';
    if ( $ssl = wp_http_supports( array( 'ssl' ) ) )
        $url = set_url_scheme( $url, 'https' );

    $raw_response = wp_remote_post( $url, $options );
    if ( $ssl && is_wp_error( $raw_response ) ) {
        trigger_error(
            sprintf(
                /* translators: %s: support forums URL */
                __( 'An unexpected error occurred. Something may be wrong with WordPress.org or this server’s configuration. If you continue to have problems, please try the <a href="%s">support forums</a>.' ),
                __( 'https://wordpress.org/support/' )
            ) . ' ' . __( '(WordPress could not establish a secure connection to WordPress.org. Please contact your server administrator.)' ),
            headers_sent() || WP_DEBUG ? E_USER_WARNING : E_USER_NOTICE
        );
        $raw_response = wp_remote_post( $http_url, $options );
    }

    if ( is_wp_error( $raw_response ) || 200 != wp_remote_retrieve_response_code( $raw_response ) ) {
        return;
    }

    $response = json_decode( wp_remote_retrieve_body( $raw_response ), true );
    foreach ( $response['plugins'] as &$plugin ) {
        $plugin = (object) $plugin;
        if ( isset( $plugin->compatibility ) ) {
            $plugin->compatibility = (object) $plugin->compatibility;
            foreach ( $plugin->compatibility as &$data ) {
                $data = (object) $data;
            }
        }
    }
    unset( $plugin, $data );
    foreach ( $response['no_update'] as &$plugin ) {
        $plugin = (object) $plugin;
    }
    unset( $plugin );

    if ( is_array( $response ) ) {
        $new_option->response = $response['plugins'];
        $new_option->translations = $response['translations'];
        // TODO: Perhaps better to store no_update in a separate transient with an expiry?
        $new_option->no_update = $response['no_update'];
    } else {
        $new_option->response = array();
        $new_option->translations = array();
        $new_option->no_update = array();
    }

    set_site_transient( 'update_plugins', $new_option );
}

There is a minor inefficiency in this code that runs every time the update process is invoked manually.  This code only runs if a manual update has fired from the dashboard or via other means or a call to wp_update_plugins() asked for extra stats.

$plugin_changed = false;
foreach ( $plugins as $file => $p ) {
    $new_option->checked[ $file ] = $p['Version'];

    if ( !isset( $current->checked[ $file ] ) || strval($current->checked[ $file ]) !== strval($p['Version']) )
        $plugin_changed = true;
}

if ( isset ( $current->response ) && is_array( $current->response ) ) {
    foreach ( $current->response as $plugin_file => $update_details ) {
        if ( ! isset($plugins[ $plugin_file ]) ) {
            $plugin_changed = true;
            break;
        }
    }
}

// Bail if we've checked recently and if nothing has changed
if ( ! $plugin_changed ) {
    return;
}

To give us some visibility into the standard data the is in play with the above code , here is a run-time sample of the key elements.

wp_update_plugins $plugins sample
wp_update_plugins $plugins sample

 

wp_update_plugins $current sample
wp_update_plugins $current sample

$plugin_changed Process Analysis

In English the process goes something like this:

  • Set $plugin_changed to false.
  • Check each of the plugins in our install, active or not …
    • set a $new_option->checked[ <plugin> ] = <version from loader.php header>
    • If the version from our PHP header does not match the version coming from the ‘update_plugins’ transient, $current->checked[ <plugin> ] in our loop, test $plugin_changed to TRUE.   Note, if the transient does not have a value they are considering to “not match” which sets $plugin_changed to TRUE as well.
  • Now that we’ve done that see if the ‘update_plugins’ transient response is set and an array. If it is then…
    • Loop through every element of the response and see if any of the plugins on the response list are missing from our installed plugins known to WordPress, if so set $plugin_changed to TRUE.|
  • If $plugins_changed is FALSE we are done with wp_update_plugins()

Transient Response Efficiency Boost

The first and easiest inefficiency to address is the second response processing mechanism.   Since the second loop is only reading local data it is not firing off other functions via hooks or filters.  The sole purpose is to set the $plugin_changed boolean to true if the response contains a plugin slug that is not already known to WordPress.

We can short-circuit this processing if ANY of the known plugins already marked $plugin_changed to true.   Adding a ! $plugin_changed to the if before looping the response does the trick.

$plugin_changed = false;
foreach ( $plugins as $file => $p ) {
    $new_option->checked[ $file ] = $p['Version'];

    if ( !isset( $current->checked[ $file ] ) || strval($current->checked[ $file ]) !== strval($p['Version']) )
        $plugin_changed = true;
}

if ( ! $plugin_changed && isset ( $current->response ) && is_array( $current->response ) ) {
    foreach ( $current->response as $plugin_file => $update_details ) {
        if ( ! isset($plugins[ $plugin_file ]) ) {
            $plugin_changed = true;
            break;
        }
    }
}

// Bail if we've checked recently and if nothing has changed
if ( ! $plugin_changed ) {
    return;
}

Why pre_set_site_transient_update_plugins Does Not Always Fire

The above code also sheds some light on why the often-cited methodology of hooking the pre_set_site_transient_update_plugins filter does not always work to check for premium plugin updates.    In the case cited above the premium plugin is slp-experience/slp-experience.php which is on version 4.8.4.   The premium server has version 4.9 available.    However since our last update worked properly the ‘update_plugins’ transient ( $current in code-speak) has the version of slp-experience set at 4.8.4 which DOES match the current file header installed on our server.

if ( !isset( $current->checked[ $file ] ) || strval($current->checked[ $file ]) !== strval($p['Version']) )

$current->checked [ <slp-experience> ] IS set and the value ‘4.8.4’ DOES match the WordPress get_plugins() value which is read in the Version: 4.8.4 from the slp-experience/slp-experience.php file header.

Since the “standard” method of getting a premium plugin to check if a new update is available against a non-WordPress server is to hook the pre_set_site_transient_update_plugins filter, the ONLY time WordPress will ask a premium server if a premium plugin update is available is if set_transient( ‘update_plugins’ , <value> ) is called.

That is only called from a few places.

pre_set_site_transient_update_plugins Triggers

When does this trigger, and thus ask “premium servers” if they have plugin updates available?

1 –  In wp_update_plugins() if the default timer is expired (it has been > 12H since the last check).
Here it fires TWICE.
Once at the start of wp_update_plugins() to set the last_checked time.
Once again after it has talked to the WordPress Server and retrieved all other updates.

  • This is THE trigger that premium plugins need to latch onto in order to run successfully as the communication with the WP server obliterates the transients response property which is where new update metadata lives.
  • This will NEVER be reached if the communication with http://api.wordpress.org/plugins/update-check/1.1/ fails for ANY reason.
    • Server time out : WordPress allows 3 seconds plus 1 second for every 10 plugins installed to get a response from the WP API server.
    • Gateway issues.
    • SSL proxy issues.
    • Any other communication issue with the WordPress.Org plugin API server.

2 – In wp_update_plugins() if $plugins_changed in the above code is true.
Which means the user manually fired check for updates and SOMETHING on the plugin list has a version discrepancy with what is installed versus that last transient lookup.

3 – A plugin has been deleted.

2 thoughts on “WordPress wp_update_plugins Deep Dive

  1. Hi

    Really interesting article related to something I am looking at.

    I was wondering if you have a list of where “wp_update_plugins” is called from.

    Thanks

    Nathan

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.