is_active() ) { Lock::release(); return 'idle'; } if ( $job->cancel_requested() ) { $c = Options::get( Job::CANCEL_OPTION ); $job->cancel( isset( $c['user'] ) ? (int) $c['user'] : 0 ); Lock::release(); return 'worked'; } if ( ! $job->has_work() ) { Lock::release(); return 'idle'; // Paused: only Resume restarts it. } $job->begin_tick( $source ); $steps = 0; while ( $job->has_work() && microtime( true ) < $deadline && ! Environment::memory_near_limit() ) { if ( $steps > 0 && $job->cancel_requested() ) { $c = Options::get( Job::CANCEL_OPTION ); $job->cancel( isset( $c['user'] ) ? (int) $c['user'] : 0 ); break; } $result = $job->step( 0 === $steps ); ++$steps; if ( 'yield' === $result ) { break; } $job->commit(); } if ( $job->is_active() ) { $job->end_tick(); } } catch ( Backup_Exception $e ) { if ( $job ) { $job->fail( $e->get_error_code(), $e->getMessage(), $e->get_context() ); } } catch ( \Throwable $e ) { if ( $job ) { $job->fail( 'unexpected', get_class( $e ) . ': ' . $e->getMessage() . ' @ ' . basename( $e->getFile() ) . ':' . $e->getLine() ); } } Lock::release(); if ( $chain && $job && $job->has_work() && 'cli' !== $source ) { self::dispatch_next(); } return 'worked'; } /** * Trigger A: fire-and-forget loopback to admin-ajax with a rotating one-time key and a nonce (§8.2 A, D73). */ public static function dispatch_next() { if ( ! apply_filters( 'dbmg_use_loopback', true ) ) { return; } $key = wp_generate_password( 32, false ); Options::set( self::KEY_OPTION, $key ); wp_remote_post( admin_url( 'admin-ajax.php' ), array( 'blocking' => false, // DECISION (D10): 1 s instead of 0.01 s so HTTPS handshakes complete; the tick ends the // response early and ignores the abort, so nothing waits on the actual work. 'timeout' => (float) apply_filters( 'dbmg_loopback_timeout', 1 ), 'sslverify' => apply_filters( 'https_local_ssl_verify', false ), // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- core filter, used as core's own loopback requests do. 'body' => array( 'action' => 'dbmg_tick', 'key' => $key, 'nonce' => wp_create_nonce( self::LOOPBACK_NONCE ), ), 'cookies' => self::loopback_cookies(), ) ); } /** * DECISION (D73): loopback requests (dbmg_tick, dbmg_ping) carry a nonce besides their one-time key (wp.org * review "Nonces and User Permissions"). A nonce is bound to the user and login session it was made for, so the * request also carries this request's WordPress login cookies (as core's Site Health loopback test does): an * admin's request makes the nonce and the loopback as that admin, WP-Cron and other logged-out contexts make both * as the logged-out user. Only the login cookies are passed, nothing else of the visitor's. * * @return array name => value */ public static function loopback_cookies() { $out = array(); foreach ( array( 'LOGGED_IN_COOKIE', 'AUTH_COOKIE', 'SECURE_AUTH_COOKIE' ) as $const ) { $name = defined( $const ) ? (string) constant( $const ) : ''; if ( '' !== $name && isset( $_COOKIE[ $name ] ) && is_string( $_COOKIE[ $name ] ) ) { $out[ $name ] = sanitize_text_field( wp_unslash( $_COOKIE[ $name ] ) ); } } return $out; } /** * Is this loopback key the current one? (constant-time). * * @param string $key Key from the request. * @return bool */ public static function key_valid( $key ) { $stored = Options::get( self::KEY_OPTION ); return is_string( $stored ) && '' !== $stored && is_string( $key ) && hash_equals( $stored, $key ); } /** * Triggers C: run a short tick if the job hasn't moved for a while (§8.2 C). Also runs the watchdog. * * @param string $source heartbeat|poll. * @return bool A tick ran. */ public static function nudge( $source ) { $job = Job::load(); if ( ! $job || ! $job->is_active() ) { return false; } self::watch( $job ); $job = Job::load(); if ( ! $job || ! $job->has_work() ) { return false; } // Browser mode has no loopback chain: every poll/heartbeat is the engine, so tick right away. $after = 'browser' === $job->s['mode'] ? 0 : self::NUDGE_AFTER; if ( time() - (int) $job->s['last_progress'] < $after || Lock::is_busy() ) { return false; } return 'worked' === self::run_tick( $source, true ); } /** * Stall / give-up detection under the lock (§8.5). * * @param Job $job Loaded job (re-read under the lock). */ public static function watch( Job $job ) { $stale = time() - (int) $job->s['last_progress']; if ( 'stalled' !== $job->s['stage'] && $stale <= Job::STALL_AFTER ) { return; } if ( ! Lock::acquire( 60 ) ) { return; } $fresh = Job::load(); if ( $fresh ) { $fresh->watchdog(); } Lock::release(); } /** * Trigger B: WP-Cron watchdog callback (§8.2 B). */ public static function cron() { $job = Job::load(); if ( ! $job || ! $job->is_active() ) { self::unschedule_watchdog(); return; } self::watch( $job ); $job = Job::load(); if ( $job && $job->has_work() && time() - (int) $job->s['last_progress'] > self::CRON_AFTER && ! Lock::is_busy() ) { self::run_tick( 'cron' ); } } /** * Resume a paused job: clear a stale lock, run one tick here, restart the chain (§8.5). * * @return bool */ public static function resume() { Lock::clear_stale(); if ( ! Lock::acquire( 60 ) ) { return false; } $job = Job::load(); if ( $job ) { $job->resume(); } Lock::release(); self::schedule_watchdog(); self::run_tick( 'browser', true ); return true; } /** * Add the 60 s cron interval. * * @param array $schedules Schedules. * @return array */ public static function cron_schedules( $schedules ) { $schedules[ self::SCHEDULE ] = array( 'interval' => 60, 'display' => __( 'Every minute (Database Export Genie, only while a backup runs)', 'database-export-genie' ), ); return $schedules; } /** * Schedule the watchdog while a job is active. */ public static function schedule_watchdog() { if ( ! wp_next_scheduled( self::CRON_HOOK ) ) { wp_schedule_event( time() + 60, self::SCHEDULE, self::CRON_HOOK ); } } /** * Remove the watchdog when the job ends. */ public static function unschedule_watchdog() { wp_clear_scheduled_hook( self::CRON_HOOK ); } /** * Start a background job right after creation (response already sent where possible). */ public static function kickoff() { self::schedule_watchdog(); self::dispatch_next(); } }