diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
index f24821258a..ec5f7e2854 100644
--- a/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/jobs/class-api-update-updater.php
@@ -2,6 +2,7 @@
namespace WordPressdotorg\Plugin_Directory\Jobs;
use WordPressdotorg\Plugin_Directory\Plugin_Directory;
+use WordPressdotorg\Plugin_Directory\Standalone\Plugins_Info_API;
use WordPressdotorg\Plugin_Directory\Template;
use WordPressdotorg\Plugin_Directory\Tools;
@@ -86,12 +87,13 @@ public static function update_single_plugin( $plugin_slug ) {
$requires_plugins = get_post_meta( $post->ID, 'requires_plugins', true );
$release = Plugin_Directory::get_release( $post, $version );
$release_time = self::compute_release_time( $post, $release );
- $existing_version = (string) $wpdb->get_var(
+ $existing_row = $wpdb->get_row(
$wpdb->prepare(
- "SELECT version FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
+ "SELECT version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
$post->post_name
)
);
+ $existing_version = (string) ( $existing_row->version ?? '' );
$release_delay = (int) ( $release['release_delay'] ?? 0 );
@@ -104,11 +106,22 @@ public static function update_single_plugin( $plugin_slug ) {
* The deferred cron fires at exactly $cooldown_until, so by definition this
* gate is false when called from cron_trigger_release() and no explicit bypass
* is needed.
+ *
+ * Only the version bump waits for the cooldown: a status change made
+ * mid-cooldown (a closure, a reopen) reaches the existing row right away,
+ * while it keeps serving the previous release's data. Until the cooldown
+ * expires, cron_trigger() keeps re-selecting the plugin and this write
+ * repeats as a no-op.
*/
if ( $release_delay && $existing_version !== (string) $version ) {
$cooldown_until = $release_time + $release_delay;
if ( $cooldown_until > time() ) {
self::queue_release_to_update_api( $post->post_name, $cooldown_until );
+
+ if ( $existing_row ) {
+ self::update_row_availability( $post, $existing_row->meta );
+ }
+
return true;
}
}
@@ -127,16 +140,7 @@ public static function update_single_plugin( $plugin_slug ) {
'last_stable_tag' => $post->last_stable_tag ?? '',
);
- if ( in_array( $post->post_status, array( 'disabled', 'closed' ) ) ) {
- $closed_data = Template::get_close_data( $post );
- if ( $closed_data ) {
- // Close date is sometimes unknown, only include the Day of closure.
- $meta['closed_at'] = $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false;
- if ( $closed_data['public'] ) {
- $meta['closed_reason'] = $closed_data['reason'] ?: 'unknown';
- }
- }
- }
+ $meta = array_merge( $meta, self::get_close_meta( $post ) );
// Add phased rollout strategy data if needed.
if ( $release && ! empty( $release['rollout_strategy'] ) ) {
@@ -152,7 +156,7 @@ public static function update_single_plugin( $plugin_slug ) {
$data = array(
'plugin_id' => $post->ID,
'plugin_slug' => $post->post_name,
- 'available' => (int) in_array( $post->post_status, array( 'publish', 'disabled' ) ),
+ 'available' => (int) self::is_available( $post ),
'version' => $version,
'stable_tag' => get_post_meta( $post->ID, 'stable_tag', true ),
'plugin_name' => strip_tags( get_post_meta( $post->ID, 'header_name', true ) ),
@@ -177,30 +181,14 @@ public static function update_single_plugin( $plugin_slug ) {
}
}
- // ~34char prefix, Memcache limit of 255char per key.
- $plugin_details_cache_key = 'plugin_details:' . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug );
- wp_cache_delete( $plugin_details_cache_key, 'update-check-3' );
-
- // Clear plugin info caches also
- if ( defined( 'GLOTPRESS_LOCALES_PATH' ) && GLOTPRESS_LOCALES_PATH ) {
- require_once GLOTPRESS_LOCALES_PATH;
-
- $locales = array_filter( array_values( wp_list_pluck( \GP_Locales::locales(), 'wp_locale' ) ) );
-
- foreach ( $locales as $locale ) {
- $cache_key = 'plugin_information:'
- . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug )
- . ":{$locale}";
- wp_cache_delete( $cache_key, 'plugin_api_info' );
- }
- }
+ self::clear_plugin_caches( $plugin_slug );
// Sync the latest version to Stats.
if ( function_exists( '\WordPressdotorg\Stats\sync_latest_version' ) ) {
\WordPressdotorg\Stats\sync_latest_version(
- 'plugin',
+ 'plugin',
array(
- $plugin_slug => $version
+ $plugin_slug => $version,
)
);
}
@@ -208,6 +196,93 @@ public static function update_single_plugin( $plugin_slug ) {
return true;
}
+ /**
+ * Sync the status-dependent `update_source` fields for a plugin whose
+ * version bump is deferred by a release cooldown.
+ *
+ * The row keeps serving the previous release's data; only its availability
+ * and closure meta follow the plugin's current status. `version` and
+ * `last_updated` are deliberately left untouched: the stale freshness
+ * marker keeps the plugin matching cron_trigger()'s out-of-date query, so
+ * the backup recovery path survives even when the version clauses are
+ * blinded by their 128-character truncation allowance.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @param string|null $row_meta The row's current `meta` column value.
+ * @return bool Whether the row changed.
+ */
+ protected static function update_row_availability( $post, $row_meta ) {
+ global $wpdb;
+
+ $meta = maybe_unserialize( $row_meta );
+ $meta = is_array( $meta ) ? $meta : array();
+ unset( $meta['closed_at'], $meta['closed_reason'] );
+ $meta = array_merge( $meta, self::get_close_meta( $post ) );
+
+ $updated = $wpdb->update(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'available' => (int) self::is_available( $post ),
+ // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- Matches the update_source meta format.
+ 'meta' => $meta ? serialize( $meta ) : '',
+ ),
+ array( 'plugin_slug' => $post->post_name )
+ );
+
+ if ( $updated ) {
+ self::clear_plugin_caches( $post->post_name );
+ }
+
+ return (bool) $updated;
+ }
+
+ /**
+ * Whether a plugin's `update_source` row should be marked available.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @return bool
+ */
+ protected static function is_available( $post ) {
+ return in_array( $post->post_status, array( 'publish', 'disabled' ), true );
+ }
+
+ /**
+ * Return the closure fields for a plugin's `update_source` meta.
+ *
+ * @param \WP_Post $post The plugin post.
+ * @return array Empty for plugins that are not disabled or closed.
+ */
+ protected static function get_close_meta( $post ) {
+ $meta = array();
+
+ if ( in_array( $post->post_status, array( 'disabled', 'closed' ), true ) ) {
+ $closed_data = Template::get_close_data( $post );
+ if ( $closed_data ) {
+ // Close date is sometimes unknown, only include the Day of closure.
+ $meta['closed_at'] = $closed_data['date'] ? gmdate( 'Y-m-d', strtotime( $closed_data['date'] ) ) : false;
+ if ( $closed_data['public'] ) {
+ $meta['closed_reason'] = $closed_data['reason'] ? $closed_data['reason'] : 'unknown';
+ }
+ }
+ }
+
+ return $meta;
+ }
+
+ /**
+ * Clear the update-check and plugin information caches for a plugin.
+ *
+ * @param string $plugin_slug The plugin slug.
+ */
+ protected static function clear_plugin_caches( $plugin_slug ) {
+ // ~34char prefix, Memcache limit of 255char per key.
+ $plugin_details_cache_key = 'plugin_details:' . ( strlen( $plugin_slug ) > 200 ? 'md5:' . md5( $plugin_slug ) : $plugin_slug );
+ wp_cache_delete( $plugin_details_cache_key, 'update-check-3' );
+
+ // Clear plugin info caches also.
+ Plugins_Info_API::flush_plugin_information_cache( $plugin_slug );
+ }
+
/**
* Determine the release timestamp for a plugin version.
*
diff --git a/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php
new file mode 100644
index 0000000000..9cdef8e8e9
--- /dev/null
+++ b/wordpress.org/public_html/wp-content/plugins/plugin-directory/tests/Update_Source_Cooldown_Test.php
@@ -0,0 +1,231 @@
+ 'cooldown-test-' . ( ++self::$plugin_count ),
+ 'post_title' => 'Cooldown Sync Test Plugin',
+ 'post_status' => 'publish',
+ )
+ );
+
+ $this->assertInstanceOf( \WP_Post::class, $plugin );
+ $this->plugin = $plugin;
+
+ /*
+ * The stub update_source table survives across runs — the WP test
+ * installer only drops core tables — so clear leftovers that would
+ * collide with this run's plugin ID or read as a served version.
+ */
+ global $wpdb;
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_id' => $this->plugin->ID ) );
+ $wpdb->delete( $wpdb->prefix . 'update_source', array( 'plugin_slug' => $this->plugin->post_name ) );
+
+ update_post_meta( $this->plugin->ID, 'version', self::STAGED_VERSION );
+ update_post_meta( $this->plugin->ID, 'stable_tag', self::STAGED_VERSION );
+ update_post_meta(
+ $this->plugin->ID,
+ 'releases',
+ array(
+ array(
+ 'date' => time(),
+ 'tag' => self::STAGED_VERSION,
+ 'version' => self::STAGED_VERSION,
+ 'zips_built' => true,
+ 'zips_built_from_revision' => 0,
+ 'confirmations' => array(),
+ 'confirmed' => true,
+ 'confirmations_required' => 0,
+ 'committer' => array(),
+ 'revision' => array(),
+ 'release_delay' => DAY_IN_SECONDS,
+ ),
+ )
+ );
+ }
+
+ /**
+ * Insert an update_source row serving the previous version.
+ */
+ private function insert_served_row(): void {
+ global $wpdb;
+
+ $wpdb->insert(
+ $wpdb->prefix . 'update_source',
+ array(
+ 'plugin_id' => $this->plugin->ID,
+ 'plugin_slug' => $this->plugin->post_name,
+ 'available' => 1,
+ 'version' => self::SERVED_VERSION,
+ 'stable_tag' => self::SERVED_VERSION,
+ 'plugin_name' => $this->plugin->post_title,
+ 'requires_plugins' => '',
+ 'last_updated' => $this->plugin->post_modified,
+ )
+ );
+ }
+
+ /**
+ * Fetch the plugin's update_source row.
+ *
+ * @return object|null The row, or null when none exists.
+ */
+ private function get_row(): ?object {
+ global $wpdb;
+
+ return $wpdb->get_row(
+ $wpdb->prepare(
+ "SELECT available, version, meta FROM {$wpdb->prefix}update_source WHERE plugin_slug = %s",
+ $this->plugin->post_name
+ )
+ );
+ }
+
+ /**
+ * Set the plugin's status, mirroring the closure meta the admin UI writes.
+ *
+ * @param string $status The new post status.
+ */
+ private function set_status( string $status ): void {
+ wp_update_post(
+ array(
+ 'ID' => $this->plugin->ID,
+ 'post_status' => $status,
+ )
+ );
+
+ if ( in_array( $status, array( 'closed', 'disabled' ), true ) ) {
+ update_post_meta( $this->plugin->ID, '_close_reason', 'security-issue' );
+ update_post_meta( $this->plugin->ID, 'plugin_closed_date', current_time( 'mysql' ) );
+ } else {
+ delete_post_meta( $this->plugin->ID, '_close_reason' );
+ delete_post_meta( $this->plugin->ID, 'plugin_closed_date' );
+ }
+ }
+
+ /**
+ * A new version inside its cooldown stays deferred; the row keeps serving
+ * the previous version.
+ */
+ public function test_version_bump_is_deferred_during_cooldown(): void {
+ $this->insert_served_row();
+
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $row = $this->get_row();
+ $this->assertSame( '1', $row->available );
+ $this->assertSame( self::SERVED_VERSION, $row->version );
+ $this->assertNotFalse( wp_next_scheduled( "release_to_update_api:{$this->plugin->post_name}" ) );
+ }
+
+ /**
+ * Closing a plugin mid-cooldown withdraws its row immediately, still on
+ * the served version.
+ */
+ public function test_closure_during_cooldown_reaches_row_immediately(): void {
+ $this->insert_served_row();
+ $this->set_status( 'closed' );
+
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $row = $this->get_row();
+ $this->assertSame( '0', $row->available );
+ $this->assertStringContainsString( 'closed_at', (string) $row->meta );
+ $this->assertSame( self::SERVED_VERSION, $row->version );
+ }
+
+ /**
+ * Disabling a plugin mid-cooldown records its closure meta while the row
+ * stays available.
+ */
+ public function test_disable_during_cooldown_records_closure_meta(): void {
+ $this->insert_served_row();
+ $this->set_status( 'disabled' );
+
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $row = $this->get_row();
+ $this->assertSame( '1', $row->available );
+ $this->assertStringContainsString( 'closed_at', (string) $row->meta );
+ $this->assertSame( self::SERVED_VERSION, $row->version );
+ }
+
+ /**
+ * Reopening a closed plugin mid-cooldown restores its row immediately;
+ * only the version bump keeps waiting for the cooldown.
+ */
+ public function test_reopen_during_cooldown_restores_row(): void {
+ $this->insert_served_row();
+
+ $this->set_status( 'closed' );
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $this->set_status( 'publish' );
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $row = $this->get_row();
+ $this->assertSame( '1', $row->available );
+ $this->assertStringNotContainsString( 'closed_at', (string) $row->meta );
+ $this->assertSame( self::SERVED_VERSION, $row->version );
+ }
+
+ /**
+ * A first-ever release in cooldown has no row to sync; none is created
+ * until the cooldown expires.
+ */
+ public function test_first_release_in_cooldown_creates_no_row(): void {
+ $this->set_status( 'closed' );
+
+ $this->assertTrue( API_Update_Updater::update_single_plugin( $this->plugin->post_name ) );
+
+ $this->assertNull( $this->get_row() );
+ }
+}