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