plugin = $plugin; } public function register(): void { add_filter('pre_update_option_cron', [$this, 'inspectCronWrite'], 10, 2); } public function deactivating(): void { $this->deactivating = true; } /** * @param mixed $new * @param mixed $old the writer's own (possibly stale, cached) view * @return mixed $new unchanged */ public function inspectCronWrite($new, $old) { if ($this->deactivating || self::contains($new)) { return $new; } global $wpdb; // Fresh read: what this write is about to replace, regardless of the writer's cache. // phpcs:ignore WordPress.DB.DirectDatabaseQuery.DirectQuery,WordPress.DB.DirectDatabaseQuery.NoCaching -- plugin-owned custom table; WordPress has no API for it; diagnostic read that must be current $current = maybe_unserialize($wpdb->get_var($wpdb->prepare("SELECT option_value FROM {$wpdb->options} WHERE option_name = %s", 'cron'))); if (self::contains($current)) { $this->record('cron.event_removed', 'warning', sprintf( 'writer_view_had_event=%s %s', self::contains($old) ? 'yes' : 'no', self::context() )); } return $new; } /** Records at most RATE_LIMIT diagnostic events per hour. */ public function record(string $action, string $outcome, string $detail = ''): bool { $key = 'aiwend_cron_diag_' . gmdate('YmdH'); $n = (int) get_transient($key); if ($n >= self::RATE_LIMIT) { // Never drop silently: count what the limit suppressed (shown by /jobs/health). update_option(self::SUPPRESSED_OPTION, (int) get_option(self::SUPPRESSED_OPTION, 0) + 1, false); return false; } set_transient($key, $n + 1, HOUR_IN_SECONDS); $this->plugin->audit->record(get_current_blog_id(), 'system', $action, 'hook:' . Plugin::CRON_HOOK, $outcome, null, $detail === '' ? null : $detail); return true; } public function runStarted(): void { update_option(self::LAST_RUN_OPTION, time(), false); } /** @param mixed $cron */ public static function contains($cron): bool { if (!is_array($cron)) { return false; } foreach ($cron as $hooks) { if (is_array($hooks) && isset($hooks[Plugin::CRON_HOOK])) { return true; } } return false; } public static function count(): int { $n = 0; foreach ((array) _get_cron_array() as $hooks) { if (is_array($hooks) && isset($hooks[Plugin::CRON_HOOK])) { $n += count($hooks[Plugin::CRON_HOOK]); } } return $n; } /** * Allowlisted, data-free description of the current process: * sapi; cli = allowlisted command name or "other" (never arguments); * request = category (wp-cron, admin-ajax, rest, admin, front), never a path or query; * doing_cron; chain = up to 8 sanitized function/method names (no arguments). */ public static function context(): string { $parts = ['sapi=' . preg_replace('/[^a-z0-9\-]/', '', PHP_SAPI)]; if (defined('WP_CLI') && WP_CLI) { $parts[] = 'cli=' . self::cliCommand((array) ($GLOBALS['argv'] ?? [])); } else { $parts[] = 'request=' . self::requestCategory(); } $parts[] = 'doing_cron=' . (wp_doing_cron() ? 'yes' : 'no'); $chain = []; // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_debug_backtrace -- records the names of the calling functions for cron diagnostics; no arguments foreach (debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 14) as $f) { $name = ($f['class'] ?? '') . ($f['type'] ?? '') . ($f['function'] ?? ''); $name = substr((string) preg_replace('/[^A-Za-z0-9_\\\\:>\-]/', '', $name), 0, 80); if ($name !== '' && !str_contains($name, 'CronDiagnostics') && $name !== 'apply_filters' && $name !== 'WP_Hook->apply_filters') { $chain[] = $name; } } $parts[] = 'chain=' . implode('<', array_slice($chain, 0, 8)); return substr(implode(' ', $parts), 0, 480); } /** @param list $argv */ public static function cliCommand(array $argv): string { $words = []; foreach (array_slice($argv, 1) as $a) { $a = (string) $a; if (str_starts_with($a, '-')) { continue; } $words[] = $a; if (count($words) === 3) { break; } } for ($n = 3; $n >= 1; $n--) { $candidate = implode(' ', array_slice($words, 0, $n)); if (in_array($candidate, self::CLI_ALLOWLIST, true)) { return $candidate; } } return 'other'; } private static function requestCategory(): string { $path = (string) wp_parse_url(sanitize_text_field(wp_unslash((string) ($_SERVER['REQUEST_URI'] ?? ''))), PHP_URL_PATH); switch (true) { case wp_doing_cron() || str_ends_with($path, '/wp-cron.php'): return 'wp-cron'; case wp_doing_ajax(): return 'admin-ajax'; // phpcs:ignore WordPress.Security.NonceVerification.Recommended -- read-only navigation or filter parameter; no state is changed case (defined('REST_REQUEST') && REST_REQUEST) || str_contains($path, '/wp-json/') || isset($_GET['rest_route']): return 'rest'; case is_admin(): return 'admin'; default: return 'front'; } } }