diff --git a/inc/spbc-scanner.php b/inc/spbc-scanner.php index 58a647019..a40fe39ec 100644 --- a/inc/spbc-scanner.php +++ b/inc/spbc-scanner.php @@ -24,6 +24,19 @@ function spbc_scanner__start() return spbc_scanner__launch(false); } +/** + * Checks whether the wrapper is called by a direct remote call which expects the output. + * Not the cron tick running inside the wake-up remote call: the result should be returned to the cron. + * + * @return bool + */ +function spbc_scanner__isDirectRemoteCall() +{ + global $spbc_cron; + + return empty($spbc_cron) && \CleantalkSP\SpbctWP\RemoteCalls::check(); +} + /** * Cron|RemoteCall wrapper function for launchBackground * @@ -34,7 +47,7 @@ function spbc_scanner__launch($is_cron_run = true) { $result = \CleantalkSP\SpbctWP\Scanner\ScannerQueue::launchBackground($is_cron_run); - if (\CleantalkSP\SpbctWP\RemoteCalls::check()) { + if (spbc_scanner__isDirectRemoteCall()) { $result = empty($result['error']) ? 'OK' : 'FAIL ' . die(json_encode($result)); @@ -82,7 +95,7 @@ function spbc_scanner__controller($transaction_id = null, $stage = null, $offset $result = \CleantalkSP\SpbctWP\Scanner\ScannerQueue::controllerBackground($transaction_id, $stage, $offset, $amount); - if (\CleantalkSP\SpbctWP\RemoteCalls::check()) { + if (spbc_scanner__isDirectRemoteCall()) { $result = empty($result['error']) ? 'OK' : 'FAIL ' . die(json_encode($result)); diff --git a/lib/CleantalkSP/Common/Cron.php b/lib/CleantalkSP/Common/Cron.php index f95e1a06b..35d2b9472 100644 --- a/lib/CleantalkSP/Common/Cron.php +++ b/lib/CleantalkSP/Common/Cron.php @@ -21,6 +21,7 @@ abstract class Cron private $task_execution_min_interval; private $cron_execution_min_interval; + private $max_tasks_per_tick; abstract protected function getCronLastStart(); @@ -36,11 +37,13 @@ abstract protected function isIDmMatch(); * * @param int $task_execution_min_interval Seconds * @param int $cron_execution_min_interval Seconds + * @param int $max_tasks_per_tick Maximum tasks to run per one cron tick, 0 means unlimited */ - public function __construct($task_execution_min_interval, $cron_execution_min_interval) + public function __construct($task_execution_min_interval, $cron_execution_min_interval, $max_tasks_per_tick = 0) { $this->task_execution_min_interval = $task_execution_min_interval; $this->cron_execution_min_interval = $cron_execution_min_interval; + $this->max_tasks_per_tick = (int) $max_tasks_per_tick; $this->tasks = static::getTasks(); $this->id = mt_rand(0, mt_getrandmax()); @@ -74,6 +77,7 @@ public function areThereTasksToRun() } $original_tasks = $this->tasks; + $due_tasks = array(); foreach ($this->tasks as $task => &$task_data) { // Update glitched tasks if ($task_data['processing'] === true && self::isBlockingTimeEnd($task_data['last_call'], $this->task_execution_min_interval)) { @@ -82,14 +86,24 @@ public function areThereTasksToRun() } if ($task_data['processing'] === false && $task_data['next_call'] <= time()) { - $task_data['processing'] = true; - $task_data['last_call'] = time(); - - $this->tasks_to_run[] = $task; + $due_tasks[ $task ] = (int) $task_data['next_call']; } } unset($task_data); + // The most overdue tasks go first + asort($due_tasks); + if ($this->max_tasks_per_tick > 0) { + $due_tasks = array_slice($due_tasks, 0, $this->max_tasks_per_tick, true); + } + + foreach (array_keys($due_tasks) as $task) { + $this->tasks[ $task ]['processing'] = true; + $this->tasks[ $task ]['last_call'] = time(); + + $this->tasks_to_run[] = $task; + } + // Save tasks only if they were changed ! $this->compareSetOfTasks($original_tasks, $this->tasks) && static::saveTasks($this->tasks); @@ -158,7 +172,11 @@ public function runTasks() : time() + round($this->tasks[ $task ]['period'] / 4); } - if (empty($this->tasks[ $task ]['next_call']) || $this->tasks[ $task ]['next_call'] < time()) { + // Overdue tasks skipped in this tick keep their next_call to be run on the next ticks + if ( + isset($this->tasks_completed[ $task ]) && + (empty($this->tasks[ $task ]['next_call']) || $this->tasks[ $task ]['next_call'] < time()) + ) { $this->tasks[ $task ]['next_call'] = time() + $this->tasks[ $task ]['period']; } } diff --git a/lib/CleantalkSP/SpbctWP/Cron.php b/lib/CleantalkSP/SpbctWP/Cron.php index c9f905ab6..8e359728a 100644 --- a/lib/CleantalkSP/SpbctWP/Cron.php +++ b/lib/CleantalkSP/SpbctWP/Cron.php @@ -2,6 +2,8 @@ namespace CleantalkSP\SpbctWP; +use CleantalkSP\Variables\Server; + /** * CleanTalk Security Cron class * @@ -18,10 +20,26 @@ class Cron extends \CleantalkSP\Common\Cron const CRON_OPTION_NAME = 'spbc_cron'; const ID_OPTION = 'spbc_cron_id'; const LAST_START_OPTION = 'spbc_cron_last_start'; + const WAKEUP_LAST_OPTION = 'spbc_cron_wakeup_last'; + const WAKEUP_SENT_OPTION = 'spbc_cron_wakeup_sent'; + const WAKEUP_FAILED_OPTION = 'spbc_cron_wakeup_failed'; + const WAKEUP_RC_ACTION = 'cron_wakeup'; // Interval in seconds for restarting the task const TASK_EXECUTION_MIN_INTERVAL = 90; const CRON_EXECUTION_MIN_INTERVAL = 90; + // Limits the load of a single request which the cron tick shares with a site visitor + const MAX_TASKS_PER_TICK = 1; + // Limit for ticks in background requests (wake-up remote call, wp-cron.php) nobody waits for + const MAX_TASKS_PER_BACKGROUND_TICK = 3; + // Minimal interval in seconds between the background wake-ups + const WAKEUP_INTERVAL = 1200; + // The wake-up is considered failed if no tick has started within this time after sending + const WAKEUP_RESPONSE_TIMEOUT = 60; + // How long in seconds to use the local fallback tick after a failed wake-up before retrying + const WAKEUP_RETRY_INTERVAL = 86400; + // Minimal interval in seconds between the local fallback ticks on regular requests + const LOCAL_TICK_INTERVAL = 300; public $tasks; // Array with tasks public $tasks_to_run; // Array with tasks which should be run now @@ -30,12 +48,163 @@ class Cron extends \CleantalkSP\Common\Cron /** * Cron constructor. * Getting tasks option. + * + * @param int $max_tasks_per_tick */ - public function __construct() + public function __construct($max_tasks_per_tick = self::MAX_TASKS_PER_TICK) { $this->tasks = self::getTasks(); - parent::__construct(self::TASK_EXECUTION_MIN_INTERVAL, self::CRON_EXECUTION_MIN_INTERVAL); + parent::__construct( + self::TASK_EXECUTION_MIN_INTERVAL, + self::CRON_EXECUTION_MIN_INTERVAL, + $max_tasks_per_tick + ); + } + + /** + * Runs the cron tick in the current request. + * + * @param int $max_tasks_per_tick + * + * @return void + */ + public static function runTick($max_tasks_per_tick) + { + global $spbc_cron; // Letting know functions that they are running under spbc_cron + $spbc_cron = new self($max_tasks_per_tick); + $spbc_cron->execute(); + $spbc_cron = null; + } + + /** + * Handles the request where the cron tick is not allowed (GET) to keep the tasks running + * on sites with anonymous GET traffic only and not working WP cron. + * Sends the background wake-up remote call. If it is unavailable (no valid key, broken loopback) + * runs the local throttled tick with a single task instead. + * + * @return void + */ + public static function handleSkippedTick() + { + global $spbc; + + $last_start = (int) get_option(self::LAST_START_OPTION, 0); + if ( ! self::isBlockingTimeEnd($last_start, self::LOCAL_TICK_INTERVAL) || ! self::hasDueTasks() ) { + return; + } + + $is_wakeup_available = ! empty($spbc->key_is_ok) && + ! empty($spbc->api_key) && + ! self::isWakeupFailing($last_start); + + if ( $is_wakeup_available ) { + if ( + self::isBlockingTimeEnd($last_start, self::WAKEUP_INTERVAL) && + self::claimDispatch(self::WAKEUP_INTERVAL) + ) { + update_option(self::WAKEUP_SENT_OPTION, time()); + RemoteCalls::performToHostNonBlocking(self::WAKEUP_RC_ACTION); + } + + return; + } + + if ( self::claimDispatch(self::LOCAL_TICK_INTERVAL) ) { + self::runTick(self::MAX_TASKS_PER_TICK); + } + } + + /** + * @return bool + */ + private static function hasDueTasks() + { + foreach (self::getTasks() as $task_data) { + if (isset($task_data['next_call']) && $task_data['next_call'] <= time()) { + return true; + } + } + + return false; + } + + /** + * Checks whether the last wake-up did not lead to a tick (e.g. the loopback request is blocked). + * The failure is remembered for WAKEUP_RETRY_INTERVAL. + * + * @param int $last_start + * + * @return bool + */ + private static function isWakeupFailing($last_start) + { + $sent_at = (int) get_option(self::WAKEUP_SENT_OPTION, 0); + $failed_at = (int) get_option(self::WAKEUP_FAILED_OPTION, 0); + + if ( + $sent_at > $last_start && + $failed_at < $sent_at && + self::isBlockingTimeEnd($sent_at, self::WAKEUP_RESPONSE_TIMEOUT) + ) { + $failed_at = time(); + update_option(self::WAKEUP_FAILED_OPTION, $failed_at); + } + + return ! self::isBlockingTimeEnd($failed_at, self::WAKEUP_RETRY_INTERVAL); + } + + /** + * Atomically claims the right to dispatch the tick, so concurrent visitors don't do it in parallel. + * + * @param int $interval Seconds since the previous claim + * + * @return bool + */ + private static function claimDispatch($interval) + { + global $wpdb; + + // The row should exist for the conditional update. Not autoloaded: read by the direct query only. + add_option(self::WAKEUP_LAST_OPTION, 0, '', false); + + $now = time(); + $claimed = $wpdb->query( + $wpdb->prepare( + "UPDATE {$wpdb->options} SET option_value = %s WHERE option_name = %s AND CAST(option_value AS UNSIGNED) < %d", + (string) $now, + self::WAKEUP_LAST_OPTION, + $now - $interval + ) + ); + + if ( $claimed !== 1 ) { + return false; + } + + // The option was updated bypassing the cache + wp_cache_delete(self::WAKEUP_LAST_OPTION, 'options'); + + return true; + } + + /** + * Checks whether the cron tick is allowed on the current request. + * The tick is skipped on remote calls and on regular GET page loads to not slow down visitors. + * Allowed on POST requests (AJAX, heartbeat, forms, WP loopback to wp-cron.php) + * and on any request to wp-cron.php (e.g. system cron calls it via GET). + * + * @param bool $is_remote_call + * + * @return bool + */ + public static function isTickAllowed($is_remote_call) + { + if ($is_remote_call) { + return false; + } + + return Server::isPost() || spbc_wp_doing_cron(); } /** diff --git a/lib/CleantalkSP/SpbctWP/HTTP/Request.php b/lib/CleantalkSP/SpbctWP/HTTP/Request.php index e1dae515f..e530f1b88 100644 --- a/lib/CleantalkSP/SpbctWP/HTTP/Request.php +++ b/lib/CleantalkSP/SpbctWP/HTTP/Request.php @@ -194,6 +194,19 @@ protected function processPresets() global $spbc; + // Send the request without waiting for the response, like WP spawn_cron() does. + // The total wait is limited by 1 second: cURL can't use a smaller timeout with the system DNS resolver. + if ( in_array('nonblocking', $this->presets, true) ) { + if ( $spbc->settings['wp__use_builtin_http_api'] ) { + $this->options['timeout'] = 0.01; + $this->options['connect_timeout'] = 1; + $this->options['blocking'] = false; + } else { + $this->options[CURLOPT_TIMEOUT] = 1; + $this->options[CURLOPT_CONNECTTIMEOUT] = 1; + } + } + if ( $spbc->settings['wp__use_builtin_http_api'] ) { foreach ( $this->presets as $preset ) { switch ( $preset ) { diff --git a/lib/CleantalkSP/SpbctWP/RemoteCalls.php b/lib/CleantalkSP/SpbctWP/RemoteCalls.php index 4e3fb308c..c18c7944c 100644 --- a/lib/CleantalkSP/SpbctWP/RemoteCalls.php +++ b/lib/CleantalkSP/SpbctWP/RemoteCalls.php @@ -192,6 +192,58 @@ public static function performToHost($rc_action, $params = array(), $patterns = ); } + /** + * Performs the remote call to the current website without waiting for the response. + * Unlike the 'async' pattern (waits up to 3 seconds), returns almost immediately like WP spawn_cron(). + * + * @param string $rc_action + * @param array $params + * + * @return void + */ + public static function performToHostNonBlocking($rc_action, $params = array()) + { + global $spbc; + + if (function_exists('wp_is_maintenance_mode') && wp_is_maintenance_mode()) { + return; + } + + $home_url = is_multisite() ? get_blog_option(get_current_blog_id(), 'home') : get_option('home'); + + // The result is not needed: the success is detected by the started cron tick + $http = new HTTP\Request(); + $http + ->setUrl($home_url) + ->setData(static::buildParameters($rc_action, 'spbc', $spbc->api_key, $params)) + ->setPresets(array('no_cache', 'nonblocking')) + ->request(); + } + + /** + * Runs the cron tick in the background request. Sent by Cron::wakeup(). + * Responds before the tick, so the initiator (a site visitor request) does not wait for the tasks. + * + * @psalm-suppress PossiblyUnusedMethod + */ + public static function action__cron_wakeup() // phpcs:ignore PSR1.Methods.CamelCapsMethodName.NotCamelCaps + { + // The initiator drops the connection right after the sending + ignore_user_abort(true); + + echo 'OK'; + // Same as wp-cron.php. Without these SAPIs the initiator waits up to its 1-second timeout. + if ( function_exists('fastcgi_finish_request') ) { + fastcgi_finish_request(); + } elseif ( function_exists('litespeed_finish_request') ) { + litespeed_finish_request(); + } + + SpbcCron::runTick(SpbcCron::MAX_TASKS_PER_BACKGROUND_TICK); + + die(); + } + /** * @psalm-suppress PossiblyUnusedMethod */ diff --git a/lib/CleantalkSP/SpbctWP/State.php b/lib/CleantalkSP/SpbctWP/State.php index c20019e43..47adf9d24 100644 --- a/lib/CleantalkSP/SpbctWP/State.php +++ b/lib/CleantalkSP/SpbctWP/State.php @@ -271,6 +271,8 @@ class State extends \CleantalkSP\Common\State 'drop_security_firewall' => array( 'last_call' => 0, ), 'update_settings' => array( 'last_call' => 0, 'cooldown' => 3), 'cron_update_task' => array( 'last_call' => 0, ), + // No cooldown: the tick has own interval and transaction, the cooldown is consumed before the token check + 'cron_wakeup' => array( 'last_call' => 0, 'cooldown' => 0 ), 'perform_service_get' => array( 'last_call' => 0, ), 'run_service_template_get' => array( 'last_call' => 0, 'cooldown' => 60 ), 'license_update' => array( 'last_call' => 0, ), diff --git a/security-malware-firewall.php b/security-malware-firewall.php index e8e2a87d5..59e10e6f4 100644 --- a/security-malware-firewall.php +++ b/security-malware-firewall.php @@ -227,13 +227,18 @@ function __spbc($text) add_action('init', function () { // Cron - global $spbc_cron, $spbc; // Letting know functions that they are running under spbc_cron - $spbc_cron = new SpbcCron(); - ! SpbcRemoteCalls::check() && $spbc_cron->execute(); - unset($spbc_cron); + global $spbc; + $is_remote_call = SpbcRemoteCalls::check(); + if ( SpbcCron::isTickAllowed($is_remote_call) ) { + SpbcCron::runTick( + spbc_wp_doing_cron() ? SpbcCron::MAX_TASKS_PER_BACKGROUND_TICK : SpbcCron::MAX_TASKS_PER_TICK + ); + } elseif ( ! $is_remote_call ) { + SpbcCron::handleSkippedTick(); + } // Remote calls - if ( SpbcRemoteCalls::check() ) { + if ( $is_remote_call ) { try { if ( Get::getString('spbc_remote_call_action') === 'run_service_template_get' ) { require_once(SPBC_PLUGIN_DIR . 'inc/spbc-settings.php'); diff --git a/tests/Inc/SpbcScannerTest.php b/tests/Inc/SpbcScannerTest.php new file mode 100644 index 000000000..51e00c5db --- /dev/null +++ b/tests/Inc/SpbcScannerTest.php @@ -0,0 +1,72 @@ + 'scanner__controller', + 'spbc_remote_call_token' => 'some_token', + 'plugin_name' => 'spbc', + ); + + protected function setUp(): void + { + $this->resetRequest(); + } + + protected function tearDown(): void + { + global $spbc_cron; + + $spbc_cron = null; + $this->resetRequest(); + } + + /** + * @param array $params + * + * @return void + */ + private function resetRequest($params = array()) + { + foreach ( self::RC_PARAMS as $name => $_value ) { + unset($_REQUEST[$name]); + } + foreach ( $params as $name => $value ) { + $_REQUEST[$name] = $value; + } + + // The wrapper caches variables + Request::getInstance()->variables = array(); + } + + public function testRegularRequestIsNotDirectRemoteCall() + { + $this->assertFalse(spbc_scanner__isDirectRemoteCall()); + } + + public function testRemoteCallIsDirectRemoteCall() + { + $this->resetRequest(self::RC_PARAMS); + + $this->assertTrue(spbc_scanner__isDirectRemoteCall()); + } + + public function testCronTickInsideWakeupRemoteCallIsNotDirectRemoteCall() + { + global $spbc_cron; + + $this->resetRequest(array_merge(self::RC_PARAMS, array('spbc_remote_call_action' => 'cron_wakeup'))); + $spbc_cron = new Cron(); + + $this->assertFalse( + spbc_scanner__isDirectRemoteCall(), + 'Scanner cron handlers must return the result to the cron instead of die()' + ); + } +} diff --git a/tests/Mock/LoopbackServerMock.php b/tests/Mock/LoopbackServerMock.php new file mode 100644 index 000000000..78589a935 --- /dev/null +++ b/tests/Mock/LoopbackServerMock.php @@ -0,0 +1,124 @@ +server = $server; + $this->url = 'http://' . stream_socket_get_name($server, false); + } + + /** + * Reads the next received request. + * + * @param float $timeout Seconds to wait for a connection + * + * @return array{method: string, target: string, headers: array, params: array}|null + * Null if there were no requests + */ + public function readRequest($timeout = 0.3) + { + $connection = @stream_socket_accept($this->server, $timeout); + if ( ! $connection ) { + return null; + } + + stream_set_timeout($connection, 1); + + $raw = ''; + while ( strpos($raw, "\r\n\r\n") === false && ! feof($connection) ) { + $chunk = fread($connection, 8192); + if ( $chunk === false || $chunk === '' ) { + break; + } + $raw .= $chunk; + } + + list($head, $body) = array_pad(explode("\r\n\r\n", $raw, 2), 2, ''); + + $lines = explode("\r\n", $head); + list($method, $target) = array_pad(explode(' ', array_shift($lines)), 2, ''); + + $headers = array(); + foreach ( $lines as $line ) { + if ( strpos($line, ':') !== false ) { + list($name, $value) = explode(':', $line, 2); + $headers[strtolower(trim($name))] = trim($value); + } + } + + $length = isset($headers['content-length']) ? (int) $headers['content-length'] : 0; + while ( strlen($body) < $length && ! feof($connection) ) { + $chunk = fread($connection, $length - strlen($body)); + if ( $chunk === false || $chunk === '' ) { + break; + } + $body .= $chunk; + } + + fclose($connection); + + return array( + 'method' => $method, + 'target' => $target, + 'headers' => $headers, + 'params' => self::parseBody($body, isset($headers['content-type']) ? $headers['content-type'] : ''), + ); + } + + public function close() + { + if ( is_resource($this->server) ) { + fclose($this->server); + } + } + + /** + * Parses the urlencoded or multipart/form-data body. + * + * @param string $body + * @param string $content_type + * + * @return array + */ + private static function parseBody($body, $content_type) + { + $params = array(); + + if ( preg_match('/boundary=("?)([^";]+)\1/i', $content_type, $matches) ) { + foreach ( explode('--' . $matches[2], $body) as $part ) { + if ( preg_match('/name="([^"]+)"\r\n\r\n(.*)\r\n$/s', $part, $field) ) { + $params[$field[1]] = $field[2]; + } + } + + return $params; + } + + parse_str($body, $params); + + return $params; + } +} diff --git a/tests/lib/CleantalkSP/Common/CronTest.php b/tests/lib/CleantalkSP/Common/CronTest.php new file mode 100644 index 000000000..aebb03837 --- /dev/null +++ b/tests/lib/CleantalkSP/Common/CronTest.php @@ -0,0 +1,215 @@ + Results to return by task name. true by default. */ + public static $results = array(); +} + +/** + * @param array $params + * + * @return mixed + */ +function cronTestHandler($params) +{ + CronHandlerLog::$calls[] = $params['name']; + + return isset(CronHandlerLog::$results[$params['name']]) ? CronHandlerLog::$results[$params['name']] : true; +} + +/** + * @see \CleantalkSP\Common\Cron + */ +class CronTest extends \SpbcTestCase +{ + const PERIOD = 3600; + + protected function setUp(): void + { + CronInMemoryStub::$stored_tasks = array(); + CronInMemoryStub::$last_start = 0; + CronHandlerLog::$calls = array(); + CronHandlerLog::$results = array(); + } + + /** + * @param string $name + * @param int $next_call + * @param array $override + * + * @return void + */ + private function addTask($name, $next_call, $override = array()) + { + CronInMemoryStub::$stored_tasks[$name] = array_merge( + array( + 'handler' => __NAMESPACE__ . '\cronTestHandler', + 'next_call' => $next_call, + 'period' => self::PERIOD, + 'params' => array('name' => $name), + 'processing' => false, + 'last_call' => 0, + ), + $override + ); + } + + private function runCron($max_tasks_per_tick) + { + $cron = new CronInMemoryStub(90, 90, $max_tasks_per_tick); + $cron->execute(); + + return $cron; + } + + public function testWithoutLimitAllDueTasksRun() + { + $now = time(); + $this->addTask('due_1', $now - 100); + $this->addTask('due_2', $now - 10); + $this->addTask('future', $now + 1000); + + $this->runCron(0); + + sort(CronHandlerLog::$calls); + $this->assertSame(array('due_1', 'due_2'), CronHandlerLog::$calls); + } + + public function testLimitRunsMostOverdueTasksFirst() + { + $now = time(); + $this->addTask('overdue_10', $now - 10); + $this->addTask('overdue_500', $now - 500); + $this->addTask('overdue_100', $now - 100); + $this->addTask('overdue_1000', $now - 1000); + + $this->runCron(2); + + $this->assertSame(array('overdue_1000', 'overdue_500'), CronHandlerLog::$calls); + } + + public function testDueTasksSkippedByLimitStayDueAndNotLocked() + { + $now = time(); + $this->addTask('first', $now - 1000); + $this->addTask('skipped', $now - 10); + + $this->runCron(1); + + $skipped = CronInMemoryStub::$stored_tasks['skipped']; + $this->assertSame($now - 10, $skipped['next_call'], 'The skipped task must keep its schedule'); + $this->assertFalse($skipped['processing'], 'The skipped task must not be locked as processing'); + $this->assertSame(0, $skipped['last_call']); + } + + public function testSkippedTaskRunsOnTheNextTick() + { + $now = time(); + $this->addTask('first', $now - 1000); + $this->addTask('second', $now - 10); + + $this->runCron(1); + CronInMemoryStub::$last_start = 0; // Emulate the cron interval passed + $this->runCron(1); + + $this->assertSame(array('first', 'second'), CronHandlerLog::$calls); + } + + public function testCompletedTaskIsRescheduledForPeriod() + { + $this->addTask('task', time() - 10); + + $this->runCron(1); + + $this->assertEqualsWithDelta( + time() + self::PERIOD, + CronInMemoryStub::$stored_tasks['task']['next_call'], + 2 + ); + } + + public function testFailedTaskIsRescheduledForQuarterOfPeriod() + { + $this->addTask('task', time() - 10); + CronHandlerLog::$results['task'] = array('error' => 'TEST_ERROR'); + + $this->runCron(1); + + $this->assertEqualsWithDelta( + time() + self::PERIOD / 4, + CronInMemoryStub::$stored_tasks['task']['next_call'], + 2 + ); + } + + public function testStuckProcessingTaskIsReleasedAndRun() + { + $this->addTask('stuck', time() - 10, array('processing' => true, 'last_call' => time() - 1000)); + $this->addTask('in_progress', time() - 10, array('processing' => true, 'last_call' => time() - 10)); + + $this->runCron(0); + + $this->assertSame(array('stuck'), CronHandlerLog::$calls); + } + + public function testNothingRunsWithinCronInterval() + { + $this->addTask('task', time() - 10); + CronInMemoryStub::$last_start = time() - 10; + + $this->runCron(0); + + $this->assertSame(array(), CronHandlerLog::$calls); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/CronTest.php b/tests/lib/CleantalkSP/SpbctWP/CronTest.php new file mode 100644 index 000000000..119be7cb9 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/CronTest.php @@ -0,0 +1,452 @@ +close(); + parent::tearDownAfterClass(); + } + + protected function setUp(): void + { + global $spbc, $spbc_cron; + + foreach ( self::OPTIONS as $option ) { + $this->options_backup[$option] = get_option($option); + } + + foreach ( self::OPTIONS as $option ) { + if ( $option !== 'home' ) { + delete_option($option); + } + } + + // Drop requests left by the previous tests + while ( self::$website->readRequest(0) !== null ) { + } + update_option('home', self::$website->url); + + $spbc->key_is_ok = true; + $spbc->api_key = 'test_api_key'; + $spbc_cron = null; + + SpbcCronHandlerLog::$calls = array(); + SpbcCronHandlerLog::$under_cron = array(); + + $this->resetRequest(); + } + + protected function tearDown(): void + { + global $spbc_cron; + + foreach ( $this->options_backup as $option => $value ) { + if ( $value === false ) { + delete_option($option); + } else { + update_option($option, $value, $option === Cron::WAKEUP_LAST_OPTION ? false : null); + } + } + + remove_all_filters('wp_doing_cron'); + $spbc_cron = null; + $this->resetRequest(); + } + + /** + * @param string|null $method + * + * @return void + */ + private function resetRequest($method = null) + { + unset($_SERVER['REQUEST_METHOD'], $_REQUEST['spbc_remote_call_action']); + if ( $method !== null ) { + $_SERVER['REQUEST_METHOD'] = $method; + } + + // The wrappers cache variables + Server::getInstance()->variables = array(); + Request::getInstance()->variables = array(); + } + + /** + * @param int[] $overdue_by Seconds by task name + * + * @return void + */ + private function setDueTasks($overdue_by) + { + $tasks = array(); + foreach ( $overdue_by as $name => $seconds ) { + $tasks[$name] = array( + 'handler' => __NAMESPACE__ . '\spbcCronTestHandler', + 'next_call' => time() - $seconds, + 'period' => 3600, + 'params' => array('name' => $name), + 'processing' => false, + 'last_call' => 0, + ); + } + + update_option(Cron::CRON_OPTION_NAME, $tasks); + } + + /** + * @param int|null $last_tick_ago Seconds since the last tick + * @param int|null $last_claim_ago Seconds since the last dispatch claim + * @param int|null $wakeup_sent_ago Seconds since the last sent wake-up + * @param int|null $wakeup_failed_ago Seconds since the last detected wake-up failure + * + * @return void + */ + private function setHistory($last_tick_ago, $last_claim_ago = null, $wakeup_sent_ago = null, $wakeup_failed_ago = null) + { + $now = time(); + update_option(Cron::LAST_START_OPTION, $last_tick_ago === null ? 0 : $now - $last_tick_ago); + update_option(Cron::WAKEUP_LAST_OPTION, $last_claim_ago === null ? 0 : $now - $last_claim_ago, false); + update_option(Cron::WAKEUP_SENT_OPTION, $wakeup_sent_ago === null ? 0 : $now - $wakeup_sent_ago); + update_option(Cron::WAKEUP_FAILED_OPTION, $wakeup_failed_ago === null ? 0 : $now - $wakeup_failed_ago); + } + + private function assertWakeupSent() + { + global $spbc; + + $request = self::$website->readRequest(2); + + $this->assertNotNull($request, 'The wake-up remote call must be sent'); + $this->assertSame('POST', $request['method']); + $this->assertSame(Cron::WAKEUP_RC_ACTION, $request['params']['spbc_remote_call_action']); + $this->assertSame(md5($spbc->api_key), $request['params']['spbc_remote_call_token']); + $this->assertSame('spbc', $request['params']['plugin_name']); + $this->assertEqualsWithDelta(time(), (int) get_option(Cron::WAKEUP_SENT_OPTION), 2); + } + + private function assertWakeupNotSent() + { + // The request is sent synchronously, so it is already queued if it was sent + $this->assertNull(self::$website->readRequest(0.1), 'The wake-up remote call must not be sent'); + } + + // isTickAllowed() + + public function testTickIsNotAllowedOnRegularGetRequest() + { + $this->resetRequest('GET'); + + $this->assertFalse(Cron::isTickAllowed(false)); + } + + public function testTickIsAllowedOnPostRequest() + { + $this->resetRequest('POST'); + $this->assertTrue(Cron::isTickAllowed(false)); + + $this->resetRequest('post'); + $this->assertTrue(Cron::isTickAllowed(false), 'The method must be case-insensitive'); + } + + public function testTickIsAllowedOnWpCronRequestOfAnyMethod() + { + add_filter('wp_doing_cron', '__return_true'); + + $this->resetRequest('GET'); + + $this->assertTrue(Cron::isTickAllowed(false)); + } + + public function testTickIsNotAllowedOnRemoteCall() + { + add_filter('wp_doing_cron', '__return_true'); + + $this->resetRequest('POST'); + + $this->assertFalse(Cron::isTickAllowed(true)); + } + + // runTick() + + public function testDefaultTickRunsSingleTask() + { + $this->setDueTasks(array('a' => 100, 'b' => 200)); + + $cron = new Cron(); + $cron->execute(); + + $this->assertSame(array('b'), SpbcCronHandlerLog::$calls); + } + + public function testRunTickRunsMostOverdueTasksUpToLimit() + { + $this->setDueTasks(array('a' => 10, 'b' => 400, 'c' => 300, 'd' => 20)); + + Cron::runTick(Cron::MAX_TASKS_PER_BACKGROUND_TICK); + + $this->assertSame(array('b', 'c', 'd'), SpbcCronHandlerLog::$calls); + } + + public function testRunTickExposesCronToHandlersOnlyDuringTick() + { + global $spbc_cron; + + $this->setDueTasks(array('a' => 10)); + + Cron::runTick(Cron::MAX_TASKS_PER_TICK); + + $this->assertSame(array(true), SpbcCronHandlerLog::$under_cron, 'Handlers must see the global $spbc_cron'); + $this->assertNull($spbc_cron, 'The global $spbc_cron must be reset after the tick'); + } + + // handleSkippedTick() + + public function testNothingHappensWhenTickWasRecently() + { + $this->setDueTasks(array('a' => 10)); + $this->setHistory(Cron::LOCAL_TICK_INTERVAL - 60); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array(), SpbcCronHandlerLog::$calls); + } + + public function testNothingHappensWithoutDueTasks() + { + $this->setDueTasks(array('a' => -1000)); + $this->setHistory(Cron::WAKEUP_INTERVAL + 300); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array(), SpbcCronHandlerLog::$calls); + } + + public function testWakeupWaitsForWakeupInterval() + { + $this->setDueTasks(array('a' => 10)); + $this->setHistory(Cron::WAKEUP_INTERVAL - 300); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array(), SpbcCronHandlerLog::$calls, 'Tasks must not run in the visitor request'); + } + + public function testWakeupIsSentOnceAfterWakeupInterval() + { + $this->setDueTasks(array('a' => 10)); + $this->setHistory(Cron::WAKEUP_INTERVAL + 300); + + $started = microtime(true); + Cron::handleSkippedTick(); + $elapsed = microtime(true) - $started; + + $this->assertWakeupSent(); + $this->assertLessThan(2.5, $elapsed, 'The visitor must not wait for the wake-up response'); + $this->assertSame(array(), SpbcCronHandlerLog::$calls, 'Tasks must not run in the visitor request'); + + // The next visitor + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + } + + public function testFailedWakeupSwitchesToLocalTick() + { + $this->setDueTasks(array('a' => 10, 'b' => 20)); + // The wake-up was sent, but no tick has started since + $this->setHistory( + Cron::WAKEUP_INTERVAL + 300, + Cron::WAKEUP_INTERVAL + 100, + Cron::WAKEUP_INTERVAL + 100 + ); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array('b'), SpbcCronHandlerLog::$calls, 'The local tick must run a single task'); + $this->assertEqualsWithDelta(time(), (int) get_option(Cron::WAKEUP_FAILED_OPTION), 2); + } + + public function testRecentWakeupIsNotConsideredFailed() + { + $this->setDueTasks(array('a' => 10)); + // The wake-up was sent just now, the tick can be still loading + $this->setHistory( + Cron::WAKEUP_INTERVAL + 300, + Cron::WAKEUP_RESPONSE_TIMEOUT - 30, + Cron::WAKEUP_RESPONSE_TIMEOUT - 30 + ); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array(), SpbcCronHandlerLog::$calls); + $this->assertSame(0, (int) get_option(Cron::WAKEUP_FAILED_OPTION)); + } + + public function testLocalTickIsUsedUntilRetryInterval() + { + $this->setDueTasks(array('a' => 10)); + $this->setHistory( + Cron::LOCAL_TICK_INTERVAL + 60, + Cron::LOCAL_TICK_INTERVAL + 60, + Cron::WAKEUP_RETRY_INTERVAL - 3600, + Cron::WAKEUP_RETRY_INTERVAL - 3700 + ); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array('a'), SpbcCronHandlerLog::$calls); + } + + public function testWakeupIsRetriedAfterRetryInterval() + { + $this->setDueTasks(array('a' => 10)); + $this->setHistory( + Cron::WAKEUP_INTERVAL + 300, + Cron::WAKEUP_RETRY_INTERVAL + 200, + Cron::WAKEUP_RETRY_INTERVAL + 200, + Cron::WAKEUP_RETRY_INTERVAL + 100 + ); + + Cron::handleSkippedTick(); + + $this->assertWakeupSent(); + $this->assertSame(array(), SpbcCronHandlerLog::$calls); + } + + public function testInvalidKeyUsesLocalTick() + { + global $spbc; + + $spbc->key_is_ok = false; + $this->setDueTasks(array('a' => 10)); + $this->setHistory(Cron::LOCAL_TICK_INTERVAL + 60); + + Cron::handleSkippedTick(); + + $this->assertWakeupNotSent(); + $this->assertSame(array('a'), SpbcCronHandlerLog::$calls); + } + + public function testLocalTickIsThrottledByDispatchClaim() + { + global $spbc; + + $spbc->key_is_ok = false; + $this->setDueTasks(array('a' => 10)); + $this->setHistory(Cron::LOCAL_TICK_INTERVAL + 60, Cron::LOCAL_TICK_INTERVAL - 60); + + Cron::handleSkippedTick(); + + $this->assertSame(array(), SpbcCronHandlerLog::$calls); + } + + // claimDispatch() + + /** + * @param int $interval + * + * @return bool + */ + private function claimDispatch($interval) + { + $method = new \ReflectionMethod(Cron::class, 'claimDispatch'); + $method->setAccessible(true); + + return $method->invoke(null, $interval); + } + + public function testDispatchIsClaimedOncePerInterval() + { + $this->assertTrue($this->claimDispatch(Cron::WAKEUP_INTERVAL)); + $this->assertFalse($this->claimDispatch(Cron::WAKEUP_INTERVAL), 'The second claim within the interval must fail'); + + update_option(Cron::WAKEUP_LAST_OPTION, time() - Cron::WAKEUP_INTERVAL - 1, false); + + $this->assertTrue($this->claimDispatch(Cron::WAKEUP_INTERVAL), 'The claim must succeed after the interval'); + $this->assertEqualsWithDelta(time(), (int) get_option(Cron::WAKEUP_LAST_OPTION), 2); + } + + public function testDispatchClaimOptionIsNotAutoloaded() + { + global $wpdb; + + $this->claimDispatch(Cron::WAKEUP_INTERVAL); + + $autoload = $wpdb->get_var( + $wpdb->prepare("SELECT autoload FROM {$wpdb->options} WHERE option_name = %s", Cron::WAKEUP_LAST_OPTION) + ); + + $this->assertContains($autoload, array('no', 'off')); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/HTTP/RequestNonblockingPresetTest.php b/tests/lib/CleantalkSP/SpbctWP/HTTP/RequestNonblockingPresetTest.php new file mode 100644 index 000000000..39e3cfdb3 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/HTTP/RequestNonblockingPresetTest.php @@ -0,0 +1,99 @@ +appendOptionsObligatory(); + $this->processPresets(); + + return $this->options; + } +} + +/** + * @see \CleantalkSP\SpbctWP\HTTP\Request::processPresets() + */ +class RequestNonblockingPresetTest extends \SpbcTestCase +{ + /** + * @var mixed + */ + private $http_api_backup; + + protected function setUp(): void + { + global $spbc; + + $this->http_api_backup = $spbc->settings['wp__use_builtin_http_api']; + } + + protected function tearDown(): void + { + global $spbc; + + $spbc->settings['wp__use_builtin_http_api'] = $this->http_api_backup; + } + + /** + * @param int $use_builtin_http_api + * @param string[] $presets + * + * @return array + */ + private function prepareOptions($use_builtin_http_api, $presets) + { + global $spbc; + + $spbc->settings['wp__use_builtin_http_api'] = $use_builtin_http_api; + + $request = new NonblockingPresetRequestHarness(); + $request->setUrl('http://example.org')->setPresets($presets); + + return $request->prepareOptionsForTest(); + } + + public function testNonblockingPresetLimitsCurlWaitToOneSecond() + { + $options = $this->prepareOptions(0, array('nonblocking')); + + $this->assertSame(1, $options[CURLOPT_TIMEOUT]); + $this->assertSame(1, $options[CURLOPT_CONNECTTIMEOUT]); + } + + public function testNonblockingPresetMakesWordpressHttpApiRequestNonBlocking() + { + $options = $this->prepareOptions(1, array('nonblocking')); + + $this->assertFalse($options['blocking']); + $this->assertSame(0.01, $options['timeout']); + $this->assertSame(1, $options['connect_timeout']); + } + + public function testRequestWithoutPresetKeepsDefaultTimeouts() + { + $options = $this->prepareOptions(0, array()); + + $this->assertSame(50, $options[CURLOPT_TIMEOUT]); + $this->assertArrayNotHasKey('blocking', $options); + } + + public function testAsyncPresetIsNotAffected() + { + $curl_options = $this->prepareOptions(0, array('async')); + $this->assertSame(3, $curl_options[CURLOPT_TIMEOUT]); + $this->assertSame(3, $curl_options[CURLOPT_CONNECTTIMEOUT]); + + $wp_options = $this->prepareOptions(1, array('async')); + $this->assertSame(3, $wp_options['timeout']); + $this->assertSame(3, $wp_options['connect_timeout']); + $this->assertFalse($wp_options['blocking']); + } +} diff --git a/tests/lib/CleantalkSP/SpbctWP/RemoteCalls/RemoteCallsCronWakeupTest.php b/tests/lib/CleantalkSP/SpbctWP/RemoteCalls/RemoteCallsCronWakeupTest.php new file mode 100644 index 000000000..fa909e561 --- /dev/null +++ b/tests/lib/CleantalkSP/SpbctWP/RemoteCalls/RemoteCallsCronWakeupTest.php @@ -0,0 +1,119 @@ +close(); + parent::tearDownAfterClass(); + } + + protected function setUp(): void + { + global $spbc; + + $this->home_backup = get_option('home'); + $this->http_api_backup = $spbc->settings['wp__use_builtin_http_api']; + + while ( self::$website->readRequest(0) !== null ) { + } + update_option('home', self::$website->url); + + $spbc->api_key = 'test_api_key'; + } + + protected function tearDown(): void + { + global $spbc; + + update_option('home', $this->home_backup); + $spbc->settings['wp__use_builtin_http_api'] = $this->http_api_backup; + } + + public function testCronWakeupActionIsRegisteredWithoutCooldown() + { + global $spbc; + + $this->assertTrue(method_exists(RemoteCalls::class, 'action__cron_wakeup')); + $this->assertArrayHasKey('cron_wakeup', $spbc->default_remote_calls); + $this->assertSame( + 0, + $spbc->default_remote_calls['cron_wakeup']['cooldown'], + 'The cooldown is consumed before the token check, so it must not block legitimate wake-ups' + ); + } + + /** + * @return array + */ + public function httpModesProvider() + { + return array( + 'cURL' => array(0), + 'WordPress HTTP API' => array(1), + ); + } + + /** + * @dataProvider httpModesProvider + * + * @param int $use_builtin_http_api + */ + public function testNonBlockingCallIsSentToWebsiteWithoutWaitingForResponse($use_builtin_http_api) + { + global $spbc; + + $spbc->settings['wp__use_builtin_http_api'] = $use_builtin_http_api; + + $started = microtime(true); + RemoteCalls::performToHostNonBlocking('cron_wakeup', array('extra_param' => 'extra_value')); + $elapsed = microtime(true) - $started; + + // The website never answers. The 'async' preset would wait 3 seconds. + $this->assertLessThan(2.5, $elapsed, 'The caller must not wait for the response'); + + $request = self::$website->readRequest(2); + + $this->assertNotNull($request, 'The remote call must be sent'); + $this->assertSame('POST', $request['method']); + $this->assertStringContainsString('spbct_no_cache=', $request['target'], 'The request must bypass the page cache'); + $this->assertSame('cron_wakeup', $request['params']['spbc_remote_call_action']); + $this->assertSame(md5('test_api_key'), $request['params']['spbc_remote_call_token']); + $this->assertSame('spbc', $request['params']['plugin_name']); + $this->assertSame('extra_value', $request['params']['extra_param']); + } +}