From 3c07e3b64dd8ea95373f0c1f2e5155c6bbd02783 Mon Sep 17 00:00:00 2001 From: David Stone Date: Fri, 10 Apr 2026 18:18:07 -0600 Subject: [PATCH] fix: auto-reset stale is_publishing flag to prevent infinite 'Creating' spinner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- inc/managers/class-membership-manager.php | 39 +++++++++++ inc/models/class-site.php | 66 +++++++++++++++++- tests/WP_Ultimo/Models/Site_Test.php | 83 +++++++++++++++++++++++ 3 files changed, 187 insertions(+), 1 deletion(-) diff --git a/inc/managers/class-membership-manager.php b/inc/managers/class-membership-manager.php index 17c4c447..f76aea2a 100644 --- a/inc/managers/class-membership-manager.php +++ b/inc/managers/class-membership-manager.php @@ -167,6 +167,11 @@ public function async_publish_pending_site($membership_id) { /** * Processes a delayed site publish action. * + * Checks whether the pending site has been created. If is_publishing + * has been true for longer than 5 minutes, we assume the creation + * process was killed (PHP timeout, OOM, server restart) and reset + * the flag so the Action Scheduler retry can pick it up. + * * @since 2.0.11 */ public function check_pending_site_created() { @@ -190,6 +195,40 @@ public function check_pending_site_created() { exit; } + /* + * Detect stale publishing state. When the PHP process that was + * creating the site gets killed mid-execution, is_publishing + * stays true forever — the AS retry sees the flag and bails + * out, creating an infinite loop. Reset the flag after 5 min + * so the next AS run or cron kick can retry site creation. + * + * @since 2.5.3 + */ + if ($pending_site->is_publishing_stale()) { + $pending_site->set_publishing(false); + + $membership->update_pending_site($pending_site); + + wu_log_add( + self::LOG_FILE_NAME, + sprintf( + // translators: %d: membership ID. + __('Reset stale is_publishing flag for membership %d. The site creation process appears to have been killed before completing.', 'ultimate-multisite'), + $membership->get_id() + ) + ); + + /* + * Re-enqueue the async action so Action Scheduler retries + * the site creation without waiting for the next cron tick. + */ + wu_enqueue_async_action('wu_async_publish_pending_site', ['membership_id' => $membership->get_id()], 'membership'); + + wp_send_json(['publish_status' => 'stopped']); + + exit; + } + wp_send_json(['publish_status' => $pending_site->is_publishing() ? 'running' : 'stopped']); exit; diff --git a/inc/models/class-site.php b/inc/models/class-site.php index 120c86a4..fbe4dc3b 100644 --- a/inc/models/class-site.php +++ b/inc/models/class-site.php @@ -158,6 +158,18 @@ class Site extends Base_Model implements Limitable, Notable { */ protected $is_publishing; + /** + * Unix timestamp when is_publishing was set to true. + * + * Used to detect stale publishing state (e.g. process killed mid-creation). + * Not persisted to the sites DB table — only lives in the serialized + * pending_site metadata on memberships. + * + * @since 2.5.3 + * @var int + */ + protected $publishing_started_at = 0; + /** * Is this a active site? * @@ -753,13 +765,65 @@ public function is_publishing() { /** * Set if the site is being published. * + * When set to true, also records the current timestamp so that + * stale publishing states can be detected and reset. + * * @since 2.0.11 - * @param int $publishing Holds the ID of the customer that owns this site. + * @param bool $publishing Whether the site is currently being published. * @return void */ public function set_publishing($publishing): void { $this->is_publishing = $publishing; + + if ($publishing) { + $this->publishing_started_at = time(); + } else { + $this->publishing_started_at = 0; + } + } + + /** + * Get the Unix timestamp when publishing started. + * + * @since 2.5.3 + * @return int Unix timestamp, or 0 if not publishing. + */ + public function get_publishing_started_at() { + + return (int) $this->publishing_started_at; + } + + /** + * Check if the publishing state is stale (stuck). + * + * A publishing state is considered stale if is_publishing has been + * true for longer than the given timeout. This happens when the PHP + * process is killed mid-creation (OOM, timeout, server restart) + * after the flag is set but before the error handlers can reset it. + * + * @since 2.5.3 + * @param int $timeout_seconds Maximum allowed publishing duration in seconds. Default 300 (5 minutes). + * @return bool True if publishing started more than $timeout_seconds ago. + */ + public function is_publishing_stale($timeout_seconds = 300) { + + if ( ! $this->is_publishing()) { + return false; + } + + $started = $this->get_publishing_started_at(); + + /* + * If publishing_started_at is 0 or missing (pre-2.5.3 serialized + * objects), treat the state as stale — there's no way to know + * when it started, and the process that set it is clearly gone. + */ + if (empty($started)) { + return true; + } + + return (time() - $started) > $timeout_seconds; } /** diff --git a/tests/WP_Ultimo/Models/Site_Test.php b/tests/WP_Ultimo/Models/Site_Test.php index a9bc7e31..f30d90b8 100644 --- a/tests/WP_Ultimo/Models/Site_Test.php +++ b/tests/WP_Ultimo/Models/Site_Test.php @@ -891,6 +891,89 @@ public function test_publishing_false(): void { $this->assertFalse($this->site->is_publishing(), 'is_publishing should return false after set_publishing(false).'); } + /** + * Test set_publishing(true) records publishing_started_at timestamp. + */ + public function test_set_publishing_records_timestamp(): void { + $before = time(); + $this->site->set_publishing(true); + $after = time(); + + $started = $this->site->get_publishing_started_at(); + $this->assertGreaterThanOrEqual($before, $started); + $this->assertLessThanOrEqual($after, $started); + } + + /** + * Test set_publishing(false) clears publishing_started_at. + */ + public function test_set_publishing_false_clears_timestamp(): void { + $this->site->set_publishing(true); + $this->assertGreaterThan(0, $this->site->get_publishing_started_at()); + + $this->site->set_publishing(false); + $this->assertEquals(0, $this->site->get_publishing_started_at()); + } + + /** + * Test is_publishing_stale returns false when not publishing. + */ + public function test_is_publishing_stale_when_not_publishing(): void { + $this->site->set_publishing(false); + $this->assertFalse($this->site->is_publishing_stale()); + } + + /** + * Test is_publishing_stale returns false when recently started. + */ + public function test_is_publishing_stale_when_recent(): void { + $this->site->set_publishing(true); + $this->assertFalse($this->site->is_publishing_stale(), 'Should not be stale immediately after starting.'); + } + + /** + * Test is_publishing_stale returns true after timeout expires. + */ + public function test_is_publishing_stale_after_timeout(): void { + $this->site->set_publishing(true); + + // Simulate time passing by setting publishing_started_at to 10 minutes ago. + $reflection = new \ReflectionProperty(Site::class, 'publishing_started_at'); + $reflection->setAccessible(true); + $reflection->setValue($this->site, time() - 600); + + $this->assertTrue($this->site->is_publishing_stale(), 'Should be stale after 10 minutes (default timeout 300s).'); + } + + /** + * Test is_publishing_stale with custom timeout. + */ + public function test_is_publishing_stale_custom_timeout(): void { + $this->site->set_publishing(true); + + $reflection = new \ReflectionProperty(Site::class, 'publishing_started_at'); + $reflection->setAccessible(true); + $reflection->setValue($this->site, time() - 60); + + $this->assertFalse($this->site->is_publishing_stale(120), 'Should not be stale with 120s timeout after 60s.'); + $this->assertTrue($this->site->is_publishing_stale(30), 'Should be stale with 30s timeout after 60s.'); + } + + /** + * Test is_publishing_stale returns true for pre-2.5.3 objects without timestamp. + */ + public function test_is_publishing_stale_legacy_object_without_timestamp(): void { + $this->site->set_publishing(true); + + // Simulate a pre-2.5.3 serialized object that has is_publishing=true + // but no publishing_started_at value. + $reflection = new \ReflectionProperty(Site::class, 'publishing_started_at'); + $reflection->setAccessible(true); + $reflection->setValue($this->site, 0); + + $this->assertTrue($this->site->is_publishing_stale(), 'Pre-2.5.3 objects without timestamp should be treated as stale.'); + } + /** * Test get_template returns false when template_id does not match a site. */