Remove Site can hang indefinitely with no feedback - root cause in fetch_url_authed() timeout

Disclosure: this analysis was done with the help of Claude (Anthropic) reading through the Dashboard 6.1.2 and extension source. I did the reporting and judgment calls myself, but wanted to be upfront about the method — treat the code references below as a starting point for your own review, not as verified-by-testing findings.

Symptom

Removing a site from Manage Sites shows “Removing the site. Please wait…” and sometimes never resolves — no success message, no error, no redirect. Checking back later (minutes to hours), the site turns out to have been removed anyway. This happens on most sites, not consistently reproducible, and never leaves anything in the PHP/webserver error logs.

Investigation

remove_site()remove_website() in pages/page-mainwp-manage-sites-handler.php calls MainWP_Connect::fetch_url_authed( $website, 'deactivate' ) before removing the DB record, wrapped in try/catch.

In class/class-mainwp-connect.php, that single-site request sets:


// line 1603

curl_setopt( $ch, CURLOPT_CONNECTTIMEOUT, 10 );

...

// lines 1683-1684

$timeout = 20 * 60 * 60;

curl_setopt( $ch, CURLOPT_TIMEOUT, $timeout );

MainWP_System_Utility::set_time_limit( $timeout );

Connect phase is capped at 10s, but total transfer time is capped at 20 hours. If a child site accepts the connection but is slow to respond (busy host, security plugin holding the request, etc.), this single “deactivate” call has no realistic upper bound. The same 20 * 60 * 60 constant is used in fetch_urls_authed() (line 782, used for chunked multi-site sync) — that seems like the appropriate place for a long timeout; reusing it for a single interactive action during site removal looks unintentional.

Because the call is in a try/catch, an eventual timeout or error doesn’t block removal — the DB record still gets deleted afterward. This matches the observed behavior: the site is gone even though the browser never showed a result.

Separately, in assets/js/mainwp.js, mainwp_get_remove_calback() calls jQuery.post(ajaxurl, data, function(response) {...}, 'json') with no .fail()/error handler. If the request above does eventually error at the HTTP level (5xx, empty/non-JSON body from a webserver-level timeout), the success callback never fires and the “Removing the site…” message is stuck permanently with no feedback.

Ruled out

Audited all extensions hooking mainwp_delete_site / mainwp_site_deleted that we have active: SSL Monitor, Staging, Sucuri, UpdraftPlus, Wordfence. All five only do a single synchronous DB DELETE in their hook — no network or file I/O. Not the cause, at least for these five.

Suggested direction

  • Give the single-site deactivate call (and similar interactive single-site actions) its own short timeout, independent of the bulk-sync timeout — happy to hear what you consider reasonable.

  • Add a .fail() handler in mainwp.js so the UI always resolves to success or a clear error instead of hanging.

We’re not proposing a specific patch here — you know this code and its edge cases (staging sites, demo sites, retry logic) far better than we do. Just flagging what the code review turned up. Happy to provide more detail or test a fix if useful.

Thanks @josklever.

As I mentioned in the original thread, we’ll look into this and I’ll update you once I have more info.

This could be a quick fix:

--- a/assets/js/mainwp.js
+++ b/assets/js/mainwp.js
@@ -97,7 +97,7 @@ let mainwp_get_remove_calback = function (side_id) {
             id: side_id
         });
 
-        jQuery.post(ajaxurl, data, function (response) {
+        jQuery.post(ajaxurl, data)
+        .done(function (response) {
 
             let error = false;
 
@@ -117,7 +117,11 @@ let mainwp_get_remove_calback = function (side_id) {
                 }, 3000);
             }
 
-        }, 'json');
+        })
+        .fail(function () {
+            feedback('mainwp-message-zone', __('The request failed or timed out. Please reload the page to check whether the site was removed before trying again.', 'mainwp'), 'red');
+        });
     }
 
 }

But this could be made more robust in case a child site might return a different content-type header. So this might not be the solution, but it illustrates the idea.