Commit 4554e1861e for woocommerce
commit 4554e1861e3198fdc8eca582d62902e9e022bcf2
Author: Christopher Allford <6451942+ObliviousHarmony@users.noreply.github.com>
Date: Thu Feb 5 09:40:23 2026 -0800
Prevent Concurrent Installations (#63100)
Due to the way that transients were being used, WC_Install::install()
was vulnerable to time-of-check to time-of-use race conditions.
This commit replaces the transient lock with an atomic one
that relies on database semantics.
diff --git a/plugins/woocommerce/changelog/fix-wc-install-atomicity b/plugins/woocommerce/changelog/fix-wc-install-atomicity
new file mode 100644
index 0000000000..dfa855b96f
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-wc-install-atomicity
@@ -0,0 +1,4 @@
+Significance: minor
+Type: fix
+
+Use atomic locking to prevent WC_Install::install() from running concurrently.
diff --git a/plugins/woocommerce/includes/class-wc-install.php b/plugins/woocommerce/includes/class-wc-install.php
index 411e92a8b6..5971a15c29 100644
--- a/plugins/woocommerce/includes/class-wc-install.php
+++ b/plugins/woocommerce/includes/class-wc-install.php
@@ -586,19 +586,16 @@ class WC_Install {
return;
}
- // Check if we are not already running this routine.
- if ( self::is_installing() ) {
+ // Create a lock to prevent multiple installs from running simultaneously.
+ if ( ! self::create_lock() ) {
return;
}
- // If we made it till here nothing is running yet, lets set the transient now.
- set_transient( 'wc_installing', 'yes', MINUTE_IN_SECONDS * 10 );
- wc_maybe_define_constant( 'WC_INSTALLING', true );
-
try {
+ wc_maybe_define_constant( 'WC_INSTALLING', true );
self::install_core();
} finally {
- delete_transient( 'wc_installing' );
+ self::release_lock();
}
// Use add_option() here to avoid overwriting this value with each
@@ -661,12 +658,50 @@ class WC_Install {
}
/**
- * Returns true if we're installing.
+ * Attempts to acquire an installation lock.
*
- * @return bool
+ * @return bool True if a lock was acquired, otherwise false.
+ */
+ private static function create_lock(): bool {
+ global $wpdb;
+
+ // Insert will fail if it already exists so this functions as a mutex.
+ $created_lock = $wpdb->query(
+ $wpdb->prepare(
+ "INSERT INTO {$wpdb->options} (option_name, option_value, autoload) VALUES ('wc_installing', %d, 'no')",
+ time()
+ )
+ );
+
+ // Take over the lock if it's stale (older than 10 minutes).
+ if ( ! $created_lock ) {
+ $created_lock = $wpdb->query(
+ $wpdb->prepare(
+ "UPDATE {$wpdb->options} SET option_value = %d WHERE option_name = 'wc_installing' AND option_value < %d",
+ time(),
+ time() - ( MINUTE_IN_SECONDS * 10 )
+ )
+ );
+ }
+
+ if ( $created_lock ) {
+ // Set the transient for backward compatibility in case others are relying on it to signal an ongoing install.
+ set_transient( 'wc_installing', 'yes', MINUTE_IN_SECONDS * 10 );
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Releases the installation lock.
*/
- private static function is_installing() {
- return 'yes' === get_transient( 'wc_installing' );
+ private static function release_lock(): void {
+ // Delete the transient BEFORE the option to avoid races that might result in an active lock with an empty transient.
+ delete_transient( 'wc_installing' );
+
+ global $wpdb;
+ $wpdb->query( "DELETE FROM {$wpdb->options} WHERE option_name = 'wc_installing'" );
}
/**
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/util/install.php b/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
index 7bf4bd0da2..70c15e4f32 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/util/install.php
@@ -190,5 +190,6 @@ class WC_Tests_Install extends WC_Unit_Test_Case {
include dirname( dirname( dirname( dirname( __DIR__ ) ) ) ) . '/uninstall.php';
delete_transient( 'wc_installing' );
+ delete_option( 'wc_installing' );
}
}
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/category-lookup.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/category-lookup.php
index 90da372105..8b605ea98d 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/category-lookup.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/category-lookup.php
@@ -26,7 +26,9 @@ class WC_Admin_Tests_Category_Lookup extends WP_UnitTestCase {
*/
public function setUp(): void {
- delete_transient('wc_installing');
+ delete_transient( 'wc_installing' );
+ delete_option( 'wc_installing' );
+
parent::setUp();
$parent = wp_insert_term( 'test_parent', 'product_cat' );
$parent2 = wp_insert_term( 'test_parent_2', 'product_cat' );
diff --git a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
index 37d75aa9af..104f320e13 100644
--- a/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
+++ b/plugins/woocommerce/tests/legacy/unit-tests/woocommerce-admin/install.php
@@ -139,6 +139,8 @@ class WC_Admin_Tests_Install extends WP_UnitTestCase {
*/
public function test_options_are_set() {
delete_transient( 'wc_installing' );
+ delete_option( 'wc_installing' );
+
WC_Install::install();
$options = array(
@@ -157,6 +159,8 @@ class WC_Admin_Tests_Install extends WP_UnitTestCase {
*/
public function test_woocommerce_admin_installed_action() {
delete_transient( 'wc_installing' );
+ delete_option( 'wc_installing' );
+
WC_Install::install();
$this->assertTrue( did_action( 'woocommerce_admin_installed' ) > 0 );
}
@@ -229,6 +233,8 @@ class WC_Admin_Tests_Install extends WP_UnitTestCase {
*/
public function test_migrate_options() {
delete_transient( 'wc_installing' );
+ delete_option( 'wc_installing' );
+
WC_Install::install();
$this->assertTrue( defined( 'WC_ADMIN_MIGRATING_OPTIONS' ) );
$migrated_options = array(