From bc3a991d6b12571f8581a51436c8ff2a74917c0f Mon Sep 17 00:00:00 2001 From: David Stone Date: Mon, 8 Jun 2026 11:22:25 -0600 Subject: [PATCH] fix(checkout): delay pending site fallback watchdog --- inc/models/class-membership.php | 54 +++++++- .../Managers/Membership_Manager_Test.php | 125 +++++++++++++----- 2 files changed, 145 insertions(+), 34 deletions(-) diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index 1d221fa3..e4e3da24 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -61,6 +61,13 @@ class Membership extends Base_Model implements Limitable, Billable, Notable { */ const META_PENDING_SITE = 'pending_site'; + /** + * Delay before the pending-site watchdog fallback runs. + * + * @since 2.5.x + */ + const PENDING_SITE_PUBLISH_WATCHDOG_DELAY = 5 * MINUTE_IN_SECONDS; + /** * ID of the customer attached to this membership. * @@ -2118,13 +2125,16 @@ public function publish_pending_site_async(): void { * @param bool $use_loopback Whether to trigger the loopback request. * @param Membership $membership The membership publishing its pending site. */ - $use_loopback = (bool) apply_filters('wu_publish_pending_site_use_loopback', true, $this); + $use_loopback = (bool) apply_filters('wu_publish_pending_site_use_loopback', true, $this); $can_finish_request = function_exists('litespeed_finish_request') || function_exists('fastcgi_finish_request'); $can_finish_request = (bool) apply_filters('wu_publish_pending_site_can_finish_request', $can_finish_request, $this); - $loopback_started = false; + $loopback_started = false; + $args = ['membership_id' => $this->get_id()]; if ($use_loopback) { + $this->schedule_pending_site_async_watchdog($args); + // We first try to generate the site through request to start earlier as possible. // Generate a short-lived HMAC token for the loopback request. $expires = time() + 60; @@ -2174,13 +2184,49 @@ public function publish_pending_site_async(): void { } } - wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership'); + if ($loopback_started && $can_finish_request) { + return; + } + + wu_enqueue_async_action('wu_async_publish_pending_site', $args, 'membership'); if ( ! $loopback_started) { $this->dispatch_pending_site_async_queue(); } } + /** + * Schedules a delayed Action Scheduler watchdog for a loopback publish attempt. + * + * A blocking 2xx loopback only proves the HMAC-gated AJAX handler started and + * flushed its response; the long-running site publish can still be killed after + * that response. Keep a delayed fallback so the site is still published without + * creating the immediate duplicate AS race fixed by GH#1305. + * + * @since 2.5.x + * + * @param array $args Action Scheduler arguments. + * @return void + */ + protected function schedule_pending_site_async_watchdog($args) { + + if (false !== wu_next_scheduled_action('wu_async_publish_pending_site', $args, 'membership')) { + return; + } + + /** + * Filters the delayed fallback window for pending-site loopback publishing. + * + * @since 2.5.x + * + * @param int $delay Delay in seconds before the watchdog fallback runs. + * @param Membership $membership The membership publishing its pending site. + */ + $delay = (int) apply_filters('wu_publish_pending_site_watchdog_delay', self::PENDING_SITE_PUBLISH_WATCHDOG_DELAY, $this); + + wu_schedule_single_action(time() + max(1, $delay), 'wu_async_publish_pending_site', $args, 'membership'); + } + /** * Dispatches the Action Scheduler async runner immediately. * @@ -2338,6 +2384,8 @@ public function publish_pending_site() { $profile_stage = microtime(true); $this->delete_pending_site(); + wu_unschedule_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership'); + if (is_numeric($saved) && class_exists('\Ultimate_Multisite_Multi_Tenancy\Providers\Local_Provider')) { \Ultimate_Multisite_Multi_Tenancy\Providers\Local_Provider::profile_stage( (int) $saved, diff --git a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php index 63bf441f..be820b02 100644 --- a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php @@ -1260,13 +1260,15 @@ public function test_publish_pending_site_async_generates_token(): void { // Mock wp_remote_request to capture the URL. $captured_url = null; + $http_filter = function ($preempt, $r, $url) use (&$captured_url) { + $captured_url = $url; + // Return a successful response to prevent actual HTTP request. + return ['response' => ['code' => 200]]; + }; + add_filter( 'pre_http_request', - function ($preempt, $r, $url) use (&$captured_url) { - $captured_url = $url; - // Return a successful response to prevent actual HTTP request. - return ['response' => ['code' => 200]]; - }, + $http_filter, 10, 3 ); @@ -1298,7 +1300,54 @@ function ($preempt, $r, $url) use (&$captured_url) { ); remove_filter('wu_publish_pending_site_can_finish_request', '__return_true'); - remove_filter('pre_http_request', 10); + remove_filter('pre_http_request', $http_filter, 10); + } + + /** + * Test successful blocking loopback gets a delayed watchdog fallback only. + * + * A blocking HTTP 2xx means the loopback publish handler started, not that the + * long-running publish completed. Schedule a delayed watchdog to preserve the + * retry path, but do not enqueue an immediate duplicate Action Scheduler job. + */ + public function test_publish_pending_site_async_schedules_delayed_watchdog_after_successful_loopback(): void { + + $membership = $this->create_membership(); + + $pending_site = $membership->create_pending_site([ + 'title' => 'Test Site', + 'domain' => 'test-' . wp_rand() . '.example.com', + ]); + + $membership->update_pending_site($pending_site); + + $http_filter = function ($preempt, $r, $url) { + unset($preempt, $r, $url); + + return ['response' => ['code' => 200]]; + }; + + $delay_filter = function () { + return 120; + }; + + add_filter('pre_http_request', $http_filter, 10, 3); + add_filter('wu_publish_pending_site_can_finish_request', '__return_true'); + add_filter('wu_publish_pending_site_watchdog_delay', $delay_filter); + + $membership->publish_pending_site_async(); + + $scheduled_at = wu_next_scheduled_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership'); + + $this->assertIsInt( + $scheduled_at, + 'Successful blocking loopback should schedule a delayed watchdog instead of an immediate async action.' + ); + $this->assertGreaterThan(time() + 60, $scheduled_at, 'Watchdog fallback should be delayed to avoid an immediate duplicate publish race.'); + + remove_filter('wu_publish_pending_site_watchdog_delay', $delay_filter); + remove_filter('wu_publish_pending_site_can_finish_request', '__return_true'); + remove_filter('pre_http_request', $http_filter, 10); } /** @@ -1321,15 +1370,17 @@ public function test_publish_pending_site_async_can_skip_loopback_via_filter(): $membership->update_pending_site($pending_site); $captured_url = null; + $http_filter = function ($preempt, $r, $url) use (&$captured_url) { + if (strpos($url, 'wu_publish_pending_site') !== false) { + $captured_url = $url; + } + + return ['response' => ['code' => 200]]; + }; + add_filter( 'pre_http_request', - function ($preempt, $r, $url) use (&$captured_url) { - if (strpos($url, 'wu_publish_pending_site') !== false) { - $captured_url = $url; - } - - return ['response' => ['code' => 200]]; - }, + $http_filter, 10, 3 ); @@ -1339,9 +1390,13 @@ function ($preempt, $r, $url) use (&$captured_url) { $membership->publish_pending_site_async(); $this->assertNull($captured_url, 'Loopback HTTP request should not fire when the filter returns false.'); + $this->assertTrue( + as_has_scheduled_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership'), + 'Action Scheduler fallback should be queued when loopback is disabled.' + ); remove_filter('wu_publish_pending_site_use_loopback', '__return_false'); - remove_filter('pre_http_request', 10); + remove_filter('pre_http_request', $http_filter, 10); } /** @@ -1363,15 +1418,17 @@ public function test_publish_pending_site_async_uses_non_blocking_loopback_witho $membership->update_pending_site($pending_site); $captured_args = null; + $http_filter = function ($preempt, $r, $url) use (&$captured_args) { + if (strpos($url, 'wu_publish_pending_site') !== false) { + $captured_args = $r; + } + + return ['response' => ['code' => false]]; + }; + add_filter( 'pre_http_request', - function ($preempt, $r, $url) use (&$captured_args) { - if (strpos($url, 'wu_publish_pending_site') !== false) { - $captured_args = $r; - } - - return ['response' => ['code' => false]]; - }, + $http_filter, 10, 3 ); @@ -1389,7 +1446,7 @@ function ($preempt, $r, $url) use (&$captured_args) { ); remove_filter('wu_publish_pending_site_can_finish_request', '__return_false'); - remove_filter('pre_http_request', 10); + remove_filter('pre_http_request', $http_filter, 10); } /** @@ -1411,16 +1468,22 @@ public function test_publish_pending_site_async_enqueues_fallback_on_http_errors $membership->update_pending_site($pending_site); // Mock wp_remote_request to return a 400 error. + $http_filter = function ($preempt, $r, $url) { + unset($preempt, $r, $url); + + return [ + 'response' => [ + 'code' => 400, + 'message' => 'Bad Request', + ], + ]; + }; + add_filter( 'pre_http_request', - function () { - return [ - 'response' => [ - 'code' => 400, - 'message' => 'Bad Request', - ], - ]; - } + $http_filter, + 10, + 3 ); add_filter('wu_publish_pending_site_can_finish_request', '__return_true'); @@ -1434,6 +1497,6 @@ function () { ); remove_filter('wu_publish_pending_site_can_finish_request', '__return_true'); - remove_filter('pre_http_request', 10); + remove_filter('pre_http_request', $http_filter, 10); } }