1<?php
2/**
3 * A pseudo-cron daemon for scheduling WordPress tasks.
4 *
5 * Patched version of wp-cron.php, that allows for returning a list of scheduled events
6 *
7 * @package WordPress
8 */
9
10
11// disable any error output
12define('WP_DEBUG', false);
13ini_set('display_errors', 0);
14// Turn off caching Comet/WP Super cache
15define('DONOTCACHEPAGE', false);
16
17class CronRunner
18{
19 private $actionSchedulerSupport = false;
20 private $metrics = [];
21 private $doingWpCron = "";
22
23 // we won't start running another cron event once we have gone over TIME_LIMIT
24 public const TIME_LIMIT = 60;
25
26 public function __construct(bool $actionSchedulerSupport)
27 {
28 $this->actionSchedulerSupport = $actionSchedulerSupport;
29 }
30
31 private function startOutput(): void
32 {
33 if (!headers_sent()) {
34 header('Expires: Wed, 11 Jan 1984 05:00:00 GMT');
35 header('Cache-Control: no-cache, must-revalidate, max-age=0');
36 header('Content-Type: text/event-stream');
37 }
38 }
39
40 private function sendEvent(string $type, array $data): void
41 {
42 $marker = base64_encode(random_bytes(8));
43 echo ">>> $marker\n";
44 echo json_encode([
45 'type' => $type,
46 'time' => date('Y-m-d\TH:i:s+00:00'),
47 'data' => $data,
48 ]);
49 echo "\n<<< $marker\n";
50 flush();
51 }
52
53
54 public function run(): void
55 {
56 ignore_user_abort(true);
57 $this->startOutput();
58
59 // we will normally be making requests to plat-cron.php over http, but we would like WordPress to generate any links as https
60 // there are multiple ways to solve this, but the easiest is to just set the headers that wordpress uses to determine https
61 $_SERVER['HTTPS'] = 'on';
62
63 /**
64 * Tell WordPress the cron task is running.
65 *
66 * @var bool
67 */
68 define('DOING_CRON', true);
69
70 if (! defined('ABSPATH')) {
71 /** Set up WordPress environment */
72 require_once __DIR__ . '/wp-load.php';
73 }
74
75 // Attempt to raise the PHP memory limit for cron event processing.
76 wp_raise_memory_limit('cron');
77
78 $crons = _get_cron_array();
79 $schedule = $this->buildCronSchedule($crons);
80 $this->sendEvent("cron-schedule", $schedule);
81
82 if (!$this->grabCronLock()) {
83 return;
84 }
85
86 $start = microtime(true);
87 $this->sendEvent("start", []);
88 $ran = $this->runCrons($crons);
89
90 $crons = _get_cron_array();
91 $schedule = $this->buildCronSchedule($crons);
92 $this->sendEvent("cron-schedule", $schedule);
93 $this->sendEvent("end", [
94 'duration' => round(microtime(true) - $start, 3) * 1000,
95 'events' => $ran
96 ]);
97
98 $this->releaseCronLock();
99 }
100
101 private function grabCronLock(): string
102 {
103 $gmt_time = microtime(true);
104
105 // The cron lock: a unix timestamp from when the cron was spawned.
106 $doing_cron_transient = get_transient('doing_cron');
107
108 // Use global $doing_wp_cron lock, otherwise use the GET lock. If no lock, try to grab a new lock.
109 if (empty($doing_wp_cron)) {
110 // Called from external script/job. Try setting a lock.
111 if ($doing_cron_transient && ($doing_cron_transient + WP_CRON_LOCK_TIMEOUT > $gmt_time)) {
112 $this->sendEvent(
113 "cron-lock-failed",
114 [
115 'error' => "Failed to acquire lock. Another cron process is already running.",
116 'doing_cron_transient' => $doing_cron_transient,
117 'WP_CRON_LOCK_TIMEOUT' => WP_CRON_LOCK_TIMEOUT,
118 'gmt_time' => $gmt_time,
119 ]
120 );
121 }
122 $doing_wp_cron = sprintf('%.22F', microtime(true));
123 $doing_cron_transient = $doing_wp_cron;
124 set_transient('doing_cron', $doing_wp_cron);
125 }
126
127 /*
128 * The cron lock (a unix timestamp set when the cron was spawned),
129 * must match $doing_wp_cron (the "key").
130 */
131 if ($doing_cron_transient !== $doing_wp_cron) {
132 $this->sendEvent(
133 "cron-lock-failed",
134 [
135 'error' => "Failed to acquire lock. Another cron process is already running.",
136 'doing_cron_transient' => $doing_cron_transient,
137 'doing_wp_cron' => $doing_wp_cron,
138 ]
139 );
140 }
141 $this->doingWpCron = $doing_wp_cron;
142
143 return $doing_wp_cron;
144 }
145
146 private function releaseCronLock()
147 {
148 if (_get_cron_lock() === $this->doingWpCron) {
149 delete_transient('doing_cron');
150 }
151 }
152
153 private function buildCronSchedule(array $crons)
154 {
155 $now = time();
156 $out = [];
157 foreach ($crons as $time => $hooks) {
158 foreach ($hooks as $hook => $hook_events) {
159 foreach ($hook_events as $sig => $data) {
160
161 if ($this->actionSchedulerSupport && $hook == 'action_scheduler_run_queue') {
162 $out[] = $this->getActionSchedulerEvent();
163 continue;
164 }
165 $out[] = (object) array(
166 'hook' => $hook,
167 'next_run_gmt' => gmdate('c', $time),
168 );
169
170 }
171 }
172 }
173 return $out;
174 }
175
176 private function getActionSchedulerEvent()
177 {
178 $store = ActionScheduler::store();
179 $pending = $store->query_actions([
180 'status' => [ActionScheduler_Store::STATUS_PENDING],
181 'orderby' => 'date',
182 'order' => 'ASC',
183 ]);
184 if (!empty($pending)) {
185 $next = $store->fetch_action($pending[0])->get_schedule()->get_date()->getTimestamp();
186 } else {
187 $next = strtotime('+1 hour');
188 }
189
190 $now = time();
191 return [
192 'hook' => 'action_scheduler_run_queue',
193 'next_run_gmt' => gmdate('c', $next),
194 ];
195 }
196
197 private function runCrons(array $crons): int
198 {
199 $gmt_time = microtime(true);
200 $ran = 0;
201 foreach ($crons as $timestamp => $cronhooks) {
202
203 if ($timestamp > $gmt_time) {
204 break;
205 }
206
207 foreach ($cronhooks as $hook => $keys) {
208
209 foreach ($keys as $k => $v) {
210 $this->sendEvent("event-start", [
211 'hook' => $hook,
212 'lateness' => round($gmt_time - $timestamp, 3) * 1000,
213 ]);
214 $start = microtime(true);
215
216 $schedule = $v['schedule'];
217 $ran++;
218
219 if ($schedule) {
220 $result = wp_reschedule_event($timestamp, $schedule, $hook, $v['args'], true);
221
222 if (is_wp_error($result)) {
223 error_log(
224 sprintf(
225 /* translators: 1: Hook name, 2: Error code, 3: Error message, 4: Event data. */
226 __('Cron reschedule event error for hook: %1$s, Error code: %2$s, Error message: %3$s, Data: %4$s'),
227 $hook,
228 $result->get_error_code(),
229 $result->get_error_message(),
230 wp_json_encode($v)
231 )
232 );
233
234 /**
235 * Fires when an error happens rescheduling a cron event.
236 *
237 * @since 6.1.0
238 *
239 * @param WP_Error $result The WP_Error object.
240 * @param string $hook Action hook to execute when the event is run.
241 * @param array $v Event data.
242 */
243 do_action('cron_reschedule_event_error', $result, $hook, $v);
244 }
245 }
246
247 $result = wp_unschedule_event($timestamp, $hook, $v['args'], true);
248
249 if (is_wp_error($result)) {
250 error_log(
251 sprintf(
252 /* translators: 1: Hook name, 2: Error code, 3: Error message, 4: Event data. */
253 __('Cron unschedule event error for hook: %1$s, Error code: %2$s, Error message: %3$s, Data: %4$s'),
254 $hook,
255 $result->get_error_code(),
256 $result->get_error_message(),
257 wp_json_encode($v)
258 )
259 );
260
261 /**
262 * Fires when an error happens unscheduling a cron event.
263 *
264 * @since 6.1.0
265 *
266 * @param WP_Error $result The WP_Error object.
267 * @param string $hook Action hook to execute when the event is run.
268 * @param array $v Event data.
269 */
270 do_action('cron_unschedule_event_error', $result, $hook, $v);
271 }
272
273 // use alternate action hook scheduler if enabled
274 if ($this->actionSchedulerSupport && $hook == 'action_scheduler_run_queue') {
275 $this->sendEvent("event-end", [
276 'hook' => 'action_scheduler_run_queue',
277 'duration' => round(microtime(true) - $start, 3) * 1000,
278 'optimized' => true,
279 ]);
280 $this->runActionScheduler();
281 } else {
282 /**
283 * Fires scheduled events.
284 *
285 * @ignore
286 * @since 2.1.0
287 *
288 * @param string $hook Name of the hook that was scheduled to be fired.
289 * @param array $args The arguments to be passed to the hook.
290 */
291 do_action_ref_array($hook, $v['args']);
292
293 $this->sendEvent("event-end", [
294 'hook' => $hook,
295 'duration' => round(microtime(true) - $start, 3) * 1000,
296 ]);
297 }
298
299 // If the hook ran too long and another cron process stole the lock, quit.
300 if (_get_cron_lock() !== $this->doingWpCron) {
301 return $ran;
302 }
303
304 // if the cron ran from over self::TIME_LIMIT quit
305 if (microtime(true) - $gmt_time > self::TIME_LIMIT) {
306 return $ran;
307 }
308 }
309 }
310 }
311 return $ran;
312 }
313
314 private function runActionScheduler()
315 {
316 $start = microtime(true);
317
318 $runner = ActionScheduler::runner();
319 $store = ActionScheduler::store();
320
321 if ($store->has_pending_actions_due()) {
322 $actions = [];
323 $starts = [];
324 add_action('action_scheduler_begin_execute', function ($action_id, $context) use ($store, &$actions, &$starts) {
325 $actions[$action_id] = $store->fetch_action($action_id);
326 $starts[$action_id] = $now;
327 $this->sendEvent("event-start", [
328 'hook' => $actions[$action_id]->get_hook(),
329 'lateness' => round(microtime(true) - $actions[$action_id]->get_schedule()->get_date()->getTimestamp(), 3) * 1000,
330 ]);
331 }, 10, 2);
332 add_action('action_scheduler_failed_execution', function ($action_id, $error, $context) use (&$actions, &$starts) {
333 $start = $starts[$action_id];
334 $this->sendEvent("event-end", [
335 'hook' => $actions[$action_id]->get_hook(),
336 'duration' => round(microtime(true) - $start, 3) * 1000,
337 ]);
338 }, 10, 3);
339 add_action('action_scheduler_after_execute', function ($action_id, $action, $context) use (&$actions, &$starts) {
340 $start = $starts[$action_id];
341
342 $this->sendEvent("event-end", [
343 'hook' => $actions[$action_id]->get_hook(),
344 'duration' => round(microtime(true) - $start, 3) * 1000,
345 ]);
346 }, 10, 3);
347
348 $count = $runner->run();
349 }
350 //$out['action_scheduler_status'] = $store->action_counts();
351 }
352}
353
354$actionSchedulerSupport = class_exists('ActionScheduler_QueueRunner') && !empty($_GET['action_scheduler_support']);
355$cronRunner = new CronRunner($actionSchedulerSupport);
356$cronRunner->run();
357
358
359/**
360 * Retrieves the cron lock.
361 *
362 * Returns the uncached `doing_cron` transient.
363 *
364 * @ignore
365 * @since 3.3.0
366 *
367 * @global wpdb $wpdb WordPress database abstraction object.
368 *
369 * @return string|int|false Value of the `doing_cron` transient, 0|false otherwise.
370 */
371function _get_cron_lock()
372{
373 global $wpdb;
374
375 $value = 0;
376 if (wp_using_ext_object_cache()) {
377 /*
378 * Skip local cache and force re-fetch of doing_cron transient
379 * in case another process updated the cache.
380 */
381 $value = wp_cache_get('doing_cron', 'transient', true);
382 } else {
383 $row = $wpdb->get_row($wpdb->prepare("SELECT option_value FROM $wpdb->options WHERE option_name = %s LIMIT 1", '_transient_doing_cron'));
384 if (is_object($row)) {
385 $value = $row->option_value;
386 }
387 }
388
389 return $value;
390}
391