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' );
+	}
 }