From 7569c55a3a88f7064dd771f5998ebbae9559c696 Mon Sep 17 00:00:00 2001 From: David Stone Date: Sat, 6 Jun 2026 13:25:41 -0600 Subject: [PATCH] fix(checkout): dispatch pending site async on frankenphp --- inc/managers/class-membership-manager.php | 74 ++++++++++++++++--- inc/models/class-membership.php | 32 ++++++-- .../Managers/Membership_Manager_Test.php | 33 ++++----- 3 files changed, 105 insertions(+), 34 deletions(-) diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index b17abd6b7..9859a0a28 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -150,22 +150,76 @@ public function publish_pending_site(): void { ignore_user_abort(true); - // Send JSON response to client. - // Don't use wp_send_json because it will exit prematurely. - header('Content-Type: application/json; charset=' . get_option('blog_charset')); - echo wp_json_encode(['status' => 'creating-site']); + $this->send_pending_site_publish_started_response(); + + $this->async_publish_pending_site($membership_id); + + exit; // Just exit the request + } + + /** + * Sends the loopback response before the long-running publish starts. + * + * The checkout fast-path calls this endpoint with a normal blocking + * wp_remote_request() so it can confirm that the HMAC-protected action was + * reached. The endpoint must therefore complete the HTTP response before it + * starts copying/provisioning the pending site, otherwise FrankenPHP and other + * non-FastCGI SAPIs keep the checkout waiting for the full publish. + * + * @since 2.5.x + * @return void + */ + protected function send_pending_site_publish_started_response() { + + $response = wp_json_encode(['status' => 'creating-site']); + + if ( ! headers_sent()) { + header('Content-Type: application/json; charset=' . get_option('blog_charset')); + header('Cache-Control: no-cache, must-revalidate, max-age=0'); + header('Content-Length: ' . strlen($response)); + + /* + * The manual fallback below relies on the client being able to treat + * the response body as complete after Content-Length bytes even though + * PHP continues executing the publish in the same request. + */ + header('Connection: close'); + } + + echo $response; + + $this->finish_pending_site_publish_response(); + } + + /** + * Finishes or flushes the HTTP response while allowing PHP to keep running. + * + * @since 2.5.x + * @return void + */ + protected function finish_pending_site_publish_response() { + + if (function_exists('litespeed_finish_request')) { + litespeed_finish_request(); + return; + } - // Don't make the request block till we finish, if possible. if (function_exists('fastcgi_finish_request')) { fastcgi_finish_request(); - // Response is sent, but the php process continues to run and update the site. + return; } - // Note: When fastcgi_finish_request is unavailable, the client will wait - // for the operation to complete but still receives the JSON response. - $this->async_publish_pending_site($membership_id); + /* + * FrankenPHP does not expose a PHP-level finish_request() function in all + * contexts. Flush every active output buffer and the SAPI buffer so the + * loopback client can receive the Content-Length-delimited response before + * the pending-site publish work starts. + */ + while (ob_get_level() > 0) { + ob_end_flush(); + } - exit; // Just exit the request + flush(); } /** diff --git a/inc/models/class-membership.php b/inc/models/class-membership.php index 9cc8d8c65..da3ef35cd 100644 --- a/inc/models/class-membership.php +++ b/inc/models/class-membership.php @@ -2119,8 +2119,12 @@ public function publish_pending_site_async(): void { * @param Membership $membership The membership publishing its pending site. */ $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; - if ($use_loopback) { + if ($use_loopback && $can_finish_request) { // 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; @@ -2145,10 +2149,6 @@ public function publish_pending_site_async(): void { 'headers' => $headers, ]; - if ( ! function_exists('fastcgi_finish_request')) { - // We do not have fastcgi but can make the request continue without listening with blocking = false. - $request_args['blocking'] = false; - } $result = wp_remote_request( $rest_path, $request_args @@ -2165,11 +2165,33 @@ public function publish_pending_site_async(): void { // translators: %d HTTP status code. sprintf(__('Loopback fast-path returned HTTP %d — falling back to Action Scheduler.', 'ultimate-multisite'), $code) ); + } else { + $loopback_started = true; } } } wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $this->get_id()], 'membership'); + + if ( ! $loopback_started) { + $this->dispatch_pending_site_async_queue(); + } + } + + /** + * Dispatches the Action Scheduler async runner immediately. + * + * @since 2.5.x + * @return void + */ + protected function dispatch_pending_site_async_queue() { + + if ( ! class_exists('\ActionScheduler') || ! class_exists('\ActionScheduler_AsyncRequest_QueueRunner')) { + return; + } + + $runner = new \ActionScheduler_AsyncRequest_QueueRunner(\ActionScheduler::store()); + $runner->maybe_dispatch(); } /** diff --git a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php index 8f0dde7d3..4c92c5e7b 100644 --- a/tests/WP_Ultimo/Managers/Membership_Manager_Test.php +++ b/tests/WP_Ultimo/Managers/Membership_Manager_Test.php @@ -1271,6 +1271,8 @@ function ($preempt, $r, $url) use (&$captured_url) { 3 ); + add_filter('wu_publish_pending_site_can_finish_request', '__return_true'); + // Call the async publish method. $membership->publish_pending_site_async(); @@ -1295,6 +1297,7 @@ function ($preempt, $r, $url) use (&$captured_url) { 'Token in URL should be valid' ); + remove_filter('wu_publish_pending_site_can_finish_request', '__return_true'); remove_filter('pre_http_request', 10); } @@ -1321,7 +1324,9 @@ public function test_publish_pending_site_async_can_skip_loopback_via_filter(): add_filter( 'pre_http_request', function ($preempt, $r, $url) use (&$captured_url) { - $captured_url = $url; + if (strpos($url, 'wu_publish_pending_site') !== false) { + $captured_url = $url; + } return ['response' => ['code' => 200]]; }, @@ -1340,11 +1345,12 @@ function ($preempt, $r, $url) use (&$captured_url) { } /** - * Test publish_pending_site_async logs non-2xx responses. + * Test publish_pending_site_async falls back from non-2xx responses. * - * Verifies that HTTP error responses are logged. + * Verifies that HTTP error responses still leave a queued Action Scheduler + * fallback for the pending-site publish. */ - public function test_publish_pending_site_async_logs_http_errors(): void { + public function test_publish_pending_site_async_enqueues_fallback_on_http_errors(): void { $membership = $this->create_membership(); @@ -1369,28 +1375,17 @@ function () { } ); - // Capture log output. - $log_file = WP_CONTENT_DIR . '/wu-logs/membership-' . $membership->get_id() . '.log'; - if (file_exists($log_file)) { - unlink($log_file); - } + add_filter('wu_publish_pending_site_can_finish_request', '__return_true'); // Call the async publish method. $membership->publish_pending_site_async(); - // Verify the error was logged. $this->assertTrue( - file_exists($log_file), - 'Log file should be created for HTTP error' - ); - - $log_content = file_get_contents($log_file); - $this->assertStringContainsString( - 'HTTP 400', - $log_content, - 'Log should contain HTTP error code' + as_has_scheduled_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership'), + 'Action Scheduler fallback should be queued when the loopback returns HTTP error.' ); + remove_filter('wu_publish_pending_site_can_finish_request', '__return_true'); remove_filter('pre_http_request', 10); } }