Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
74 changes: 64 additions & 10 deletions inc/managers/class-membership-manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -150,22 +150,76 @@

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;

Check warning on line 189 in inc/managers/class-membership-manager.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

All output should be run through an escaping function (see the Security sections in the WordPress Developer Handbooks), found '$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();
}

/**
Expand Down
32 changes: 27 additions & 5 deletions inc/models/class-membership.php
Original file line number Diff line number Diff line change
Expand Up @@ -2118,9 +2118,13 @@
* @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);

Check warning on line 2121 in inc/models/class-membership.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Equals sign not aligned with surrounding assignments; expected 7 spaces but found 1 space
$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;

Check warning on line 2125 in inc/models/class-membership.php

View workflow job for this annotation

GitHub Actions / Code Quality Checks

Equals sign not aligned with surrounding assignments; expected 3 spaces but found 1 space

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;
Expand All @@ -2145,10 +2149,6 @@
'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
Expand All @@ -2165,11 +2165,33 @@
// 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();
}

/**
Expand Down
33 changes: 14 additions & 19 deletions tests/WP_Ultimo/Managers/Membership_Manager_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -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();

Expand All @@ -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);
}

Expand All @@ -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]];
},
Expand All @@ -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();

Expand All @@ -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);
}
}
Loading