self::last_run(), 'due' => count( $due ), 'overdue' => self::is_overdue( $due ), ); } /** * Is this site's cron demonstrably not running? * * ⛔ BOTH HALVES, ALWAYS. Silence proves nothing on its own — a site can go * quiet because there is nothing to do, which is the healthy case and by far * the common one. Only silence WITH work sitting due is evidence, and it is * also the only case where a nudge would do anything. * * @param array|null $due Pre-read due list, when the caller already has one. * @return bool */ public static function is_overdue( $due = null ) { $due = null === $due ? self::due() : (array) $due; if ( ! $due ) { return false; } $last = self::last_run(); // Never recorded AND work is due: either a site whose cron has not run // since this version arrived, or one whose cron does not run at all. The // nudge is harmless in the first case and the whole point in the second. return $last < 1 || ( time() - $last ) > self::OVERDUE_AFTER; } /** * This plugin's scheduled events that are past their time. * * @return array */ public static function due() { $crons = _get_cron_array(); if ( ! is_array( $crons ) ) { return array(); } $now = time(); $out = array(); foreach ( $crons as $timestamp => $hooks ) { if ( (int) $timestamp > $now ) { break; // The array is ordered by time; nothing after this is due. } foreach ( (array) $hooks as $hook => $events ) { if ( 0 !== strpos( (string) $hook, self::PREFIX ) ) { continue; // ⛔ Not ours to run. } foreach ( (array) $events as $event ) { $out[] = array( 'timestamp' => (int) $timestamp, 'hook' => (string) $hook, 'schedule' => isset( $event['schedule'] ) ? $event['schedule'] : false, 'args' => isset( $event['args'] ) ? (array) $event['args'] : array(), ); } } } return $out; } /** * Run this plugin's due jobs, here, now, in this request. * * The same steps `wp-cron.php` takes for each event, in the same order: * a recurring event is booked forward FIRST and only then removed, so a * failure between the two leaves the schedule intact rather than silently * ending it. Everything WordPress does around that — the doing_cron * transient, the loopback, the whole-queue walk — belongs to that file and * is deliberately not copied here. * * ⚠️ Budgeted, because a job may make a network call. What does not fit is * left due, and the next nudge takes it: the queue drains across page views * instead of one page view carrying the whole backlog. * * @param float $budget Seconds of work to attempt. * @return array{ran:int,remaining:int,skipped:string} */ public static function run_due( $budget = self::BUDGET ) { // ⛔ We ARE the cron pass. Everything due is about to run anyway, and // running it twice from inside itself is the one collision no lock can // undo afterwards. if ( wp_doing_cron() ) { return array( 'ran' => 0, 'remaining' => count( self::due() ), 'skipped' => 'cron' ); } // ⛔ A REAL PASS IS IN FLIGHT on a host whose cron works. It will run // these same jobs seconds from now; standing down is the whole of our // job here. if ( self::pass_in_flight() ) { return array( 'ran' => 0, 'remaining' => count( self::due() ), 'skipped' => 'busy' ); } if ( ! self::lock() ) { // Another tab of ours got here first. return array( 'ran' => 0, 'remaining' => count( self::due() ), 'skipped' => 'busy' ); } // And now the other direction: while we work, a real pass must see the // site as busy, exactly as it would while another pass ran. ⚠️ This // holds up OTHER plugins' due jobs for the few seconds we take — the // same contract every cron pass has always had, and the price of not // running the same job twice. $held = self::hold_pass(); $ran = 0; // ⛔ No floor under the budget. It used to be max(1.0, …), which quietly // made any smaller budget mean one second — and a caller asking for a // tighter wall got a wider one without being told. Progress is // guaranteed by the first-job rule below, not by a minimum here. $deadline = microtime( true ) + (float) $budget; try { foreach ( self::due() as $event ) { // ⛔ BEFORE the job, never after. Checking afterwards only stops // the run once the overrun has already happened — a second slow // job could still be started at 7.9s, and the "8 second budget" // bought nothing. Never START work past the wall; what is // already running cannot be interrupted from here at all. // ⭐ …but the FIRST job always runs. A budget spent before the // loop begins (a caller passing a tiny one, a clock that moved) // would otherwise leave a site making no progress at all, which // is the one outcome worse than a slow request. if ( $ran > 0 && microtime( true ) >= $deadline ) { break; } $hook = $event['hook']; $args = $event['args']; // ⛔ THE LAST-INSTANT CLAIM. `wp_unschedule_event()` unsets and // reports success whether or not the entry was still there, so // it can never tell us we lost a race. This can: the list was // read before the first job ran, and a job — ours or anyone's — // may have cleared what comes after it. if ( ! self::still_scheduled( $event ) ) { continue; } if ( $event['schedule'] ) { $booked = wp_reschedule_event( $event['timestamp'], (string) $event['schedule'], $hook, $args, true ); if ( is_wp_error( $booked ) ) { continue; // Leave it due rather than run something we cannot re-book. } } $cleared = wp_unschedule_event( $event['timestamp'], $hook, $args, true ); if ( is_wp_error( $cleared ) ) { continue; // ⛔ Never run an event we could not claim: two runners would both run it. } /** This is the scheduled job itself, run in a request instead of a cron pass. */ do_action_ref_array( $hook, $args ); ++$ran; } } finally { self::release_pass( $held ); self::unlock(); } // A job that ran IS the site's background work happening, whoever asked // for it — so the heartbeat moves. Without this the screen would go on // calling a site broken while this very mechanism kept it healthy. if ( $ran > 0 ) { self::mark_ran(); } return array( 'ran' => $ran, 'remaining' => count( self::due() ), 'skipped' => '' ); } /** * Put the nudge on an admin page — but only on a site that needs it. * * ⛔ A healthy site loads nothing: no script, no request, no cost. The gate * is two autoloaded option reads, which is cheaper than the query a badge * count already costs on the same page. * * ⚠️ Any admin screen, not just ours. The owner of a site with no working * cron is far more likely to be writing a post than reading our card, and a * self-healing mechanism that only heals while you watch it is a button with * extra steps. * * @return void */ public static function maybe_nudge() { if ( wp_doing_ajax() || ! is_admin() ) { return; } // The nudge acts as this user; a user who cannot run the plugin's own // routes must not be handed work that will only 403. if ( ! current_user_can( 'manage_options' ) ) { return; } if ( ! self::is_overdue() ) { return; } // ⛔ A real pass is already running: it is about to do exactly this // work. One transient read here saves a whole request that would only // stand down at the other end. if ( self::pass_in_flight() ) { return; } // Ten tabs open on a slow morning is still one runner a minute. $last_try = (int) get_transient( self::ATTEMPT ); if ( $last_try > 0 ) { return; } set_transient( self::ATTEMPT, time(), self::THROTTLE ); wp_register_script( 'agentimus-cron-nudge', false, array(), AGENTIMUS_VERSION, true ); wp_enqueue_script( 'agentimus-cron-nudge' ); wp_localize_script( 'agentimus-cron-nudge', 'AgentimusCron', array( 'url' => esc_url_raw( rest_url( 'agentimus/v1/cron/run' ) ), 'nonce' => wp_create_nonce( 'wp_rest' ), // A ceiling on how much one page view will chase. The queue is // drained across visits by design — a page that keeps firing // requests until a backlog clears is a page that never settles. 'rounds' => 3, ) ); wp_add_inline_script( 'agentimus-cron-nudge', self::script() ); } /** * The nudge itself. * * ⭐ Fired when the page is idle, never during load: the owner's screen * finishes drawing first, and the work happens in a request of its own. * ⛔ Silent by design — this is the plugin repairing itself, not an errand * for the owner. What it cannot repair is said on the card instead. * * @return string */ private static function script() { return <<<'JS' (function () { var d = window.AgentimusCron; if (!d || !window.fetch) { return; } var left = d.rounds; function run() { fetch(d.url, { method: 'POST', credentials: 'same-origin', headers: { 'Content-Type': 'application/json', 'X-WP-Nonce': d.nonce }, body: '{}' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (j) { // More still due and rounds left: come back for the next chunk. if (j && j.remaining > 0 && --left > 0) { setTimeout(run, 1500); } }).catch(function () { /* a nudge that fails changes nothing */ }); } if (window.requestIdleCallback) { requestIdleCallback(run, { timeout: 4000 }); } else { setTimeout(run, 2000); } }()); JS; } /** * Is one of this event's entries still in the schedule? * * The same three keys core stores it under — timestamp, hook, and the hash * of its arguments. * * @param array $event One {@see due()} row. * @return bool */ private static function still_scheduled( array $event ) { $crons = _get_cron_array(); $key = md5( serialize( $event['args'] ) ); // phpcs:ignore WordPress.PHP.DiscouragedPHPFunctions.serialize_serialize -- core's own key for a scheduled event; it must match byte for byte. return isset( $crons[ $event['timestamp'] ][ $event['hook'] ][ $key ] ); } /** * Is WordPress running a cron pass right now? * * Core's own test, to the letter {@see spawn_cron()}: the lock is a * microtime string, and it is stale once WP_CRON_LOCK_TIMEOUT has passed — * a pass that died mid-run must never wedge the site. * * @return bool */ private static function pass_in_flight() { $lock = (float) get_transient( self::WP_LOCK ); if ( $lock <= 0 ) { return false; } $timeout = defined( 'WP_CRON_LOCK_TIMEOUT' ) ? (int) WP_CRON_LOCK_TIMEOUT : MINUTE_IN_SECONDS; // A lock stamped far in the future is a broken value, not a running pass. if ( $lock > microtime( true ) + 10 * MINUTE_IN_SECONDS ) { return false; } return ( $lock + $timeout ) > microtime( true ); } /** * Take WordPress's cron lock for the length of this run. * * @return string The value written, for {@see release_pass()}. */ private static function hold_pass() { $value = sprintf( '%.22F', microtime( true ) ); set_transient( self::WP_LOCK, $value ); return $value; } /** * Give it back — ⛔ only if it is still ours. A pass that started after us * owns the lock now, and deleting it would let a third runner in beside it. * (wp-cron.php ends the same way, for the same reason.) * * @param string $value What {@see hold_pass()} wrote. * @return void */ private static function release_pass( $value ) { if ( (string) get_transient( self::WP_LOCK ) === (string) $value ) { delete_transient( self::WP_LOCK ); } } /** * A lock that survives across requests. * * ⛔ NOT {@see Cache::acquire_lock()}, which is `wp_cache_add` — without a * persistent object cache that is a per-request memory array, so every * request would win it and two tabs would run the same job. An option is * the one store every site has. * * @return bool True to the single caller that should run. */ private static function lock() { $held = (int) get_option( self::LOCK, 0 ); if ( $held > 0 && ( time() - $held ) < self::LOCK_TTL ) { return false; } update_option( self::LOCK, time(), false ); return true; } /** * @return void */ private static function unlock() { delete_option( self::LOCK ); } }