Commit 60f162174b7 for woocommerce
commit 60f162174b70b107ba079d8164b56e3ca2e13573
Author: Néstor Soriano <konamiman@konamiman.com>
Date: Thu Jun 11 15:50:37 2026 +0200
Fix undefined behavior in WC_Post_Data::do_deferred_product_sync (#65664)
Syncing a product can defer more products for syncing (e.g. from save
hooks added by translation plugins), which mutated the array being
iterated by array_walk and could cause an infinite loop on shutdown.
The queue is now drained in rounds over local snapshots, syncing each
product at most once per request.
diff --git a/plugins/woocommerce/changelog/fix-do_deferred_product_sync-undefined-behavior b/plugins/woocommerce/changelog/fix-do_deferred_product_sync-undefined-behavior
new file mode 100644
index 00000000000..7e324690cdf
--- /dev/null
+++ b/plugins/woocommerce/changelog/fix-do_deferred_product_sync-undefined-behavior
@@ -0,0 +1,4 @@
+Significance: patch
+Type: fix
+
+Fix undefined behavior and potential infinite loop in WC_Post_Data::do_deferred_product_sync when more products are deferred for sync while a deferred sync is running.
diff --git a/plugins/woocommerce/includes/class-wc-post-data.php b/plugins/woocommerce/includes/class-wc-post-data.php
index 3326045eed1..5359505289b 100644
--- a/plugins/woocommerce/includes/class-wc-post-data.php
+++ b/plugins/woocommerce/includes/class-wc-post-data.php
@@ -113,9 +113,18 @@ class WC_Post_Data {
public static function do_deferred_product_sync() {
global $wc_deferred_product_sync;
- if ( ! empty( $wc_deferred_product_sync ) ) {
- $wc_deferred_product_sync = wp_parse_id_list( $wc_deferred_product_sync );
- array_walk( $wc_deferred_product_sync, array( __CLASS__, 'deferred_product_sync' ) );
+ // Syncing a product may defer more products (e.g. from hooks fired while saving it),
+ // so the queue is drained in rounds, syncing each product at most once.
+ $processed = array();
+ while ( ! empty( $wc_deferred_product_sync ) ) {
+ $product_ids = array_diff( wp_parse_id_list( $wc_deferred_product_sync ), $processed );
+ $wc_deferred_product_sync = array();
+
+ foreach ( $product_ids as $product_id ) {
+ self::deferred_product_sync( $product_id );
+ }
+
+ $processed = array_merge( $processed, $product_ids );
}
}
diff --git a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
index 3384529bec7..1c9ba3227b7 100644
--- a/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
+++ b/plugins/woocommerce/tests/php/includes/class-wc-post-data-test.php
@@ -162,4 +162,94 @@ class WC_Post_Data_Test extends \WC_Unit_Test_Case {
remove_action( 'woocommerce_product_published', $callback );
$product->delete( true );
}
+
+ /**
+ * @testdox do_deferred_product_sync should sync each queued product once (even if queued multiple times) and empty the queue.
+ */
+ public function test_do_deferred_product_sync_syncs_queued_products(): void {
+ global $wc_deferred_product_sync;
+
+ $wc_deferred_product_sync = array();
+ $product_1 = WC_Helper_Product::create_grouped_product();
+ $product_2 = WC_Helper_Product::create_grouped_product();
+
+ $synced_ids = array();
+ $callback = function ( $product_id ) use ( &$synced_ids ) {
+ $synced_ids[] = $product_id;
+ };
+ add_action( 'woocommerce_update_product', $callback );
+
+ wc_deferred_product_sync( $product_1->get_id() );
+ wc_deferred_product_sync( $product_2->get_id() );
+ wc_deferred_product_sync( $product_1->get_id() );
+
+ WC_Post_Data::do_deferred_product_sync();
+
+ remove_action( 'woocommerce_update_product', $callback );
+
+ $this->assertSame( array( $product_1->get_id(), $product_2->get_id() ), $synced_ids, 'Each queued product should be synced exactly once' );
+ $this->assertEmpty( $wc_deferred_product_sync, 'The queue should be empty after the sync' );
+ }
+
+ /**
+ * @testdox do_deferred_product_sync should also sync products that get deferred while another product is being synced.
+ */
+ public function test_do_deferred_product_sync_processes_products_deferred_during_sync(): void {
+ global $wc_deferred_product_sync;
+
+ $wc_deferred_product_sync = array();
+ $product_1 = WC_Helper_Product::create_grouped_product();
+ $product_2 = WC_Helper_Product::create_grouped_product();
+
+ $synced_ids = array();
+ $callback = function ( $product_id ) use ( &$synced_ids, $product_1, $product_2 ) {
+ $synced_ids[] = $product_id;
+ if ( $product_1->get_id() === $product_id ) {
+ wc_deferred_product_sync( $product_2->get_id() );
+ }
+ };
+ add_action( 'woocommerce_update_product', $callback );
+
+ wc_deferred_product_sync( $product_1->get_id() );
+
+ WC_Post_Data::do_deferred_product_sync();
+
+ remove_action( 'woocommerce_update_product', $callback );
+
+ $this->assertSame( array( $product_1->get_id(), $product_2->get_id() ), $synced_ids, 'Products deferred while syncing another product should be synced too' );
+ $this->assertEmpty( $wc_deferred_product_sync, 'The queue should be empty after the sync' );
+ }
+
+ /**
+ * @testdox do_deferred_product_sync should terminate, syncing each product at most once, when synced products keep re-deferring each other.
+ */
+ public function test_do_deferred_product_sync_terminates_on_mutual_re_deferral(): void {
+ global $wc_deferred_product_sync;
+
+ $wc_deferred_product_sync = array();
+ $product_1 = WC_Helper_Product::create_grouped_product();
+ $product_2 = WC_Helper_Product::create_grouped_product();
+
+ // Each product defers the other one when synced, as e.g. translation plugins do.
+ // With the old array_walk-based implementation this caused an infinite loop,
+ // hence the cap on the number of syncs: it makes the test fail instead of hanging.
+ $synced_ids = array();
+ $callback = function ( $product_id ) use ( &$synced_ids, $product_1, $product_2 ) {
+ $synced_ids[] = $product_id;
+ if ( count( $synced_ids ) > 100 ) {
+ $this->fail( 'do_deferred_product_sync does not terminate when synced products keep re-deferring each other' );
+ }
+ wc_deferred_product_sync( $product_1->get_id() === $product_id ? $product_2->get_id() : $product_1->get_id() );
+ };
+ add_action( 'woocommerce_update_product', $callback );
+
+ wc_deferred_product_sync( $product_1->get_id() );
+
+ WC_Post_Data::do_deferred_product_sync();
+
+ remove_action( 'woocommerce_update_product', $callback );
+
+ $this->assertSame( array( $product_1->get_id(), $product_2->get_id() ), $synced_ids, 'Each product should be synced at most once per request' );
+ $this->assertEmpty( $wc_deferred_product_sync, 'The queue should be empty after the sync' );
+ }
}