Skip to content

Commit 8c0e873

Browse files
authored
fix: auto-reset stale is_publishing flag to prevent infinite 'Creating' spinner (#792)
When publish_pending_site() sets is_publishing=true but the PHP process is killed mid-creation (OOM, timeout, server restart), the flag stays true permanently. The AJAX polling handler returns 'running' forever, and the Action Scheduler retry bails out because it sees is_publishing=true. Fix: - Add publishing_started_at timestamp to Site model, recorded automatically when set_publishing(true) is called - Add is_publishing_stale() method with configurable timeout (default 5 min) - check_pending_site_created() now detects stale state, resets the flag, re-enqueues the async action, and returns 'stopped' so the JS retry flow can pick it up - Pre-2.5.3 serialized objects (without timestamp) are treated as stale when is_publishing is true, since the process that set it is clearly gone Reported by Michael Carter (maatos.app) — site accessible immediately but thank-you page stuck on 'Creating' indefinitely.
1 parent a4bb4df commit 8c0e873

3 files changed

Lines changed: 187 additions & 1 deletion

File tree

inc/managers/class-membership-manager.php

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,11 @@ public function async_publish_pending_site($membership_id) {
167167
/**
168168
* Processes a delayed site publish action.
169169
*
170+
* Checks whether the pending site has been created. If is_publishing
171+
* has been true for longer than 5 minutes, we assume the creation
172+
* process was killed (PHP timeout, OOM, server restart) and reset
173+
* the flag so the Action Scheduler retry can pick it up.
174+
*
170175
* @since 2.0.11
171176
*/
172177
public function check_pending_site_created() {
@@ -190,6 +195,40 @@ public function check_pending_site_created() {
190195
exit;
191196
}
192197

198+
/*
199+
* Detect stale publishing state. When the PHP process that was
200+
* creating the site gets killed mid-execution, is_publishing
201+
* stays true forever — the AS retry sees the flag and bails
202+
* out, creating an infinite loop. Reset the flag after 5 min
203+
* so the next AS run or cron kick can retry site creation.
204+
*
205+
* @since 2.5.3
206+
*/
207+
if ($pending_site->is_publishing_stale()) {
208+
$pending_site->set_publishing(false);
209+
210+
$membership->update_pending_site($pending_site);
211+
212+
wu_log_add(
213+
self::LOG_FILE_NAME,
214+
sprintf(
215+
// translators: %d: membership ID.
216+
__('Reset stale is_publishing flag for membership %d. The site creation process appears to have been killed before completing.', 'ultimate-multisite'),
217+
$membership->get_id()
218+
)
219+
);
220+
221+
/*
222+
* Re-enqueue the async action so Action Scheduler retries
223+
* the site creation without waiting for the next cron tick.
224+
*/
225+
wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership');
226+
227+
wp_send_json(['publish_status' => 'stopped']);
228+
229+
exit;
230+
}
231+
193232
wp_send_json(['publish_status' => $pending_site->is_publishing() ? 'running' : 'stopped']);
194233

195234
exit;

inc/models/class-site.php

Lines changed: 65 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -158,6 +158,18 @@ class Site extends Base_Model implements Limitable, Notable {
158158
*/
159159
protected $is_publishing;
160160

161+
/**
162+
* Unix timestamp when is_publishing was set to true.
163+
*
164+
* Used to detect stale publishing state (e.g. process killed mid-creation).
165+
* Not persisted to the sites DB table — only lives in the serialized
166+
* pending_site metadata on memberships.
167+
*
168+
* @since 2.5.3
169+
* @var int
170+
*/
171+
protected $publishing_started_at = 0;
172+
161173
/**
162174
* Is this a active site?
163175
*
@@ -753,13 +765,65 @@ public function is_publishing() {
753765
/**
754766
* Set if the site is being published.
755767
*
768+
* When set to true, also records the current timestamp so that
769+
* stale publishing states can be detected and reset.
770+
*
756771
* @since 2.0.11
757-
* @param int $publishing Holds the ID of the customer that owns this site.
772+
* @param bool $publishing Whether the site is currently being published.
758773
* @return void
759774
*/
760775
public function set_publishing($publishing): void {
761776

762777
$this->is_publishing = $publishing;
778+
779+
if ($publishing) {
780+
$this->publishing_started_at = time();
781+
} else {
782+
$this->publishing_started_at = 0;
783+
}
784+
}
785+
786+
/**
787+
* Get the Unix timestamp when publishing started.
788+
*
789+
* @since 2.5.3
790+
* @return int Unix timestamp, or 0 if not publishing.
791+
*/
792+
public function get_publishing_started_at() {
793+
794+
return (int) $this->publishing_started_at;
795+
}
796+
797+
/**
798+
* Check if the publishing state is stale (stuck).
799+
*
800+
* A publishing state is considered stale if is_publishing has been
801+
* true for longer than the given timeout. This happens when the PHP
802+
* process is killed mid-creation (OOM, timeout, server restart)
803+
* after the flag is set but before the error handlers can reset it.
804+
*
805+
* @since 2.5.3
806+
* @param int $timeout_seconds Maximum allowed publishing duration in seconds. Default 300 (5 minutes).
807+
* @return bool True if publishing started more than $timeout_seconds ago.
808+
*/
809+
public function is_publishing_stale($timeout_seconds = 300) {
810+
811+
if ( ! $this->is_publishing()) {
812+
return false;
813+
}
814+
815+
$started = $this->get_publishing_started_at();
816+
817+
/*
818+
* If publishing_started_at is 0 or missing (pre-2.5.3 serialized
819+
* objects), treat the state as stale — there's no way to know
820+
* when it started, and the process that set it is clearly gone.
821+
*/
822+
if (empty($started)) {
823+
return true;
824+
}
825+
826+
return (time() - $started) > $timeout_seconds;
763827
}
764828

765829
/**

tests/WP_Ultimo/Models/Site_Test.php

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -891,6 +891,89 @@ public function test_publishing_false(): void {
891891
$this->assertFalse($this->site->is_publishing(), 'is_publishing should return false after set_publishing(false).');
892892
}
893893

894+
/**
895+
* Test set_publishing(true) records publishing_started_at timestamp.
896+
*/
897+
public function test_set_publishing_records_timestamp(): void {
898+
$before = time();
899+
$this->site->set_publishing(true);
900+
$after = time();
901+
902+
$started = $this->site->get_publishing_started_at();
903+
$this->assertGreaterThanOrEqual($before, $started);
904+
$this->assertLessThanOrEqual($after, $started);
905+
}
906+
907+
/**
908+
* Test set_publishing(false) clears publishing_started_at.
909+
*/
910+
public function test_set_publishing_false_clears_timestamp(): void {
911+
$this->site->set_publishing(true);
912+
$this->assertGreaterThan(0, $this->site->get_publishing_started_at());
913+
914+
$this->site->set_publishing(false);
915+
$this->assertEquals(0, $this->site->get_publishing_started_at());
916+
}
917+
918+
/**
919+
* Test is_publishing_stale returns false when not publishing.
920+
*/
921+
public function test_is_publishing_stale_when_not_publishing(): void {
922+
$this->site->set_publishing(false);
923+
$this->assertFalse($this->site->is_publishing_stale());
924+
}
925+
926+
/**
927+
* Test is_publishing_stale returns false when recently started.
928+
*/
929+
public function test_is_publishing_stale_when_recent(): void {
930+
$this->site->set_publishing(true);
931+
$this->assertFalse($this->site->is_publishing_stale(), 'Should not be stale immediately after starting.');
932+
}
933+
934+
/**
935+
* Test is_publishing_stale returns true after timeout expires.
936+
*/
937+
public function test_is_publishing_stale_after_timeout(): void {
938+
$this->site->set_publishing(true);
939+
940+
// Simulate time passing by setting publishing_started_at to 10 minutes ago.
941+
$reflection = new \ReflectionProperty(Site::class, 'publishing_started_at');
942+
$reflection->setAccessible(true);
943+
$reflection->setValue($this->site, time() - 600);
944+
945+
$this->assertTrue($this->site->is_publishing_stale(), 'Should be stale after 10 minutes (default timeout 300s).');
946+
}
947+
948+
/**
949+
* Test is_publishing_stale with custom timeout.
950+
*/
951+
public function test_is_publishing_stale_custom_timeout(): void {
952+
$this->site->set_publishing(true);
953+
954+
$reflection = new \ReflectionProperty(Site::class, 'publishing_started_at');
955+
$reflection->setAccessible(true);
956+
$reflection->setValue($this->site, time() - 60);
957+
958+
$this->assertFalse($this->site->is_publishing_stale(120), 'Should not be stale with 120s timeout after 60s.');
959+
$this->assertTrue($this->site->is_publishing_stale(30), 'Should be stale with 30s timeout after 60s.');
960+
}
961+
962+
/**
963+
* Test is_publishing_stale returns true for pre-2.5.3 objects without timestamp.
964+
*/
965+
public function test_is_publishing_stale_legacy_object_without_timestamp(): void {
966+
$this->site->set_publishing(true);
967+
968+
// Simulate a pre-2.5.3 serialized object that has is_publishing=true
969+
// but no publishing_started_at value.
970+
$reflection = new \ReflectionProperty(Site::class, 'publishing_started_at');
971+
$reflection->setAccessible(true);
972+
$reflection->setValue($this->site, 0);
973+
974+
$this->assertTrue($this->site->is_publishing_stale(), 'Pre-2.5.3 objects without timestamp should be treated as stale.');
975+
}
976+
894977
/**
895978
* Test get_template returns false when template_id does not match a site.
896979
*/

0 commit comments

Comments
 (0)