activity( 'scheduler_error', array( 'scan_id' => $id, 'message' => 'WordPress did not accept the scan wake-up. Use Continue batch or WP-CLI.', ) ); return false; } } return true; } public static function settingsChanged( array $settings ): void { wp_clear_scheduled_hook( 'antivirus_ai_daily' ); if ( empty( $settings['daily'] ) ) { $state = get_option( 'antivirus_ai_schedule_state', array() ); $state = is_array( $state ) ? $state : array(); $state['enabled'] = false; $state['next_due'] = 0; $state['retry_due'] = 0; $state['consecutive_failures'] = 0; update_option( 'antivirus_ai_schedule_state', $state, false ); return; } $state = get_option( 'antivirus_ai_schedule_state', array() ); $state = is_array( $state ) ? $state : array(); $state['retry_due'] = 0; $state['consecutive_failures'] = 0; unset( $state['blocking_scan_id'] ); update_option( 'antivirus_ai_schedule_state', $state, false ); self::nextDaily( time() + self::interval() ); } public static function interval(): int { return max( 900, min( 86400, (int) apply_filters( 'antivirus_ai_scan_interval', 86400 ) ) ); } private static function nextDaily( int $due ): void { $state = get_option( 'antivirus_ai_schedule_state', array() ); $state = is_array( $state ) ? $state : array(); $state['enabled'] = true; $state['next_due'] = $due; $state['interval'] = self::interval(); $result = wp_schedule_single_event( $due, 'antivirus_ai_daily', array(), true ); // Accepting a future wake-up does not prove the previous scan start succeeded. $state['execution_error'] = $state['execution_error'] ?? ( $state['last_error'] ?? '' ); $state['schedule_error'] = is_wp_error( $result ) ? $result->get_error_message() : ( false === $result ? 'WordPress did not accept the scheduled sweep.' : '' ); $state['last_error'] = $state['execution_error'] ?: $state['schedule_error']; update_option( 'antivirus_ai_schedule_state', $state, false ); } public static function daily(): void { $settings = get_option( 'antivirus_ai_settings', array() ); if ( empty( $settings['daily'] ) ) { return; } $store = new Store(); $token = $store->lock( 'daily_scheduler', 60 ); if ( ! $token ) { // A consumed cron wake-up must not disappear just because another request holds the lease. if ( ! wp_next_scheduled( 'antivirus_ai_daily' ) ) { wp_schedule_single_event( time() + 60, 'antivirus_ai_daily', array(), true ); } return; } try { if ( empty( Store::freshOption( 'antivirus_ai_settings', array() )['daily'] ) ) { return; } $state = get_option( 'antivirus_ai_schedule_state', array() ); $state = is_array( $state ) ? $state : array(); $state['last_run'] = time(); if ( empty( $state['retry_due'] ) ) { $state['consecutive_failures'] = 0; } $state['attempt_due'] = (int) ( $state['retry_due'] ?? 0 ) > 0 ? (int) ( $state['attempt_due'] ?? $state['next_due'] ?? time() ) : (int) ( $state['next_due'] ?? time() ); $state['last_delay_seconds'] = max( 0, time() - $state['attempt_due'] ); update_option( 'antivirus_ai_schedule_state', $state, false ); try { $scanner = new Scanner( $store ); $latest = $store->list( 'scan', 'queued', 1 )[0] ?? $store->list( 'scan', 'running', 1 )[0] ?? null; $resuming = $latest && in_array( $latest['_status'], array( 'queued', 'running' ), true ); $id = $resuming ? $latest['_id'] : $scanner->start( (string) apply_filters( 'antivirus_ai_scheduled_scan_mode', 'full' ) ); // Re-read the returned scan: start() may encounter a concurrently created targeted scan. $selected = $store->get( $id ); if ( ! $selected || 'scan' !== ( $selected['_kind'] ?? '' ) ) { throw new \RuntimeException( 'The scheduled scan could not be confirmed in local storage.' ); } if ( ! empty( $selected['target_finding_id'] ) || 'targeted' === ( $selected['mode'] ?? '' ) ) { self::scheduleTick( $id ); $state['last_scan_action'] = 'deferred_targeted'; $state['blocking_scan_id'] = $id; $state['last_deferred_at'] = time(); $state['retry_due'] = time() + 120; unset( $state['last_scan_id'], $state['last_scan_requested_at'] ); Store::saveOption( 'antivirus_ai_schedule_state', $state ); if ( ! empty( Store::freshOption( 'antivirus_ai_settings', array() )['daily'] ) ) { wp_clear_scheduled_hook( 'antivirus_ai_daily' ); self::nextDaily( $state['retry_due'] ); } return; } if ( ! self::scheduleTick( $id ) ) { throw new \RuntimeException( 'The scan was retained but its next batch could not be scheduled. A bounded retry will resume it.' ); } $state['retry_due'] = 0; $state['consecutive_failures'] = 0; unset( $state['blocking_scan_id'] ); $state['last_scan_id'] = $id; $state['last_scan_action'] = $resuming ? 'resumed' : 'requested'; $state['last_scan_requested_at'] = time(); $state['execution_error'] = ''; $state['last_error'] = $state['schedule_error'] ?? ''; update_option( 'antivirus_ai_schedule_state', $state, false ); } catch ( \Throwable $error ) { $state['last_error'] = substr( $error->getMessage(), 0, 300 ); $state['execution_error'] = $state['last_error']; $state['consecutive_failures'] = (int) ( $state['consecutive_failures'] ?? 0 ) + 1; $state['retry_due'] = $state['consecutive_failures'] < 3 ? time() + 120 * $state['consecutive_failures'] : 0; update_option( 'antivirus_ai_schedule_state', $state, false ); $store->activity( 'scheduler_error', array( 'message' => $state['last_error'] ) ); } // Always leave a future wake-up after a transient scan-start failure. if ( ! empty( Store::freshOption( 'antivirus_ai_settings', array() )['daily'] ) && ! wp_next_scheduled( 'antivirus_ai_daily' ) ) { self::nextDaily( $state['retry_due'] ?: time() + self::interval() ); } $store->prune(); } finally { $store->unlock( 'daily_scheduler', $token ); } } public static function diagnostics(): array { $state = get_option( 'antivirus_ai_schedule_state', array() ); $worker = get_option( 'antivirus_ai_worker_state', array() ); $proWorker = get_option( 'antivirus_ai_pro_worker_state', array() ); $settings = get_option( 'antivirus_ai_settings', array() ); $scan = ( new Scanner( new Store() ) )->latest(); $active = $scan && in_array( $scan['_status'], array( 'queued', 'running' ), true ); $enabled = ! empty( $settings['daily'] ); $due = (int) ( $state['next_due'] ?? 0 ); $seen = (int) ( $worker['finished_at'] ?? $worker['started_at'] ?? 0 ); $scheduledScan = ! empty( $state['last_scan_id'] ) && is_string( $state['last_scan_id'] ) ? ( new Store() )->get( $state['last_scan_id'] ) : null; if ( $scheduledScan && 'scan' !== ( $scheduledScan['_kind'] ?? '' ) ) { $scheduledScan = null; } return array( 'enabled' => $enabled, 'next_due' => $due, 'last_run' => (int) ( $state['last_run'] ?? 0 ), 'last_scan_id' => $scheduledScan['_id'] ?? null, 'last_scan_action' => in_array( $state['last_scan_action'] ?? '', array( 'requested', 'resumed', 'deferred_targeted' ), true ) ? $state['last_scan_action'] : null, 'last_scan_requested_at' => (int) ( $state['last_scan_requested_at'] ?? 0 ), 'last_scan_status' => $scheduledScan['_status'] ?? 'not_recorded', 'last_scan_completed_at' => $scheduledScan['completed_at'] ?? null, 'last_delay_seconds' => max( 0, (int) ( $state['last_delay_seconds'] ?? 0 ) ), 'retry_due' => max( 0, (int) ( $state['retry_due'] ?? 0 ) ), 'consecutive_failures' => max( 0, (int) ( $state['consecutive_failures'] ?? 0 ) ), 'overlap_deferred' => $enabled && (int) ( $state['retry_due'] ?? 0 ) > 0 && 'deferred_targeted' === ( $state['last_scan_action'] ?? '' ), 'scheduled_wakeup' => (int) wp_next_scheduled( 'antivirus_ai_daily' ), 'last_error' => substr( (string) ( $state['last_error'] ?? '' ), 0, 300 ), 'execution_error' => substr( (string) ( $state['execution_error'] ?? '' ), 0, 300 ), 'schedule_error' => substr( (string) ( $state['schedule_error'] ?? '' ), 0, 300 ), 'overdue' => $enabled && $due > 0 && $due < time() - max( 300, min( 7200, self::interval() ) ), 'active_stalled' => $active && (int) ( $scan['heartbeat'] ?? 0 ) < time() - 900, 'missing_wakeup' => ( $enabled && ! wp_next_scheduled( 'antivirus_ai_daily' ) ) || ( $active && ! wp_next_scheduled( 'antivirus_ai_tick', array( $scan['_id'] ) ) ), 'worker_last_seen' => $seen, 'worker_stale' => $seen <= 0 || $seen > time() || $seen < time() - 900, 'worker_error' => substr( (string) ( ! empty( $worker['last_error'] ) ? $worker['last_error'] : ( $proWorker['last_error'] ?? '' ) ), 0, 300 ), ); } /** Called by the authenticated local CLI, never registered on visitor requests. */ public static function work( int $maxBatches = 1 ): array { $store = new Store(); $token = $store->lock( 'background_worker', 300 ); if ( ! $token ) { return array( 'busy' => true ) + self::diagnostics(); } $worker = array( 'started_at' => time(), 'last_error' => '' ); update_option( 'antivirus_ai_worker_state', $worker, false ); try { $state = get_option( 'antivirus_ai_schedule_state', array() ); if ( ! empty( get_option( 'antivirus_ai_settings', array() )['daily'] ) ) { $due = wp_next_scheduled( 'antivirus_ai_daily' ); if ( ( $due && $due <= time() ) || (int) ( $state['next_due'] ?? 0 ) <= time() ) { wp_clear_scheduled_hook( 'antivirus_ai_daily' ); self::daily(); } elseif ( ! $due ) { self::nextDaily( (int) $state['next_due'] ); } } for ( $batch = 0; $batch < max( 1, min( 20, $maxBatches ) ); ++$batch ) { $scan = ( new Scanner( $store ) )->latest(); if ( ! $scan || ! in_array( $scan['_status'], array( 'queued', 'running' ), true ) ) { break; } // A host worker explicitly advances retained work even if its wake-up was lost. wp_clear_scheduled_hook( 'antivirus_ai_tick', array( $scan['_id'] ) ); self::tick( $scan['_id'] ); } try { do_action( 'antivirus_ai_worker' ); } catch ( \Throwable $error ) { $worker['last_error'] = substr( $error->getMessage(), 0, 300 ); $store->activity( 'background_extension_error', array( 'message' => $worker['last_error'] ) ); } $worker['finished_at'] = time(); update_option( 'antivirus_ai_worker_state', $worker, false ); return array( 'busy' => false ) + self::diagnostics(); } catch ( \Throwable $error ) { $worker['last_error'] = substr( $error->getMessage(), 0, 300 ); $worker['failed_at'] = time(); update_option( 'antivirus_ai_worker_state', $worker, false ); throw $error; } finally { $store->unlock( 'background_worker', $token ); } } public static function tick( string $id ): void { if ( ! preg_match( '/^[a-z0-9_]{1,64}$/D', $id ) ) { return; } $store = new Store(); $scan = $store->get( $id ); if ( ! $scan || 'scan' !== $scan['_kind'] || ! in_array( $scan['_status'], array( 'queued', 'running' ), true ) ) { return; } $retryId = 'job_' . substr( hash( 'sha256', $id ), 0, 56 ); try { $result = ( new Scanner( $store ) )->tick( $id, 25 ); $store->put( 'job', $retryId, array( 'scan_id' => $id, 'failures' => 0, ), 'active' ); if ( in_array( $result['_status'] ?? $result['status'] ?? '', array( 'queued', 'running' ), true ) ) { self::scheduleTick( $id, 15 ); } else { $store->delete( $retryId ); $store->prune(); } } catch ( \Throwable $error ) { $job = $store->get( $retryId ); $attempts = (int) ( $job['failures'] ?? 0 ) + 1; $after = $store->get( $id ); $terminal = ! $after || ! in_array( $after['_status'], array( 'queued', 'running' ), true ); $store->put( 'job', $retryId, array( 'scan_id' => $id, 'failures' => $attempts, 'last_error' => substr( $error->getMessage(), 0, 300 ), ), $attempts >= 3 || $terminal ? 'failed' : 'retry' ); $store->activity( 'scan_batch_error', array( 'scan_id' => $id, 'attempt' => $attempts, 'message' => substr( $error->getMessage(), 0, 300 ), ) ); if ( $terminal ) { $store->prune(); return; } if ( $attempts < 3 ) { self::scheduleTick( $id, 60 * $attempts ); } else { $scan = $store->get( $id ); if ( $scan && in_array( $scan['_status'], array( 'queued', 'running' ), true ) ) { $scan['status'] = 'incomplete'; $scan['result_state'] = 'confirmed_threat' === ( $scan['result_state'] ?? '' ) || in_array( 'confirmed', $scan['observed_findings'] ?? array(), true ) ? 'confirmed_threat' : 'scan_incomplete'; $scan['completed_at'] = gmdate( 'c' ); $scan['limitations'][] = 'Three scheduled batches failed. The scan is incomplete; inspect Activity and start a fresh scan after resolving the error.'; $store->put( 'scan', $id, $scan, 'incomplete', $scan['_revision'] ); } } } } }