self::WAKE, 'group' => Hooks::GROUP, 'status' => 'pending', 'per_page' => 1, ), 'ids' ) && ! wp_next_scheduled( Hooks::TICK ) && ! wp_next_scheduled( self::WAKE ); } return (bool) wp_next_scheduled( Hooks::TICK ) && (bool) wp_next_scheduled( self::WAKE ); } /** Cheap idle guard; no image inventory or filesystem scan on a public page view. An idle site never takes the owner lock here. */ public static function ensure(): void { if ( ! self::wanted() || self::scheduled() ) { return; } $im_reviewed_owner_lock = Reviewed_Store::enter();try { if ( ! self::wanted() ) { return; } $lock = Repository::acquire( 'schedule' ); if ( ! $lock ) { return; } try { if ( get_option( 'afsd_im_legacy_scheduler_cleanup', false ) && 'Action Scheduler' === self::backend() ) { as_unschedule_all_actions( 'afsd_im_continue', array(), Engine::GROUP ); Reviewed_Store::update_option( 'afsd_im_legacy_scheduler_cleanup', false, false ); } if ( 'Action Scheduler' === self::backend() ) { // A recurring rescue survives a killed single action. Do not change global AS limits. if ( ! as_has_scheduled_action( Engine::HOOK, array(), Engine::GROUP ) ) { if ( ! as_schedule_recurring_action( time() + 120, 120, Engine::HOOK, array(), Engine::GROUP, true, 20 ) ) { self::schedule_failure(); } } $pending = as_get_scheduled_actions( array( 'hook' => self::WAKE, 'group' => Engine::GROUP, 'status' => 'pending', 'per_page' => 1, ), 'ids' ); if ( ! $pending && ! as_schedule_single_action( time() + 15, self::WAKE, array(), Engine::GROUP, false, 20 ) ) { self::schedule_failure(); } // AS can use WP-Cron to drive its own runner; clear only OUR old worker hooks. Reviewed_Store::wp_clear_scheduled_hook( Engine::HOOK ); Reviewed_Store::wp_clear_scheduled_hook( self::WAKE ); } else { if ( ! wp_next_scheduled( Engine::HOOK ) ) { $r = Reviewed_Store::wp_schedule_event( time() + 120, 'afsd_im_two_minutes', Engine::HOOK, array(), true ); if ( ! $r || is_wp_error( $r ) ) { self::schedule_failure(); } } if ( ! wp_next_scheduled( self::WAKE ) ) { $r = Reviewed_Store::wp_schedule_single_event( time() + 15, self::WAKE, array(), true ); if ( ! $r || is_wp_error( $r ) ) { self::schedule_failure(); } } } } catch ( \Throwable $e ) { self::schedule_failure(); } finally { Repository::release( $lock, 'schedule' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Note that scheduling failed (in the activity history at most every five minutes). */ private static function schedule_failure(): void { $previous = (int) get_option( 'afsd_im_schedule_error', 0 ); Reviewed_Store::update_option( 'afsd_im_schedule_error', time(), false ); if ( $previous < time() - 300 ) { Activity::add( 'schedule_failed' ); } } /** * Unschedule background work in both schedulers. */ public static function stop_work(): void { $im_reviewed_owner_lock = Reviewed_Store::enter();try { Reviewed_Store::wp_clear_scheduled_hook( Engine::HOOK ); Reviewed_Store::wp_clear_scheduled_hook( self::WAKE ); if ( 'Action Scheduler' === self::backend() ) { as_unschedule_all_actions( Engine::HOOK, array(), Engine::GROUP ); as_unschedule_all_actions( self::WAKE, array(), Engine::GROUP ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Unschedule background work and the health probe, and forget the probe's result. */ public static function stop_all(): void { $im_reviewed_owner_lock = Reviewed_Store::enter();try { self::stop_work(); $health = (array) get_option( self::HEALTH, array() ); self::clear_probe( $health ); Reviewed_Store::update_option( self::HEALTH, array(), false ); } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Unschedule one health probe. * * @param array $h The saved probe (token and scheduler). */ private static function clear_probe( array $h ): void { if ( empty( $h['token'] ) || empty( $h['backend'] ) ) { return; } $args = array( $h['token'], $h['backend'] ); Reviewed_Store::wp_clear_scheduled_hook( self::PROBE, $args ); if ( 'Action Scheduler' === self::backend() ) { as_unschedule_all_actions( self::PROBE, $args, Engine::GROUP ); } } /** * Schedules a harmless independent callback. No foreground worker ever marks this successful. * * @param bool $force True to schedule a new probe even when one ran in the last day under the same scheduler. */ public static function check( bool $force = false ): void { $im_reviewed_owner_lock = Reviewed_Store::enter();try { $lock = Repository::acquire( 'health' ); if ( ! $lock ) { return; } try { $old = (array) Settings::fresh_option( self::HEALTH, array() ); if ( ! empty( $old['requested'] ) && empty( $old['executed'] ) && $old['requested'] > time() - 180 && empty( $old['error'] ) ) { return; } if ( ! $force && ! empty( $old['executed'] ) && $old['executed'] > time() - 86400 && ( $old['backend'] ?? '' ) === self::backend() ) { return; } self::clear_probe( $old ); $h = array( 'token' => bin2hex( random_bytes( 24 ) ), 'backend' => self::backend(), 'requested' => time(), 'due' => time() + 1, 'executed' => 0, 'delay' => 0, 'error' => false, ); Reviewed_Store::update_option( self::HEALTH, $h, false ); $args = array( $h['token'], $h['backend'] ); try { $r = 'Action Scheduler' === $h['backend'] ? as_schedule_single_action( $h['due'], self::PROBE, $args, Engine::GROUP, true, 20 ) : Reviewed_Store::wp_schedule_single_event( $h['due'], self::PROBE, $args, true ); if ( ! $r || is_wp_error( $r ) ) { $h['error'] = true; Reviewed_Store::update_option( self::HEALTH, $h, false ); } } catch ( \Throwable $e ) { $h['error'] = true; Reviewed_Store::update_option( self::HEALTH, $h, false ); } Activity::add( $h['error'] ? 'schedule_failed' : 'probe_started' ); } finally { Repository::release( $lock, 'health' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Record that the probe ran, when its token and scheduler match the one scheduled. * * @param mixed $token The probe's token. * @param mixed $backend The scheduler it ran under. */ public static function probe_executed( $token, $backend ): void { $im_reviewed_owner_lock = Reviewed_Store::enter();try { if ( ! is_string( $token ) || ! is_string( $backend ) ) { return; } $lock = Repository::acquire( 'health' ); if ( ! $lock ) { return; } try { $h = (array) Settings::fresh_option( self::HEALTH, array() ); if ( empty( $h['token'] ) || ! hash_equals( $h['token'], $token ) || ( $h['backend'] ?? '' ) !== $backend || ! empty( $h['executed'] ) || time() < (int) $h['due'] ) { return; } $h['executed'] = time(); $h['delay'] = max( 0, time() - (int) $h['due'] ); $h['error'] = false; $h['runner'] = defined( 'WP_CLI' ) && WP_CLI ? 'WP-CLI' : ( function_exists( 'wp_doing_cron' ) && wp_doing_cron() ? 'WP-Cron request' : 'Action Scheduler request' ); Reviewed_Store::update_option( self::HEALTH, $h, false ); Reviewed_Store::update_option( 'afsd_im_schedule_error', 0, false ); Activity::add( 'probe_passed' ); } finally { Repository::release( $lock, 'health' ); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** Public to the registered scheduler only; never exposed as an anonymous plugin endpoint. */ public static function dispatch(): void { $im_reviewed_owner_lock = Reviewed_Store::enter();try { if ( self::$dispatching || is_multisite() || (int) get_option( 'afsd_im_schema' ) !== SCHEMA ) { return; } self::$dispatching = true; try { if ( Settings::fresh()['paused'] ) { return; } Reviewed_Store::update_option( 'afsd_im_last_background', time(), false ); Workflow::run_batch( 'background' ); } finally { self::$dispatching = false; // Saves that met this run's lock were recorded (Tracking); the owner tracks them before it lets go of the lock. Tracking::drain(); self::reconcile(); } } finally { Reviewed_Store::leave( $im_reviewed_owner_lock ); } } /** * Make the schedule match the work: scheduled while work waits and processing is not paused, unscheduled otherwise. */ public static function reconcile(): void { $pending = Workflow::pending(); Reviewed_Store::update_option( self::NEEDED, $pending, false ); if ( ! $pending || Settings::get()['paused'] ) { self::stop_work(); } else { self::ensure(); } } /** * Proof and recency, not a promise that a host will never stop scheduling. * * @param bool $work True when the status is about work waiting (it changes the words). * @return array The probe's state in code and words, the scheduler, times and whether to keep watching. */ public static function status( bool $work = false ): array { $h = (array) get_option( self::HEALTH, array() ); $backend = self::backend(); $last = (int) get_option( 'afsd_im_last_background', 0 ); $executed = (int) ( $h['executed'] ?? 0 ); $requested = (int) ( $h['requested'] ?? 0 ); $code = 'unchecked'; $title = __( 'Background processing not checked', 'affstackdev-image-manager' ); $detail = __( 'Run a check in Tools to see whether your host is executing scheduled jobs.', 'affstackdev-image-manager' ); if ( ! empty( $h['error'] ) ) { $code = 'error'; $title = __( 'Background check could not be scheduled', 'affstackdev-image-manager' ); $detail = __( 'Processing here remains available. Review your host scheduler, then run the check again.', 'affstackdev-image-manager' ); } elseif ( $executed && ( $h['backend'] ?? '' ) === $backend && $executed >= time() - 86400 ) { $code = 'confirmed'; $title = __( 'Background check passed', 'affstackdev-image-manager' ); $detail = sprintf( /* translators: 1: date and time in UTC when the check ran; 2: seconds the check ran after it was due. */ _n( 'A separate scheduled task ran at %1$s UTC, %2$d seconds after it was due. This is a recent execution check, not a guarantee of continuous hosting availability.', 'A separate scheduled task ran at %1$s UTC, %2$d seconds after it was due. This is a recent execution check, not a guarantee of continuous hosting availability.', (int) ( $h['delay'] ?? 0 ), 'affstackdev-image-manager' ), gmdate( 'Y-m-d H:i:s', $executed ), (int) ( $h['delay'] ?? 0 ) ); } elseif ( $requested && ! $executed && ( $h['backend'] ?? '' ) === $backend ) { $late = time() - $requested >= 180; $code = $late ? 'delayed' : 'checking'; $title = $late ? __( 'Waiting for the host scheduler', 'affstackdev-image-manager' ) : __( 'Checking background processing', 'affstackdev-image-manager' ); $detail = $late ? ( $work ? __( 'The independent task has not run yet. Keep this workspace open for assisted processing, or ask your host to run scheduled WordPress tasks.', 'affstackdev-image-manager' ) : __( 'The independent check has not run yet. Previously completed work is retained. Check host scheduling before relying on unattended processing.', 'affstackdev-image-manager' ) ) : __( 'A harmless task is waiting to run separately. Processing in this workspace is not counted as proof.', 'affstackdev-image-manager' ); } elseif ( $executed ) { $code = 'stale'; $title = __( 'Background check needs refreshing', 'affstackdev-image-manager' ); $detail = __( 'The last execution check is older than one day or used a different scheduler. Run it again in Tools.', 'affstackdev-image-manager' ); } $job = Workflow::job(); $reference = max( $last, (int) ( $job['started'] ?? 0 ), (int) get_option( 'afsd_im_last_resume', 0 ), (int) get_option( 'afsd_im_work_requested', 0 ) ); if ( $work && 'confirmed' === $code && $reference < time() - 240 ) { $code = 'delayed'; $title = __( 'Background processing has not checked in recently', 'affstackdev-image-manager' ); $detail = __( 'Saved progress is retained. This workspace can assist while you check your host scheduler.', 'affstackdev-image-manager' ); } if ( $work && (int) get_option( 'afsd_im_schedule_error', 0 ) > max( $last, $executed ) ) { $code = 'error'; $title = __( 'A background task could not be scheduled', 'affstackdev-image-manager' ); $detail = __( 'This workspace can continue processing. Check the host scheduler and retry the background check in Tools.', 'affstackdev-image-manager' ); } // Wait briefly for the probe. Browser work does not create scheduler success evidence. $assist = $work && 'confirmed' !== $code && ( 'checking' !== $code || time() - $requested >= 15 ); return array( 'code' => $code, 'title' => $title, 'detail' => $detail, 'backend' => $backend, 'requested' => $requested, 'executed' => $executed, 'delay' => (int) ( $h['delay'] ?? 0 ), 'last_background' => $last, 'assist' => $assist, 'watch' => 'checking' === $code, 'cron_disabled' => defined( 'DISABLE_WP_CRON' ) && DISABLE_WP_CRON, ); } }